diff --git a/functions/index.js b/functions/index.js index 60f72ad..ebd8ed3 100644 --- a/functions/index.js +++ b/functions/index.js @@ -15,8 +15,10 @@ function render(d) { return html`

Hello ${d.name}!

- ${d.isLoggedIn - ? html`You are logged in. Go to dashboard` - : html`Please sign in to continue.`} + ${ + d.isLoggedIn + ? html`You are logged in. Go to dashboard` + : html`Please sign in to continue.` + } ` } diff --git a/package-lock.json b/package-lock.json index 96a8f2d..6a3c77a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "passkeys", - "version": "4.1.3", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "passkeys", - "version": "4.1.3", + "version": "5.0.0", "license": "MIT", "dependencies": { "@simplewebauthn/server": "^13.3.2", diff --git a/package.json b/package.json index 6a71dff..ce71785 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passkeys", - "version": "4.1.3", + "version": "5.0.0", "type": "module", "description": "", "main": "passkeys.js", @@ -9,8 +9,9 @@ "start": "npm install && npx -y wrangler pages dev ./public --kv=KV --r2=R2 --d1 D1=d1 --live-reload", "build": "npx -y treeder/flaregun build", "run": "npx -y wrangler pages dev ./public --kv=KV --r2=R2 --d1 D1=d1", - "test:run": "node test/test.js", - "test": "npx -y treeder/testkit --port=8788" + "test:run": "node test/test_passkeys_multi.js && node test/test.js", + "test": "npx -y treeder/testkit --port=8788", + "format": "prettier --write ." }, "license": "MIT", "dependencies": { diff --git a/public/components/sign-in.js b/public/components/sign-in.js index ea9822e..741c5cb 100644 --- a/public/components/sign-in.js +++ b/public/components/sign-in.js @@ -86,7 +86,9 @@ export class SignIn extends LitElement { if (this.isLoggedIn()) { if (!this.capable) { return html` -
+
${err}
You are signed in.

@@ -96,20 +98,24 @@ export class SignIn extends LitElement { ` } - s = html`
+ s = html`
${err} - ${this.hasPasskey - ? html` -
- You already have a passkey.

- Continue to dashboard. -
- ` - : html` - - `} + ${ + this.hasPasskey + ? html` +
+ You already have a passkey.

+ Continue to dashboard. +
+ ` + : html` + + ` + }
Create Passkey
@@ -125,7 +131,8 @@ export class SignIn extends LitElement { return html` ${s} -
+
${err} Continue - ${this.capable - ? html` -
-
-
- Sign in with Passkey - ` - : ''} + ${ + this.capable + ? html` +
+
+
+ Sign in with Passkey + ` + : '' + }
` } diff --git a/src/passkeys.js b/src/passkeys.js index 4356727..6a84b29 100644 --- a/src/passkeys.js +++ b/src/passkeys.js @@ -134,6 +134,22 @@ export class Passkeys { let sess = await getSession(this.c2(c)) let emailOrId = sess.email || sess.userId + let user = null + if (sess.userId) { + let r = await this.opts.kv.get(`users-${sess.userId}`) + if (r) { + user = JSON.parse(r) + } + } + + const excludeCredentials = + user && Array.isArray(user.passkeys) + ? user.passkeys.map((authenticator) => ({ + id: authenticator.id, + transports: authenticator.transports, + })) + : [] + let options = { rpName: this.opts.appName, rpID: cookieDomain(this.c2(c), this.opts.domainLevels), @@ -142,14 +158,7 @@ export class Passkeys { userDisplayName: emailOrId || 'user', // - can add this for a real username /** @type {'none'} */ attestationType: 'none', - // Prevent users from re-registering existing authenticators - // excludeCredentials: userAuthenticators.map(authenticator => ({ - // id: authenticator.credentialID, - // type: 'public-key', - // // Optional - // transports: authenticator.transports, - // })), - excludeCredentials: [], + excludeCredentials, // See "Guiding use of authenticators via authenticatorSelection" below authenticatorSelection: { /** @type {'required'} */ @@ -230,10 +239,29 @@ export class Passkeys { await this.opts.kv.put(`passkeys-${newPasskey.id}`, JSON.stringify(newPasskey)) // also store all passkeys for user to find them later - let user = { - id: userId, - email: sess.email, - passkeys: [newPasskey], + let user = null + let rUser = await this.opts.kv.get(`users-${userId}`) + if (rUser) { + user = JSON.parse(rUser) + } + if (!user) { + user = { + id: userId, + email: sess.email, + passkeys: [], + } + } + if (!Array.isArray(user.passkeys)) { + user.passkeys = [] + } + if (sess.email && !user.email) { + user.email = sess.email + } + const existingIndex = user.passkeys.findIndex((pk) => pk.id === newPasskey.id) + if (existingIndex >= 0) { + user.passkeys[existingIndex] = newPasskey + } else { + user.passkeys.push(newPasskey) } await this.opts.kv.put(`users-${userId}`, JSON.stringify(user)) @@ -310,7 +338,19 @@ export class Passkeys { // update counter shallowCopy.counter = verification.authenticationInfo.newCounter - await this.opts.kv.put(`passkeys-${passkey.id}`, JSON.stringify(shallowCopy)) + await this.opts.kv.put(`passkeys-${shallowCopy.id}`, JSON.stringify(shallowCopy)) + + let userStr = await this.opts.kv.get(`users-${userId}`) + if (userStr) { + let user = JSON.parse(userStr) + if (Array.isArray(user.passkeys)) { + let pk = user.passkeys.find((p) => p.id === shallowCopy.id) + if (pk) { + pk.counter = shallowCopy.counter + await this.opts.kv.put(`users-${userId}`, JSON.stringify(user)) + } + } + } if (this.opts.passkeyVerified) { await this.opts.passkeyVerified({ userId, email: sessionData.email }) diff --git a/test/test_passkeys_multi.js b/test/test_passkeys_multi.js new file mode 100644 index 0000000..5ad82e0 --- /dev/null +++ b/test/test_passkeys_multi.js @@ -0,0 +1,136 @@ +import { Passkeys } from '../src/passkeys.js' +import assert from 'node:assert' + +class MockKV { + constructor() { + this.store = new Map() + } + async get(key) { + return this.store.get(key) || null + } + async put(key, value) { + this.store.set(key, value) + } +} + +async function runTests() { + console.log('Running multi-passkey tests...') + + const kv = new MockKV() + const passkeys = new Passkeys({ + baseURL: 'http://localhost:8788', + appName: 'Test App', + kv, + }) + + const userId = 'user-123' + const sessionData = { + userId, + email: 'user@example.com', + } + + // Mock context helper + const createMockContext = (sessionObj = sessionData, body = {}) => ({ + request: { + url: 'http://localhost:8788/passkeys/test', + headers: new Headers({ + cookie: `session=test-sess`, + }), + json: async () => body, + }, + data: {}, + env: {}, + }) + + // Put mock session in KV + await kv.put('session-test-sess', JSON.stringify(sessionData)) + + // Test 1: Passkeys.new when user has no passkeys + { + const c = createMockContext() + const res = await passkeys.new(c) + const data = await res.json() + assert.strictEqual(res.status, 200) + assert.deepStrictEqual(data.excludeCredentials, [], 'excludeCredentials should be empty for new user') + console.log('✔ Test 1 passed: new() returns empty excludeCredentials for user with no passkeys') + } + + // Test 2: Passkeys.check when user has no passkeys + { + const c = createMockContext() + const res = await passkeys.check(c) + const data = await res.json() + assert.strictEqual(data.numPasskeys, 0) + console.log('✔ Test 2 passed: check() returns 0 passkeys') + } + + // Test 3: Set up legacy user record without passkeys array + { + await kv.put( + `users-${userId}`, + JSON.stringify({ + id: userId, + email: sessionData.email, + }), + ) + const c = createMockContext() + const res = await passkeys.check(c) + const data = await res.json() + assert.strictEqual(data.numPasskeys, 0, 'Legacy user without passkeys field should return 0') + const resNew = await passkeys.new(c) + const dataNew = await resNew.json() + assert.deepStrictEqual(dataNew.excludeCredentials, [], 'Legacy user should have empty excludeCredentials') + console.log('✔ Test 3 passed: handles legacy user records without passkeys field gracefully') + } + + // Test 4: Set up existing passkeys in user record and test Passkeys.new() + { + const existingPasskeys = [ + { + id: 'cred-1', + publicKey: [1, 2, 3], + counter: 0, + transports: ['internal'], + }, + { + id: 'cred-2', + publicKey: [4, 5, 6], + counter: 5, + transports: ['usb', 'nfc'], + }, + ] + + await kv.put( + `users-${userId}`, + JSON.stringify({ + id: userId, + email: sessionData.email, + passkeys: existingPasskeys, + }), + ) + + const c = createMockContext() + const res = await passkeys.new(c) + const data = await res.json() + assert.strictEqual(data.excludeCredentials.length, 2) + assert.strictEqual(data.excludeCredentials[0].id, 'cred-1') + assert.strictEqual(data.excludeCredentials[1].id, 'cred-2') + console.log('✔ Test 4 passed: new() populates excludeCredentials from existing passkeys') + } + + // Test 5: Passkeys.check when user has 2 passkeys + { + const c = createMockContext() + const res = await passkeys.check(c) + const data = await res.json() + assert.strictEqual(data.numPasskeys, 2) + console.log('✔ Test 5 passed: check() returns 2 passkeys') + } + + console.log('\nAll multi-passkey unit tests passed successfully!') +} + +runTests().catch((err) => { + console.error('Test failed:', err) + process.exit(1) +})