import type { Context, MiddlewareHandler } from 'hono'; import { mkdirSync, statSync, renameSync } from 'node:fs'; import { appendFile } from 'node:fs/promises'; import { join } from 'node:path'; import { DATA_PATH } from '../data-path'; import { isKnownIdentity, knownIdentityCount } from './known-users'; // Every attempt to authenticate as somebody this platform has never heard of, written to a file. // // Officer has exactly one account and is reachable from the public internet, so a request naming any // other identity is never a mistyped login — it is somebody working through a list. That is worth a // durable record, and a durable record is the point: this is deliberately a FILE, not a table. It has // to survive the database being unreachable, has to be readable with `tail` from a terminal at 3am, // and has to be one `grep` away from an IP. JSON lines, appended, rotated once at 5 MB. // // WHAT IS NEVER WRITTEN: the password, or anything else credential-shaped. The attempt is the signal; // the secret adds nothing to it and would turn this file into a thing worth stealing. Bodies are // recorded as their KEY NAMES plus the identity fields — see safeBody(). Headers are recorded in full // except authorization/cookie, which become `[redacted:]`, because how long a bearer somebody // forged is occasionally interesting and its bytes never are. // // The middleware only ever observes. It cannot reject, cannot change a response, and swallows its own // failures — a full disk must not be able to lock the owner out of their own platform. const LOG_DIR = join(DATA_PATH, 'logs'); const LOG_PATH = join(LOG_DIR, 'auth-attempts.log'); const ROTATED_PATH = join(LOG_DIR, 'auth-attempts.1.log'); const MAX_BYTES = 5 * 1024 * 1024; /** Header names whose VALUE is a credential. Kept as a set of exact lowercase names, not a regex. */ const SECRET_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization', 'x-api-key']); /** Body keys safe to record verbatim. Everything else contributes its name only. */ const PUBLIC_BODY_KEYS = new Set(['email', 'username', 'name']); // Appends are chained rather than fired in parallel: two concurrent probes must not interleave halves // of two JSON lines into one unparseable one. let writeQueue: Promise = Promise.resolve(); let dirReady = false; function rotateIfNeeded() { try { if (statSync(LOG_PATH).size < MAX_BYTES) return; renameSync(LOG_PATH, ROTATED_PATH); } catch { /* no file yet, or the rename lost a race — either way the append below still works */ } } function writeRecord(record: Record) { writeQueue = writeQueue .then(async () => { if (!dirReady) { mkdirSync(LOG_DIR, { recursive: true }); dirReady = true; } rotateIfNeeded(); await appendFile(LOG_PATH, `${JSON.stringify(record)}\n`, 'utf8'); }) .catch((err) => { console.error('[auth-audit] could not write the attempt log:', String(err)); }); } const headerSnapshot = (ctx: Context): Record => { const out: Record = {}; for (const [name, value] of Object.entries(ctx.req.header())) { out[name] = SECRET_HEADERS.has(name.toLowerCase()) ? `[redacted:${value.length}]` : value; } return out; }; /** Key names always; values only for the fields that identify rather than authenticate. */ function safeBody(body: Record | undefined): { keys: string[]; values: Record } { const keys = body ? Object.keys(body) : []; const values: Record = {}; for (const key of keys) { if (!PUBLIC_BODY_KEYS.has(key)) continue; const value = body?.[key]; if (typeof value === 'string') values[key] = value.slice(0, 200); } return { keys, values }; } /** * The identity a request is claiming. `POST /signin` and friends carry it in the body; the passkey * routes put it in the last path segment (`/passkeys/signin/:email`). Read from the path directly * rather than through `ctx.req.param`, which is not populated for a middleware mounted at the router * root — the value would silently be undefined for exactly the unauthenticated routes that matter. */ function claimedIdentity(ctx: Context, path: string): string | null { const body = ctx.get('body') as Record | undefined; for (const field of ['email', 'username'] as const) { const value = body?.[field]; if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 320); } if (!path.includes('/auth/passkeys/')) return null; const last = decodeURIComponent(path.split('/').pop() ?? ''); return last.includes('@') ? last.slice(0, 320) : null; } const clientAddress = (ctx: Context): string => ctx.req.header('x-forwarded-for')?.split(',')[0]?.trim() || ctx.req.header('x-real-ip') || 'unknown'; /** * Mount on the auth router directly after `bodyParser()` and before everything else, so it wraps origin * validation and the rate limiter too — a probe rejected at the origin check is precisely the one the * owner wants a record of, and it never reaches a handler. */ export const authAudit: MiddlewareHandler = async (ctx, next) => { const started = Date.now(); let failure: unknown = null; try { await next(); } catch (err) { failure = err; throw err; } finally { const url = new URL(ctx.req.url); const identity = claimedIdentity(ctx, url.pathname); // No name claimed → nothing to classify. Signout, /me and the token routes authenticate with a // bearer, and their failures are the userMiddleware's business, not this file's. if (identity) { // Deliberately after next(): the handler may be slow, and blocking a login on a lookup that only // decides whether to write a log line would be the wrong trade. The set is in memory anyway. void isKnownIdentity(identity) .then((known) => { if (known) return; const body = safeBody(ctx.get('body') as Record | undefined); const record = { ts: new Date().toISOString(), event: 'auth.unknown-identity', identity, method: ctx.req.method, path: url.pathname, query: url.search || null, status: failure ? 'threw' : ctx.res.status, ms: Date.now() - started, ip: clientAddress(ctx), forwardedFor: ctx.req.header('x-forwarded-for') ?? null, realIp: ctx.req.header('x-real-ip') ?? null, host: ctx.req.header('host') ?? null, origin: ctx.req.header('origin') ?? null, referer: ctx.req.header('referer') ?? null, userAgent: ctx.req.header('user-agent') ?? null, acceptLanguage: ctx.req.header('accept-language') ?? null, contentType: ctx.req.header('content-type') ?? null, bodyKeys: body.keys, bodyValues: body.values, headers: headerSnapshot(ctx), knownIdentities: knownIdentityCount(), error: failure ? String((failure as { message?: unknown })?.message ?? failure).slice(0, 300) : null, }; writeRecord(record); // Also to the process log, one line, so it surfaces in `pm2 logs officer` without anyone // having to know this file exists. console.warn( `[auth-audit] unknown identity "${identity}" from ${record.ip} on ${record.method} ${url.pathname}`, ); }) .catch(() => { /* classification failed; never let it touch the response */ }); } } }; export const AUTH_AUDIT_LOG_PATH = LOG_PATH;