From 8bd44b33c787c39c4b50a94663cd16adb339d464 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 09:32:00 -0400 Subject: [PATCH] feat(02-01): dev-auth bypass middleware with production hard guard - Create apps/api/src/auth/devBypass.ts: devAuthBypass() middleware with NODE_ENV=production hard guard as first conditional (T-02-01 mitigation) - Exports DEV_USER const (id:1, color:COLOR_PALETTE[0]) for test reference - Mount devAuthBypass() before oidcAuthMiddleware on /api/* in index.ts - Add devBypass.test.ts: all three behavioral cases pass (production guard, unset-flag passthrough, active-injection) - Add DEV_AUTH_BYPASS to .env.example with production warning comment - Extend docs/deployment.md with dev-auth bypass section and production prohibition --- .env.example | 3 + apps/api/src/auth/devBypass.ts | 64 ++++++++++++++++++ apps/api/src/index.ts | 6 ++ apps/api/tests/auth/devBypass.test.ts | 96 +++++++++++++++++++++++++++ docs/deployment.md | 38 +++++++++++ 5 files changed, 207 insertions(+) create mode 100644 apps/api/src/auth/devBypass.ts create mode 100644 apps/api/tests/auth/devBypass.test.ts diff --git a/.env.example b/.env.example index a8e45b6..0a2d291 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ OIDC_AUTH_EXTERNAL_URL=https://familysync.yourdomain.com # CalDAV broker encryption key — generate with: # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" APP_PASSWORD_ENCRYPTION_KEY= + +# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod. +# DEV_AUTH_BYPASS=true diff --git a/apps/api/src/auth/devBypass.ts b/apps/api/src/auth/devBypass.ts new file mode 100644 index 0000000..3149473 --- /dev/null +++ b/apps/api/src/auth/devBypass.ts @@ -0,0 +1,64 @@ +/** + * 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 (e.g. routes/me.ts + * calls getAuth(c) from @hono/oidc-auth; the events route will read c.get('user') directly). + * In dev bypass mode, c.get('user') returns DEV_USER. getAuth(c) is still called by me.ts + * but will return null because no OIDC session cookie is present; me.ts guards this with + * `if (!auth) return 401`. When using the bypass, consume c.get('user') directly in routes + * that need the user object (events route pattern in Plan 02). + * + * Security: + * - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading + * any other env var. This is the hard guard (T-02-01). 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 { 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 + +/** + * 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() + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2998863..db80d15 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { meRouter } from './routes/me.js' import { eventsRouter } from './routes/events.js' import { sseRouter } from './routes/sse.js' import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js' +import { devAuthBypass } from './auth/devBypass.js' import { startBrokerPoller } from './broker/poller.js' export const app = new Hono() @@ -17,6 +18,11 @@ app.get('/callback', (c) => processOAuthCallback(c)) // GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05) app.route('/health', healthRouter) +// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. +// When active, injects a fixed dev user so the OIDC guard below is not required for local dev. +// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). +app.use('/api/*', devAuthBypass()) + // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint. // OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct diff --git a/apps/api/tests/auth/devBypass.test.ts b/apps/api/tests/auth/devBypass.test.ts new file mode 100644 index 0000000..0853032 --- /dev/null +++ b/apps/api/tests/auth/devBypass.test.ts @@ -0,0 +1,96 @@ +/** + * devAuthBypass() middleware — unit tests. + * + * Tests the three behavioral cases: + * 1. NODE_ENV='production' → pure passthrough (hard guard), regardless of DEV_AUTH_BYPASS + * 2. NODE_ENV!='production' + DEV_AUTH_BYPASS unset → passthrough (no user injected) + * 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' + +// We import after env manipulation since devAuthBypass() reads env vars at call time. +// Each test resets the module registry via vi.resetModules() to re-evaluate the function +// with the current process.env values. + +describe('devAuthBypass middleware', () => { + const originalNodeEnv = process.env.NODE_ENV + const originalBypassFlag = process.env.DEV_AUTH_BYPASS + + afterEach(() => { + // Restore env after each test + process.env.NODE_ENV = originalNodeEnv + if (originalBypassFlag === undefined) { + delete process.env.DEV_AUTH_BYPASS + } else { + process.env.DEV_AUTH_BYPASS = originalBypassFlag + } + }) + + it('is a pure passthrough in production (NODE_ENV=production), even when DEV_AUTH_BYPASS=true', async () => { + process.env.NODE_ENV = 'production' + process.env.DEV_AUTH_BYPASS = 'true' + + // Import after env setup + const { devAuthBypass } = await import('../../src/auth/devBypass.js') + + const app = new Hono() + app.use('/api/*', devAuthBypass()) + + let capturedUser: unknown = undefined + app.get('/api/test', (c) => { + capturedUser = c.get('user') + return c.json({ ok: true }) + }) + + const res = await app.request('/api/test') + expect(res.status).toBe(200) + // Hard guard: user must NOT be injected in production + expect(capturedUser).toBeUndefined() + }) + + it('is a passthrough when NODE_ENV!=production and DEV_AUTH_BYPASS is not set', async () => { + process.env.NODE_ENV = 'test' + delete process.env.DEV_AUTH_BYPASS + + const { devAuthBypass } = await import('../../src/auth/devBypass.js') + + const app = new Hono() + app.use('/api/*', devAuthBypass()) + + let capturedUser: unknown = undefined + app.get('/api/test', (c) => { + capturedUser = c.get('user') + return c.json({ ok: true }) + }) + + const res = await app.request('/api/test') + expect(res.status).toBe(200) + expect(capturedUser).toBeUndefined() + }) + + it('injects DEV_USER when NODE_ENV!=production and DEV_AUTH_BYPASS=true', async () => { + process.env.NODE_ENV = 'test' + process.env.DEV_AUTH_BYPASS = 'true' + + const { devAuthBypass, DEV_USER } = await import('../../src/auth/devBypass.js') + + const app = new Hono() + app.use('/api/*', devAuthBypass()) + + let capturedUser: unknown = undefined + app.get('/api/test', (c) => { + capturedUser = c.get('user') + return c.json({ ok: true }) + }) + + const res = await app.request('/api/test') + expect(res.status).toBe(200) + // User must be the fixed DEV_USER + expect(capturedUser).toBeDefined() + expect(capturedUser).toEqual(DEV_USER) + expect((capturedUser as typeof DEV_USER).displayName).toBe('Dev User') + expect((capturedUser as typeof DEV_USER).oidcSub).toBe('dev-user') + }) +}) diff --git a/docs/deployment.md b/docs/deployment.md index 78df766..2cc36c2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -235,3 +235,41 @@ curl -N -H "Cookie: oidc-auth=" https://familysync.DOMAIN/api/sse/heartbe as a Phase 4 constraint and plan a reconnect/fallback strategy. Record results in `.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md`. + +--- + +## Dev-auth bypass (Phase 2+ local development) + +FamilySync builds Phase 2 and Phase 3 features behind a dev-auth bypass so live Authelia is not +required during development (D-14). The bypass injects a fixed dev user into the request context +and short-circuits the OIDC guard. + +**Activation (local dev only):** + +```bash +# In your local .env: +DEV_AUTH_BYPASS=true +NODE_ENV=development # or test, or any value other than 'production' +``` + +**Hard production guard:** + +The bypass middleware's FIRST conditional is `process.env.NODE_ENV === 'production'`. If this is +true, the middleware returns a no-op passthrough regardless of any other env var. This means: + +- Even if `DEV_AUTH_BYPASS=true` is accidentally present in a production container, it has zero + effect. The OIDC guard fires normally. +- The hard guard is checked before `DEV_AUTH_BYPASS` is read — there is no code path where + production + bypass = unauthenticated access. + +**Production Docker Compose prohibition:** + +The production `docker-compose.yml` MUST NOT include `DEV_AUTH_BYPASS` in the environment block. +The `.env.example` entry for `DEV_AUTH_BYPASS` is commented out by default as a reminder. + +**What the bypass does:** + +Sets `c.set('user', DEV_USER)` in the Hono context before `oidcAuthMiddleware` runs. Routes that +read `c.get('user')` receive a fixed dev user `{ id: 1, displayName: 'Dev User', color: '#4A90D9' }`. +Routes that call `getAuth(c)` from `@hono/oidc-auth` will still return null (no OIDC cookie is +present) — those routes must be updated to prefer `c.get('user')` when building Phase 2+.