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
8 changes: 5 additions & 3 deletions functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ function render(d) {
return html`
<h2>Hello ${d.name}!</h2>

${d.isLoggedIn
? html`You are logged in. <a href="/dashboard">Go to dashboard</a>`
: html`Please sign in to continue.`}
${
d.isLoggedIn
? html`You are logged in. <a href="/dashboard">Go to dashboard</a>`
: html`Please sign in to continue.`
}
`
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "passkeys",
"version": "4.1.3",
"version": "5.0.0",
"type": "module",
"description": "",
"main": "passkeys.js",
Expand All @@ -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": {
Expand Down
55 changes: 32 additions & 23 deletions public/components/sign-in.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export class SignIn extends LitElement {
if (this.isLoggedIn()) {
if (!this.capable) {
return html`
<div class="flex col g24 aic" style="width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
<div
class="flex col g24 aic"
style="width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
${err}
<div>
You are signed in.<br /><br />
Expand All @@ -96,20 +98,24 @@ export class SignIn extends LitElement {
`
}

s = html` <div class="flex col g24 aic" style="width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
s = html` <div
class="flex col g24 aic"
style="width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
${err}
${this.hasPasskey
? html`
<div>
You already have a passkey. <br /><br />
<a href="${this.afterLoginHref}">Continue to dashboard</a>.
</div>
`
: html`
<div>
<a href="${this.afterLoginHref}">Skip this and create passkey later</a>
</div>
`}
${
this.hasPasskey
? html`
<div>
You already have a passkey. <br /><br />
<a href="${this.afterLoginHref}">Continue to dashboard</a>.
</div>
`
: html`
<div>
<a href="${this.afterLoginHref}">Skip this and create passkey later</a>
</div>
`
}
<div>
<md-button color="filled" @click=${this.createPasskey}>Create Passkey</md-button>
</div>
Expand All @@ -125,7 +131,8 @@ export class SignIn extends LitElement {

return html`
${s}
<div style="display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
<div
style="display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: 400px; box-sizing: border-box; padding-top: 40px;">
${err}
<!-- <input type="text" id="email" autocomplete="webauthn"> -->
<md-text-field
Expand All @@ -137,14 +144,16 @@ export class SignIn extends LitElement {
required
autocomplete="${this.capable ? 'webauthn' : 'email'}"></md-text-field>
<md-button color="filled" @click=${this.emailStart}>Continue</md-button>
${this.capable
? html`
<div>
<hr />
</div>
<md-button color="filled" @click=${this.signin}>Sign in with Passkey</md-button>
`
: ''}
${
this.capable
? html`
<div>
<hr />
</div>
<md-button color="filled" @click=${this.signin}>Sign in with Passkey</md-button>
`
: ''
}
</div>
`
}
Expand Down
66 changes: 53 additions & 13 deletions src/passkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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'} */
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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 })
Expand Down
136 changes: 136 additions & 0 deletions test/test_passkeys_multi.js
Original file line number Diff line number Diff line change
@@ -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)
})
Loading