diff --git a/apps/api/src/auth/localAuthMiddleware.ts b/apps/api/src/auth/localAuthMiddleware.ts index f4eef0b..b952137 100644 --- a/apps/api/src/auth/localAuthMiddleware.ts +++ b/apps/api/src/auth/localAuthMiddleware.ts @@ -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(); }; diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 1737635..d730a94 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -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./callback * OIDC_AUTH_EXTERNAL_URL — https://familysync. — 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). * diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6bd5dd3..205afe7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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