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      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]);
172
173    // signing in with the new one is the same account
174    const other = browser();
175    const login = await other.call('login/options', {});
176    const signed = await other.call('login/verify', await second.get(login.body, ORIGIN));
177    expect(signed.body.user.id).toBe(options.user.id);
178
179    // 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});
186
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('a​b')).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});