/** * Auth: Passport.js local strategy behind a connect-compat adapter over * Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite * (D-009). * * Passport expects connect/express-style middleware, which Bun.serve does not * have. The adapter here is deliberately minimal and honest: it shims only * what `passport.authenticate("local", callback)` actually touches — the * strategy reads `req.body`/`req.query` and, because we pass a custom * callback with `session: false`, never writes to `res`. Session issuance, * cookies, and serialization are driven by us around that call rather than by * passport's own connect middleware. * * Routes implemented (wired in src/index.ts, shared by dev + production): * POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409 * POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401 * POST /api/auth/logout → 204 (always) + cookie cleared * GET /api/auth/me → 200 {user} | 401 * * Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days; * Secure when NODE_ENV=production. Expiry is enforced server-side via * sessions.expires_at — a replayed cookie past that instant is rejected even * though the browser would still send it. */ import passport from "passport"; import { Strategy as LocalStrategy } from "passport-local"; import { type AppDb, type User, getDb } from "./db.ts"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- /** The safe-to-serialize projection of a user (never the password hash). */ export interface SessionUser { id: number; username: string; } export interface AuthHandlers { register(request: Request): Promise; login(request: Request): Promise; logout(request: Request): Promise; me(request: Request): Promise; /** * Resolves the user behind a request's `sid` cookie, or null. Exported so * the M5 transcribe endpoint can guard itself with the same session logic. */ getSessionUser(request: Request): SessionUser | null; } // --------------------------------------------------------------------------- // Constants + small helpers // --------------------------------------------------------------------------- const COOKIE_NAME = "sid"; const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; const MIN_PASSWORD_LENGTH = 8; function json(body: unknown, status: number, headers?: HeadersInit): Response { const h = new Headers(headers); h.set("Content-Type", "application/json"); return new Response(JSON.stringify(body), { status, headers: h }); } /** Serializes the session cookie per the contract in the module doc comment. */ function sessionCookie(sid: string, maxAgeSeconds: number): string { const attributes = [ `${COOKIE_NAME}=${sid}`, "HttpOnly", "SameSite=Lax", "Path=/", `Max-Age=${maxAgeSeconds}`, ]; // Checked per-call (not at module load) so tests can flip NODE_ENV. if (process.env.NODE_ENV === "production") attributes.push("Secure"); return attributes.join("; "); } /** Max-Age=0 tells the browser to drop the cookie immediately. */ const clearedCookie = (): string => sessionCookie("", 0); /** Extracts the `sid` cookie value from a request, if any. */ function readSessionId(request: Request): string | null { const header = request.headers.get("cookie"); if (!header) return null; for (const part of header.split(";")) { const eq = part.indexOf("="); if (eq === -1) continue; if (part.slice(0, eq).trim() === COOKIE_NAME) { const value = part.slice(eq + 1).trim(); return value.length > 0 ? value : null; } } return null; } const toSessionUser = (user: User): SessionUser => ({ id: user.id, username: user.username, }); interface Credentials { username: string; password: string; } /** * Parses a JSON `{username, password}` body. Returns null for anything that * is not that shape (bad JSON, wrong types) — callers answer 400. */ async function parseCredentials(request: Request): Promise { let body: unknown; try { body = await request.json(); } catch { return null; } if (typeof body !== "object" || body === null) return null; const { username, password } = body as Record; if (typeof username !== "string" || typeof password !== "string") return null; return { username, password }; } /** * Argon2id hash used to burn comparable time when the username does not * exist, so login latency doesn't reveal which usernames are registered. * Computed lazily (hashing is deliberately slow) and cached. */ let dummyHash: Promise | undefined; const getDummyHash = (): Promise => (dummyHash ??= Bun.password.hash("dummy-password-for-constant-time")); // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * Builds the four route handlers plus `getSessionUser` against a database * source. Taking a getter (not an instance) keeps the default export lazy: * importing this module never opens ./data/app.db, and tests inject * `() => createDb(":memory:")` databases without touching disk. */ export function createAuth(getDatabase: () => AppDb): AuthHandlers { // A private Authenticator instance (not the module-level singleton) so two // createAuth() calls — e.g. app + tests — can't clobber each other's // strategy registration. const authenticator = new passport.Passport(); authenticator.use( new LocalStrategy((username, password, done) => { // passport-local's verify is callback-style; bridge to async/await and // route thrown errors into done() so they surface as 500s, not hangs. void (async () => { const user = getDatabase().findUserByUsername(username); if (!user) { await Bun.password.verify(password, await getDummyHash()); return done(null, false, { message: "invalid credentials" }); } const ok = await Bun.password.verify(password, user.passwordHash); return ok ? done(null, user) : done(null, false, { message: "invalid credentials" }); })().catch(done); }), ); /** * The connect-compat adapter (D-008): run passport's authenticate chain * against a shim of exactly the surface passport-local reads. With * `session: false` and a custom callback, passport calls back with * (err, user|false) and never touches `res`, so an empty object suffices. */ function authenticateLocal(credentials: Credentials): Promise { return new Promise((resolve, reject) => { const req = { body: credentials, query: {}, headers: {}, method: "POST", url: "/api/auth/login", }; const middleware = authenticator.authenticate( "local", { session: false }, (err: unknown, user: unknown) => err ? reject(err) : resolve((user as User | false) || false), ) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void; // next() only fires on internal errors here (the callback intercepts // success and failure), so treat any call as a failure path. middleware(req, {}, (err?: unknown) => err ? reject(err) : resolve(false), ); }); } /** Issues a fresh session row + Set-Cookie header for `user`. */ function issueSession(user: User): { user: SessionUser; cookie: string } { const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS); return { user: toSessionUser(user), cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000), }; } function getSessionUser(request: Request): SessionUser | null { const sid = readSessionId(request); if (!sid) return null; const db = getDatabase(); const session = db.findValidSession(sid); if (!session) return null; const user = db.findUserById(session.userId); return user ? toSessionUser(user) : null; } return { async register(request) { const credentials = await parseCredentials(request); if (!credentials) { return json({ error: "expected JSON body with username and password" }, 400); } if (!USERNAME_RE.test(credentials.username)) { return json( { error: "username must be 3-32 characters (letters, digits, _ . -)" }, 400, ); } if (credentials.password.length < MIN_PASSWORD_LENGTH) { return json( { error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400, ); } // Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4. const passwordHash = await Bun.password.hash(credentials.password); const user = getDatabase().createUser(credentials.username, passwordHash); // createUser lets the UNIQUE constraint arbitrate, so concurrent // registrations of the same name can't both succeed. if (!user) return json({ error: "username taken" }, 409); const issued = issueSession(user); return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie }); }, async login(request) { const credentials = await parseCredentials(request); if (!credentials) { return json({ error: "expected JSON body with username and password" }, 400); } const user = await authenticateLocal(credentials); // One message for both unknown-user and wrong-password: no username // enumeration via response text (timing handled by the dummy hash). if (!user) return json({ error: "invalid credentials" }, 401); const issued = issueSession(user); return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie }); }, async logout(request) { const sid = readSessionId(request); if (sid) getDatabase().deleteSession(sid); // Idempotent by contract: 204 whether or not a session existed. No body // on 204, but always clear the cookie so stale clients converge. return new Response(null, { status: 204, headers: { "Set-Cookie": clearedCookie() }, }); }, async me(request) { const user = getSessionUser(request); return user ? json({ user }, 200) : json({ error: "not authenticated" }, 401); }, getSessionUser, }; } // --------------------------------------------------------------------------- // Default instance (the real app database) + convenience re-export for M5 // --------------------------------------------------------------------------- export const auth: AuthHandlers = createAuth(getDb); /** Session lookup against the app database — see AuthHandlers.getSessionUser. */ export function getSessionUser(request: Request): SessionUser | null { return auth.getSessionUser(request); }