import { describe, it, expect, vi, afterEach } from 'vitest' import { processAuth } from '../../skills/data-portal/template/functions/api/auth' import { generatePasscode, hashPasscode, } from '../../skills/data-portal/template/functions/api/_lib/passcode.mjs' import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types' const SALT = '000102030405060708090a0b0c0d0e0f' // Real passcodes, because hashPasscode now refuses anything below the pinned // strength — 'right' and 'wrong' would throw before they ever reached a route. const RIGHT = generatePasscode() const WRONG = generatePasscode() const IP = '203.0.113.7' async function envWith(people: Array<{ ownerId: string; passcode: string }>, attemptCount = 0) { const rows = await Promise.all( people.map(async (p) => ({ ownerId: p.ownerId, salt: SALT, hash: await hashPasscode(p.passcode, SALT), })), ) const sessions: unknown[][] = [] const cleared: unknown[][] = [] const scopes: unknown[] = [] const env = { DB: { prepare(query: string) { let bound: unknown[] = [] const stmt = { bind(...v: unknown[]) { bound = v if (/auth_attempts/i.test(query)) scopes.push(v[0]) return stmt }, async run() { if (/INSERT INTO sessions/i.test(query)) sessions.push(bound) if (/DELETE FROM auth_attempts/i.test(query)) cleared.push(bound) return { meta: { changes: 1 } } }, async all() { return { results: [] } }, async first() { if (/FROM auth_attempts/i.test(query)) return { count: attemptCount + 1 } as T if (/FROM people/i.test(query)) { return (rows.find((r) => r.ownerId === bound[0]) ?? null) as T | null } return null as T | null }, } return stmt }, }, BUCKET: {} as never, } as unknown as PortalEnv return { env, sessions, cleared, scopes } } afterEach(() => vi.restoreAllMocks()) describe('processAuth', () => { it('accepts the right passcode and mints a session', async () => { const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const lines: string[] = [] const res = await processAuth( { ownerId: 'alice', passcode: RIGHT }, env, (l) => lines.push(l), () => 'sess-1', 1_000_000, IP, ) expect(res.status).toBe(200) expect(res.sessionId).toBe('sess-1') expect(sessions.length).toBe(1) expect(lines.join('\n')).toContain('op=auth owner="alice" result=ok') }) it('denies the wrong passcode and mints no session', async () => { const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const lines: string[] = [] const res = await processAuth( { ownerId: 'alice', passcode: WRONG }, env, (l) => lines.push(l), () => 'sess-1', 1_000_000, IP, ) expect(res.status).toBe(401) expect(sessions.length).toBe(0) expect(lines.join('\n')).toContain('result=denied') }) it('denies an unknown owner without revealing that it is unknown', async () => { const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const res = await processAuth( { ownerId: 'nobody', passcode: RIGHT }, env, () => {}, () => 'sess-1', 1_000_000, IP, ) expect(res.status).toBe(401) // Same payload as a wrong passcode: an enumeration oracle would otherwise // tell an attacker which people exist. expect(res.payload).toEqual({ ok: false, error: 'denied' }) }) it('rate-limits before comparing the passcode', async () => { const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }], 5) const lines: string[] = [] const res = await processAuth( { ownerId: 'alice', passcode: RIGHT }, env, (l) => lines.push(l), () => 'sess-1', 1_000_000, IP, ) expect(res.status).toBe(429) expect(sessions.length).toBe(0) expect(lines.join('\n')).toContain('result=rate-limited') }) it('never logs the passcode', async () => { const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const lines: string[] = [] await processAuth( { ownerId: 'alice', passcode: RIGHT }, env, (l) => lines.push(l), () => 'sess-1', 1_000_000, IP, ) expect(lines.join('\n')).not.toContain(RIGHT) }) it('rejects a malformed body', async () => { const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const res = await processAuth({} as never, env, () => {}, () => 'sess-1', 1_000_000, IP) expect(res.status).toBe(400) }) it('hashes for an unknown owner too, closing the timing oracle', async () => { // The identical DENIED payload is cosmetic on its own: an unknown owner // returning after one D1 read while a known one also hashes enumerates the // enrolled people outright. Both paths must pay. // // 1689 closed a ~5ms-vs-250ms gap here. 1708 replaced PBKDF2 with one // SHA-256, shrinking the gap to a measured 43.8us — still inside the band // remote timing attacks have been shown to resolve, so the dummy stays. A // call count is the right instrument: it is deterministic where a clock is // not, and it fails if someone drops the dummy without restoring the cost. // Both envs are built BEFORE the spy: envWith hashes its own fixtures, and // counting that setup work would let the two totals match without the route // hashing at all. const known = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const unknown = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const spy = vi.spyOn(crypto.subtle, 'digest') await processAuth({ ownerId: 'alice', passcode: WRONG }, known.env, () => {}, () => 's', 1_000_000, IP) const hashesForKnown = spy.mock.calls.length spy.mockClear() await processAuth({ ownerId: 'nobody', passcode: WRONG }, unknown.env, () => {}, () => 's', 1_000_000, IP) const hashesForUnknown = spy.mock.calls.length expect(hashesForKnown).toBeGreaterThan(0) expect(hashesForUnknown).toBe(hashesForKnown) }) it('scopes the rate limiter on owner AND ip, so one caller cannot lock out another', async () => { // Keyed on the owner alone the counter is a weapon: six unauthenticated // requests naming a person lock that person out of their own portal. const { env, scopes } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP) expect(scopes[0]).toBe(`alice|${IP}`) expect(String(scopes[0])).toContain(IP) }) it('clears the counter on a successful sign-in, so a person cannot self-lock', async () => { const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP) expect(cleared.length).toBe(1) expect(cleared[0][0]).toBe(`alice|${IP}`) }) it('does not clear the counter on a failed sign-in', async () => { const { env, cleared } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) await processAuth({ ownerId: 'alice', passcode: WRONG }, env, () => {}, () => 's', 1_000_000, IP) expect(cleared.length).toBe(0) }) it('binds the session to the passcode, so rotation revokes it', async () => { // sessions carries pcCheck; resolveSession joins people and requires it to // still match. Rotation changes the hash, so old sessions die by // construction rather than by an operator remembering a cleanup step. const { env, sessions } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) await processAuth({ ownerId: 'alice', passcode: RIGHT }, env, () => {}, () => 's', 1_000_000, IP) const expected = (await hashPasscode(RIGHT, SALT)).slice(0, 16) // bind order: sessionId, ownerId, pcCheck, expiresAt, createdAt expect(sessions[0][2]).toBe(expected) }) it('quotes the owner in log lines, so a crafted ownerId cannot forge one', async () => { const { env } = await envWith([{ ownerId: 'alice', passcode: RIGHT }]) const lines: string[] = [] const forged = 'x result=ok\n[data-portal] op=auth owner=alice result=ok' await processAuth({ ownerId: forged, passcode: WRONG }, env, (l) => lines.push(l), () => 's', 1_000_000, IP) // Exactly one line, and the injected newline must not have started another. expect(lines.length).toBe(1) expect(lines[0].split('\n').length).toBe(1) expect(lines[0]).toMatch(/result=denied/) }) })