- `}
+ ${
+ this.hasPasskey
+ ? html`
+
@@ -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)
+})