fix(02): dev-auth bypass no longer blocked by oidcAuthMiddleware

- index.ts: compute devBypassActive at startup; skip app.use(oidcAuthMiddleware)
  entirely when active so the OIDC guard never runs in local dev
- routes/me.ts: read c.get('user') first; return dev identity directly when
  devAuthBypass injected it, bypassing getAuth() and the DB upsert
- auth/devBypass.ts: add ContextVariableMap augmentation for 'user' key;
  correct stale comment that claimed getAuth/401 path was still active
This commit is contained in:
Lucas Berger
2026-06-05 13:48:00 -04:00
parent 11595e7924
commit 4b34b16f02
3 changed files with 60 additions and 11 deletions
+18 -6
View File
@@ -9,12 +9,12 @@
* When the bypass is inactive (wrong env, or NODE_ENV=production) the middleware is a * When the bypass is inactive (wrong env, or NODE_ENV=production) the middleware is a
* pure no-op passthrough — production behaviour is unchanged. * pure no-op passthrough — production behaviour is unchanged.
* *
* Context key: 'user' — matches the key read by downstream consumers (e.g. routes/me.ts * Context key: 'user' — matches the key read by downstream consumers.
* 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. index.ts does NOT mount
* In dev bypass mode, c.get('user') returns DEV_USER. getAuth(c) is still called by me.ts * oidcAuthMiddleware when devBypassActive is true, so getAuth(c) is never called.
* but will return null because no OIDC session cookie is present; me.ts guards this with * routes/me.ts reads c.get('user') first and returns the dev identity directly,
* `if (!auth) return 401`. When using the bypass, consume c.get('user') directly in routes * skipping the DB upsert and getAuth path entirely. Other routes (e.g. events)
* that need the user object (events route pattern in Plan 02). * also read c.get('user') directly — same pattern, no change needed there.
* *
* Security: * Security:
* - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading * - The FIRST conditional is always `NODE_ENV === 'production'` — checked before reading
@@ -35,6 +35,18 @@ export const DEV_USER = {
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
} as const } as const
/**
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
* are statically typed throughout the app. The value type is the DEV_USER shape,
* which is compatible with both the bypass path and any future app-level user object
* stored on context (they share the same id/displayName/color subset).
*/
declare module 'hono' {
interface ContextVariableMap {
user: typeof DEV_USER
}
}
/** /**
* Returns a Hono MiddlewareHandler that injects DEV_USER into the request context * 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. * when the dev-auth bypass is active, or a pure passthrough when inactive.
+17 -2
View File
@@ -11,6 +11,17 @@ import { startBrokerPoller } from './broker/poller.js'
export const app = new Hono() export const app = new Hono()
// Compute once at startup: bypass is active only in non-production with explicit opt-in.
// In production NODE_ENV='production' → devBypassActive=false → OIDC is always mounted.
const devBypassActive =
process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true'
if (devBypassActive) {
console.warn(
'⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.',
)
}
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the // OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
// authorization-code exchange is not itself intercepted by the auth check (T-02-02) // authorization-code exchange is not itself intercepted by the auth check (T-02-02)
app.get('/callback', (c) => processOAuthCallback(c)) app.get('/callback', (c) => processOAuthCallback(c))
@@ -19,15 +30,19 @@ app.get('/callback', (c) => processOAuthCallback(c))
app.route('/health', healthRouter) app.route('/health', healthRouter)
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // 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. // When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
app.use('/api/*', devAuthBypass()) app.use('/api/*', devAuthBypass())
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
// Skipped entirely when devBypassActive so that local dev works without Authelia.
// In production devBypassActive is always false — OIDC is unconditionally mounted.
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint. // Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct // OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>. // redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
app.use('/api/*', oidcAuthMiddleware()) if (!devBypassActive) {
app.use('/api/*', oidcAuthMiddleware())
}
// Protected API routes (behind oidcAuthMiddleware) // Protected API routes (behind oidcAuthMiddleware)
app.route('/api/me', meRouter) app.route('/api/me', meRouter)
+25 -3
View File
@@ -1,13 +1,19 @@
/** /**
* GET /api/me — returns the authenticated user's identity and assigned color. * GET /api/me — returns the authenticated user's identity and assigned color.
* *
* Flow: * Flow (normal — OIDC active):
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie * 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated) * (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
* 2. upsertUser(iss, sub, email) writes the user row on first visit, returns * 2. upsertUser(iss, sub, email) writes the user row on first visit, returns
* the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10) * the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10)
* 3. Returns { user: { id, displayName, color } } * 3. Returns { user: { id, displayName, color } }
* *
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
* This handler reads c.get('user') first and short-circuits to return the dev identity
* directly, skipping the DB upsert.
*
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03). * The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
* No credential or refresh-token data is included in the response (T-02-04). * No credential or refresh-token data is included in the response (T-02-04).
*/ */
@@ -15,12 +21,28 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js' import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js' import { upsertUser } from '../auth/user.js'
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
export const meRouter = new Hono() export const meRouter = new Hono()
meRouter.get('/', async (c) => { meRouter.get('/', async (c) => {
// getAuth returns null only if the session is invalid — oidcAuthMiddleware on // Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
// /api/* redirects unauthenticated requests before this handler is reached. // Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
const devUser = c.get('user')
if (devUser) {
return c.json({
user: {
id: devUser.id,
displayName: devUser.displayName,
color: devUser.color,
},
})
}
// Normal OIDC path: getAuth returns null only if the session is invalid.
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
// is reached, so null here indicates a genuine session error.
const auth = await getAuth(c) const auth = await getAuth(c)
if (!auth) { if (!auth) {
return c.json({ error: 'Unauthorized' }, 401) return c.json({ error: 'Unauthorized' }, 401)