Phase 12: Initial Setup Wizard #22

Merged
luckberg merged 76 commits from gsd/phase-12-initial-setup-wizard into main 2026-06-16 19:10:33 -04:00
3 changed files with 80 additions and 1 deletions
Showing only changes of commit 67a9d29dc1 - Show all commits
+67
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -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');