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 }); 87 const { results } = await db.prepare('SELECT id_hash FROM sessions').all<{ id_hash: string }>(); 88 expect(results).toEqual([{ id_hash: await tokenHash(b.cookie()!) }]); 89 expect(JSON.stringify(results)).not.toContain(b.cookie()); 90 // the challenge is gone once used 91 expect((await db.prepare('SELECT count(*) AS n FROM auth_challenges').first<{ n: number }>())!.n).toBe(0); 92 }); 93 94 it('uses each challenge once', async () => { 95 const b = browser(); 96 const passkey = await authenticator(); 97 const options = await b.call('register/options', { displayName: 'Zelda' }); 98 const response = await passkey.create(options.body, ORIGIN); 99 expect((await b.call('register/verify', response)).res.status).toBe(200); 100 const again = await browser().call('register/verify', response); 101 expect(again.res.status).toBe(400); 102 expect(again.body.error).toMatch(/expired or was already used/); 103 }); 104 105 it('refuses a challenge older than its lifetime', async () => { 106 const b = browser(); 107 const passkey = await authenticator(); 108 const options = await b.call('register/options', { displayName: 'Zelda' }); 109 clock += CHALLENGE_TTL_MS; 110 const late = await b.call('register/verify', await passkey.create(options.body, ORIGIN)); 111 expect(late.res.status).toBe(400); 112 expect(late.set).toBeNull(); 113 }); 114 115 it('refuses a challenge it never issued, or issued for signing in', async () => { 116 const passkey = await authenticator(); 117 const made = await passkey.create({ challenge: 'A'.repeat(43), rp: { id: RP_ID }, user: { id: 'x' } }, ORIGIN); 118 expect((await browser().call('register/verify', made)).res.status).toBe(400); 119 const b = browser(); 120 const login = await b.call('login/options', {}); 121 const wrong = await passkey.create({ ...login.body, rp: { id: RP_ID }, user: { id: 'x' } }, ORIGIN); 122 expect((await b.call('register/verify', wrong)).res.status).toBe(400); 123 }); 124 125 it('refuses a response made on another origin, for another RP ID, or without user verification', async () => { 126 for (const [origin, rpId, flags] of [ 127 ['https://evil.example', RP_ID, undefined], 128 [ORIGIN, 'evil.example', undefined], 129 [ORIGIN, RP_ID, FLAGS.UP | FLAGS.AT], 130 ] as const) { 131 const b = browser(); 132 const passkey = await authenticator(); 133 const options = await b.call('register/options', { displayName: 'Ganon' }); 134 const res = await b.call('register/verify', await passkey.create(options.body, origin, { rpId, flags })); 135 expect(res.res.status, `${origin} ${rpId} ${flags}`).toBe(400); 136 expect(res.set).toBeNull(); 137 } 138 expect((await db.prepare('SELECT count(*) AS n FROM users').first<{ n: number }>())!.n).toBe(0); 139 }); 140 141 it('refuses a POST from another site, and a missing or bad display name', async () => { 142 expect((await browser('https://evil.example').call('register/options', { displayName: 'x' })).res.status).toBe(403); 143 expect((await browser().call('register/options', {})).res.status).toBe(400); 144 expect((await browser().call('register/options', { displayName: ' ' })).res.status).toBe(400); 145 expect((await browser().call('register/options', { displayName: 'x'.repeat(41) })).res.status).toBe(400); 146 }); 147 148 it('is rate limited per visitor', async () => { 149 limited = true; 150 const res = await browser().call('register/options', { displayName: 'Link' }); 151 expect(res.res.status).toBe(429); 152 expect(res.res.headers.get('Retry-After')).toBe( 153 String(config.ratelimits.find((r) => r.name === 'AUTH_LIMIT')!.simple.period), 154 ); 155 }); 156 157 it('adds a further passkey to the signed-in account, and never registers one twice', async () => { 158 const { b, passkey, options } = await register(); 159 const second = await authenticator(); 160 const more = await b.call('register/options', {}); 161 expect(more.body.user.id).toBe(options.user.id); 162 expect(more.body.excludeCredentials).toEqual([{ id: passkey.id, type: 'public-key', transports: ['internal', 'hybrid'] }]); 163 const added = await b.call('register/verify', await second.create(more.body, ORIGIN)); 164 expect(added.res.status).toBe(200); 165 expect(added.set).toBeNull(); // still the same session 166 expect((await accounts().credentialIds(options.user.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
175 const again = await b.call('register/options', {}); 176 expect((await b.call('register/verify', await passkey.create(again.body, ORIGIN))).res.status).toBe(409); 177 // and a display name while signed in means another account: sign out first 178 expect((await b.call('register/options', { displayName: 'Dark Link' })).res.status).toBe(400); 179 }); 180});
182describe('signing in and out', () => { 183 it('signs in with a registered passkey, keeping its counter', async () => { 184 const { passkey, options } = await register(); 185 const b = browser(); 186 expect((await b.call('me')).body).toEqual({ user: null, budget: null }); 187 const login = await b.call('login/options', {}); 188 expect(login.body).toMatchObject({ rpId: RP_ID, userVerification: 'required' }); 189 expect(login.body.allowCredentials ?? []).toEqual([]); 190 const signed = await b.call('login/verify', await passkey.get(login.body, ORIGIN)); 191 expect(signed.res.status).toBe(200); 192 expect(signed.body.user).toEqual({ id: options.user.id, displayName: 'Link' }); 193 expect(signed.set).toMatch(/HttpOnly/); 194 expect((await accounts().credential(passkey.id))!.signCount).toBe(1); 195 expect((await b.call('me')).body.user.displayName).toBe('Link'); 196 }); 197 198 it('refuses a replayed sign-in, an unknown passkey, and a mismatched user handle', async () => { 199 const { passkey } = await register(); 200 const b = browser(); 201 const login = await b.call('login/options', {}); 202 const response = await passkey.get(login.body, ORIGIN); 203 expect((await b.call('login/verify', response)).res.status).toBe(200); 204 expect((await browser().call('login/verify', response)).res.status).toBe(400); 205 206 const stranger = await authenticator(); 207 const l2 = await b.call('login/options', {}); 208 await stranger.create({ challenge: 'x', rp: { id: RP_ID } }, ORIGIN); 209 expect((await b.call('login/verify', await stranger.get(l2.body, ORIGIN))).body.error).toMatch(/not registered/); 210 211 const l3 = await b.call('login/options', {}); 212 const other = await passkey.get(l3.body, ORIGIN, { handle: 'AAAAAAAAAAAAAAAAAAAAAA' }); 213 expect((await b.call('login/verify', other)).body.error).toMatch(/another account/); 214 }); 215 216 it('refuses a signature from another origin or without user verification', async () => { 217 const { passkey } = await register(); 218 for (const [origin, flags] of [ 219 ['https://evil.example', undefined], 220 [ORIGIN, FLAGS.UP], 221 ] as const) { 222 const b = browser(); 223 const login = await b.call('login/options', {}); 224 const res = await b.call('login/verify', await passkey.get(login.body, origin, { flags })); 225 expect(res.res.status).toBe(400); 226 expect(res.set).toBeNull(); 227 } 228 }); 229 230 it('signs out: the session is deleted and the cookie cleared', async () => { 231 const { b } = await register(); 232 const token = b.cookie()!; 233 const out = await b.call('logout', {}); 234 expect(out.res.status).toBe(204); 235 expect(out.set).toMatch(/Max-Age=0/); 236 expect(b.cookie()).toBeNull(); 237 b.setCookie(token); 238 expect((await b.call('me')).body.user).toBeNull(); 239 expect((await db.prepare('SELECT count(*) AS n FROM sessions').first<{ n: number }>())!.n).toBe(0); 240 }); 241 242 it('ends a session when it expires', async () => { 243 const { b } = await register(); 244 clock += SESSION_TTL_MS; 245 expect((await b.call('me')).body.user).toBeNull(); 246 }); 247 248 it('ignores a cookie that is not one it issued', async () => { 249 const b = browser(); 250 b.setCookie('not-a-token'); 251 expect((await b.call('me')).body.user).toBeNull(); 252 b.setCookie('A'.repeat(43)); 253 expect((await b.call('me')).body.user).toBeNull(); 254 }); 255}); 256 257describe('displayName', () => { 258 it('trims and folds spaces, and refuses empty, long or control-character names', () => { 259 expect(displayName(' Hero of Time ')).toBe('Hero of Time'); 260 expect(displayName('Ná')).toBe('Ná'); 261 expect(displayName('')).toBeNull(); 262 expect(displayName(7)).toBeNull(); 263 expect(displayName('a\u0000b')).toBeNull(); 264 expect(displayName('ab')).toBeNull(); 265 expect(displayName('ł'.repeat(40))).toBe('ł'.repeat(40)); 266 expect(displayName('ł'.repeat(41))).toBeNull(); 267 }); 268}); 269 270describe('the accounts schema', () => { 271 it('holds only what it says it holds', async () => { 272 const store = accounts(); 273 const credential = { id: 'c'.repeat(16), publicKey: new Uint8Array([1]), signCount: 0, transports: [] }; 274 await expect(store.createUser({ id: 'short', displayName: 'x' }, credential, 't', 1)).rejects.toThrow(/CHECK/); 275 await expect(store.createUser({ id: 'u'.repeat(22), displayName: ' x' }, credential, 't', 1)).rejects.toThrow( 276 /CHECK/, 277 ); 278 await expect( 279 store.putChallenge({ challenge: 'c'.repeat(43), purpose: 'login', userId: 'u'.repeat(22), displayName: null }, 1), 280 ).rejects.toThrow(/CHECK/); 281 await expect( 282 store.putChallenge({ challenge: 'c'.repeat(43), purpose: 'register', userId: 'u'.repeat(22), displayName: null }, 1), 283 ).rejects.toThrow(/CHECK/); 284 // a credential needs its user 285 await expect(store.addCredential({ ...credential, userId: 'u'.repeat(22) })).rejects.toThrow(/FOREIGN KEY/); 286 }); 287});