175 lines
7.7 KiB
TypeScript
175 lines
7.7 KiB
TypeScript
/**
|
|
* 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();
|
|
};
|
|
}
|