Repository Public
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