/** * Authentication primitives. * * Passwords are hashed with argon2id via `Bun.password`. Sessions ride on the * existing cookie-backed `SessionStore`: logging a user in stores a serializable * user object under the "user" key, and `sessionAuth` hydrates `ctx.user` from * it on every request. `requireAuth` is a guard middleware for protected routes. */ import type { Context, Middleware } from "./context.ts"; /** Session key under which the authenticated user is stored. */ export const SESSION_USER_KEY = "user"; /** Hash a plaintext password (argon2id). Store the returned string. */ export function hashPassword(password: string): Promise { return Bun.password.hash(password); } /** Verify a plaintext password against a stored hash. Safe against bad hashes. */ export async function verifyPassword(password: string, hash: string): Promise { if (!hash) return false; try { return await Bun.password.verify(password, hash); } catch { return false; } } /** Persist the authenticated user in the session and on the context. */ export function logIn(ctx: Context, user: U): void { // Regenerate the session id first so a pre-login (possibly attacker-planted) // id can't be reused post-login — defends against session fixation. ctx.session.regenerate(); ctx.session.set(SESSION_USER_KEY, user); ctx.user = user; } /** Clear the session and forget the current user. */ export function logOut(ctx: Context): void { ctx.session.clear(); ctx.user = null; } /** * The currently-authenticated user, or null. Reads `ctx.user` first (set by * `sessionAuth`/`logIn`), falling back to the session store. */ export function getUser(ctx: Context): U | null { if (ctx.user != null) return ctx.user as U; const fromSession = ctx.session.get(SESSION_USER_KEY); return fromSession ?? null; } /** * Hydrate `ctx.user` from the session for every request. Register this early in * the middleware chain so downstream pages and API routes can read `ctx.user`. */ export function sessionAuth(): Middleware { return (ctx, next) => { ctx.user = ctx.session.get(SESSION_USER_KEY) ?? null; return next(); }; } export interface RequireAuthOptions { /** Where to redirect unauthenticated page requests. Default "/login". */ loginPath?: string; } /** * Guard that requires an authenticated user. Unauthenticated requests that look * like an API/fetch call get a 401 JSON response; page navigations get a 302 * redirect to the login page with the original target preserved as `?next=`. */ export function requireAuth(options: RequireAuthOptions = {}): Middleware { const loginPath = options.loginPath ?? "/login"; return (ctx, next) => { if (getUser(ctx) != null) return next(); if (wantsJson(ctx)) { return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } const target = encodeURIComponent(ctx.url.pathname + ctx.url.search); return new Response(null, { status: 302, headers: { Location: `${loginPath}?next=${target}` }, }); }; } function wantsJson(ctx: Context): boolean { if (ctx.url.pathname.startsWith("/api/")) return true; const accept = ctx.req.headers.get("accept") ?? ""; return accept.includes("application/json") && !accept.includes("text/html"); }