CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
16 KiB
TypeScript
308 lines
16 KiB
TypeScript
import { fileURLToPath } from 'node:url';
|
|
import { realpathSync } from 'node:fs';
|
|
import { serve } from '@hono/node-server';
|
|
import { serveStatic } from '@hono/node-server/serve-static';
|
|
import { Hono } from 'hono';
|
|
import { healthRouter } from './routes/health.js';
|
|
import { meRouter } from './routes/me.js';
|
|
import { eventsRouter } from './routes/events.js';
|
|
import { sseRouter } from './routes/sse.js';
|
|
import { listsRouter, listItemsRouter } from './routes/lists.js';
|
|
import { pushRouter } from './routes/push.js';
|
|
import { adminRouter } from './routes/admin.js';
|
|
import { setupRouter } from './routes/setup.js';
|
|
import { authModeRouter } from './routes/authMode.js';
|
|
import { localAuthRouter } from './routes/localAuth.js';
|
|
import {
|
|
oidcAuthMiddleware,
|
|
processOAuthCallback,
|
|
oidcConfigFallbackMiddleware,
|
|
} from './auth/middleware.js';
|
|
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
|
|
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
|
import { verifyLocalSessionCookie } from './auth/localSession.js';
|
|
import { consumeLinkNonce } from './auth/linkNonceStore.js';
|
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
|
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();
|
|
|
|
// Compute once at startup: bypass is active only in non-production with explicit opt-in.
|
|
// In production NODE_ENV='production' → devBypassActive=false → OIDC is always mounted.
|
|
const devBypassActive =
|
|
process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true';
|
|
|
|
if (devBypassActive) {
|
|
console.warn('⚠ DEV_AUTH_BYPASS active — OIDC guard DISABLED. Never use in production.');
|
|
}
|
|
|
|
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
|
// 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;
|
|
let linkNonce: string | 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;
|
|
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
|
|
}
|
|
} 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 {
|
|
// IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise
|
|
// replayable for its full 10-minute signature lifetime; consuming the nonce here means
|
|
// a captured state can be used at most once. A replay (already-consumed), unknown, or
|
|
// expired nonce is rejected before any binding occurs.
|
|
if (!linkNonce || !consumeLinkNonce(linkNonce)) {
|
|
console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.');
|
|
return c.redirect('/?error=oidc-link-conflict');
|
|
}
|
|
|
|
// BL-03: cross-check that the local session completing this callback is the SAME
|
|
// user the link flow was initiated for. The signed `state` JWT proves the state was
|
|
// minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
|
|
// is that user. Without this check, an attacker who gets a victim to complete an OIDC
|
|
// login while replaying a still-valid (10-min) captured link state would bind the
|
|
// ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the
|
|
// initiating local session to still be present and to match linkUserId.
|
|
const sessionUserId = await verifyLocalSessionCookie(c);
|
|
if (sessionUserId !== linkUserId) {
|
|
console.warn(
|
|
'[callback] OIDC-link rejected: local session does not match link state (possible replay).',
|
|
);
|
|
return c.redirect('/?error=oidc-link-conflict');
|
|
}
|
|
|
|
const auth = await getAuth(c);
|
|
if (auth) {
|
|
const iss = (auth.iss as string | undefined) ?? '';
|
|
const sub = auth.sub ?? '';
|
|
// BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/
|
|
// oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would
|
|
// both corrupt identity and lock the user out of BOTH auth methods. Reject instead.
|
|
if (!iss || !sub) {
|
|
console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.');
|
|
return c.redirect('/?error=oidc-link-conflict');
|
|
}
|
|
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);
|
|
|
|
// /api/setup/* — pre-auth wizard surface; mounted BEFORE the /api/* middleware chain
|
|
// so the wizard is never caught by devAuthBypass or oidcAuthMiddleware (Pitfall 1 / T-12-09).
|
|
// Mirrors the /health pre-auth pattern. isSetupLocked() in each handler provides the
|
|
// 423 lock after setup is complete (SETUP-04 / D-10).
|
|
app.route('/api/setup', setupRouter);
|
|
|
|
// Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout.
|
|
// Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05).
|
|
app.route('/api/auth', authModeRouter);
|
|
app.route('/api/auth', localAuthRouter);
|
|
|
|
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'.
|
|
// When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted.
|
|
// Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
|
|
app.use('/api/*', devAuthBypass());
|
|
|
|
// Phase 19 Option C (AUTH-LOCAL-16, D-14/D-15): issue a real local-session cookie for DEV_USER
|
|
// under bypass so the PWA login gate sees a valid session and skips /login. Pure no-op outside
|
|
// bypass mode (production guard is FIRST check — T-19-24; see auth/devBypass.ts).
|
|
// Mount AFTER devAuthBypass() so DEV_USER is already in context; BEFORE localAuthMiddleware.
|
|
app.use('/api/*', devSessionCookieMiddleware());
|
|
|
|
// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie.
|
|
// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated.
|
|
// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard.
|
|
// The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard).
|
|
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 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
|
|
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
|
if (!devBypassActive) {
|
|
// Phase 12 / D-02 / D-03: env-OR-app_config fallback for non-secret OIDC config.
|
|
// Reads OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when
|
|
// absent from process.env — so a fresh unconfigured instance does not crash at boot
|
|
// and a wizard-configured instance reads the DB values before a container restart.
|
|
// A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so
|
|
// injecting into process.env here is safe and effective (see auth/middleware.ts A2 note).
|
|
app.use('/api/*', oidcConfigFallbackMiddleware);
|
|
// 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;
|
|
}
|
|
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
|
|
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
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());
|
|
}
|
|
|
|
// Protected API routes (behind oidcAuthMiddleware)
|
|
|
|
// GET /api/login — login entry point for the PWA.
|
|
// Flow (production): unauthenticated top-level nav hits the OIDC guard above,
|
|
// 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
|
|
// mounted, so /api/login reaches this handler directly and still redirects to /.
|
|
app.get('/api/login', (c) => c.redirect('/'));
|
|
|
|
app.route('/api/me', meRouter);
|
|
app.route('/api/events', eventsRouter);
|
|
app.route('/api/sse', sseRouter);
|
|
app.route('/api/lists', listsRouter);
|
|
app.route('/api/list-items', listItemsRouter);
|
|
app.route('/api/push', pushRouter);
|
|
app.route('/api/admin', adminRouter);
|
|
|
|
// WR-04: background worker startup (cron schedules) moved into the isMainModule()
|
|
// guard below. Calling them at top level registered real node-cron schedules whenever
|
|
// ./index.js was imported — the route tests import `app` from here, so the poller/outbox
|
|
// drain fired during the test run, touched the mocked DB/CalDAV layers nondeterministically,
|
|
// and left open handles that blocked clean process exit. They now start only when the
|
|
// module is the process entrypoint.
|
|
|
|
// Serve React PWA static assets from ./public (Vite build output).
|
|
// MUST serve the whole ./public tree, not just /assets/* — root-level PWA files
|
|
// (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js, icon-*.png,
|
|
// apple-touch-icon.png) live at the root. serveStatic calls next() when a file
|
|
// is not found, so SPA routes fall through to the index.html catch-all below.
|
|
// (Registered AFTER /health, /api/*, and /callback, so those win.)
|
|
app.use('/*', serveStatic({ root: './public' }));
|
|
app.get('*', serveStatic({ path: './public/index.html' }));
|
|
|
|
/**
|
|
* True only when this module is the process entrypoint (run directly), not when it
|
|
* is imported (e.g. by route tests that import `app`).
|
|
*
|
|
* WR-05: the previous basename-tail comparison
|
|
* import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))
|
|
* was fragile — a symlinked entrypoint or a differently-located file sharing the same
|
|
* basename could make it mis-fire (start the server during an unrelated import, or fail
|
|
* to start it in production). Compare fully-resolved real paths instead. realpathSync
|
|
* resolves symlinks on argv[1]; fileURLToPath turns the module URL into a real path.
|
|
*/
|
|
function isMainModule(): boolean {
|
|
if (!process.argv[1]) return false;
|
|
try {
|
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- process.argv[1] is the Node runtime entry path, not user input
|
|
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Only start the HTTP server AND background workers when this module is run directly
|
|
// (not imported in tests). WR-04: gating the cron schedules here keeps them out of the
|
|
// test process.
|
|
if (isMainModule()) {
|
|
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
|
|
assertNotDevBypassInProduction();
|
|
// D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode.
|
|
assertLocalSessionSecretSet();
|
|
|
|
// Configure VAPID credentials for web-push before starting background workers.
|
|
// VAPID_SUBJECT must be a mailto: or https: URL identifying the operator.
|
|
// The private key is NEVER served to clients; it signs push requests server-side only.
|
|
const vapidSubject = process.env.VAPID_SUBJECT ?? '';
|
|
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY ?? '';
|
|
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY ?? '';
|
|
if (vapidSubject && vapidPublicKey && vapidPrivateKey) {
|
|
try {
|
|
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
|
|
} catch (err) {
|
|
console.warn(
|
|
'[startup] setVapidDetails failed — push notifications will not work:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
}
|
|
} else {
|
|
console.warn(
|
|
'[startup] VAPID env vars not set — push notifications will fail. Set VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY.',
|
|
);
|
|
}
|
|
|
|
// Start the CalDAV broker poller (5-min cron, D-13 ctag change-detection).
|
|
// Runs in the background — errors are caught and logged per-credential (T-03-04).
|
|
startBrokerPoller();
|
|
// Drain the D-05 outbox every 15s: dispatches pending CalDAV writes to Fastmail.
|
|
startOutboxWorker();
|
|
initOutboxTrigger(); // subscribe drain signal listener (D-01)
|
|
// Start the 1-min reminder scan for shared timed events starting in ~15 min (NOTIF-01).
|
|
// VAPID must be configured (above) before this starts or push sends will fail.
|
|
startReminderScheduler();
|
|
|
|
serve({ fetch: app.fetch, port: 3000 }, (info) => {
|
|
console.log(`FamilySync API running on http://localhost:${info.port}`);
|
|
});
|
|
}
|