jevstrudel.git / worker / src / auth.test.ts
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);
23
24// 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}
47
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]);
167
168    // signing in with the new one is the same account
169    const other = browser();
170    const login = await other.call('login/options', {});
171    const signed = await other.call('login/verify', await second.get(login.body, ORIGIN));
172    expect(signed.body.user.id).toBe(options.user.id);
173
174    // 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});
181
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('a​b')).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});