SPB Git

Repository Public

webauthn.test.ts 12.5 KB 202 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { Client, ORIGIN, PASSWORD, codeFor, createHarness, type Harness } from '../../testkit/harness.js';3import { SoftAuthenticator } from '../../testkit/soft-authenticator.js';45const RP = { origin: ORIGIN, rpId: 'localhost' };6let h: Harness;7let admin: Client;8const key = new SoftAuthenticator();910type RegOptions = { challenge: string; user: { id: string; name: string }; rp: { id: string; name: string }; authenticatorSelection: Record<string, string>; attestation: string; excludeCredentials?: { id: string }[] };11type AuthOptions = { challenge: string; rpId: string; userVerification: string; allowCredentials?: { id: string }[] };12const regOptions = async (c: Client): Promise<RegOptions> => (await c.post('/auth/passkey/register/options')).json<RegOptions>();13const loginOptions = async (c: Client): Promise<AuthOptions> => {14  const res = await c.post('/auth/passkey/login/options');15  expect(res.statusCode, res.body).toBe(200);16  return res.json<AuthOptions>();17};18const clearThrottle = (): Promise<unknown> => h.server.ctx.db.q('DELETE FROM login_attempts');1920beforeAll(async () => {21  h = await createHarness();22  admin = new Client(h);23  await admin.post('/auth/setup', { setupToken: h.setupToken(), username: 'admin', password: PASSWORD });24  await admin.sync();25});26afterAll(async () => {27  await h.close();28});2930describe('passkey registration', () => {31  it('options: RP = origin hostname, user verification + resident key required, challenge kept server-side', async () => {32    const o = await regOptions(admin);33    expect(o.rp).toEqual({ id: 'localhost', name: 'Claude Remote' });34    expect(o.user.name).toBe('admin');35    expect(o.authenticatorSelection).toMatchObject({ userVerification: 'required', residentKey: 'required' });36    expect(o.attestation).toBe('none');37    expect(o.challenge).toMatch(/^[A-Za-z0-9_-]{40,}$/);38    const pending = await h.server.ctx.db.one<{ pending: { webauthnRegister: { challenge: string; exp: number } } }>('SELECT pending FROM auth_sessions WHERE revoked_at IS NULL');39    expect(pending?.pending.webauthnRegister.challenge).toBe(o.challenge);40    expect(pending?.pending.webauthnRegister.exp).toBeGreaterThan(Date.now());41    expect((await regOptions(admin)).challenge).not.toBe(o.challenge); // fresh every time42  });43  it('rejects a wrong origin, a wrong RP, a missing UV flag, a foreign challenge — each consumes the challenge', async () => {44    const attempt = async (make: (o: RegOptions) => Record<string, unknown>): Promise<number> => {45      const o = await regOptions(admin);46      return (await admin.post('/auth/passkey/register/verify', { credential: make(o) })).statusCode;47    };48    const k = new SoftAuthenticator();49    expect(await attempt((o) => k.register(o, { ...RP, origin: 'https://evil.example' }))).toBe(401);50    expect(await attempt((o) => k.register(o, { ...RP, rpId: 'evil.example' }))).toBe(401);51    expect(await attempt((o) => k.register(o, { ...RP, userVerified: false }))).toBe(401);52    expect(await attempt((o) => k.register({ ...o, challenge: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }, RP))).toBe(401);53    expect((await admin.post('/auth/passkey/register/verify', { credential: { id: 'x' } })).statusCode).toBe(400);54    expect((await h.server.ctx.db.q('SELECT 1 FROM devices')).length).toBe(0);55    expect((await admin.sync()).authenticated).toBe(false);56    await clearThrottle();57  });58  it('a valid attestation stores the credential and upgrades the enrolment session (token rotated)', async () => {59    const o = await regOptions(admin);60    const credential = key.register(o, RP);61    const before = admin.cookies.cr_sid;62    const res = await admin.post('/auth/passkey/register/verify', { credential, label: 'Laptop Touch ID' });63    expect(res.statusCode, res.body).toBe(200);64    expect(res.json()).toEqual({ ok: true, next: 'done' });65    expect(admin.cookies.cr_sid).not.toBe(before);66    const s = await admin.sync();67    expect(s).toMatchObject({ authenticated: true, methods: { passkeys: 1, totp: false } });68    const dev = await h.server.ctx.db.one<{ label: string; credential_id: string; counter: number; transports: string[] }>('SELECT label, credential_id, counter, transports FROM devices');69    expect(dev).toEqual({ label: 'Laptop Touch ID', credential_id: key.id, counter: 0, transports: ['internal'] });70    // the same response cannot be replayed: its challenge is gone71    const replay = await admin.post('/auth/passkey/register/verify', { credential });72    expect(replay.statusCode).toBe(409);73    // registering the same authenticator twice is refused; options now exclude it74    const o2 = await regOptions(admin);75    expect(o2.excludeCredentials?.map((c) => c.id)).toEqual([key.id]);76    expect((await admin.post('/auth/passkey/register/verify', { credential: key.register(o2, RP) })).statusCode).toBe(409);77    expect((await admin.get('/auth/devices')).json<{ kind: string; label: string }[]>().filter((d) => d.kind === 'passkey')).toHaveLength(1);78  });79  it('an expired registration challenge is refused', async () => {80    const o = await regOptions(admin);81    h.clock.offsetMs += 6 * 60_000;82    const res = await admin.post('/auth/passkey/register/verify', { credential: new SoftAuthenticator().register(o, RP) });83    h.clock.offsetMs -= 6 * 60_000;84    expect(res.statusCode).toBe(409);85  });86});8788describe('passkey login (user verification ⇒ full authentication on its own)', () => {89  it('options before any session: discoverable credentials, challenge bound to a pre-auth cookie', async () => {90    const c = new Client(h);91    const o = await loginOptions(c);92    expect(o).toMatchObject({ rpId: 'localhost', userVerification: 'required' });93    expect(o.allowCredentials ?? []).toEqual([]); // credential ids are not disclosed to anonymous callers94    expect(c.cookies.cr_pre).toMatch(/^[A-Za-z0-9_-]{43}$/);95    const rows = await h.server.ctx.db.q<{ token_hash: string; challenge: string }>('SELECT token_hash, challenge FROM preauth_challenges');96    expect(rows.some((r) => r.challenge === o.challenge)).toBe(true);97    expect(rows.some((r) => r.token_hash === c.cookies.cr_pre)).toBe(false); // only the hash is stored98  });99  it('a valid assertion signs in fully, stores the counter, and cannot be replayed', async () => {100    const c = new Client(h);101    const o = await loginOptions(c);102    const credential = key.authenticate(o, RP);103    const res = await c.post('/auth/passkey/login/verify', { credential });104    expect(res.statusCode, res.body).toBe(200);105    expect(res.json()).toEqual({ ok: true, next: 'done' });106    expect(c.cookies.cr_pre).toBeUndefined();107    expect((await c.sync()).authenticated).toBe(true);108    expect((await c.get('/sessions')).statusCode).toBe(200);109    expect((await h.server.ctx.db.one<{ counter: number }>('SELECT counter FROM devices'))?.counter).toBe(1);110    // replay with the consumed challenge (even presenting the old cookie value again)111    const again = new Client(h);112    again.cookies.cr_pre = 'x'.repeat(43);113    expect((await again.post('/auth/passkey/login/verify', { credential })).statusCode).toBe(409);114    expect((await new Client(h).post('/auth/passkey/login/verify', { credential })).statusCode).toBe(409);115  });116  it('a bare credential body (without the {credential} wrapper) is accepted too', async () => {117    const c = new Client(h);118    const res = await c.post('/auth/passkey/login/verify', key.authenticate(await loginOptions(c), RP));119    expect(res.statusCode, res.body).toBe(200);120  });121  it('rejects: counter not increased, missing UV, wrong origin, another cookie\'s challenge, unknown credential, bad signature', async () => {122    const fail = async (make: (o: AuthOptions, c: Client) => Record<string, unknown> | Promise<Record<string, unknown>>): Promise<number> => {123      const c = new Client(h);124      const o = await loginOptions(c);125      const res = await c.post('/auth/passkey/login/verify', { credential: await make(o, c) });126      expect(res.cookies.some((k) => k.name === 'cr_sid' && k.value)).toBe(false);127      return res.statusCode;128    };129    expect(await fail((o) => key.authenticate(o, { ...RP, keepCounter: true }))).toBe(401); // cloned authenticator / replayed counter130    expect(await fail((o) => key.authenticate(o, { ...RP, userVerified: false }))).toBe(401);131    expect(await fail((o) => key.authenticate(o, { ...RP, origin: 'https://evil.example' }))).toBe(401);132    await clearThrottle();133    expect(await fail(async () => key.authenticate(await loginOptions(new Client(h)), RP))).toBe(401); // challenge issued to someone else134    expect(await fail((o) => new SoftAuthenticator().authenticate(o, RP))).toBe(401); // unregistered credential135    expect(await fail((o) => {136      const cred = key.authenticate(o, RP) as { response: { signature: string } };137      cred.response.signature = Buffer.from('not a signature').toString('base64url');138      return cred as unknown as Record<string, unknown>;139    })).toBe(401);140    await clearThrottle();141    const stored = (await h.server.ctx.db.one<{ counter: number }>('SELECT counter FROM devices'))?.counter as number;142    key.signCount = stored; // resync the soft key with what the server accepted143  });144  it('an expired pre-auth challenge is refused', async () => {145    const c = new Client(h);146    const o = await loginOptions(c);147    await h.server.ctx.db.q("UPDATE preauth_challenges SET expires_at = now() - interval '1 second'");148    expect((await c.post('/auth/passkey/login/verify', { credential: key.authenticate(o, RP) })).statusCode).toBe(409);149    key.signCount--;150  });151  it('failed passkey verifications are throttled like passwords', async () => {152    await clearThrottle();153    const stranger = new SoftAuthenticator();154    for (let i = 0; i < 10; i++) {155      const c = new Client(h);156      const o = await loginOptions(c);157      expect((await c.post('/auth/passkey/login/verify', { credential: stranger.authenticate(o, RP) })).statusCode).toBe(401);158    }159    const c = new Client(h);160    expect((await c.post('/auth/passkey/login/options')).statusCode).toBe(429);161    await clearThrottle();162  });163});164165describe('passkey as the second step after the password', () => {166  it('LoginStep says passkey; the restricted session cannot enrol; the assertion upgrades it', async () => {167    const c = new Client(h);168    expect((await c.post('/auth/login', { username: 'admin', password: PASSWORD })).json()).toEqual({ ok: true, next: 'passkey' });169    await c.sync();170    expect((await c.post('/auth/passkey/register/options')).statusCode).toBe(403);171    expect((await c.get('/sessions')).statusCode).toBe(403);172    const o = await loginOptions(c);173    expect(o.allowCredentials?.map((x) => x.id)).toEqual([key.id]); // password already proven ⇒ ids may be listed174    expect(c.cookies.cr_pre).toBeUndefined(); // challenge lives in the session, not a pre-auth cookie175    const before = c.cookies.cr_sid;176    const res = await c.post('/auth/passkey/login/verify', { credential: key.authenticate(o, RP) });177    expect(res.statusCode, res.body).toBe(200);178    expect(c.cookies.cr_sid).not.toBe(before);179    expect((await c.sync()).authenticated).toBe(true);180  });181});182183describe('second-factor management', () => {184  it('the last second factor cannot be removed; with TOTP added the passkey can go', async () => {185    const passkey = (await admin.get('/auth/devices')).json<{ id: string; kind: string }[]>().find((d) => d.kind === 'passkey') as { id: string };186    expect((await admin.req('DELETE', `/auth/devices/${passkey.id}`)).statusCode).toBe(409);187    const start = (await admin.post('/auth/totp/enroll/start')).json<{ secret: string; otpauthUrl: string }>();188    expect(start.otpauthUrl).toContain('otpauth://totp/Claude%20Remote:admin?secret=');189    expect((await admin.post('/auth/totp/enroll/finish', { code: '000000' })).statusCode).toBe(401);190    expect((await admin.post('/auth/totp/enroll/finish', { code: codeFor(start.secret, h) })).statusCode).toBe(200);191    expect((await admin.req('DELETE', '/auth/totp')).statusCode).toBe(200); // a passkey remains192    expect((await admin.req('DELETE', `/auth/devices/${passkey.id}`)).statusCode).toBe(409); // and is the last one again193    const again = (await admin.post('/auth/totp/enroll/start')).json<{ secret: string }>();194    h.clock.offsetMs += 30_000;195    expect((await admin.post('/auth/totp/enroll/finish', { code: codeFor(again.secret, h) })).statusCode).toBe(200);196    expect((await admin.req('DELETE', `/auth/devices/${passkey.id}`)).statusCode).toBe(200);197    expect((await admin.req('DELETE', '/auth/totp')).statusCode).toBe(409);198    expect((await admin.sync()).methods).toEqual({ password: true, passkeys: 0, totp: true });199    await clearThrottle();200  });201});202
Files main
Menu

Theme