Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions functions/v2/auth/[[catchall]].js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export async function onRequest(c) {
if (p[1] == 'check') {
return await passkeys.check(c)
}
if (p[1] == 'list') {
return await passkeys.list(c)
}
if (p[1] == 'delete' || p[1] == 'remove') {
return await passkeys.delete(c)
}
}
return Response.json({})
}
79 changes: 76 additions & 3 deletions src/passkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ export class Passkeys {
const input = await c.request.json()

let userId = input.userId
userId = isoBase64URL.toUTF8String(userId)
try {
userId = isoBase64URL.toUTF8String(userId)
} catch (e) {}

let sess = await getSession(this.c2(c))
let emailOrId = sess.email || sess.userId || userId
Expand Down Expand Up @@ -225,6 +227,8 @@ export class Passkeys {
webAuthnUserID: userId,
// A unique identifier for the credential
id: credential.id,
name: input.name || (credentialDeviceType === 'multiDevice' ? 'Passkey' : 'Security Key'),
createdAt: new Date().toISOString(),
// The public key bytes, used for subsequent authentication signature verification
publicKey: credential.publicKey,
// The number of times the authenticator has been used on this site so far
Expand Down Expand Up @@ -373,15 +377,84 @@ export class Passkeys {

async check(c) {
let sess = await getSession(this.c2(c))
if (!sess || !sess.userId) {
let userId = sess.userId || c.data?.user?.id || c.data?.userID

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In src/auth.js (setupUserSession), the user identifier is stored as c.data.userId with a lowercase 'd'. The fallback chain checks c.data?.userID and c.data?.user?.id, but omits c.data?.userId. Including c.data?.userId avoids potential authentication lookup misses when middleware sets userId directly on c.data.

Suggested change
let userId = sess.userId || c.data?.user?.id || c.data?.userID
let userId = sess.userId || c.data?.userId || c.data?.user?.id || c.data?.userID

if (!userId) {
throw new APIError(`Not logged in`, { status: 401 })
}
let user = await this.opts.kv.get(`users-${sess.userId}`)
let user = await this.opts.kv.get(`users-${userId}`)
if (!user) {
return Response.json({ message: '0 passkeys found', numPasskeys: 0 })
}
user = JSON.parse(user)
const numPasskeys = user.passkeys ? user.passkeys.length : 0
return Response.json({ message: `${numPasskeys} passkeys found`, numPasskeys })
}

async list(c) {
let sess = await getSession(this.c2(c))
let userId = sess.userId || c.data?.user?.id || c.data?.userID

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 To remain consistent with src/auth.js and check(), consider adding c.data?.userId to the fallback chain here as well.

Suggested change
let userId = sess.userId || c.data?.user?.id || c.data?.userID
let userId = sess.userId || c.data?.userId || c.data?.user?.id || c.data?.userID

if (!userId) {
return Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
}
let userRaw = await this.opts.kv.get(`users-${userId}`)
let passkeysList = []
if (userRaw) {
let parsed = JSON.parse(userRaw)
passkeysList = parsed.passkeys || []
}
return Response.json({
passkeys: passkeysList.map((pk) => ({
id: pk.id,
name: pk.name || (pk.deviceType === 'multiDevice' ? 'Passkey' : 'Security Key'),
deviceType: pk.deviceType,
backedUp: pk.backedUp,
createdAt: pk.createdAt || null,
transports: pk.transports || [],
})),
})
}

async delete(c) {
let sess = await getSession(this.c2(c))
let userId = sess.userId || c.data?.user?.id || c.data?.userID

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 To remain consistent with src/auth.js and check(), consider adding c.data?.userId to the fallback chain here as well.

Suggested change
let userId = sess.userId || c.data?.user?.id || c.data?.userID
let userId = sess.userId || c.data?.userId || c.data?.user?.id || c.data?.userID

if (!userId) {
return Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
}
let passkeyId = c.params?.catchall?.[2] || c.params?.id
if (!passkeyId) {
try {
let req = typeof c.request.clone === 'function' ? c.request.clone() : c.request
let body = await req.json()
passkeyId = body?.id
} catch (e) {}
}
if (!passkeyId && c.request?.url) {
try {
const url = new URL(c.request.url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 If c.request.url is a relative path (which often occurs in test mocks or Node.js HTTP contexts), new URL(c.request.url) will throw an invalid URL error. Because it is caught by the try...catch block, extracting id from the query string fails silently. Providing a base URL fallback ensures that both relative and absolute URLs parse successfully.

Suggested change
const url = new URL(c.request.url)
const url = new URL(c.request.url, 'http://localhost')

passkeyId = url.searchParams.get('id')
} catch (e) {}
}
if (!passkeyId) {
return Response.json({ error: { message: 'Passkey ID is required' } }, { status: 400 })
}

let userRaw = await this.opts.kv.get(`users-${userId}`)
if (!userRaw) {
return Response.json({ error: { message: 'User not found' } }, { status: 404 })
}
let userObj = JSON.parse(userRaw)
let existingList = userObj.passkeys || []
if (!existingList.some((pk) => pk.id === passkeyId)) {
return Response.json({ error: { message: 'Passkey not found for user' } }, { status: 404 })
}

await this.opts.kv.delete(`passkeys-${passkeyId}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The PasskeysOptions JSDoc previously specified that kv only requires put() and get() methods. If a consumer provides a custom KV implementation without a delete method, this will throw an unhandled TypeError. Checking whether delete is a function before calling it makes custom KV stores safer and backwards-compatible.

Suggested change
await this.opts.kv.delete(`passkeys-${passkeyId}`)
if (typeof this.opts.kv.delete === 'function') {
await this.opts.kv.delete(`passkeys-${passkeyId}`)
}

userObj.passkeys = existingList.filter((pk) => pk.id !== passkeyId)
await this.opts.kv.put(`users-${userId}`, JSON.stringify(userObj))
return Response.json({ success: true, message: 'Passkey deleted' })
}

async remove(c) {
return await this.delete(c)
}
}
66 changes: 66 additions & 0 deletions test/test_passkeys_multi.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class MockKV {
async put(key, value) {
this.store.set(key, value)
}
async delete(key) {
this.store.delete(key)
}
}

async function runTests() {
Expand Down Expand Up @@ -127,6 +130,69 @@ async function runTests() {
console.log('✔ Test 5 passed: check() returns 2 passkeys')
}

// Test 6: Passkeys.list returns user's passkeys with metadata
{
const unauthC = {
request: {
url: 'http://localhost:8788/passkeys/list',
headers: new Headers(),
json: async () => ({}),
},
data: {},
env: {},
}
const unauthRes = await passkeys.list(unauthC)
assert.strictEqual(unauthRes.status, 401)

const c = createMockContext()
const res = await passkeys.list(c)
assert.strictEqual(res.status, 200)
const data = await res.json()
assert.strictEqual(Array.isArray(data.passkeys), true)
assert.strictEqual(data.passkeys.length, 2)
assert.strictEqual(data.passkeys[0].id, 'cred-1')
assert.strictEqual(data.passkeys[0].name, 'Security Key')
assert.strictEqual(data.passkeys[1].id, 'cred-2')
console.log('✔ Test 6 passed: list() handles unauthorized and returns formatted passkeys')
}

// Test 7: Passkeys.delete validates ownership and removes passkey
{
await kv.put('passkeys-cred-1', JSON.stringify({ id: 'cred-1' }))
await kv.put('passkeys-cred-2', JSON.stringify({ id: 'cred-2' }))

// Missing passkey ID returns 400
const emptyC = createMockContext(sessionData, {})
const emptyRes = await passkeys.delete(emptyC)
assert.strictEqual(emptyRes.status, 400)

// Non-existent passkey returns 404
const notFoundC = createMockContext(sessionData, { id: 'non-existent' })
const notFoundRes = await passkeys.delete(notFoundC)
assert.strictEqual(notFoundRes.status, 404)

// Delete cred-1 via body
const deleteC = createMockContext(sessionData, { id: 'cred-1' })
const deleteRes = await passkeys.delete(deleteC)
assert.strictEqual(deleteRes.status, 200)
const deleteData = await deleteRes.json()
assert.strictEqual(deleteData.success, true)

// Verify cred-1 is deleted from KV and user record
assert.strictEqual(await kv.get('passkeys-cred-1'), null)
const userRaw = await kv.get(`users-${userId}`)
const userObj = JSON.parse(userRaw)
assert.strictEqual(userObj.passkeys.length, 1)
assert.strictEqual(userObj.passkeys[0].id, 'cred-2')

// Verify check() and list() now reflect 1 passkey
const checkRes = await passkeys.check(createMockContext())
const checkData = await checkRes.json()
assert.strictEqual(checkData.numPasskeys, 1)

console.log('✔ Test 7 passed: delete() validates ID/ownership, deletes from KV, and updates user record')
}

console.log('\nAll multi-passkey unit tests passed successfully!')
}

Expand Down
Loading