feat(19-02): self-change password and hasLocalCredential on GET /api/me

- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
This commit is contained in:
Lucas Berger
2026-06-17 16:34:10 -04:00
parent 80b5906bb8
commit c88f7d41e5
+82 -8
View File
@@ -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<number | 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 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);
}
},
);