Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
3 changed files with 76 additions and 11 deletions
Showing only changes of commit 9b569efeab - Show all commits
+4 -2
View File
@@ -33,6 +33,7 @@ import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
import { verifyLocalSessionCookie } from './localSession.js';
import type { DEV_USER } from './devBypass.js';
/**
* Returns a Hono MiddlewareHandler that:
@@ -82,14 +83,15 @@ export function localAuthMiddleware(): MiddlewareHandler {
// Populate c.get('user') with the same shape as DEV_USER (devBypass.ts ContextVariableMap).
// oidcIss/oidcSub: local users have nullable oidcIss/oidcSub — use fallback strings so the
// shape matches typeof DEV_USER (all required fields, no undefined in the value object).
// shape is compatible with typeof DEV_USER at runtime. Cast required because ContextVariableMap
// is narrowed to the const DEV_USER literal type.
c.set('user', {
id: row.id,
oidcIss: row.oidcIss ?? 'local',
oidcSub: row.oidcSub ?? String(row.id),
displayName: row.displayName ?? null,
color: row.color ?? '#4A90D9',
});
} as typeof DEV_USER);
await next();
};
+4 -4
View File
@@ -1,12 +1,12 @@
/**
* OIDC authentication middleware wiring.
*
* Configures @hono/oidc-auth for Authelia as the identity provider.
* 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 — Authelia base URL (middleware fetches /.well-known/openid-configuration)
* OIDC_CLIENT_ID — registered client ID in Authelia
* 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)
@@ -30,7 +30,7 @@
* 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
* Authelia's refresh_token_lifespan.
* the OIDC provider's refresh_token_lifespan.
*
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
*
+68 -5
View File
@@ -25,6 +25,9 @@ import { startBrokerPoller } from './broker/poller.js';
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
import { startReminderScheduler } from './broker/reminderScheduler.js';
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js';
import { getAuth } from './auth/middleware.js';
import { Jwt } from 'hono/utils/jwt';
import { linkOidcToUser, OidcLinkConflictError } from './auth/linkOidc.js';
import webpush from 'web-push';
export const app = new Hono();
@@ -39,8 +42,63 @@ if (devBypassActive) {
}
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
app.get('/callback', (c) => processOAuthCallback(c));
// authorization-code exchange is not itself intercepted by the auth check (T-02-02).
//
// Phase 19 — link mode (AUTH-LOCAL-10, T-19-15):
// When POST /api/me/link-oidc initiates a link flow, a signed JWT state is included
// in the authorization URL as the `state` parameter. On callback, we read that raw URL
// state param, try to decode it as our signed JWT, and if it carries `linkUserId`, we
// call linkOidcToUser after processOAuthCallback establishes the OIDC session.
//
// Security: the signed state JWT prevents CSRF (T-19-09); linkOidcToUser preflight
// prevents account takeover via conflict (T-19-15 / T-19-08).
//
// Normal (non-link) callbacks are unaffected — processOAuthCallback is called in all paths.
app.get('/callback', async (c) => {
// Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback
// consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
let linkUserId: number | null = null;
const rawState = c.req.query('state');
if (rawState) {
const secret = process.env.LOCAL_SESSION_SECRET;
if (secret) {
try {
const payload = await Jwt.verify(rawState, secret, 'HS256');
if (typeof payload.linkUserId === 'number') {
linkUserId = payload.linkUserId;
}
} catch {
// Not our signed link state — normal OIDC callback, proceed normally.
}
}
}
// Process the OIDC authorization-code exchange (sets the OIDC session cookie + redirects).
const callbackResponse = await processOAuthCallback(c);
// Link mode: after session is established, bind the OIDC identity to the local user.
if (linkUserId !== null) {
try {
const auth = await getAuth(c);
if (auth) {
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
await linkOidcToUser(linkUserId, iss, sub);
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
}
} catch (err) {
if (err instanceof OidcLinkConflictError) {
// 409: iss+sub already linked to a different user — redirect to conflict error page.
// UI-SPEC Surface 13 error copy: "This OIDC identity is already linked to another account."
return c.redirect('/?error=oidc-link-conflict');
}
// Unexpected error during link binding — log and continue with normal redirect.
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err));
}
}
return callbackResponse;
});
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
app.route('/health', healthRouter);
@@ -68,7 +126,7 @@ app.use('/api/*', devAuthBypass());
app.use('/api/*', localAuthMiddleware());
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
// Skipped entirely when devBypassActive so that local dev works without Authelia.
// Skipped entirely when devBypassActive so that local dev works without the OIDC provider.
// In production devBypassActive is always false — OIDC is unconditionally mounted.
// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
@@ -84,12 +142,17 @@ if (!devBypassActive) {
// Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set.
// A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC
// provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1).
//
// IMPORTANT: oidcAuthMiddleware() factory is called ONCE at app construction time (not per
// request) to match the prior behavior and keep test assertions about "called once" valid.
// The returned handler is stored and invoked per-request inside the wrapper.
const oidcHandler = oidcAuthMiddleware();
app.use('/api/*', async (c, next) => {
if (c.get('user')) {
await next();
return;
}
await oidcAuthMiddleware()(c, next);
await oidcHandler(c, next);
});
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
app.use('/api/*', persistSessionCookie());
@@ -99,7 +162,7 @@ if (!devBypassActive) {
// GET /api/login — login entry point for the PWA.
// Flow (production): unauthenticated top-level nav hits the OIDC guard above,
// which 302-redirects to Authelia. After login, Authelia POSTs to /callback,
// which 302-redirects to the OIDC provider. After login, the provider POSTs to /callback,
// the middleware sets a `continue` cookie pointing back to /api/login, and the
// browser follows it here — now authenticated. The handler then redirects to /
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not