From 7d61148415e7dbf6d3cd4fb3f2f827ea333501a6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:14:48 -0400 Subject: [PATCH] feat(19-01): implement localSession JWT cookie helpers and assertLocalSessionSecretSet boot guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify) - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4) - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9) - verifyLocalSessionCookie returns null (never throws) on any error - bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies) - index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction() - All 5 unit tests pass; typecheck exits 0 --- apps/api/src/auth/localSession.ts | 106 ++++++++++++++++++++++++++++++ apps/api/src/index.ts | 4 +- apps/api/src/lib/bootGuards.ts | 32 +++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/localSession.ts diff --git a/apps/api/src/auth/localSession.ts b/apps/api/src/auth/localSession.ts new file mode 100644 index 0000000..1308e8b --- /dev/null +++ b/apps/api/src/auth/localSession.ts @@ -0,0 +1,106 @@ +/** + * 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, + // Use secure:true for delete (browsers only accept the attribute in matching context) + // In practice this is safe because logout should happen over HTTPS in production. + secure: true, + sameSite: 'Lax', + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 353e53d..214822f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,7 +21,7 @@ import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; import { startReminderScheduler } from './broker/reminderScheduler.js'; -import { assertNotDevBypassInProduction } from './lib/bootGuards.js'; +import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js'; import webpush from 'web-push'; export const app = new Hono(); @@ -134,6 +134,8 @@ function isMainModule(): boolean { if (isMainModule()) { // D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve(). assertNotDevBypassInProduction(); + // D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode. + assertLocalSessionSecretSet(); // Configure VAPID credentials for web-push before starting background workers. // VAPID_SUBJECT must be a mailto: or https: URL identifying the operator. diff --git a/apps/api/src/lib/bootGuards.ts b/apps/api/src/lib/bootGuards.ts index 315c53b..aa0cff0 100644 --- a/apps/api/src/lib/bootGuards.ts +++ b/apps/api/src/lib/bootGuards.ts @@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void { process.exit(1); } } + +/** + * Refuses to start the process when LOCAL_SESSION_SECRET is absent or shorter + * than 32 characters, UNLESS dev-bypass mode is active. + * + * Rationale (D-05 / T-19-03 / Pitfall 10): + * LOCAL_SESSION_SECRET signs the local-session JWT cookie. A missing or weak + * secret means any issued session cookie can be trivially forged. This boot + * guard converts a silent misconfiguration into an immediate loud failure + * instead of letting the API start and issue insecure JWTs. + * + * DEV_AUTH_BYPASS=true is exempt: bypass mode never issues local-session cookies + * (the OIDC/dev-bypass path handles auth), so the secret is not required there. + * This mirrors assertNotDevBypassInProduction's exempt logic. + * + * Call immediately after assertNotDevBypassInProduction() in the isMainModule() + * boot block in index.ts. + */ +export function assertLocalSessionSecretSet(): void { + // Exempt when dev-bypass is active — bypass mode doesn't issue local-session JWTs + if (process.env.DEV_AUTH_BYPASS === 'true') return; + + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret || secret.length < 32) { + console.error( + '[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. ' + + 'This secret signs local-session JWT cookies. Refusing to start. ' + + 'Run: node scripts/generate-secrets.mjs to generate a value.', + ); + process.exit(1); + } +}