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