diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index e144da6..82e5247 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -11,6 +11,20 @@ * OIDC_REDIRECT_URI — https://familysync./callback * OIDC_AUTH_EXTERNAL_URL — https://familysync. — 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 { + 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(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b76a55f..353e53d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,7 +11,11 @@ import { listsRouter, listItemsRouter } from './routes/lists.js'; import { pushRouter } from './routes/push.js'; import { adminRouter } from './routes/admin.js'; import { setupRouter } from './routes/setup.js'; -import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'; +import { + oidcAuthMiddleware, + processOAuthCallback, + oidcConfigFallbackMiddleware, +} from './auth/middleware.js'; import { devAuthBypass } from './auth/devBypass.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; @@ -56,6 +60,13 @@ app.use('/api/*', devAuthBypass()); // OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct // redirect_uri (Pitfall 1). Set it to https://familysync.. if (!devBypassActive) { + // Phase 12 / D-02 / D-03: env-OR-app_config fallback for non-secret OIDC config. + // Reads OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when + // absent from process.env — so a fresh unconfigured instance does not crash at boot + // and a wizard-configured instance reads the DB values before a container restart. + // A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so + // injecting into process.env here is safe and effective (see auth/middleware.ts A2 note). + app.use('/api/*', oidcConfigFallbackMiddleware); app.use('/api/*', oidcAuthMiddleware()); // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). app.use('/api/*', persistSessionCookie()); diff --git a/apps/api/tests/routes/push.test.ts b/apps/api/tests/routes/push.test.ts index b292fb3..e7c2349 100644 --- a/apps/api/tests/routes/push.test.ts +++ b/apps/api/tests/routes/push.test.ts @@ -117,6 +117,7 @@ describe('POST /api/push/subscription', () => { oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), + oidcConfigFallbackMiddleware: async (_c: unknown, next: () => Promise) => next(), })); const { app: freshApp } = await import('../../src/index.js?v=unauth');