diff --git a/functions/v2/auth/[[catchall]].js b/functions/v2/auth/[[catchall]].js index 45076e1..058c5f4 100644 --- a/functions/v2/auth/[[catchall]].js +++ b/functions/v2/auth/[[catchall]].js @@ -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({}) } diff --git a/src/passkeys.js b/src/passkeys.js index 6a84b29..42c5cd4 100644 --- a/src/passkeys.js +++ b/src/passkeys.js @@ -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 @@ -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 @@ -373,10 +377,11 @@ 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 + 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 }) } @@ -384,4 +389,72 @@ export class Passkeys { 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 + 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 + 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) + 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}`) + 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) + } } diff --git a/test/test_passkeys_multi.js b/test/test_passkeys_multi.js index 5ad82e0..17c3506 100644 --- a/test/test_passkeys_multi.js +++ b/test/test_passkeys_multi.js @@ -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() { @@ -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!') }