SPB Git

Repository Public

auth.test.ts 12.4 KB 166 lines typescript

1import { afterAll, describe, expect, it } from 'vitest';2import { createInvite } from '../src/auth.ts';3import { config } from '../src/config.ts';4import { totpAt } from '../src/crypto.ts';5import { sql } from '../src/db.ts';6import { SoftAuthenticator } from './authenticator.ts';7import { anon, Client, closeAll, server, signup } from './harness.ts';89afterAll(closeAll);10const base32decode = (s: string) => { const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = ''; for (const c of s) bits += A.indexOf(c).toString(2).padStart(5, '0'); return Buffer.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2))); };1112describe('accounts and passkeys', () => {13  it('registers through an invitation, once', async () => {14    await server();15    const inv = await createInvite(sql, { email: 'once@example.test', role: 'user' });16    const a = new Client(new SoftAuthenticator(config.origin, config.rpId));17    const o = await a.post('/api/v1/auth/register/options', { invite: inv.token, displayName: 'Once' });18    const v = await a.post('/api/v1/auth/register/verify', { invite: inv.token, challengeId: o.body.challengeId, response: a.auth!.create(o.body.options) });19    expect(v.status).toBe(200);20    expect(v.body.recoveryCodes).toHaveLength(10);21    expect((await a.get('/api/v1/auth/state')).body).toMatchObject({ authenticated: true, user: { email: 'once@example.test', role: 'user' } });22    // the invitation is spent23    const b = new Client(new SoftAuthenticator(config.origin, config.rpId));24    expect((await b.post('/api/v1/auth/register/options', { invite: inv.token, displayName: 'Twice' })).status).toBe(403);25    expect((await b.post('/api/v1/auth/invite', { invite: 'x'.repeat(43) })).status).toBe(403);26  });27  it('signs in with a passkey and refuses a forged origin, a replayed challenge and an unknown credential', async () => {28    const { client } = await signup();29    const fresh = new Client(client.auth);30    expect((await fresh.get('/api/v1/folders/root')).status).toBe(401);31    expect((await fresh.login()).status).toBe(200);32    expect((await fresh.get('/api/v1/folders/root')).status).toBe(200);33    // phishing origin34    const p = new Client(client.auth);35    const o = await p.post('/api/v1/auth/login/options');36    expect((await p.post('/api/v1/auth/login/verify', { challengeId: o.body.challengeId, response: client.auth!.get(o.body.options, undefined, { origin: 'https://evil.example' }) })).status).toBe(401);37    // a challenge is single-use38    const o2 = await p.post('/api/v1/auth/login/options');39    const assertion = client.auth!.get(o2.body.options);40    expect((await p.post('/api/v1/auth/login/verify', { challengeId: o2.body.challengeId, response: assertion })).status).toBe(200);41    expect((await new Client().post('/api/v1/auth/login/verify', { challengeId: o2.body.challengeId, response: assertion })).status).toBe(401);42    // a credential the server never saw43    const stranger = new SoftAuthenticator(config.origin, config.rpId);44    stranger.create({ challenge: 'x', user: { id: 'x' } });45    const o3 = await p.post('/api/v1/auth/login/options');46    expect((await new Client().post('/api/v1/auth/login/verify', { challengeId: o3.body.challengeId, response: stranger.get(o3.body.options) })).status).toBe(401);47  });48  it('sets hardened session cookies and requires the CSRF header and origin', async () => {49    const { client } = await signup();50    const fresh = new Client(client.auth);51    const o = await fresh.post('/api/v1/auth/login/options');52    const app = await server();53    const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login/verify', headers: { 'x-spd-csrf': '1', 'content-type': 'application/json' }, payload: JSON.stringify({ challengeId: o.body.challengeId, response: client.auth!.get(o.body.options) }) });54    const setCookie = String(res.headers['set-cookie']);55    expect(setCookie).toMatch(/HttpOnly/); expect(setCookie).toMatch(/SameSite=Lax/); expect(setCookie).toMatch(/Path=\//);56    const token = /spd=([^;]+)/.exec(setCookie)![1]!;57    const [row] = await sql`select 1 from sessions where token_hash = ${token}`;58    expect(row).toBeUndefined(); // only a hash of the token is stored59    const cookie = `spd=${token}`;60    expect((await app.inject({ method: 'POST', url: '/api/v1/folders', headers: { cookie, 'content-type': 'application/json' }, payload: '{"parentId":"root","name":"x"}' })).statusCode).toBe(403);61    expect((await app.inject({ method: 'POST', url: '/api/v1/folders', headers: { cookie, 'x-spd-csrf': '1', origin: 'https://evil.example', 'content-type': 'application/json' }, payload: '{"parentId":"root","name":"x"}' })).statusCode).toBe(403);62    expect((await app.inject({ method: 'POST', url: '/api/v1/folders', headers: { cookie, 'x-spd-csrf': '1', origin: config.origin, 'content-type': 'application/json' }, payload: '{"parentId":"root","name":"x"}' })).statusCode).toBe(200);63  });64  it('lists and revokes sessions; logout kills the cookie server-side', async () => {65    const { client } = await signup();66    const other = new Client(client.auth);67    await other.login();68    const list = await client.get('/api/v1/account/sessions');69    expect(list.body).toHaveLength(2);70    const victim = list.body.find((s: any) => !s.current);71    expect((await client.del(`/api/v1/account/sessions/${victim.id}`)).status).toBe(200);72    expect((await other.get('/api/v1/folders/root')).status).toBe(401);73    const saved = new Map(client.cookies);74    await client.post('/api/v1/auth/logout');75    const ghost = new Client(); ghost.cookies = saved;76    expect((await ghost.get('/api/v1/folders/root')).status).toBe(401);77  });78  it('rotates the session token daily and keeps the previous one valid only briefly', async () => {79    const { client } = await signup();80    const before = client.cookies.get('spd')!;81    await sql`update sessions set rotated_at = now() - interval '25 hours' where user_id = (select id from users order by created_at desc limit 1)`;82    expect((await client.get('/api/v1/folders/root')).status).toBe(200);83    const after = client.cookies.get('spd')!;84    expect(after).not.toBe(before);85    const old = new Client(); old.cookies.set('spd', before);86    expect((await old.get('/api/v1/folders/root')).status).toBe(200); // grace for requests in flight87    await sql`update sessions set prev_valid_until = now() - interval '1 second' where prev_token_hash is not null`;88    expect((await old.get('/api/v1/folders/root')).status).toBe(401);89    expect((await client.get('/api/v1/folders/root')).status).toBe(200);90  });91  it('requires a fresh passkey assertion for sensitive operations and protects the last passkey', async () => {92    const { client } = await signup();93    await sql`update sessions set reauth_at = now() - interval '10 minutes'`;94    expect((await client.post('/api/v1/account/recovery-codes')).status).toBe(401);95    expect((await client.post('/api/v1/account/passkeys/options')).body.error).toBe('reauth_required');96    await client.reauth();97    const o = await client.post('/api/v1/account/passkeys/options');98    expect(o.status).toBe(200);99    const second = new SoftAuthenticator(config.origin, config.rpId);100    expect((await client.post('/api/v1/account/passkeys/verify', { challengeId: o.body.challengeId, response: second.create(o.body.options), label: 'Clé USB' })).status).toBe(200);101    const keys = (await client.get('/api/v1/account/passkeys')).body;102    expect(keys).toHaveLength(2);103    expect((await new Client(second).login()).status).toBe(200); // the second key signs in104    expect((await client.del(`/api/v1/account/passkeys/${keys[1].id}`)).status).toBe(200);105    expect((await new Client(second).login()).status).toBe(401); // and is dead once revoked106    expect((await client.del(`/api/v1/account/passkeys/${keys[0].id}`)).body.error).toBe('last_passkey');107  });108  it('recovers an account with a one-time code into a session that can only enrol a passkey', async () => {109    const { client, email, recoveryCodes } = await signup();110    const r = anon();111    expect((await r.post('/api/v1/auth/recovery', { email, code: 'AAAA-AAAA-AAAA-AAAA' })).status).toBe(401);112    expect((await r.post('/api/v1/auth/recovery', { email: 'nobody@example.test', code: recoveryCodes[0] })).status).toBe(401);113    expect((await r.post('/api/v1/auth/recovery', { email, code: recoveryCodes[0] })).status).toBe(200);114    expect((await r.get('/api/v1/auth/state')).body.recovery).toBe(true);115    expect((await r.get('/api/v1/folders/root')).body.error).toBe('recovery_session'); // no file access116    expect((await r.get('/api/v1/account/sessions')).status).toBe(403);117    const o = await r.post('/api/v1/account/passkeys/options');118    const key = new SoftAuthenticator(config.origin, config.rpId);119    expect((await r.post('/api/v1/account/passkeys/verify', { challengeId: o.body.challengeId, response: key.create(o.body.options) })).status).toBe(200);120    expect((await r.get('/api/v1/folders/root')).status).toBe(200); // upgraded to a normal session121    expect((await anon().post('/api/v1/auth/recovery', { email, code: recoveryCodes[0] })).status).toBe(401); // a code works once122    const notes = (await client.get('/api/v1/notifications')).body.map((n: any) => n.kind);123    expect(notes).toContain('security.recovery_used'); expect(notes).toContain('security.passkey_added');124  });125  it('locks recovery after repeated failures, whatever address they come from', async () => {126    const { email, recoveryCodes } = await signup();127    for (let i = 0; i < 10; i++) { const c = anon(); c.ip = `10.9.${i}.1`; expect((await c.post('/api/v1/auth/recovery', { email, code: `WRONG-CODE-${i}000` })).status).toBe(401); }128    const c = anon(); c.ip = '10.9.99.1';129    expect((await c.post('/api/v1/auth/recovery', { email, code: recoveryCodes[1] })).status).toBe(429);130  });131  it('demands the TOTP code with the recovery code once TOTP is enabled, and refuses a replayed code', async () => {132    const { client, email, recoveryCodes } = await signup();133    const setup = await client.post('/api/v1/account/totp/setup');134    expect(setup.body.uri).toContain('otpauth://totp/');135    const secret = base32decode(setup.body.secret);136    expect((await client.post('/api/v1/account/totp/enable', { challengeId: setup.body.challengeId, code: '000000' })).status).toBe(400);137    const code = totpAt(secret, Date.now() / 1000);138    expect((await client.post('/api/v1/account/totp/enable', { challengeId: setup.body.challengeId, code })).status).toBe(200);139    const [stored] = await sql<{ totp_secret: Buffer }[]>`select totp_secret from users where email = ${email}`;140    expect(Buffer.from(stored!.totp_secret).includes(secret)).toBe(false); // sealed at rest141    expect((await anon().post('/api/v1/auth/recovery', { email, code: recoveryCodes[0] })).status).toBe(401);142    expect((await anon().post('/api/v1/auth/recovery', { email, code: recoveryCodes[0], totp: code })).status).toBe(401); // step already used at enrolment143    expect((await anon().post('/api/v1/auth/recovery', { email, code: recoveryCodes[0], totp: totpAt(secret, Date.now() / 1000 + 30) })).status).toBe(200);144  });145  it('lets an administrator invite, limit and disable accounts; others cannot see the admin API', async () => {146    const admin = await signup({ role: 'admin' });147    const user = await signup();148    expect((await user.client.get('/api/v1/admin/users')).status).toBe(404);149    const inv = await admin.client.post('/api/v1/admin/invites', { email: 'guest@example.test' });150    expect(inv.body.url).toMatch(/\/invite\/[A-Za-z0-9_-]{40,}$/);151    const users = (await admin.client.get('/api/v1/admin/users')).body;152    expect(users.find((u: any) => u.id === user.userId)).toMatchObject({ role: 'user', disabled: false });153    expect((await admin.client.patch(`/api/v1/admin/users/${user.userId}`, { disabled: true })).status).toBe(200);154    expect((await user.client.get('/api/v1/folders/root')).status).toBe(401); // sessions die with the account155    expect((await new Client(user.client.auth).login()).status).toBe(401);156    expect((await admin.client.patch(`/api/v1/admin/users/${admin.userId}`, { disabled: true })).body.error).toBe('self_lockout');157  });158  it('audits sign-ins and failures without storing secrets', async () => {159    const { client } = await signup();160    const act = (await client.get('/api/v1/account/activity')).body.map((e: any) => e.action);161    expect(act).toContain('account.created');162    const rows = await sql<{ meta: unknown }[]>`select meta from audit_events`;163    expect(JSON.stringify(rows)).not.toMatch(/"challenge"|clientDataJSON|authenticatorData|attestationObject/);164  });165});166
Files main
Menu

Theme