/** * localSession.ts — stateless JWT session-cookie helpers for local auth (D-05). * * Issues and verifies a signed httpOnly JWT cookie named 'local-session', * distinct from the OIDC cookie 'oidc-auth' (Pitfall 4). * * Uses Hono's built-in Jwt from 'hono/utils/jwt' via the { Jwt } namespace import * (Pitfall 8 — named sign/verify do not exist; Jwt.sign/Jwt.verify is correct). * * Security properties (T-19-02): * - HS256 signed with LOCAL_SESSION_SECRET from env (never stored in DB — SC-3) * - httpOnly: true — not accessible from client JavaScript * - secure: true in production, false in non-production (allows local dev over HTTP) * - sameSite: 'Lax' — CSRF mitigation for browser navigation * - Jwt.verify throws on expiry (Pitfall 9) — verifyLocalSessionCookie wraps in try/catch * - verifyLocalSessionCookie returns null (never throws) on any error * * Boot guard: * assertLocalSessionSecretSet() in lib/bootGuards.ts refuses to start if secret * is missing or < 32 chars when not in dev-bypass mode (Pitfall 10 / T-19-03). */ import { Jwt } from 'hono/utils/jwt'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; import type { Context } from 'hono'; // Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4) const COOKIE_NAME = 'local-session'; // Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); /** * Issue a signed local-session JWT cookie for the given userId. * * The JWT payload carries: { userId, iat, exp } (HS256, signed with LOCAL_SESSION_SECRET). * Throws if LOCAL_SESSION_SECRET is not set — the boot guard should have caught this. */ export async function issueLocalSessionCookie(c: Context, userId: number): Promise { const secret = process.env.LOCAL_SESSION_SECRET; if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set'); const now = Math.floor(Date.now() / 1000); const payload = { userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS, }; // Pitfall 8: use Jwt.sign (namespace import), NOT named sign from hono/utils/jwt const token = await Jwt.sign(payload, secret, 'HS256'); setCookie(c, COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', path: '/', maxAge: SESSION_MAX_AGE_SECONDS, }); } /** * Verify the local-session JWT cookie and return the userId, or null. * * Returns null (never throws) when: * - LOCAL_SESSION_SECRET is not set * - No 'local-session' cookie is present * - The JWT is expired (Jwt.verify throws JwtTokenExpired — caught here, Pitfall 9) * - The JWT has been tampered with * - The payload.userId is not a number * - Any other crypto/parse error */ export async function verifyLocalSessionCookie(c: Context): Promise { const secret = process.env.LOCAL_SESSION_SECRET; if (!secret) return null; const token = getCookie(c, COOKIE_NAME); if (!token) return null; try { // Pitfall 8: Jwt.sign/Jwt.verify (namespace import — NOT named exports) // Pitfall 9: Jwt.verify throws on expiry — must catch all errors and return null const payload = await Jwt.verify(token, secret, 'HS256'); return typeof payload.userId === 'number' ? payload.userId : null; } catch { // Includes JwtTokenExpired, tampered signature, malformed token, etc. return null; } } /** * Clear the local-session cookie. * * Cookie attributes must match the ones set on issue so the browser correctly * expires the cookie (path, httpOnly, sameSite all must match). */ export function clearLocalSessionCookie(c: Context): void { deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, // BL-02: mirror the issue-time `secure` logic. issueLocalSessionCookie sets // secure:false over plain HTTP (non-production), and a browser will REJECT a // Secure delete-cookie sent over HTTP — so a hard-coded secure:true left the // local-session cookie uncleared on every non-HTTPS deployment (local dev and any // HTTP-only self-host), leaving the user "logged in" after logout. Match the // context so the deletion cookie is accepted. secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', }); }