Files
familysync/apps/api/src/auth/middleware.ts
T
Lucas Berger 9b569efeab feat(19-03): wire /callback link branch, OIDC-guard skip, de-Authelia comments
- /callback: reads signed state, extracts linkUserId, calls linkOidcToUser after OIDC session set; OidcLinkConflictError redirects to /?error=oidc-link-conflict
- OIDC guard: oidcAuthMiddleware() factory called once at construction, handler invoked per-request inside skip-when-user-set wrapper (D-03)
- middleware.ts: de-Authelia-ize comments — generic OIDC identity provider language (D-06, AUTH-LOCAL-18)
- localAuthMiddleware.ts: cast to typeof DEV_USER for ContextVariableMap type compatibility
- All 446 tests pass; typecheck clean
2026-06-17 16:59:06 -04:00

115 lines
5.5 KiB
TypeScript

/**
* OIDC authentication middleware wiring.
*
* Configures @hono/oidc-auth for the generic OIDC identity provider (D-06).
*
* Required env vars:
* OIDC_AUTH_SECRET — 32+ char random string for JWT cookie signing (T-02-03)
* OIDC_ISSUER — OIDC issuer URL (middleware fetches /.well-known/openid-configuration)
* OIDC_CLIENT_ID — registered client ID at the OIDC provider
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
* 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
* the token endpoint with the stored refresh token — no iframe required (D-12).
* Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and
* the OIDC provider's refresh_token_lifespan.
*
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
*
* 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).
//
// WR-03 — single-write semantics: once written, process.env is NOT re-read
// from DB on subsequent requests (the needsXxx guard above is false once set).
// Consequence: if the operator changes these values via the wizard after the
// container is already running, the in-process value is stale until restart.
// A container restart is required to pick up any changed OIDC config values.
if (key === 'oidc_issuer') {
console.info(
'[oidcFallback] Writing OIDC_ISSUER from app_config — container restart required to update this value',
);
process.env.OIDC_ISSUER = row.value;
}
if (key === 'oidc_client_id') {
console.info(
'[oidcFallback] Writing OIDC_CLIENT_ID from app_config — container restart required to update this value',
);
process.env.OIDC_CLIENT_ID = row.value;
}
if (key === 'app_external_url') {
console.info(
'[oidcFallback] Writing OIDC_AUTH_EXTERNAL_URL from app_config — container restart required to update this value',
);
process.env.OIDC_AUTH_EXTERNAL_URL = row.value;
}
}
}
}
await next();
}