/** * GET /api/me — returns the authenticated user's identity, admin role, and provider setup status. * * Flow (normal — OIDC active): * 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie * (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated) * 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback) * then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a * previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10) * 3. Queries users.isAdmin, member_credentials existence, and local_credentials existence * 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup, hasLocalCredential } } * * Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production): * devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware * is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called. * This handler reads c.get('user') first and short-circuits using the dev user's id, * but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check) * and member_credentials/local_credentials existence. * * Security (D-03, T-10-06): * isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary. * The server enforces the role on every /api/admin/* request via requireAdmin (Plan 03). * * The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03). * No credential or refresh-token data is included in the response (T-02-04, T-10-07). */ import { Hono } from 'hono'; import type { Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { Jwt } from 'hono/utils/jwt'; import { randomBytes } from 'node:crypto'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; import { db } from '../db/client.js'; import { users, memberCredentials, localCredentials } from '../db/schema.js'; import { validateEncryptAndStoreCredential, CredentialValidationError, } from '../broker/credentialSync.js'; import { hashPassword, verifyPassword } from '../auth/localCredentials.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; export const meRouter = new Hono(); /** * Looks up isAdmin, needsProviderSetup, and hasLocalCredential for a given userId. * Always reads from the DB — bypass only skips OIDC, not this check (T-10-05). * hasLocalCredential (AUTH-LOCAL-17): true when a local_credentials row exists for userId. */ async function resolveAdminAndSetupStatus(userId: number) { const [userRow] = await db .select({ isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, userId)) .limit(1); const [cred] = await db .select({ id: memberCredentials.id }) .from(memberCredentials) .where(eq(memberCredentials.userId, userId)) .limit(1); // AUTH-LOCAL-17: expose whether the user has a local username/password credential const [localCred] = await db .select({ id: localCredentials.id }) .from(localCredentials) .where(eq(localCredentials.userId, userId)) .limit(1); return { isAdmin: userRow?.isAdmin ?? false, needsProviderSetup: !cred, hasLocalCredential: Boolean(localCred), // AUTH-LOCAL-17 }; } // --------------------------------------------------------------------------- // Auth helper — resolves the current user id from dev-bypass or OIDC session. // Per project convention: duplicated per router (not extracted to shared module). // --------------------------------------------------------------------------- async function resolveUserId(c: Context): Promise { const devUser = c.get('user') as { id: number } | undefined; if (devUser) return devUser.id; const auth = await getAuth(c); if (!auth) return null; const iss = (auth.iss as string | undefined) ?? ''; const sub = auth.sub ?? ''; const displayName = deriveDisplayName(auth); const user = await upsertUser(iss, sub, displayName); return user?.id ?? null; } meRouter.get('/', async (c) => { // Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active. // Use the injected dev identity's id for DB lookups — no OIDC session needed, // but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05). const devUser = c.get('user'); if (devUser) { const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id); return c.json({ user: { id: devUser.id, displayName: devUser.displayName, color: devUser.color, isAdmin, needsProviderSetup, hasLocalCredential, // AUTH-LOCAL-17 }, }); } // Normal OIDC path: getAuth returns null only if the session is invalid. // oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler // is reached, so null here indicates a genuine session error. const auth = await getAuth(c); if (!auth) { return c.json({ error: 'Unauthorized' }, 401); } // iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10). const iss = (auth.iss as string | undefined) ?? ''; const sub = auth.sub ?? ''; // Derive the best available display name from OIDC claims (name → // preferred_username → email → sub fallback). Shared helper keeps every // upsert call site in agreement (see deriveDisplayName). const displayName = deriveDisplayName(auth); const user = await upsertUser(iss, sub, displayName); if (!user) { return c.json({ error: 'Could not resolve user' }, 500); } const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id); return c.json({ user: { id: user.id, displayName: user.displayName, color: user.color, isAdmin, needsProviderSetup, hasLocalCredential, // AUTH-LOCAL-17 }, }); }); // --------------------------------------------------------------------------- // POST /api/me/credential — member self-service credential endpoint (D-07) // // Security contract (T-10-12 Pitfall 6): // - ALWAYS writes to currentUserId from the session — NEVER a body userId. // - Any body.userId field is IGNORED — this endpoint cannot cross-write. // - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path. // - Does NOT require requireAdmin — any authenticated member can set their own credential. // - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400 // with NO echoed password (noEchoHook + CredentialValidationError → 400). // --------------------------------------------------------------------------- const meCredentialSchema = z.object({ // D-07: no userId field — body userId is not accepted (Pitfall 6) providerType: z.literal('caldav'), fastmailEmail: z.string().email().max(256), appPassword: z.string().min(1).max(500), }); /** * noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7). */ const meNoEchoHook = (result: { success: boolean }, c: Context) => { if (!result.success) { return c.json({ error: 'Invalid request' }, 400); } }; meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook), async (c) => { // Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body. const currentUserId = await resolveUserId(c); if (!currentUserId) { return c.json({ error: 'Unauthorized' }, 401); } const { fastmailEmail, appPassword, providerType } = c.req.valid('json'); // T-10-10: NEVER log appPassword or c.req.valid('json') here try { // D-07: identical validate→encrypt→store→sync path as admin, but always with // currentUserId (not a body userId). Admin passes the target member's userId; // self-service passes the authenticated session userId. Same helper, same argument order. await validateEncryptAndStoreCredential( currentUserId, fastmailEmail, appPassword, providerType, ); } catch (err) { if (err instanceof CredentialValidationError) { return c.json({ error: 'Invalid request' }, 400); } console.error( '[me/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/me/password — self-change password (AUTH-LOCAL-09, T-19-07) // // Security contract: // - T-19-07: verifyPassword(current) required before any update; resolveUserId from // session (not body). User can only change their OWN password. // - meNoEchoHook: NEVER return Zod error details (contains submitted passwords, T-19-06). // - Never log currentPassword, newPassword, or c.req.valid('json') (T-19-06). // --------------------------------------------------------------------------- const mePasswordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8), }); meRouter.post( '/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => { // T-19-07: ALWAYS resolve userId from session — never from body const currentUserId = await resolveUserId(c); if (!currentUserId) { return c.json({ error: 'Unauthorized' }, 401); } const { currentPassword, newPassword } = c.req.valid('json'); // T-19-06: NEVER log currentPassword, newPassword, or the request body // Look up the user's local_credentials row (404 if none — no local credential to change) const [credRow] = await db .select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId }) .from(localCredentials) .where(eq(localCredentials.userId, currentUserId)) .limit(1); if (!credRow) { return c.json({ error: 'No local credential found' }, 404); } // T-19-07: verify current password before any update const isCorrect = verifyPassword(credRow.passwordHash, currentPassword); if (!isCorrect) { return c.json({ error: 'Current password incorrect' }, 401); } try { await db .update(localCredentials) .set({ passwordHash: hashPassword(newPassword) }) .where(eq(localCredentials.userId, currentUserId)); return c.json({ ok: true }, 200); } catch (err) { console.error( '[me/POST /password] Unexpected error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } }, ); // --------------------------------------------------------------------------- // POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09) // // Purpose: initiates the OIDC authorization-code flow for the currently-authenticated // local user. The current userId is encoded in a signed OIDC `state` parameter so that // the /callback handler (plan 19-03) can bind the returned iss+sub to this user. // // Security contract (T-19-09 — OIDC-link CSRF): // - state payload: { linkUserId, nonce } signed with LOCAL_SESSION_SECRET (HS256) // - nonce: 16-byte random hex string per request — prevents state replay // - Only the user encoded in `state.linkUserId` is bound on callback (T-19-09) // // Returns: // { signedState: string, authorizationUrl: string | null } // signedState: JWT for the OIDC state parameter (plan 19-03 /callback reads this) // authorizationUrl: OIDC authorization endpoint URL with state, or null if OIDC not configured // // The PWA (Surface 13) redirects the user to authorizationUrl. The actual binding // (UPDATE users + DELETE local_credentials) happens in the /callback handler (19-03). // --------------------------------------------------------------------------- meRouter.post('/link-oidc', async (c) => { const currentUserId = await resolveUserId(c); if (!currentUserId) { return c.json({ error: 'Unauthorized' }, 401); } const secret = process.env.LOCAL_SESSION_SECRET; if (!secret) { return c.json({ error: 'Service unavailable' }, 503); } // Produce a signed state token encoding the current userId + a per-request nonce // T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce) const nonce = randomBytes(16).toString('hex'); const now = Math.floor(Date.now() / 1000); const signedState = await Jwt.sign( { linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window secret, 'HS256', ); // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button) const issuer = process.env.OIDC_ISSUER ?? null; const clientId = process.env.OIDC_CLIENT_ID ?? null; const redirectUri = process.env.OIDC_REDIRECT_URI ?? null; let authorizationUrl: string | null = null; if (issuer && clientId && redirectUri) { // Construct the authorization URL. plan 19-03 will handle the full PKCE flow; // for now encode the signed state so the callback can read linkUserId. const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('scope', 'openid profile email'); url.searchParams.set('state', signedState); authorizationUrl = url.toString(); } return c.json({ signedState, authorizationUrl }, 200); });