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
This commit is contained in:
@@ -33,6 +33,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { users } from '../db/schema.js';
|
import { users } from '../db/schema.js';
|
||||||
import { verifyLocalSessionCookie } from './localSession.js';
|
import { verifyLocalSessionCookie } from './localSession.js';
|
||||||
|
import type { DEV_USER } from './devBypass.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a Hono MiddlewareHandler that:
|
* 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).
|
// 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
|
// 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', {
|
c.set('user', {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
oidcIss: row.oidcIss ?? 'local',
|
oidcIss: row.oidcIss ?? 'local',
|
||||||
oidcSub: row.oidcSub ?? String(row.id),
|
oidcSub: row.oidcSub ?? String(row.id),
|
||||||
displayName: row.displayName ?? null,
|
displayName: row.displayName ?? null,
|
||||||
color: row.color ?? '#4A90D9',
|
color: row.color ?? '#4A90D9',
|
||||||
});
|
} as typeof DEV_USER);
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* OIDC authentication middleware wiring.
|
* 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:
|
* Required env vars:
|
||||||
* OIDC_AUTH_SECRET — 32+ char random string for JWT cookie signing (T-02-03)
|
* 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_ISSUER — OIDC issuer URL (middleware fetches /.well-known/openid-configuration)
|
||||||
* OIDC_CLIENT_ID — registered client ID in Authelia
|
* OIDC_CLIENT_ID — registered client ID at the OIDC provider
|
||||||
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
|
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
|
||||||
* 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)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
||||||
* the token endpoint with the stored refresh token — no iframe required (D-12).
|
* 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
|
* 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).
|
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
|
||||||
*
|
*
|
||||||
|
|||||||
+68
-5
@@ -25,6 +25,9 @@ import { startBrokerPoller } from './broker/poller.js';
|
|||||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
|
||||||
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
import { startReminderScheduler } from './broker/reminderScheduler.js';
|
||||||
import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.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';
|
import webpush from 'web-push';
|
||||||
|
|
||||||
export const app = new Hono();
|
export const app = new Hono();
|
||||||
@@ -39,8 +42,63 @@ if (devBypassActive) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
||||||
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
// authorization-code exchange is not itself intercepted by the auth check (T-02-02).
|
||||||
app.get('/callback', (c) => processOAuthCallback(c));
|
//
|
||||||
|
// 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)
|
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
||||||
app.route('/health', healthRouter);
|
app.route('/health', healthRouter);
|
||||||
@@ -68,7 +126,7 @@ app.use('/api/*', devAuthBypass());
|
|||||||
app.use('/api/*', localAuthMiddleware());
|
app.use('/api/*', localAuthMiddleware());
|
||||||
|
|
||||||
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
// 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.
|
// In production devBypassActive is always false — OIDC is unconditionally mounted.
|
||||||
// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
|
// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint.
|
||||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
// 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.
|
// 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
|
// 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).
|
// 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) => {
|
app.use('/api/*', async (c, next) => {
|
||||||
if (c.get('user')) {
|
if (c.get('user')) {
|
||||||
await next();
|
await next();
|
||||||
return;
|
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).
|
// 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());
|
||||||
@@ -99,7 +162,7 @@ if (!devBypassActive) {
|
|||||||
|
|
||||||
// GET /api/login — login entry point for the PWA.
|
// GET /api/login — login entry point for the PWA.
|
||||||
// Flow (production): unauthenticated top-level nav hits the OIDC guard above,
|
// 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
|
// the middleware sets a `continue` cookie pointing back to /api/login, and the
|
||||||
// browser follows it here — now authenticated. The handler then redirects to /
|
// 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
|
// so the SPA boots with a valid session. Under DEV_AUTH_BYPASS the guard is not
|
||||||
|
|||||||
Reference in New Issue
Block a user