From 9e1aac9bca8b3e97f7a5e5e5e5f0aa225216fca3 Mon Sep 17 00:00:00 2001 From: MagentaManifold <17zhaomingyuan@gmail.com> Date: Fri, 24 Oct 2025 18:54:40 -0400 Subject: [PATCH] fix(settings): redirect user on AAL mismatch in mfa guard Because: * General Application Error when interacting with site after 2FA enabled This commit: * redirects user on AAL mismatch in mfa guard Closes #FXA-12576 --- .../Settings/MfaGuard/index.test.tsx | 91 +++++++++++++++---- .../components/Settings/MfaGuard/index.tsx | 75 +++++++++------ .../PageChangePassword/index.test.tsx | 16 +++- .../PageSecondaryEmailAdd/index.test.tsx | 49 +++++++--- .../PageSecondaryEmailVerify/index.test.tsx | 59 +++++++++--- .../UnitRowRecoveryKey/index.test.tsx | 8 +- .../UnitRowSecondaryEmail/index.test.tsx | 3 + .../UnitRowTwoStepAuth/index.test.tsx | 3 + 8 files changed, 227 insertions(+), 77 deletions(-) diff --git a/packages/fxa-settings/src/components/Settings/MfaGuard/index.test.tsx b/packages/fxa-settings/src/components/Settings/MfaGuard/index.test.tsx index a2cccfa48c2..0348941c3c5 100644 --- a/packages/fxa-settings/src/components/Settings/MfaGuard/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/MfaGuard/index.test.tsx @@ -24,9 +24,13 @@ const mockAuthClient = { } return Promise.reject(AuthUiErrors.INVALID_EXPIRED_OTP_CODE); }), + sessionStatus: jest.fn().mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: true }, + }), }; const mockAlertBar = { error: jest.fn() }; const mockNavigate = jest.fn(); +const mockNavigateWithQuery = jest.fn(); jest.mock('../../../lib/cache', () => { const actual = jest.requireActual('../../../lib/cache'); @@ -43,6 +47,10 @@ jest.mock('../../../models', () => ({ useAlertBar: () => mockAlertBar, })); +jest.mock('../../../lib/hooks/useNavigateWithQuery', () => ({ + useNavigateWithQuery: () => mockNavigateWithQuery, +})); + jest.mock('@reach/router', () => ({ ...jest.requireActual('@reach/router'), useNavigate: () => mockNavigate, @@ -50,7 +58,7 @@ jest.mock('@reach/router', () => ({ async function submitCode(otp: string = mockOtp) { await userEvent.type( - screen.getByRole('textbox', { name: 'Enter 6-digit code' }), + await screen.findByRole('textbox', { name: 'Enter 6-digit code' }), otp ); await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); @@ -61,6 +69,10 @@ describe('MfaGuard', () => { JwtTokenCache.removeToken(mockSessionToken, mockScope); MfaOtpRequestCache.remove(mockSessionToken, mockScope); jest.clearAllMocks(); + + mockAuthClient.sessionStatus.mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: true }, + }); }); it('requests OTP and shows modal when JWT missing', async () => { @@ -72,10 +84,12 @@ describe('MfaGuard', () => { ); - expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalledWith( - mockSessionToken, - mockScope - ); + await waitFor(() => { + expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalledWith( + mockSessionToken, + mockScope + ); + }); expect( await screen.findByText('Enter confirmation code') @@ -110,7 +124,7 @@ describe('MfaGuard', () => { }); }); - it('renders children when JWT exists', () => { + it('renders children when JWT exists', async () => { JwtTokenCache.setToken(mockSessionToken, mockScope, 'jwt-present'); renderWithRouter( @@ -121,7 +135,7 @@ describe('MfaGuard', () => { ); - expect(screen.getByText('secured')).toBeInTheDocument(); + expect(await screen.findByText('secured')).toBeInTheDocument(); expect( screen.queryByText('Enter confirmation code') ).not.toBeInTheDocument(); @@ -160,7 +174,9 @@ describe('MfaGuard', () => { ); - expect(screen.queryByText('Enter confirmation code')).toBeInTheDocument(); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); await submitCode('654321'); expect( @@ -181,7 +197,9 @@ describe('MfaGuard', () => { ); - expect(screen.getByText('Enter confirmation code')).toBeInTheDocument(); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); await submitCode('654321'); expect( await screen.findByText('Invalid or expired confirmation code') @@ -209,7 +227,7 @@ describe('MfaGuard', () => { // Trigger an error first await userEvent.type( - screen.getByRole('textbox', { name: 'Enter 6-digit code' }), + await screen.findByRole('textbox', { name: 'Enter 6-digit code' }), '654321' ); await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); @@ -218,7 +236,7 @@ describe('MfaGuard', () => { ).toBeInTheDocument(); await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); expect( await screen.findByText('A new code was sent to your email.') @@ -242,7 +260,7 @@ describe('MfaGuard', () => { ); await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); expect( await screen.findByText('A new code was sent to your email.') @@ -253,11 +271,46 @@ describe('MfaGuard', () => { ); await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); expect(await screen.findByText('Unexpected error')).toBeInTheDocument(); }); + it('navigates to signin_totp_code when session does not meet minimum AAL', async () => { + // Mock sessionStatus to return false for AAL check + mockAuthClient.sessionStatus.mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: false }, + }); + + const context = mockAppContext(); + + renderWithRouter( + + +
secured
+
+
+ ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith('/signin_totp_code', { + state: { + email: context.account!.email, + sessionToken: mockSessionToken, + uid: context.account!.uid, + verified: false, + isSessionAALUpgrade: true, + }, + }); + }); + + expect(mockAuthClient.mfaRequestOtp).not.toHaveBeenCalled(); + }); + it('goes home and shows error alert bar if request for OTP fails', async () => { mockAuthClient.mfaRequestOtp.mockRejectedValueOnce( AuthUiErrors.UNEXPECTED_ERROR @@ -297,7 +350,9 @@ describe('MfaGuard', () => { ); - await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await userEvent.click( + await screen.findByRole('button', { name: 'Cancel' }) + ); expect(mockOnDismiss).toHaveBeenCalledTimes(1); }); @@ -317,25 +372,25 @@ describe('MfaGuard', () => { // Should be debounced! The dialog just rendered and a code went out... await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); await act(async () => { await new Promise((r) => setTimeout(r, 101)); }); await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); // Should be debounced! The resend request above was just clicked... await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); await act(async () => { await new Promise((r) => setTimeout(r, 101)); }); await userEvent.click( - screen.getByRole('button', { name: 'Email new code.' }) + await screen.findByRole('button', { name: 'Email new code.' }) ); expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalledTimes(3); diff --git a/packages/fxa-settings/src/components/Settings/MfaGuard/index.tsx b/packages/fxa-settings/src/components/Settings/MfaGuard/index.tsx index 60c15b0210b..32961338f66 100644 --- a/packages/fxa-settings/src/components/Settings/MfaGuard/index.tsx +++ b/packages/fxa-settings/src/components/Settings/MfaGuard/index.tsx @@ -30,6 +30,8 @@ import * as Sentry from '@sentry/react'; import { MfaErrorBoundary } from './error-boundary'; import { getLocalizedErrorMessage } from '../../../lib/error-utils'; import GleanMetrics from '../../../lib/glean'; +import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; /** * This is a guard component designed to wrap around components that perform @@ -58,9 +60,12 @@ export const MfaGuard = ({ const [resendCodeLoading, setResendCodeLoading] = useState(false); const [showResendSuccessBanner, setShowResendSuccessBanner] = useState(false); + const [modalLoading, setModalLoading] = useState(true); + const resetStates = useCallback(() => { setLocalizedErrorBannerMessage(undefined); setShowResendSuccessBanner(false); + setModalLoading(true); }, []); // Reactive state: if the store state changes, a re-render is triggered @@ -71,6 +76,7 @@ export const MfaGuard = ({ const account = useAccount(); const authClient = useAuthClient(); const navigate = useNavigate(); + const navigateWithQuery = useNavigateWithQuery(); const sessionToken = getSessionToken(); const ftlMsgResolver = useFtlMsgResolver(); @@ -105,7 +111,25 @@ export const MfaGuard = ({ // Modal Setup useEffect(() => { (async () => { - // To avoid requesting multiple OTPs on mount + // Ensure the session meets the minimum AAL required + const { + details: { sessionVerificationMeetsMinimumAAL }, + } = await authClient.sessionStatus(sessionToken); + if (!sessionVerificationMeetsMinimumAAL) { + console.warn('2FA must be entered to access /settings!'); + navigateWithQuery('/signin_totp_code', { + state: { + email: account.email, + sessionToken: sessionToken, + uid: account.uid, + verified: false, + isSessionAALUpgrade: true, + }, + }); + return; + } + setModalLoading(false); + if (JwtTokenCache.hasToken(sessionToken, requiredScope)) { return; } @@ -122,10 +146,6 @@ export const MfaGuard = ({ await authClient.mfaRequestOtp(sessionToken, requiredScope); } catch (err) { MfaOtpRequestCache.remove(sessionToken, requiredScope); - if (err.code === 401) { - handleError(err); - return; - } if (err.code === 429) { setShowResendSuccessBanner(false); setLocalizedErrorBannerMessage( @@ -150,6 +170,9 @@ export const MfaGuard = ({ onDismiss, config.mfa.otp.expiresInMinutes, debounce, + navigateWithQuery, + account.email, + account.uid, ]); const onSubmitOtp = async (code: string) => { @@ -191,11 +214,6 @@ export const MfaGuard = ({ } catch (err) { MfaOtpRequestCache.remove(sessionToken, requiredScope); setShowResendSuccessBanner(false); - if (err.code === 401) { - handleError(err); - return; - } - setShowResendSuccessBanner(false); setLocalizedErrorBannerMessage( getLocalizedErrorMessage(ftlMsgResolver, err) ); @@ -207,24 +225,25 @@ export const MfaGuard = ({ const email = account.email; const expirationTime = config.mfa.otp.expiresInMinutes; - const getModal = () => ( - setLocalizedErrorBannerMessage(undefined), - resendCodeLoading, - showResendSuccessBanner, - localizedErrorBannerMessage, - reason, - }} - > -

Re-verify Account!

-
- ); + const getModal = () => + modalLoading ? ( + + ) : ( + setLocalizedErrorBannerMessage(undefined), + resendCodeLoading, + showResendSuccessBanner, + localizedErrorBannerMessage, + reason, + }} + /> + ); // If we don't have a JWT, we need to open the modal to prompt for it. // Note: I'm torn on whether we should render the child components or not. It seems diff --git a/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx index 96830ee8296..bb02a158a7a 100644 --- a/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx @@ -59,11 +59,18 @@ const mockAuthClient = { kA: 'kA-key', kB: 'kB-key', }), + sessionStatus: jest.fn().mockResolvedValue({ + details: { + sessionVerificationMeetsMinimumAAL: true, + }, + }), } as any; // Use 'as any' to avoid TypeScript strict typing for mock // Mock the cache module to provide session token and JWT cache const mockSessionToken = 'mock-session-token'; -const mockJwtState: Record = { [`${mockSessionToken}-password`]: 'mock-jwt-token' }; +const mockJwtState: Record = { + [`${mockSessionToken}-password`]: 'mock-jwt-token', +}; jest.mock('../../../lib/cache', () => { const actual = jest.requireActual('../../../lib/cache'); return { @@ -102,7 +109,12 @@ const settingsContext = mockSettingsContext(); const render = async (mockAccount = account) => { renderWithRouter( - + diff --git a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx index 1dbbdc01695..aaf5bab6ca4 100644 --- a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx @@ -190,21 +190,30 @@ describe('PageSecondaryEmailAdd', () => { }); }); - describe('MfaGuard', () => { + describe('MfaGuard', () => { const mockEmail = 'user@example.com'; const mockAuthClient = new AuthClient('http://localhost:9000'); let user: UserEvent; const setupMockAuthClient = () => { - mockAuthClient.mfaRequestOtp = jest.fn().mockResolvedValueOnce({ code: 200, errno: 0 }); - mockAuthClient.mfaOtpVerify = jest.fn().mockResolvedValueOnce({ accessToken: mockJwt }); - } + mockAuthClient.mfaRequestOtp = jest + .fn() + .mockResolvedValueOnce({ code: 200, errno: 0 }); + mockAuthClient.mfaOtpVerify = jest + .fn() + .mockResolvedValueOnce({ accessToken: mockJwt }); + mockAuthClient.sessionStatus = jest.fn().mockResolvedValue({ + details: { + sessionVerificationMeetsMinimumAAL: true, + }, + }); + }; const resetJwtCache = () => { if (JwtTokenCache.hasToken(mockSessionToken, requiredScope)) { JwtTokenCache.removeToken(mockSessionToken, requiredScope); } - } + }; beforeEach(() => { jest.clearAllMocks(); @@ -222,16 +231,20 @@ describe('PageSecondaryEmailAdd', () => { renderWithRouter( - + ); expect(screen.getByTestId('secondary-email-input')).toBeInTheDocument(); - expect(screen.queryByText('Enter confirmation code')).not.toBeInTheDocument(); + expect( + screen.queryByText('Enter confirmation code') + ).not.toBeInTheDocument(); expect(mockAuthClient.mfaRequestOtp).not.toHaveBeenCalled(); }); - it('renders MFA guard if JWT does not exist', () => { + it('renders MFA guard if JWT does not exist', async () => { const appCtx = mockAppContext({ authClient: mockAuthClient as any, account: { ...(account as any), email: mockEmail }, @@ -239,18 +252,24 @@ describe('PageSecondaryEmailAdd', () => { renderWithRouter( - + ); - expect(screen.getByText('Enter confirmation code')).toBeInTheDocument(); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); }); it('renders MFA guard if the JWT is invalid', async () => { // an invalid token should be picked up by the guard which will // "send" a new one and display the modal again. JwtTokenCache.setToken(mockSessionToken, requiredScope, 'invalid-jwt'); - account.createSecondaryEmail = jest.fn().mockRejectedValue({ code: 401, errno: 110 }); + account.createSecondaryEmail = jest + .fn() + .mockRejectedValue({ code: 401, errno: 110 }); const appCtx = mockAppContext({ authClient: mockAuthClient as any, account: { ...(account as any), email: mockEmail }, @@ -258,7 +277,9 @@ describe('PageSecondaryEmailAdd', () => { renderWithRouter( - + ); @@ -266,7 +287,9 @@ describe('PageSecondaryEmailAdd', () => { await user.click(screen.getByTestId('save-button')); expect(account.createSecondaryEmail).toHaveBeenCalledWith(mockEmail); - expect(await screen.findByText('Enter confirmation code')).toBeInTheDocument(); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); }); }); }); diff --git a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailVerify/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailVerify/index.test.tsx index 782357b68eb..ecbbce8ea32 100644 --- a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailVerify/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailVerify/index.test.tsx @@ -122,15 +122,22 @@ describe('PageSecondaryEmailVerify', () => { let user: UserEvent; const setupMockAuthClient = () => { - mockAuthClient.mfaRequestOtp = jest.fn().mockResolvedValueOnce({ code: 200, errno: 0 }); - mockAuthClient.mfaOtpVerify = jest.fn().mockResolvedValueOnce({ accessToken: mockJwt }); - } + mockAuthClient.mfaRequestOtp = jest + .fn() + .mockResolvedValueOnce({ code: 200, errno: 0 }); + mockAuthClient.mfaOtpVerify = jest + .fn() + .mockResolvedValueOnce({ accessToken: mockJwt }); + mockAuthClient.sessionStatus = jest.fn().mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: true }, + }); + }; const resetJwtCache = () => { if (JwtTokenCache.hasToken(mockSessionToken, requiredScope)) { JwtTokenCache.removeToken(mockSessionToken, requiredScope); } - } + }; beforeEach(() => { jest.clearAllMocks(); @@ -148,16 +155,22 @@ describe('PageSecondaryEmailVerify', () => { renderWithRouter( - + ); - expect(screen.getByTestId('secondary-email-verify-form')).toBeInTheDocument(); - expect(screen.queryByText('Enter confirmation code')).not.toBeInTheDocument(); + expect( + screen.getByTestId('secondary-email-verify-form') + ).toBeInTheDocument(); + expect( + screen.queryByText('Enter confirmation code') + ).not.toBeInTheDocument(); expect(mockAuthClient.mfaRequestOtp).not.toHaveBeenCalled(); }); - it('renders MFA guard if JWT does not exist', () => { + it('renders MFA guard if JWT does not exist', async () => { const appCtx = mockAppContext({ authClient: mockAuthClient as any, account: { ...(account as any), email: mockEmail }, @@ -165,18 +178,24 @@ describe('PageSecondaryEmailVerify', () => { renderWithRouter( - + ); - expect(screen.getByText('Enter confirmation code')).toBeInTheDocument(); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); }); it('renders MFA guard if the JWT is invalid', async () => { // an invalid token should be picked up by the guard which will // "send" a new one and display the modal again. JwtTokenCache.setToken(mockSessionToken, requiredScope, 'invalid-jwt'); - account.verifySecondaryEmail = jest.fn().mockRejectedValue({ code: 401, errno: 110 }); + account.verifySecondaryEmail = jest + .fn() + .mockRejectedValue({ code: 401, errno: 110 }); const appCtx = mockAppContext({ authClient: mockAuthClient as any, account: { ...(account as any), email: mockEmail }, @@ -184,17 +203,27 @@ describe('PageSecondaryEmailVerify', () => { renderWithRouter( - + ); // mock the submit for the verification code, which should trigger the MFA guard // because the JWT is considered invalid server-side. - await user.type(screen.getByTestId('verification-code-input-field'), '123456'); + await user.type( + screen.getByTestId('verification-code-input-field'), + '123456' + ); await user.click(screen.getByTestId('secondary-email-verify-submit')); - expect(account.verifySecondaryEmail).toHaveBeenCalledWith(mockEmail, '123456'); - expect(await screen.findByText('Enter confirmation code')).toBeInTheDocument(); + expect(account.verifySecondaryEmail).toHaveBeenCalledWith( + mockEmail, + '123456' + ); + expect( + await screen.findByText('Enter confirmation code') + ).toBeInTheDocument(); }); }); }); diff --git a/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx index d440ac9b946..ab02350717d 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx @@ -40,7 +40,13 @@ const accountWithoutPassword = { recoveryKey: { exists: false }, } as unknown as Account; -const authClient = {} as unknown as AuthClient; +const authClient = { + sessionStatus: jest.fn().mockResolvedValue({ + details: { + sessionVerificationMeetsMinimumAAL: true, + }, + }), +} as unknown as AuthClient; const renderWithContext = ( account: Partial, diff --git a/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.test.tsx index a458d6c45fe..1a2588ffa51 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowSecondaryEmail/index.test.tsx @@ -54,6 +54,9 @@ describe('UnitRowSecondaryEmail', () => { mockAuthClient.mfaOtpVerify = jest .fn() .mockResolvedValue({ accessToken: mockJwt }); + mockAuthClient.sessionStatus = jest.fn().mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: true }, + }); }; const resetJwtCache = () => { diff --git a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx index 6f8cf9c4f89..37994aa326a 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx @@ -20,6 +20,9 @@ jest.mock('../../../models', () => ({ ...jest.requireActual('../../../models'), useAuthClient: () => ({ mfaRequestOtp: jest.fn(), + sessionStatus: jest.fn().mockResolvedValue({ + details: { sessionVerificationMeetsMinimumAAL: true }, + }), }), }));