/** * Dev-auth bypass middleware (Pitfall 7 — T-02-01). * * Active ONLY when DEV_AUTH_BYPASS=true AND NODE_ENV !== 'production'. * Injects a fixed dev user into the Hono context so the OIDC auth guard is effectively * bypassed for local development WITHOUT live Authelia (D-14). * * Mount BEFORE oidcAuthMiddleware on /api/* in index.ts. * When the bypass is inactive (wrong env, or NODE_ENV=production) the middleware is a * pure no-op passthrough — production behaviour is unchanged. * * Context key: 'user' — matches the key read by downstream consumers. * In dev bypass mode, c.get('user') returns DEV_USER. index.ts does NOT mount * oidcAuthMiddleware when devBypassActive is true, so getAuth(c) is never called. * routes/me.ts reads c.get('user') first and returns the dev identity directly, * skipping the DB upsert and getAuth path entirely. Other routes (e.g. events) * also read c.get('user') directly — same pattern, no change needed there. * * Phase 19 — Option C (AUTH-LOCAL-16, D-14/D-15): * devSessionCookieMiddleware() complements devAuthBypass() by issuing a real * local-session JWT cookie for DEV_USER on each request that lacks one. This lets * the PWA login gate (which checks the local-session cookie) see a valid session and * skip to the app, so existing Phase 7/8 Playwright specs still reach the authed PWA * without manual login. Mount AFTER devAuthBypass() in index.ts. * * Security: * - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading * any other env var. This is the hard guard (T-02-01 / T-19-24). Even if DEV_AUTH_BYPASS is * accidentally set in production config, the guard fires and returns a no-op. * - The production Docker Compose MUST NOT set DEV_AUTH_BYPASS. See docs/deployment.md. * - This file must never be removed — the pattern is referenced by Plan 02 routes. */ import type { MiddlewareHandler } from 'hono'; import { getCookie } from 'hono/cookie'; import { issueLocalSessionCookie } from './localSession.js'; import { COLOR_PALETTE } from './user.js'; export const DEV_USER = { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot } as const; /** * The shape stored on c.get('user') across the bypass, local-session, and OIDC paths. * * BL-04: oidcIss/oidcSub are NULLABLE. Local users have null OIDC fields, and * localAuthMiddleware must NOT fabricate sentinel ('local'/String(id)) values — those * share the uniqueness domain (uniq_oidc_identity) with real OIDC identities and could * collide with a genuine (iss,sub) pair if ever persisted. DEV_USER carries non-null * 'dev'/'dev-user' values and remains assignable to this widened shape. */ export interface ContextUser { id: number; oidcIss: string | null; oidcSub: string | null; displayName: string | null; color: string; } /** * Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...) * are statically typed throughout the app. The value type is ContextUser — the shape * shared by the dev-bypass path, the local-session path (nullable oidc fields), and any * future app-level user object stored on context. */ declare module 'hono' { interface ContextVariableMap { user: ContextUser; } } /** * Returns a Hono MiddlewareHandler that injects DEV_USER into the request context * when the dev-auth bypass is active, or a pure passthrough when inactive. * * The function evaluates env vars at call time (when the app starts), not at request time. * This means the middleware choice is fixed for the lifetime of the process — intentional, * since changing auth mode requires a restart. */ export function devAuthBypass(): MiddlewareHandler { // Hard production guard — FIRST check, before reading any other env var. // Ensures this middleware can never grant access in production regardless of config. if (process.env.NODE_ENV === 'production') { return async (_c, next) => next(); } // Bypass flag not set — passthrough; OIDC auth proceeds normally. if (process.env.DEV_AUTH_BYPASS !== 'true') { return async (_c, next) => next(); } // Bypass active: inject fixed dev user into Hono context. // Routes that read c.get('user') will receive DEV_USER. return async (c, next) => { c.set('user', DEV_USER); await next(); }; } /** * Phase 19 Option C (AUTH-LOCAL-16): issues a real local-session JWT cookie for DEV_USER * so the PWA login gate sees a valid session and skips /login during dev-bypass runs. * * Mount AFTER devAuthBypass() on /api/* in index.ts. This middleware is a pure no-op * passthrough in all non-bypass contexts: * 1. NODE_ENV === 'production' → immediate no-op (hard guard, T-19-24 / D-15) * 2. DEV_AUTH_BYPASS !== 'true' → immediate no-op (inactive outside bypass mode) * 3. LOCAL_SESSION_SECRET not set → no-op (issueLocalSessionCookie will throw, but * in bypass mode the boot guard exempts the secret check — skip gracefully) * 4. 'local-session' cookie already present → no-op (avoids re-signing on every request) * * Security: the production hard-guard is the FIRST check — identical guard order to * devAuthBypass() so assertNotDevBypassInProduction (IMG-01) catches both at boot. */ export function devSessionCookieMiddleware(): MiddlewareHandler { // Hard production guard — FIRST check, before reading any other env var. // Ensures this middleware can never issue a session cookie in production. if (process.env.NODE_ENV === 'production') { return async (_c, next) => next(); } // Bypass flag not set — passthrough; no cookie is issued. if (process.env.DEV_AUTH_BYPASS !== 'true') { return async (_c, next) => next(); } // LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement // (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot // issue a cookie without it. Degrade gracefully so devAuthBypass still works. const secret = process.env.LOCAL_SESSION_SECRET; if (!secret) { return async (_c, next) => next(); } // BL-01: do not treat "present" as "safe". The boot guard's length floor // (assertLocalSessionSecretSet, >= 32 chars) is SKIPPED in bypass mode, so apply the // same floor here before minting a real, signature-valid local-session JWT for DEV_USER // (id=1). A short/forgeable secret must NOT issue a genuine session token. Degrade to a // no-op so the cookie is never signed with a weak key. if (secret.length < 32) { console.warn( '[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is shorter than 32 characters — ' + 'refusing to issue a dev local-session cookie. Generate a strong value with ' + 'node scripts/generate-secrets.mjs.', ); return async (_c, next) => next(); } // BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine, // signature-valid session token minted under this known value is trivially forgeable // if the same secret ever leaks into a non-bypass environment. if (secret === 'dev-secret-change-me-0000000000000000') { console.warn( '[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' + 'This is acceptable ONLY for local dev/CI under DEV_AUTH_BYPASS — never reuse this ' + 'value in any non-bypass or shared environment.', ); } // Bypass active + secret set: issue a real local-session cookie for DEV_USER // on each request that does not already carry one. return async (c, next) => { const existing = getCookie(c, 'local-session'); if (!existing) { // issueLocalSessionCookie is async (JWT sign) — await before next() await issueLocalSessionCookie(c, DEV_USER.id); } await next(); }; }