diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 83b6082..5cd2640 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -7,15 +7,15 @@ * 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 and member_credentials existence for the resolved user - * 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup } } + * 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 existence. + * 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. @@ -33,19 +33,21 @@ import { eq } from 'drizzle-orm'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; import { db } from '../db/client.js'; -import { users, memberCredentials } from '../db/schema.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 and needsProviderSetup for a given userId. + * 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 @@ -60,9 +62,17 @@ async function resolveAdminAndSetupStatus(userId: number) { .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 }; } @@ -88,10 +98,10 @@ async function resolveUserId(c: Context): Promise { 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 and needsProviderSetup are still resolved from the DB (T-10-05). + // but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05). const devUser = c.get('user'); if (devUser) { - const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id); + const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id); return c.json({ user: { id: devUser.id, @@ -99,6 +109,7 @@ meRouter.get('/', async (c) => { color: devUser.color, isAdmin, needsProviderSetup, + hasLocalCredential, // AUTH-LOCAL-17 }, }); } @@ -126,7 +137,7 @@ meRouter.get('/', async (c) => { return c.json({ error: 'Could not resolve user' }, 500); } - const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id); + const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id); return c.json({ user: { @@ -135,6 +146,7 @@ meRouter.get('/', async (c) => { color: user.color, isAdmin, needsProviderSetup, + hasLocalCredential, // AUTH-LOCAL-17 }, }); }); @@ -200,3 +212,65 @@ meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook 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); + } + }, +);