feat(12-02): implement setup router — all 7 pre-auth routes + guard-first pattern

- Fill setupRouter: GET /status, POST /config, POST /validate/{db,oidc,vapid},
  POST /credential, POST /complete (SETUP-01/02)
- isSetupLocked() is FIRST statement in every handler; returns 423 if locked (SETUP-04/D-10)
- /status uses isSetupLocked() directly: covers both explicit + effective-config branches
- /config: zod-validates {oidcIssuer:https, oidcClientId, vapidPublicKey, appExternalUrl};
  upserts oidc_issuer|oidc_client_id|vapid_public_key|app_external_url into app_config
- /validate/db: db.execute(sql`SELECT 1`); 200 ok, 503 on failure
- /validate/oidc: fetches discovery doc with AbortSignal.timeout(5000); reads oidc_issuer
  from app_config; 200 ok, 400 on unreachable/non-2xx
- /validate/vapid: webpush.setVapidDetails() structural check; reads ONLY from process.env
  (VAPID_PRIVATE_KEY never from app_config, never returned; T-12-06/SC-3)
- /credential: inserts local user (oidcIss=null, claimed=false, isAdmin=true) FIRST
  (Pitfall 5 FK), then calls validateEncryptAndStoreCredential(); noEchoHook + error map
- /complete: upserts setup_complete='true'; 200 first call, 423 second (Pitfall 8/D-10)
- Mount setupRouter pre-auth in index.ts BEFORE devAuthBypass() (T-12-09/Pitfall 1)
- All 394 tests pass (5 todo = D-08 RED scaffolds); typecheck clean
This commit is contained in:
Lucas Berger
2026-06-15 13:58:51 -04:00
parent 4748d578e7
commit 20f91e4548
2 changed files with 321 additions and 4 deletions
+7
View File
@@ -10,6 +10,7 @@ import { sseRouter } from './routes/sse.js';
import { listsRouter, listItemsRouter } from './routes/lists.js'; import { listsRouter, listItemsRouter } from './routes/lists.js';
import { pushRouter } from './routes/push.js'; import { pushRouter } from './routes/push.js';
import { adminRouter } from './routes/admin.js'; import { adminRouter } from './routes/admin.js';
import { setupRouter } from './routes/setup.js';
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'; import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
import { devAuthBypass } from './auth/devBypass.js'; import { devAuthBypass } from './auth/devBypass.js';
import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js';
@@ -37,6 +38,12 @@ app.get('/callback', (c) => processOAuthCallback(c));
// 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);
// /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);
// Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // 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. // 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). // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts).
+314 -4
View File
@@ -5,13 +5,323 @@
* Mount position: BEFORE app.use('/api/*', devAuthBypass()) so the wizard * Mount position: BEFORE app.use('/api/*', devAuthBypass()) so the wizard
* is reachable pre-authentication — same pre-auth surface as /health (T-01-03). * is reachable pre-authentication — same pre-auth surface as /health (T-01-03).
* *
* Wave-0 stub (Plan 01): empty router — all handlers are added in Plan 02. * Security contract (Pitfall 8, T-12-04, T-12-05, T-12-06):
* Plan 02 owns the index.ts mount as well, to keep file-ownership clean. * - Every handler calls isSetupLocked() as its FIRST statement; returns 423 if locked.
* - noEchoHook: Zod validation errors for the credential step NEVER return received values.
* - App password is NEVER logged or echoed (T-12-05).
* - VAPID_PRIVATE_KEY is read ONLY from process.env — never from app_config or returned (T-12-06).
* - validateEncryptAndStoreCredential is the ONLY credential-handling path (D-09 / no new crypto).
* - Shared helper called directly — no admin route invocation (pre-auth endpoint cannot reach it).
* *
* Security note: every handler added in Plan 02 MUST call isSetupLocked() as * Routes:
* its first statement and return 423 if locked (SETUP-04 / Pitfall 8 / D-10). * GET /api/setup/status → { setupComplete: boolean }
* POST /api/setup/config → upsert oidc_issuer, oidc_client_id, vapid_public_key, app_external_url
* POST /api/setup/validate/db → SELECT 1 connectivity check
* POST /api/setup/validate/oidc → fetch {issuer}/.well-known/openid-configuration
* POST /api/setup/validate/vapid → setVapidDetails structural check (env keys only)
* POST /api/setup/credential → insert local user + validateEncryptAndStoreCredential
* POST /api/setup/complete → set app_config.setup_complete='true'
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, appConfig } from '../db/schema.js';
import { isSetupLocked } from '../lib/setupGuard.js';
import { COLOR_PALETTE } from '../auth/user.js';
import {
validateEncryptAndStoreCredential,
CredentialValidationError,
} from '../broker/credentialSync.js';
import webpush from 'web-push';
export const setupRouter = new Hono(); export const setupRouter = new Hono();
// ---------------------------------------------------------------------------
// noEchoHook — NEVER return Zod error details for credential endpoints (T-12-05 / Pitfall 7).
// Zod's error object contains issues[].received which echoes the submitted value.
// Always return { error: 'Invalid request' } 400, no other fields.
// ---------------------------------------------------------------------------
const noEchoHook = (result: { success: boolean }, c: Context) => {
if (!result.success) {
return c.json({ error: 'Invalid request' }, 400);
}
};
// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------
const configSchema = z.object({
oidcIssuer: z
.string()
.url()
.refine((v) => v.startsWith('https://'), { message: 'oidcIssuer must be an https URL' }),
oidcClientId: z.string().min(1).max(256),
vapidPublicKey: z.string().min(1).max(512),
appExternalUrl: z.string().url().max(512),
});
const credentialSchema = z.object({
fastmailEmail: z.string().email().max(256),
appPassword: z.string().min(1).max(500),
});
// ---------------------------------------------------------------------------
// GET /api/setup/status
//
// Returns { setupComplete: boolean } derived from app_config.setup_complete.
// Reachable pre-auth (no OIDC guard). Does NOT check the 423 guard — status
// is always readable so the PWA can decide whether to show the wizard.
// ---------------------------------------------------------------------------
setupRouter.get('/status', async (c) => {
// isSetupLocked() is the authoritative check for both explicit and effective-config
// completion (D-10). Using it here keeps the status response in sync with the guard
// without a second DB read pattern (covers setup_complete AND effective-config).
const locked = await isSetupLocked();
return c.json({ setupComplete: locked });
});
// ---------------------------------------------------------------------------
// POST /api/setup/config
//
// Collects non-secret operator config and upserts into app_config:
// oidc_issuer, oidc_client_id, vapid_public_key, app_external_url
//
// Validates: oidcIssuer must be an https URL (T-12-08 SSRF mitigation).
// All other fields are non-secret (D-01 / D-02).
// ---------------------------------------------------------------------------
setupRouter.post('/config', zValidator('json', configSchema), async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
const { oidcIssuer, oidcClientId, vapidPublicKey, appExternalUrl } = c.req.valid('json');
await db
.insert(appConfig)
.values({ key: 'oidc_issuer', value: oidcIssuer })
.onDuplicateKeyUpdate({ set: { value: oidcIssuer } });
await db
.insert(appConfig)
.values({ key: 'oidc_client_id', value: oidcClientId })
.onDuplicateKeyUpdate({ set: { value: oidcClientId } });
await db
.insert(appConfig)
.values({ key: 'vapid_public_key', value: vapidPublicKey })
.onDuplicateKeyUpdate({ set: { value: vapidPublicKey } });
await db
.insert(appConfig)
.values({ key: 'app_external_url', value: appExternalUrl })
.onDuplicateKeyUpdate({ set: { value: appExternalUrl } });
return c.json({ ok: true }, 200);
});
// ---------------------------------------------------------------------------
// POST /api/setup/validate/db
//
// Proves DB connectivity via SELECT 1.
// Returns 200 { ok: true } on success, 503 on failure.
// ---------------------------------------------------------------------------
setupRouter.post('/validate/db', async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
try {
await db.execute(sql`SELECT 1`);
return c.json({ ok: true }, 200);
} catch (err) {
console.error('[setup/validate/db] DB round-trip failed:', err instanceof Error ? err.message : String(err));
return c.json({ ok: false, error: 'DB unavailable' }, 503);
}
});
// ---------------------------------------------------------------------------
// POST /api/setup/validate/oidc
//
// Validates OIDC issuer by fetching {issuer}/.well-known/openid-configuration.
// Reads oidc_issuer from app_config (written by /config step).
// 5-second timeout via AbortSignal.timeout (Node.js 18+).
// Returns 200 { ok: true } on success, 400 { ok: false } on failure.
// ---------------------------------------------------------------------------
setupRouter.post('/validate/oidc', async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'oidc_issuer'))
.limit(1);
const issuer = row?.value;
if (!issuer) {
return c.json({ ok: false, error: 'OIDC issuer not configured' }, 400);
}
try {
const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return c.json({ ok: true }, 200);
} catch (err) {
return c.json({
ok: false,
error: 'OIDC discovery failed: ' + (err instanceof Error ? err.message : String(err)),
}, 400);
}
});
// ---------------------------------------------------------------------------
// POST /api/setup/validate/vapid
//
// Validates VAPID key pair structure by calling webpush.setVapidDetails().
// Reads BOTH keys ONLY from process.env — NEVER from app_config (T-12-06 / D-01 / SC-3).
// VAPID_PRIVATE_KEY is never returned in any response.
// Returns 200 { ok: true } on structural validity, 400 { ok: false } on failure.
// ---------------------------------------------------------------------------
setupRouter.post('/validate/vapid', async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
const privateKey = process.env.VAPID_PRIVATE_KEY ?? '';
const publicKey = process.env.VAPID_PUBLIC_KEY ?? '';
if (!privateKey || !publicKey) {
return c.json({ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, 400);
}
try {
// setVapidDetails runs validatePrivateKey (32-byte check) and validatePublicKey (65-byte check)
// internally — this is the library's own structural validation (Pattern 7).
// Using a placeholder subject for validation only (actual subject used at startup in index.ts).
webpush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:validate@familysync.local',
publicKey,
privateKey,
);
return c.json({ ok: true }, 200);
} catch (err) {
return c.json({
ok: false,
error: err instanceof Error ? err.message : 'VAPID validation failed',
}, 400);
}
});
// ---------------------------------------------------------------------------
// POST /api/setup/credential
//
// Creates the pre-OIDC local user (oidcIss=null, oidcSub=null, claimed=false,
// is_admin=true) FIRST — FK constraint on member_credentials requires the user
// row to exist before inserting the credential (Pitfall 5).
//
// Then calls validateEncryptAndStoreCredential() to validate + encrypt + store
// the Fastmail app password (D-09 — reuses shared helper, no new crypto).
//
// Security (T-12-05 / Pitfall 7):
// - noEchoHook prevents Zod error details from including the app password.
// - app password is NEVER logged or echoed.
// - CredentialValidationError maps to generic 400.
// ---------------------------------------------------------------------------
setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook), async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
const { fastmailEmail, appPassword } = c.req.valid('json');
// T-12-05: NEVER log appPassword or c.req.valid('json') here
// Step 1: Assign color (first unused from palette, or round-robin fallback)
const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color));
const color =
COLOR_PALETTE.find((col) => !usedColors.has(col)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// Step 2: Insert the local user (oidcIss=null, oidcSub=null, claimed=false)
// mysql2 has no RETURNING clause — use $returningId() then re-select (Pattern 4)
const [inserted] = await db
.insert(users)
.values({
oidcIss: null,
oidcSub: null,
displayName: null,
color,
isAdmin: true,
claimed: false,
})
.$returningId();
const [localUser] = await db
.select()
.from(users)
.where(eq(users.id, inserted.id))
.limit(1);
if (!localUser) {
return c.json({ error: 'Service unavailable' }, 503);
}
// Step 3: Validate + encrypt + store the credential via the shared helper (D-09)
// The user row MUST exist before this call (Pitfall 5 — FK constraint).
try {
await validateEncryptAndStoreCredential(
localUser.id,
fastmailEmail,
appPassword,
'caldav',
);
} catch (err) {
// Roll back the local user insert on credential failure to avoid orphaned rows
await db.delete(users).where(eq(users.id, localUser.id));
if (err instanceof CredentialValidationError) {
// T-12-05: map validation failure to generic 400 — no echo of password
return c.json({ error: 'Invalid request' }, 400);
}
// Unexpected errors (DB failure, network, etc.) — log message only, no credential data
console.error(
'[setup/POST /credential] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
}
return c.json({ ok: true }, 200);
});
// ---------------------------------------------------------------------------
// POST /api/setup/complete
//
// Marks setup as complete by setting app_config.setup_complete='true'.
// The guard re-evaluates on the NEXT call — second call returns 423 (Pitfall 8).
// Returns 200 { ok: true } on the first (successful) call.
// ---------------------------------------------------------------------------
setupRouter.post('/complete', async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
await db
.insert(appConfig)
.values({ key: 'setup_complete', value: 'true' })
.onDuplicateKeyUpdate({ set: { value: 'true' } });
return c.json({ ok: true }, 200);
});