Phase 12: Initial Setup Wizard #22
@@ -10,6 +10,7 @@ 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 { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
|
||||
import { devAuthBypass } from './auth/devBypass.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)
|
||||
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'.
|
||||
// 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).
|
||||
|
||||
@@ -5,13 +5,323 @@
|
||||
* Mount position: BEFORE app.use('/api/*', devAuthBypass()) so the wizard
|
||||
* 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.
|
||||
* Plan 02 owns the index.ts mount as well, to keep file-ownership clean.
|
||||
* Security contract (Pitfall 8, T-12-04, T-12-05, T-12-06):
|
||||
* - 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
|
||||
* its first statement and return 423 if locked (SETUP-04 / Pitfall 8 / D-10).
|
||||
* Routes:
|
||||
* 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 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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user