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