import { createHash, randomBytes } from 'node:crypto'; import { findLiveApiKeyByHash, touchApiKey } from 'officerdb'; import { verify } from './jwt'; // How a bearer string becomes a caller. There are two credentials — the 30-day session JWT the web app // signs in with, and an API key a user mints for an app or a device — and exactly one place that tells // them apart. // // ── One resolver, two doors ── // // Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router // mounts, and `originScopeMiddleware`, which runs globally in hono.ts and re-verifies the token itself // because it must also cover routes that never mount `userMiddleware`. They have to agree about who a // caller is, and the way they stop agreeing is somebody teaching one of them a credential format the // other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and // wave the request through its account backstop. // // That is not a hypothetical failure here: the capability totality check exists because a Member was // 403ing on GET /api/tasks and opening /api/tasks/pipeline/ws in the same minute. So `resolveAuthToken` // is the only function that turns a bearer string into a caller, and both doors call it. /** * Marks a bearer string as one of ours rather than a JWT. Not a secret and not a checksum — its job is to * let the resolver pick a path without JWT-verifying something that was never a JWT, and to make a leaked * key greppable in a log or a repository. */ export const API_KEY_PREFIX = 'ofk_'; /** Characters of the secret kept in the clear for display. Enough to tell two keys apart, far too few to use. */ const PREFIX_DISPLAY_LENGTH = API_KEY_PREFIX.length + 6; /** 32 bytes from the CSPRNG. base64url so the whole key is copy-pasteable and safe in a query string. */ const SECRET_BYTES = 32; export const hashApiKey = (secret: string): string => createHash('sha256').update(secret).digest('hex'); export const looksLikeApiKey = (token: string): boolean => token.startsWith(API_KEY_PREFIX); export type MintedApiKey = { /** The full credential. Shown to the user exactly once and never stored. */ secret: string; keyHash: string; prefix: string; }; export function mintApiKey(): MintedApiKey { const secret = `${API_KEY_PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`; return { secret, keyHash: hashApiKey(secret), prefix: secret.slice(0, PREFIX_DISPLAY_LENGTH) }; } /** * Who a caller is, however they proved it. * * Shaped to match the JWT payload the app already puts in `ctx.get('user')` — handlers read `.id` and * `.email` and must not care which credential arrived. `via` exists for the two checks that are only * meaningful for a session: the signout blacklist and `passwordChangedAt`. A key's revocation is a * column, not a blacklist row. */ export type AuthIdentity = { id: number; email: string; username?: string | null; name?: string | null; via: 'jwt' | 'api-key'; /** JWT only. */ jti?: string; /** JWT only, seconds since the epoch. */ iat?: number; /** JWT only, seconds since the epoch. */ exp?: number; }; /** * Turn an API key into a caller, or null. * * Lookup is by SHA-256 of the whole secret against a unique index, so it is one index hit and an attacker * cannot narrow the search by guessing at a prefix. SHA-256 rather than argon2 deliberately: the secret is * 32 CSPRNG bytes with no human pattern to grind, and a slow hash would have to be recomputed per * candidate row, turning every authenticated request into a table scan. */ export async function resolveApiKey(secret: string): Promise { const row = await findLiveApiKeyByHash(hashApiKey(secret)); if (!row) return null; // Fire and forget. "When was this last used" must never fail a request or add latency to one; the write // is debounced to at most once a minute per key inside touchApiKey. void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {}); return { id: row.userId, email: row.email, username: row.username, via: 'api-key' }; } /** * The single entry point both identity doors use. * * Returns null for anything it cannot vouch for — an unsigned or expired JWT, an unknown key, a revoked * one. It answers "is this credential live, and whose", and nothing else: the blacklist and * `passwordChangedAt` checks stay in `userMiddleware`, which is the door that owns session invalidation. */ export async function resolveAuthToken(token: string): Promise { if (looksLikeApiKey(token)) return resolveApiKey(token); try { const payload = await verify(token); if (!payload || typeof payload.id !== 'number') return null; return { ...payload, via: 'jwt' }; } catch { return null; } }