From 6ef8e03f8c9fdb8e7d99017e2d24051df25c6c81 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 20:17:32 -0400 Subject: [PATCH] fix(19): CR-03 return 403 for wrong current password so change-password does not log user out --- apps/api/src/routes/me.ts | 7 ++++++- apps/api/tests/routes/me.test.ts | 6 ++++-- apps/pwa/src/api/client.ts | 13 ++++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 6587312..1fff649 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -257,7 +257,12 @@ meRouter.post( // 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); + // CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global + // MutationCache treats any 401 as "session expired" and arms the re-auth + // interstitial / login redirect — so a 401 here would force-log-out a user who + // merely mistyped their current password. 403 is in-app authorization-failure and + // lets the client surface "current password incorrect" without dropping the session. + return c.json({ error: 'Current password incorrect' }, 403); } try { diff --git a/apps/api/tests/routes/me.test.ts b/apps/api/tests/routes/me.test.ts index 7eb6e30..a464bed 100644 --- a/apps/api/tests/routes/me.test.ts +++ b/apps/api/tests/routes/me.test.ts @@ -330,7 +330,7 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => expect(verifyPassword(updatedHash!, oldPassword)).toBe(false); }); - it('Test 2: wrong currentPassword → 401 and update is NOT called', async () => { + it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => { const { db } = await import('../../src/db/client.js'); const realPassword = 'real-password-correct-789'; @@ -374,7 +374,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }), }); - expect(res.status).toBe(401); + // CR-03: wrong current password returns 403 (in-app authz failure), NOT 401. + // A 401 would be interpreted by the PWA as session expiry and log the user out. + expect(res.status).toBe(403); const body = (await res.json()) as { error: string }; expect(body.error).toBe('Current password incorrect'); // Update must NOT have been called diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 2c054c9..7bd52a8 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -141,9 +141,14 @@ export async function fetchLocalLogout(): Promise { * Requires the user's current password and a new password (min 8 chars). * * Status codes: - * 401 → wrong current password (throws Error with code 'wrong-current') - * 422 → validation failure (throws Error with code 'validation') - * other non-ok → generic error + * 403 → wrong current password (throws Error('wrong-current')) — NOT a session expiry + * 401 / opaqueredirect → genuine session expiry (throws SessionExpiredError) + * other non-ok → generic error (throws Error('server')) + * + * CR-03: the server returns 403 (not 401) for an incorrect current password so this + * client can distinguish an in-app authorization failure from a real session expiry. + * Treating that case as 401 would route it to the global MutationCache session-expiry + * handler and forcibly log the user out for a simple mistyped password. */ export async function fetchChangePassword(body: { currentPassword: string; @@ -157,6 +162,8 @@ export async function fetchChangePassword(body: { body: JSON.stringify(body), }); + // 403 → wrong current password (in-app). Check BEFORE the 401 session-expiry branch. + if (res.status === 403) throw new Error('wrong-current'); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (!res.ok) { const detail = (await res.json().catch(() => ({}))) as { code?: string };