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:
+68
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user