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:
@@ -11,6 +11,20 @@
|
|||||||
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
|
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
|
||||||
* OIDC_AUTH_EXTERNAL_URL — https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
|
* 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):
|
* Session persistence (AUTH-02):
|
||||||
* @hono/oidc-auth stores the refresh token in the signed JWT cookie.
|
* @hono/oidc-auth stores the refresh token in the signed JWT cookie.
|
||||||
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
* 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
|
* 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';
|
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();
|
||||||
|
}
|
||||||
|
|||||||
+12
-1
@@ -11,7 +11,11 @@ import { listsRouter, listItemsRouter } from './routes/lists.js';
|
|||||||
import { pushRouter } from './routes/push.js';
|
import { pushRouter } from './routes/push.js';
|
||||||
import { adminRouter } from './routes/admin.js';
|
import { adminRouter } from './routes/admin.js';
|
||||||
import { setupRouter } from './routes/setup.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 { devAuthBypass } from './auth/devBypass.js';
|
||||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||||
import { startBrokerPoller } from './broker/poller.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
|
// 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>.
|
||||||
if (!devBypassActive) {
|
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());
|
app.use('/api/*', oidcAuthMiddleware());
|
||||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||||
app.use('/api/*', persistSessionCookie());
|
app.use('/api/*', persistSessionCookie());
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ describe('POST /api/push/subscription', () => {
|
|||||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) =>
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) =>
|
||||||
c.json({ ok: true }),
|
c.json({ ok: true }),
|
||||||
|
oidcConfigFallbackMiddleware: async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { app: freshApp } = await import('../../src/index.js?v=unauth');
|
const { app: freshApp } = await import('../../src/index.js?v=unauth');
|
||||||
|
|||||||
Reference in New Issue
Block a user