SPB Git

Repository Public

auth.test.ts 17.6 KB 323 lines typescript

1import fs from 'node:fs';2import path from 'node:path';3import { afterAll, beforeAll, describe, expect, it } from 'vitest';4import { migrate } from '@aos/kernel';5import { Client, PASSWORD, codeFor, createHarness, login, setupAdmin, type Harness } from '../../testkit/harness.js';67let h: Harness;8let admin: Client;9let secret: string;1011beforeAll(async () => {12  h = await createHarness();13});14afterAll(async () => {15  await h.close();16});1718describe('migrations', () => {19  it('are idempotent', async () => {20    expect((await migrate(h.databaseUrl)).applied).toEqual([]);21    const rows = await h.server.ctx.db.q<{ name: string }>('SELECT name FROM schema_migrations ORDER BY name');22    expect(rows.map((r) => r.name)).toEqual(expect.arrayContaining(['001_auth.sql', '010_kernel.sql']));23  });24});2526describe('first-run setup', () => {27  it('writes a 0600 token file and reports setupRequired', async () => {28    const file = path.join(h.dataDir, 'setup-token');29    expect(fs.statSync(file).mode & 0o777).toBe(0o600);30    expect(h.setupToken()).toMatch(/^[A-Za-z0-9_-]{43}$/);31    expect(h.logs.join('')).not.toContain(h.setupToken());32    const s = await new Client(h).sync();33    expect(s).toMatchObject({ setupRequired: true, authenticated: false, user: null, csrfToken: null });34  });35  it('rejects a wrong token (and the Origin / content-type rules apply before auth)', async () => {36    const c = new Client(h);37    const bad = await c.post('/auth/setup', { setupToken: 'x'.repeat(43), username: 'admin', password: PASSWORD });38    expect(bad.statusCode).toBe(401);39    expect(bad.json().error.code).toBe('invalid_setup_token');40    c.origin = 'https://evil.example';41    expect((await c.post('/auth/setup', { setupToken: h.setupToken(), username: 'admin', password: PASSWORD })).statusCode).toBe(403);42    c.origin = null;43    expect((await c.post('/auth/setup', { setupToken: h.setupToken(), username: 'admin', password: PASSWORD })).statusCode).toBe(403);44    c.origin = 'http://localhost:8640';45    const form = await c.req('POST', '/auth/setup', undefined, { raw: 'setupToken=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });46    expect(form.statusCode).toBe(403);47    expect((await c.post('/auth/setup', { setupToken: h.setupToken(), username: 'admin', password: 'short' })).statusCode).toBe(400);48  });49  it('creates the administrator with a restricted session, then is permanently 404', async () => {50    const token = h.setupToken();51    ({ client: admin, secret } = await setupAdmin(h));52    expect(fs.existsSync(path.join(h.dataDir, 'setup-token'))).toBe(false);53    const again = await new Client(h).post('/auth/setup', { setupToken: token, username: 'other', password: PASSWORD });54    expect(again.statusCode).toBe(404);55    expect((await h.server.ctx.db.q('SELECT 1 FROM users')).length).toBe(1);56    const stored = await h.server.ctx.db.one<{ password_hash: string; totp_secret_enc: string }>('SELECT password_hash, totp_secret_enc FROM users');57    expect(stored?.password_hash).not.toContain(PASSWORD);58    expect(stored?.totp_secret_enc).not.toContain(secret);59    expect(stored?.totp_secret_enc.startsWith('v1.')).toBe(true);60  });61  it('the admin is fully authenticated after enrolment', async () => {62    const s = await admin.sync();63    expect(s).toMatchObject({ setupRequired: false, authenticated: true, methods: { password: true, totp: true, passkeys: 0 } });64    expect((await admin.get('/projects')).statusCode).toBe(200);65  });66});6768describe('restricted session (password only)', () => {69  it('a fresh admin without a second factor may only use enrolment endpoints', async () => {70    const h2 = await createHarness();71    try {72      const c = new Client(h2);73      const res = await c.post('/auth/setup', { setupToken: h2.setupToken(), username: 'root', password: PASSWORD });74      expect(res.json()).toEqual({ ok: true, next: 'enroll_second_factor' });75      const s = await c.sync();76      expect(s.authenticated).toBe(false);77      expect(s.user).toMatchObject({ username: 'root' });78      for (const r of h2.server.ctx.routes.filter((x) => x.auth === 'full' && x.method !== 'HEAD')) {79        const url = r.url.replace(/:[A-Za-z]+/g, '00000000-0000-4000-8000-000000000000');80        const out = await c.req(r.method, url);81        expect([r.method, r.url, out.statusCode]).toEqual([r.method, r.url, 403]);82        expect(out.json().error.code).toBe('second_factor_required');83      }84      // re-login without factors is again an enrolment session85      const c2 = new Client(h2);86      expect((await c2.post('/auth/login', { username: 'root', password: PASSWORD })).json().next).toBe('enroll_second_factor');87      expect((await c.post('/auth/totp/enroll/start')).statusCode).toBe(200);88      expect((await c.post('/auth/passkey/register/options')).statusCode).toBe(200);89      expect((await c.post('/auth/logout')).statusCode).toBe(200);90      expect((await c.get('/auth/status')).json().user).toBeNull();91    } finally {92      await h2.close();93    }94  });95  it('with a second factor enrolled, the password alone cannot enrol another factor or reach the API', async () => {96    const c = new Client(h);97    const res = await c.post('/auth/login', { username: 'admin', password: PASSWORD });98    expect(res.json()).toEqual({ ok: true, next: 'totp' });99    await c.sync();100    expect((await c.post('/auth/totp/enroll/start')).statusCode).toBe(403);101    expect((await c.post('/auth/passkey/register/options')).statusCode).toBe(403);102    expect((await c.get('/missions')).statusCode).toBe(403);103    expect((await c.get('/auth/devices')).statusCode).toBe(403);104    expect((await c.post('/auth/password', { currentPassword: PASSWORD, newPassword: 'x'.repeat(20) })).statusCode).toBe(403);105  });106});107108describe('login', () => {109  it('password then TOTP; the session token is rotated on upgrade; codes cannot be replayed', async () => {110    h.clock.offsetMs += 30_000;111    const c = new Client(h);112    expect((await c.post('/auth/login', { username: 'ADMIN', password: PASSWORD })).json().next).toBe('totp');113    const before = c.cookies.aos_sid;114    await c.sync();115    const csrfBefore = c.csrf;116    const code = codeFor(secret, h);117    const ok = await c.post('/auth/totp', { code });118    expect(ok.json()).toEqual({ ok: true, next: 'done' });119    expect(c.cookies.aos_sid).toBeDefined();120    expect(c.cookies.aos_sid).not.toBe(before);121    await c.sync();122    expect(c.csrf).not.toBe(csrfBefore);123    expect((await c.get('/missions')).statusCode).toBe(200);124    // the pre-upgrade token is dead125    const old = new Client(h);126    old.cookies.aos_sid = before as string;127    expect((await old.get('/missions')).statusCode).toBe(401);128    // replay of the same code on a new login129    const c2 = new Client(h);130    const replay = await c2.post('/auth/login', { username: 'admin', password: PASSWORD, totp: code });131    expect(replay.statusCode).toBe(401);132  });133  it('wrong password, unknown user and wrong TOTP all answer 401 invalid_credentials', async () => {134    const c = new Client(h);135    const a = await c.post('/auth/login', { username: 'admin', password: 'wrong password!!' });136    const b = await c.post('/auth/login', { username: 'nobody', password: 'wrong password!!' });137    expect([a.statusCode, b.statusCode]).toEqual([401, 401]);138    expect(a.json()).toEqual(b.json());139    expect(a.cookies).toEqual([]);140    await h.server.ctx.db.q('DELETE FROM login_attempts');141    await c.post('/auth/login', { username: 'admin', password: PASSWORD });142    await c.sync();143    expect((await c.post('/auth/totp', { code: '000000' })).statusCode).toBe(401);144    expect((await c.get('/missions')).statusCode).toBe(403);145    await h.server.ctx.db.q('DELETE FROM login_attempts');146  });147  it('audits auth events without secrets', async () => {148    const rows = await h.server.ctx.db.q<{ action: string; detail: unknown; ok: boolean }>('SELECT action, detail, ok FROM audit_events');149    const actions = new Set(rows.map((r) => r.action));150    for (const a of ['auth.setup', 'auth.login', 'auth.totp', 'auth.totp.enroll.finish']) expect(actions.has(a)).toBe(true);151    expect(rows.some((r) => r.action === 'auth.login' && !r.ok)).toBe(true);152    const dump = JSON.stringify(rows);153    expect(dump).not.toContain(PASSWORD);154    expect(dump).not.toContain(secret);155  });156});157158describe('CSRF and Origin', () => {159  it('mutations need the session CSRF token and the exact Origin', async () => {160    const body = { ...(await admin.get('/settings')).json() };161    const put = (headers: Record<string, string>): Promise<number> => admin.req('PUT', '/settings', body, { headers }).then((r) => r.statusCode);162    expect(await put({})).toBe(200);163    expect(await put({ 'x-csrf-token': '' })).toBe(403);164    expect(await put({ 'x-csrf-token': 'wrong' })).toBe(403);165    expect(await put({ origin: 'https://evil.example' })).toBe(403);166    expect(await put({ origin: 'http://localhost:8621' })).toBe(403);167    expect(await put({ origin: 'null' })).toBe(403);168    const noOrigin = new Client(h);169    noOrigin.cookies = { ...admin.cookies };170    noOrigin.csrf = admin.csrf;171    noOrigin.origin = null;172    expect((await noOrigin.req('PUT', '/settings', body)).statusCode).toBe(403);173    // another session's CSRF token is not accepted174    const other = await login(h, secret);175    expect(await put({ 'x-csrf-token': other.csrf as string })).toBe(403);176  });177});178179describe('sessions: expiry, idle, revocation', () => {180  const sid = async (c: Client): Promise<string> => {181    const d = (await c.get('/auth/devices')).json<{ id: string; kind: string; current: boolean }[]>();182    return d.find((x) => x.kind === 'session' && x.current)?.id as string;183  };184  it('absolute expiry', async () => {185    const c = await login(h, secret);186    const id = await sid(c);187    await h.server.ctx.db.q("UPDATE auth_sessions SET expires_at = now() - interval '1 second' WHERE id = $1", [id]);188    expect((await c.get('/missions')).statusCode).toBe(401);189  });190  it('idle timeout (12 h) and sliding refresh', async () => {191    const c = await login(h, secret);192    const id = await sid(c);193    await h.server.ctx.db.q("UPDATE auth_sessions SET last_seen_at = now() - interval '11 hours' WHERE id = $1", [id]);194    expect((await c.get('/missions')).statusCode).toBe(200);195    const row = await h.server.ctx.db.one<{ age: number }>('SELECT extract(epoch FROM now() - last_seen_at)::float8 AS age FROM auth_sessions WHERE id = $1', [id]);196    expect(row?.age).toBeLessThan(5); // slid forward197    await h.server.ctx.db.q("UPDATE auth_sessions SET last_seen_at = now() - interval '12 hours 1 minute' WHERE id = $1", [id]);198    expect((await c.get('/missions')).statusCode).toBe(401);199  });200  it('logout revokes; only the sha256 of the token is stored', async () => {201    const c = await login(h, secret);202    const token = c.cookies.aos_sid as string;203    const hashes = await h.server.ctx.db.q<{ token_hash: string }>('SELECT token_hash FROM auth_sessions');204    expect(hashes.some((r) => r.token_hash === token)).toBe(false);205    expect((await c.post('/auth/logout')).statusCode).toBe(200);206    const ghost = new Client(h);207    ghost.cookies.aos_sid = token;208    expect((await ghost.get('/missions')).statusCode).toBe(401);209  });210  it('devices: list, revoke another session, cannot remove the last second factor', async () => {211    const a = await login(h, secret);212    const b = await login(h, secret);213    const idB = await sid(b);214    expect((await a.req('DELETE', `/auth/devices/${idB}`)).statusCode).toBe(200);215    expect((await b.get('/missions')).statusCode).toBe(401);216    expect((await a.req('DELETE', '/auth/totp')).statusCode).toBe(409);217    expect((await a.req('DELETE', '/auth/devices/00000000-0000-4000-8000-000000000000')).statusCode).toBe(404);218  });219  it('password change revokes every other session and needs the current password', async () => {220    const a = await login(h, secret);221    const b = await login(h, secret);222    expect((await a.post('/auth/password', { currentPassword: 'not the password', newPassword: 'a brand new passphrase' })).statusCode).toBe(401);223    expect((await a.post('/auth/password', { currentPassword: PASSWORD, newPassword: 'short' })).statusCode).toBe(400);224    expect((await a.post('/auth/password', { currentPassword: PASSWORD, newPassword: 'a brand new passphrase' })).statusCode).toBe(200);225    expect((await b.get('/missions')).statusCode).toBe(401);226    expect((await a.get('/missions')).statusCode).toBe(200);227    expect((await new Client(h).post('/auth/login', { username: 'admin', password: PASSWORD })).statusCode).toBe(401);228    expect((await a.post('/auth/password', { currentPassword: 'a brand new passphrase', newPassword: PASSWORD })).statusCode).toBe(200);229    await h.server.ctx.db.q('DELETE FROM login_attempts');230  });231});232233describe('throttling (DB-backed)', () => {234  it('locks a username after 5 failures with a progressive retryAfter, even for the right password', async () => {235    const db = h.server.ctx.db;236    await db.q('DELETE FROM login_attempts');237    const c = new Client(h);238    for (let i = 0; i < 5; i++) expect((await c.post('/auth/login', { username: 'admin', password: 'bad bad bad bad' })).statusCode).toBe(401);239    const locked = await c.post('/auth/login', { username: 'admin', password: PASSWORD });240    expect(locked.statusCode).toBe(429);241    const first = locked.json();242    expect(first.error.code).toBe('locked');243    expect(first.retryAfterSec).toBeGreaterThan(50);244    expect(first.retryAfterSec).toBeLessThanOrEqual(60);245    expect(locked.headers['retry-after']).toBe(String(first.retryAfterSec));246    expect(locked.cookies).toEqual([]);247    // the lock elapses, one more failure doubles it248    await db.q("UPDATE login_attempts SET at = at - interval '61 seconds'");249    expect((await c.post('/auth/login', { username: 'admin', password: 'bad bad bad bad' })).statusCode).toBe(401);250    const second = (await c.post('/auth/login', { username: 'admin', password: PASSWORD })).json();251    expect(second.retryAfterSec).toBeGreaterThan(100);252    // it survives a "restart": the state is in the database, not in memory253    expect((await db.one<{ n: number }>("SELECT count(*)::int AS n FROM login_attempts WHERE key = 'user:admin' AND NOT ok"))?.n).toBe(6);254    await db.q('DELETE FROM login_attempts');255  });256  it('locks an IP after 10 failures across usernames, and the global breaker at 100', async () => {257    const db = h.server.ctx.db;258    const c = new Client(h);259    for (let i = 0; i < 10; i++) await c.post('/auth/login', { username: `user${i}`, password: 'bad bad bad bad' });260    const res = await c.post('/auth/login', { username: 'someone-else', password: 'bad bad bad bad' });261    expect(res.statusCode).toBe(429);262    await db.q('DELETE FROM login_attempts');263    await db.q("INSERT INTO login_attempts (key, ok) SELECT 'global', false FROM generate_series(1, 100)");264    expect((await c.post('/auth/login', { username: 'admin', password: PASSWORD })).statusCode).toBe(429);265    await db.q('DELETE FROM login_attempts');266  });267  it('TOTP guesses are throttled and a correct password does not reset the counter', async () => {268    const db = h.server.ctx.db;269    for (let i = 0; i < 5; i++) {270      const c = new Client(h);271      expect((await c.post('/auth/login', { username: 'admin', password: PASSWORD })).statusCode).toBe(200);272      await c.sync();273      expect((await c.post('/auth/totp', { code: '000000' })).statusCode).toBe(401);274    }275    expect((await new Client(h).post('/auth/login', { username: 'admin', password: PASSWORD })).statusCode).toBe(429);276    await db.q('DELETE FROM login_attempts');277  });278  it('invalid setup tokens are throttled per IP', async () => {279    const h2 = await createHarness();280    try {281      const c = new Client(h2);282      for (let i = 0; i < 10; i++) expect((await c.post('/auth/setup', { setupToken: 'y'.repeat(43), username: 'admin', password: PASSWORD })).statusCode).toBe(401);283      const res = await c.post('/auth/setup', { setupToken: h2.setupToken(), username: 'admin', password: PASSWORD });284      expect(res.statusCode).toBe(429);285      expect((await h2.server.ctx.db.q('SELECT 1 FROM users')).length).toBe(0);286    } finally {287      await h2.close();288    }289  });290});291292describe('client IP', () => {293  it('trusts X-Forwarded-For only from a trusted proxy peer', async () => {294    const db = h.server.ctx.db;295    const attempt = async (remoteAddress: string): Promise<string> => {296      await db.q('DELETE FROM audit_events');297      await h.server.app.inject({298        method: 'POST', url: '/api/v1/auth/login', remoteAddress,299        headers: { origin: 'http://localhost:8640', 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.9' },300        payload: JSON.stringify({ username: 'ghost', password: 'bad bad bad bad' }),301      });302      return (await db.one<{ ip: string }>("SELECT host(ip) AS ip FROM audit_events WHERE action = 'auth.login'"))?.ip as string;303    };304    expect(await attempt('127.0.0.1')).toBe('203.0.113.9');305    expect(await attempt('10.67.0.1')).toBe('203.0.113.9');306    expect(await attempt('198.51.100.7')).toBe('198.51.100.7');307    await db.q('DELETE FROM login_attempts');308  });309});310311describe('logging', () => {312  it('never logs passwords, codes, cookies or CSRF tokens', async () => {313    const c = await login(h, secret);314    h.server.app.log.info({ req: { headers: { cookie: 'aos_sid=SECRETCOOKIE', authorization: 'Bearer SECRETAUTH', 'x-csrf-token': 'SECRETCSRF' } }, body: { password: 'SECRETPW', totp: '123456', code: '654321', setupToken: 'SECRETSETUP' }, password: 'TOPLEVELPW', command: 'SECRETCMD' }, 'probe');315    await new Promise((r) => setTimeout(r, 50));316    const all = h.logs.join('');317    expect(all).toContain('probe');318    for (const s of ['SECRETCOOKIE', 'SECRETAUTH', 'SECRETCSRF', 'SECRETPW', 'SECRETSETUP', 'TOPLEVELPW', 'SECRETCMD', '654321', PASSWORD, secret, c.cookies.aos_sid as string, c.csrf as string]) {319      expect(all.includes(s)).toBe(false);320    }321  });322});323
Files main
Menu

Theme