1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2import config from '../wrangler.json'; 3import { authenticator, FLAGS } from '../test/authenticator'; 4import { memoryBudgets } from '../test/budget'; 5import { testD1 } from '../test/d1'; 6import { d1Accounts } from './accounts-store'; 7import { auth, CHALLENGE_TTL_MS, displayName } from './auth'; 8import { SESSION_COOKIE, SESSION_TTL_MS, tokenHash } from './session'; 9 10const ORIGIN = 'https://jevstrudel.example'; 11const RP_ID = 'jevstrudel.example'; 12 13let clock: number; 14let db: D1Database; 15let limited: boolean; 16const env = () => 17 ({ 18 DB: db, 19 AUTH_LIMIT: { limit: async () => ({ success: !limited }) }, 20 BUDGET: memoryBudgets(1000, () => clock), 21 }) as never; 22const accounts = () => d1Accounts(db, () => clock);
A browser: it keeps the session cookie the Worker sets, and sends it back.
25function browser(origin = ORIGIN) { 26 let cookie: string | null = null; 27 const call = async (path: string, body?: unknown, method = path === 'me' ? 'GET' : 'POST') => { 28 const headers: Record<string, string> = { 'Content-Type': 'application/json' }; 29 if (method === 'POST') headers.Origin = origin; 30 if (cookie) headers.Cookie = `${SESSION_COOKIE}=${cookie}`; 31 const res = await auth( 32 new Request(`${ORIGIN}/jev/auth/${path}`, { 33 method, 34 headers, 35 body: method === 'POST' && body !== undefined ? JSON.stringify(body) : undefined, 36 }), 37 env(), 38 accounts(), 39 ); 40 const set = res.headers.get('Set-Cookie'); 41 if (set) cookie = /^jev_session=([^;]*)/.exec(set)![1] || null; 42 const text = await res.text(); 43 return { res, set, body: text ? JSON.parse(text) : null }; 44 }; 45 return { call, cookie: () => cookie, setCookie: (c: string | null) => void (cookie = c) }; 46}
48async function register(b = browser(), name = 'Link', key?: Awaited<ReturnType<typeof authenticator>>) { 49 const passkey = key ?? (await authenticator()); 50 const options = await b.call('register/options', { displayName: name }); 51 expect(options.res.status).toBe(200); 52 const verified = await b.call('register/verify', await passkey.create(options.body, ORIGIN)); 53 return { b, passkey, options: options.body, verified }; 54} 55 56beforeEach(() => { 57 clock = Date.UTC(2026, 8, 25, 12); 58 db = testD1(); 59 limited = false; 60 vi.spyOn(console, 'error').mockImplementation(() => {}); 61}); 62afterEach(() => vi.restoreAllMocks()); 63 64describe('registering with a passkey', () => { 65 it('makes an account, signs it in with an HttpOnly cookie, and stores only the token hash', async () => { 66 const { b, options, verified } = await register(); 67 expect(options).toMatchObject({ 68 rp: { name: 'jevstrudel', id: RP_ID }, 69 user: { name: 'Link', displayName: 'Link' }, 70 authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, 71 attestation: 'none', 72 }); 73 expect(options.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); 74 expect(verified.res.status).toBe(200); 75 expect(verified.body.user).toEqual({ id: options.user.id, displayName: 'Link' }); 76 expect(verified.set).toMatch(/HttpOnly/); 77 expect(verified.set).toMatch(/Secure/); 78 expect(verified.set).toMatch(/SameSite=Lax/); 79 expect(verified.set).toMatch(/Path=\//); 80 expect(verified.set).toMatch(new RegExp(`Max-Age=${SESSION_TTL_MS / 1000}`)); 81 82 const me = await b.call('me'); 83 expect(me.body).toEqual({ 84 user: { id: options.user.id, displayName: 'Link' }, 85 budget: { used: 0, limit: 1000, resetsAt: Date.UTC(2026, 8, 26) }, 86 passkeyHere: true, 87 }); 88 // a passkey made on another host (the site's old address) does not sign in here 89 await db.prepare('UPDATE credentials SET rp_id = ?').bind('elsewhere.example').run(); 90 expect((await b.call('me')).body.passkeyHere).toBe(false); 91 await db.prepare('UPDATE credentials SET rp_id = ?').bind(RP_ID).run(); 92 const { results } = await db.prepare('SELECT id_hash FROM sessions').all<{ id_hash: string }>(); 93 expect(results).toEqual([{ id_hash: await tokenHash(b.cookie()!) }]); 94 expect(JSON.stringify(results)).not.toContain(b.cookie()); 95 // the challenge is gone once used 96 expect((await db.prepare('SELECT count(*) AS n FROM auth_challenges').first<{ n: number }>())!.n).toBe(0); 97 }); 98 99 it('uses each challenge once', async () => { 100 const b = browser(); 101 const passkey = await authenticator(); 102 const options = await b.call('register/options', { displayName: 'Zelda' }); 103 const response = await passkey.create(options.body, ORIGIN); 104 expect((await b.call('register/verify', response)).res.status).toBe(200); 105 const again = await browser().call('register/verify', response); 106 expect(again.res.status).toBe(400); 107 expect(again.body.error).toMatch(/expired or was already used/); 108 }); 109 110 it('refuses a challenge older than its lifetime', async () => { 111 const b = browser(); 112 const passkey = await authenticator(); 113 const options = await b.call('register/options', { displayName: 'Zelda' }); 114 clock += CHALLENGE_TTL_MS; 115 const late = await b.call('register/verify', await passkey.create(options.body, ORIGIN)); 116 expect(late.res.status).toBe(400); 117 expect(late.set).toBeNull(); 118 }); 119 120 it('refuses a challenge it never issued, or issued for signing in', async () => { 121 const passkey = await authenticator(); 122 const made = await passkey.create({ challenge: 'A'.repeat(43), rp: { id: RP_ID }, user: { id: 'x' } }, ORIGIN); 123 expect((await browser().call('register/verify', made)).res.status).toBe(400); 124 const b = browser(); 125 const login = await b.call('login/options', {}); 126 const wrong = await passkey.create({ ...login.body, rp: { id: RP_ID }, user: { id: 'x' } }, ORIGIN); 127 expect((await b.call('register/verify', wrong)).res.status).toBe(400); 128 }); 129 130 it('refuses a response made on another origin, for another RP ID, or without user verification', async () => { 131 for (const [origin, rpId, flags] of [ 132 ['https://evil.example', RP_ID, undefined], 133 [ORIGIN, 'evil.example', undefined], 134 [ORIGIN, RP_ID, FLAGS.UP | FLAGS.AT], 135 ] as const) { 136 const b = browser(); 137 const passkey = await authenticator(); 138 const options = await b.call('register/options', { displayName: 'Ganon' }); 139 const res = await b.call('register/verify', await passkey.create(options.body, origin, { rpId, flags })); 140 expect(res.res.status, `${origin} ${rpId} ${flags}`).toBe(400); 141 expect(res.set).toBeNull(); 142 } 143 expect((await db.prepare('SELECT count(*) AS n FROM users').first<{ n: number }>())!.n).toBe(0); 144 }); 145 146 it('refuses a POST from another site, and a missing or bad display name', async () => { 147 expect((await browser('https://evil.example').call('register/options', { displayName: 'x' })).res.status).toBe(403); 148 expect((await browser().call('register/options', {})).res.status).toBe(400); 149 expect((await browser().call('register/options', { displayName: ' ' })).res.status).toBe(400); 150 expect((await browser().call('register/options', { displayName: 'x'.repeat(41) })).res.status).toBe(400); 151 }); 152 153 it('is rate limited per visitor', async () => { 154 limited = true; 155 const res = await browser().call('register/options', { displayName: 'Link' }); 156 expect(res.res.status).toBe(429); 157 expect(res.res.headers.get('Retry-After')).toBe( 158 String(config.ratelimits.find((r) => r.name === 'AUTH_LIMIT')!.simple.period), 159 ); 160 }); 161 162 it('adds a further passkey to the signed-in account, and never registers one twice', async () => { 163 const { b, passkey, options } = await register(); 164 const second = await authenticator(); 165 const more = await b.call('register/options', {}); 166 expect(more.body.user.id).toBe(options.user.id); 167 expect(more.body.excludeCredentials).toEqual([{ id: passkey.id, type: 'public-key', transports: ['internal', 'hybrid'] }]); 168 const added = await b.call('register/verify', await second.create(more.body, ORIGIN)); 169 expect(added.res.status).toBe(200); 170 expect(added.set).toBeNull(); // still the same session 171 expect((await accounts().credentialIds(options.user.id, RP_ID)).map((c) => c.id)).toEqual([passkey.id, second.id]);
signing in with the new one is the same account
the same passkey again is refused
180 const again = await b.call('register/options', {}); 181 expect((await b.call('register/verify', await passkey.create(again.body, ORIGIN))).res.status).toBe(409); 182 // and a display name while signed in means another account: sign out first 183 expect((await b.call('register/options', { displayName: 'Dark Link' })).res.status).toBe(400); 184 }); 185});
187describe('signing in and out', () => { 188 it('signs in with a registered passkey, keeping its counter', async () => { 189 const { passkey, options } = await register(); 190 const b = browser(); 191 expect((await b.call('me')).body).toEqual({ user: null, budget: null, passkeyHere: null }); 192 const login = await b.call('login/options', {}); 193 expect(login.body).toMatchObject({ rpId: RP_ID, userVerification: 'required' }); 194 expect(login.body.allowCredentials ?? []).toEqual([]); 195 const signed = await b.call('login/verify', await passkey.get(login.body, ORIGIN)); 196 expect(signed.res.status).toBe(200); 197 expect(signed.body.user).toEqual({ id: options.user.id, displayName: 'Link' }); 198 expect(signed.set).toMatch(/HttpOnly/); 199 expect((await accounts().credential(passkey.id))!.signCount).toBe(1); 200 expect((await b.call('me')).body.user.displayName).toBe('Link'); 201 }); 202 203 it('refuses a replayed sign-in, an unknown passkey, and a mismatched user handle', async () => { 204 const { passkey } = await register(); 205 const b = browser(); 206 const login = await b.call('login/options', {}); 207 const response = await passkey.get(login.body, ORIGIN); 208 expect((await b.call('login/verify', response)).res.status).toBe(200); 209 expect((await browser().call('login/verify', response)).res.status).toBe(400); 210 211 const stranger = await authenticator(); 212 const l2 = await b.call('login/options', {}); 213 await stranger.create({ challenge: 'x', rp: { id: RP_ID } }, ORIGIN); 214 expect((await b.call('login/verify', await stranger.get(l2.body, ORIGIN))).body.error).toMatch(/not registered/); 215 216 const l3 = await b.call('login/options', {}); 217 const other = await passkey.get(l3.body, ORIGIN, { handle: 'AAAAAAAAAAAAAAAAAAAAAA' }); 218 expect((await b.call('login/verify', other)).body.error).toMatch(/another account/); 219 }); 220 221 it('refuses a signature from another origin or without user verification', async () => { 222 const { passkey } = await register(); 223 for (const [origin, flags] of [ 224 ['https://evil.example', undefined], 225 [ORIGIN, FLAGS.UP], 226 ] as const) { 227 const b = browser(); 228 const login = await b.call('login/options', {}); 229 const res = await b.call('login/verify', await passkey.get(login.body, origin, { flags })); 230 expect(res.res.status).toBe(400); 231 expect(res.set).toBeNull(); 232 } 233 }); 234 235 it('signs out: the session is deleted and the cookie cleared', async () => { 236 const { b } = await register(); 237 const token = b.cookie()!; 238 const out = await b.call('logout', {}); 239 expect(out.res.status).toBe(204); 240 expect(out.set).toMatch(/Max-Age=0/); 241 expect(b.cookie()).toBeNull(); 242 b.setCookie(token); 243 expect((await b.call('me')).body.user).toBeNull(); 244 expect((await db.prepare('SELECT count(*) AS n FROM sessions').first<{ n: number }>())!.n).toBe(0); 245 }); 246 247 it('ends a session when it expires', async () => { 248 const { b } = await register(); 249 clock += SESSION_TTL_MS; 250 expect((await b.call('me')).body.user).toBeNull(); 251 }); 252 253 it('ignores a cookie that is not one it issued', async () => { 254 const b = browser(); 255 b.setCookie('not-a-token'); 256 expect((await b.call('me')).body.user).toBeNull(); 257 b.setCookie('A'.repeat(43)); 258 expect((await b.call('me')).body.user).toBeNull(); 259 }); 260}); 261 262describe('displayName', () => { 263 it('trims and folds spaces, and refuses empty, long or control-character names', () => { 264 expect(displayName(' Hero of Time ')).toBe('Hero of Time'); 265 expect(displayName('Ná')).toBe('Ná'); 266 expect(displayName('')).toBeNull(); 267 expect(displayName(7)).toBeNull(); 268 expect(displayName('a\u0000b')).toBeNull(); 269 expect(displayName('ab')).toBeNull(); 270 expect(displayName('ł'.repeat(40))).toBe('ł'.repeat(40)); 271 expect(displayName('ł'.repeat(41))).toBeNull(); 272 }); 273}); 274 275describe('the accounts schema', () => { 276 it('holds only what it says it holds', async () => { 277 const store = accounts(); 278 const credential = { id: 'c'.repeat(16), publicKey: new Uint8Array([1]), signCount: 0, transports: [], rpId: 'jevstrudel.example' }; 279 await expect(store.createUser({ id: 'short', displayName: 'x' }, credential, 't', 1)).rejects.toThrow(/CHECK/); 280 await expect(store.createUser({ id: 'u'.repeat(22), displayName: ' x' }, credential, 't', 1)).rejects.toThrow( 281 /CHECK/, 282 ); 283 await expect( 284 store.putChallenge({ challenge: 'c'.repeat(43), purpose: 'login', userId: 'u'.repeat(22), displayName: null }, 1), 285 ).rejects.toThrow(/CHECK/); 286 await expect( 287 store.putChallenge({ challenge: 'c'.repeat(43), purpose: 'register', userId: 'u'.repeat(22), displayName: null }, 1), 288 ).rejects.toThrow(/CHECK/); 289 // a credential needs its user 290 await expect(store.addCredential({ ...credential, userId: 'u'.repeat(22) })).rejects.toThrow(/FOREIGN KEY/); 291 }); 292});