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
This commit is contained in:
Lucas Berger
2026-06-05 09:32:00 -04:00
parent 75252eb08c
commit 8bd44b33c7
5 changed files with 207 additions and 0 deletions
+3
View File
@@ -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
+64
View File
@@ -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()
}
}
+6
View File
@@ -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
+96
View File
@@ -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')
})
})
+38
View File
@@ -235,3 +235,41 @@ curl -N -H "Cookie: oidc-auth=<value>" 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+.