Phase 19: Local Auth (No-OIDC Mode) #23
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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