Files
familysync/apps/api/src/index.ts
T
Lucas Berger be7a0aec90 feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts
- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path)
- authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config)
- localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes
- index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set
2026-06-17 16:48:17 -04:00

198 lines
10 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 } from './auth/devBypass.js';
import { localAuthMiddleware } from './auth/localAuthMiddleware.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 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)
app.get('/callback', (c) => processOAuthCallback(c));
// 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 — 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 Authelia.
// 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).
app.use('/api/*', async (c, next) => {
if (c.get('user')) {
await next();
return;
}
await oidcAuthMiddleware()(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 Authelia. After login, Authelia 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}`);
});
}