From fd35904e39eacc447387343b5bdf32f508b56538 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 16:07:28 -0700 Subject: [PATCH 1/3] fix(auth): require password proof to disable TOTP Send current-password step-up proof with the TOTP disable request so the UI matches the hardened backend contract. Made-with: Cursor --- src/components/TwoFactorCard.js | 5 +++-- src/components/TwoFactorCard.test.js | 32 ++++++++++++++++++++++++++++ src/lib/authService.js | 7 ++++-- src/lib/authService.test.js | 26 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/components/TwoFactorCard.js b/src/components/TwoFactorCard.js index 24540d2..9edda46 100644 --- a/src/components/TwoFactorCard.js +++ b/src/components/TwoFactorCard.js @@ -179,7 +179,8 @@ export default function TwoFactorCard({ setErrCode(null); setSuccess(null); try { - await authService.disableTotp(code); + const oldAuthHash = await deriveStepUpAuthHash(); + await authService.disableTotp({ code, oldAuthHash }); setSetup(null); setCode(''); setCurrentPassword(''); @@ -290,7 +291,7 @@ export default function TwoFactorCard({ ) : null} - {!status.enabled ? ( + {!setup ? (
) : null} - {!setup ? ( + {!recoveryCodes ? (
) : null} - {!recoveryCodes ? ( -
- - setCurrentPassword(e.target.value)} - /> -
- ) : null} +
+ + setCurrentPassword(e.target.value)} + /> +
{setup || status.enabled ? (
diff --git a/src/components/TwoFactorCard.test.js b/src/components/TwoFactorCard.test.js index 26ccd0f..474359f 100644 --- a/src/components/TwoFactorCard.test.js +++ b/src/components/TwoFactorCard.test.js @@ -206,6 +206,31 @@ test('keeps current password editable during active TOTP setup', async () => { expect(password).toHaveValue('NewPass1!'); }); +test('keeps current password editable while recovery codes are displayed', async () => { + const authService = service({ + enableTotp: jest.fn().mockResolvedValue({ + recoveryCodes: ['one', 'two'], + }), + }); + renderCard(authService); + + await waitFor(() => expect(authService.me).toHaveBeenCalled()); + await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled()); + await userEvent.type(screen.getByLabelText(/current password/i), 'Correct1!'); + await userEvent.click( + screen.getByRole('button', { name: /set up two-factor authentication/i }) + ); + await screen.findByLabelText(/manual setup secret fallback/i); + await userEvent.type(screen.getByLabelText(/authenticator code/i), '123456'); + await userEvent.click(screen.getByRole('button', { name: /verify and enable/i })); + + expect(await screen.findByText(/save these recovery codes/i)).toBeInTheDocument(); + const password = screen.getByLabelText(/current password/i); + expect(password).toBeInTheDocument(); + await userEvent.type(password, 'DisablePass1!'); + expect(password).toHaveValue('DisablePass1!'); +}); + test('clears stale TOTP errors after a successful status refresh', async () => { const authService = service({ getTotpStatus: jest