feat(12-02): OIDC boot env-OR-app_config fallback + pre-auth mount verification

- A2 CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL
  at per-request call time (env(c) → process.env), NOT at import time — fresh instance
  boots cleanly without OIDC env vars
- Implement oidcConfigFallbackMiddleware in auth/middleware.ts: reads OIDC_ISSUER,
  OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when process.env is absent,
  injects into process.env before oidcAuthMiddleware() reads it (D-02/D-03/Recommendation a)
- Mount oidcConfigFallbackMiddleware before oidcAuthMiddleware() in index.ts so
  wizard-configured instances work before a container restart
- Verify /api/setup mount order: line 49 < devAuthBypass line 54 (T-12-09/Pitfall 1)
- Fix push.test.ts vi.doMock for middleware.js: add oidcConfigFallbackMiddleware stub
- 394 tests pass | 5 todo (D-08 RED scaffolds); typecheck clean
This commit is contained in:
Lucas Berger
2026-06-15 14:03:15 -04:00
parent 20f91e4548
commit 67a9d29dc1
3 changed files with 80 additions and 1 deletions
+67
View File
@@ -11,6 +11,20 @@
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
* OIDC_AUTH_EXTERNAL_URL — https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
*
* Phase 12 — env-OR-app_config fallback (D-02 / D-03 / Recommendation a):
* OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL may be absent from env on a
* fresh unconfigured instance or when the wizard has written them to app_config but
* the container has not yet been restarted. oidcAuthMiddlewareWithFallback() reads
* the process.env value first; when absent, reads the app_config DB value and injects
* it via process.env for the duration of the request. This avoids a crash on fresh
* boot and allows wizard-configured values to work before a container restart.
*
* A2 CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER etc. at per-request call time
* (inside setOidcAuthEnv → env(c) → process.env). NOT at import time. A fresh instance
* without these env vars boots cleanly; the HTTP 500 only occurs if a request hits
* /api/* OIDC-protected routes before setup is complete — which is acceptable since
* /api/setup/* is pre-auth and is the only pre-setup surface.
*
* Session persistence (AUTH-02):
* @hono/oidc-auth stores the refresh token in the signed JWT cookie.
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
@@ -23,4 +37,57 @@
* Source: https://github.com/honojs/middleware/tree/main/packages/oidc-auth
*/
import type { Context, Next } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { appConfig } from '../db/schema.js';
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth';
// ---------------------------------------------------------------------------
// env-OR-app_config fallback middleware (D-02 / D-03 / Recommendation a)
//
// OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL are non-secret config
// (D-01) that the wizard writes to app_config. When the env var is absent,
// this middleware reads the app_config value and sets it into process.env so
// that the downstream oidcAuthMiddleware() picks it up from its env(c) read.
//
// Env floor vars that stay in env only (never in app_config):
// OIDC_AUTH_SECRET, OIDC_CLIENT_SECRET — secrets; must be in Docker env (D-01)
//
// Called once per /api/* request that reaches the OIDC guard.
// Reading 3 app_config rows adds negligible overhead for a 2-person household app.
// ---------------------------------------------------------------------------
export async function oidcConfigFallbackMiddleware(c: Context, next: Next): Promise<void> {
const needsIssuer = !process.env.OIDC_ISSUER;
const needsClientId = !process.env.OIDC_CLIENT_ID;
const needsExternalUrl = !process.env.OIDC_AUTH_EXTERNAL_URL;
if (needsIssuer || needsClientId || needsExternalUrl) {
// Build a minimal list of keys to read — only the absent ones
const keysToRead: string[] = [];
if (needsIssuer) keysToRead.push('oidc_issuer');
if (needsClientId) keysToRead.push('oidc_client_id');
if (needsExternalUrl) keysToRead.push('app_external_url');
// Read from app_config (written by POST /api/setup/config)
for (const key of keysToRead) {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
if (row?.value) {
// Inject into process.env so oidcAuthMiddleware()'s env(c) picks it up
// This is safe: these are non-secret, app-config-owned values (D-01)
if (key === 'oidc_issuer') process.env.OIDC_ISSUER = row.value;
if (key === 'oidc_client_id') process.env.OIDC_CLIENT_ID = row.value;
if (key === 'app_external_url') process.env.OIDC_AUTH_EXTERNAL_URL = row.value;
}
}
}
await next();
}