diff --git a/packages/functional-tests/pages/settings/index.ts b/packages/functional-tests/pages/settings/index.ts
index 78d830614fe..1af6ee5fa83 100644
--- a/packages/functional-tests/pages/settings/index.ts
+++ b/packages/functional-tests/pages/settings/index.ts
@@ -101,8 +101,21 @@ export class SettingsPage extends SettingsLayout {
}, creds.uid);
}
+ /**
+ * Removes 2FA from the account by clicking the 'disable' button on the 2FA row.
+ */
async disconnectTotp() {
await this.totp.disableButton.click();
+
+ // Obtain the MFA JWT if necessary
+ if (await this.isMfaGuardVisible()) {
+ const email = await this.primaryEmail.status.textContent();
+ if (!email) {
+ throw new Error('Could not determine primary email!');
+ }
+ await this.confirmMfaGuard(email);
+ }
+
await this.modalConfirmButton.click();
await expect(this.settingsHeading).toBeVisible();
@@ -110,6 +123,16 @@ export class SettingsPage extends SettingsLayout {
await expect(this.totp.status).toHaveText('Disabled');
}
+ /**
+ * Indicates that the MFA guard's modal dialog is currently displayed.
+ * @returns true if the MFA modal is open
+ */
+ async isMfaGuardVisible() {
+ return await this.page
+ .getByRole('heading', { name: 'Enter confirmation code' })
+ .isVisible();
+ }
+
/**
* Confirms the MFA modal (MfaGuard) by retrieving the code from the inbox
* and submitting it. Use when an action triggers the protected modal.
diff --git a/packages/functional-tests/tests/oauth/totp.spec.ts b/packages/functional-tests/tests/oauth/totp.spec.ts
index dd3d8839a14..626a54750b7 100644
--- a/packages/functional-tests/tests/oauth/totp.spec.ts
+++ b/packages/functional-tests/tests/oauth/totp.spec.ts
@@ -57,14 +57,7 @@ test.describe('severity-1 #smoke', () => {
await expect(settings.alertBar).toHaveText(
'Two-step authentication has been enabled'
);
- await settings.totp.disableButton.click();
- await settings.clickModalConfirm();
-
- await expect(settings.modalConfirmButton).toBeHidden();
- await expect(settings.totp.status).toHaveText('Disabled');
- await expect(settings.alertBar).toHaveText(
- 'Two-step authentication disabled'
- );
+ await settings.disconnectTotp();
await relier.goto();
await relier.clickEmailFirst();
diff --git a/packages/fxa-auth-client/lib/client.ts b/packages/fxa-auth-client/lib/client.ts
index ddc4e5e64c0..14ad56d7a2b 100644
--- a/packages/fxa-auth-client/lib/client.ts
+++ b/packages/fxa-auth-client/lib/client.ts
@@ -1871,10 +1871,30 @@ export default class AuthClient {
return this.jwtPost('/mfa/totp/replace/confirm', jwt, { code }, headers);
}
+ /**
+ * @deprecated Use deleteTotpTokenWithJwt instead
+ *
+ * Disables 2FA Protection on the account.
+ *
+ * @param sessionToken - required, must be a verified session token
+ * @param headers - Optional additional headers for the request
+ * @returns A promise that resolves when the 2FA has been removed
+ */
async deleteTotpToken(sessionToken: hexstring, headers?: Headers) {
return this.sessionPost('/totp/destroy', sessionToken, {}, headers);
}
+ /**
+ * Disables 2FA Protection on the account.
+ *
+ * @param jwt - required, must be a verified session token
+ * @param headers - Optional additional headers for the request
+ * @returns A promise that resolves when the 2FA has been removed
+ */
+ async deleteTotpTokenWithJwt(jwt: string, headers?: Headers) {
+ return this.jwtPost('/mfa/totp/destroy', jwt, {}, headers);
+ }
+
async checkTotpTokenExists(
sessionToken: hexstring,
headers?: Headers
diff --git a/packages/fxa-auth-server/docs/swagger/totp-api.ts b/packages/fxa-auth-server/docs/swagger/totp-api.ts
index 748ee9f9f2d..ff2ba27f94f 100644
--- a/packages/fxa-auth-server/docs/swagger/totp-api.ts
+++ b/packages/fxa-auth-server/docs/swagger/totp-api.ts
@@ -32,6 +32,17 @@ const TOTP_DESTROY_POST = {
`,
],
};
+const MFA_TOTP_DESTROY_POST = {
+ ...TAGS_TOTP,
+ description: '/mfa/totp/destroy',
+ notes: [
+ dedent`
+ 🔒 Authenticated with MFA JWT (scope: mfa:2fa)
+
+ Deletes the current TOTP token for the user. The underlying session needs to have been verified by TOTP to remove it. It does not bypass that requirement.
+ `,
+ ],
+};
const TOTP_EXISTS_GET = {
...TAGS_TOTP,
@@ -158,6 +169,7 @@ const API_DOCS = {
SESSION_VERIFY_TOTP_POST,
TOTP_CREATE_POST,
TOTP_DESTROY_POST,
+ MFA_TOTP_DESTROY_POST,
TOTP_EXISTS_GET,
TOTP_VERIFY_POST,
TOTP_VERIFY_RECOVERY_CODE_POST,
diff --git a/packages/fxa-auth-server/lib/routes/totp.js b/packages/fxa-auth-server/lib/routes/totp.js
index b55cc053ee5..bbd099b5237 100644
--- a/packages/fxa-auth-server/lib/routes/totp.js
+++ b/packages/fxa-auth-server/lib/routes/totp.js
@@ -262,7 +262,7 @@ module.exports = (
}
}
- return [
+ const routes = [
{
method: 'POST',
path: '/totp/create',
@@ -588,6 +588,27 @@ module.exports = (
}
},
},
+ {
+ method: 'POST',
+ path: '/mfa/totp/destroy',
+ options: {
+ ...TOTP_DOCS.MFA_TOTP_DESTROY_POST,
+ auth: {
+ strategy: 'mfa',
+ scope: ['mfa:2fa'],
+ payload: false,
+ },
+ response: {},
+ },
+ handler: async function (request) {
+ return routes
+ .find(
+ (route) =>
+ route.path === '/v1/totp/destroy' && route.method === 'POST'
+ )
+ .handler(request);
+ },
+ },
{
method: 'POST',
path: '/totp/destroy',
@@ -1163,4 +1184,6 @@ module.exports = (
handler: handleTotpReplaceConfirm,
},
];
+
+ return routes;
};
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 e8931d563de..317a85a37a9 100644
--- a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx
+++ b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx
@@ -12,6 +12,7 @@ import {
MOCK_NATIONAL_FORMAT_PHONE_NUMBER,
} from '../../../pages/mocks';
import GleanMetrics from '../../../lib/glean';
+import { JwtTokenCache } from '../../../lib/cache';
jest.mock('../../../models/AlertBarInfo');
@@ -21,7 +22,32 @@ jest.mock('../../../lib/glean', () => ({
},
}));
+jest.mock('../../../models', () => ({
+ ...jest.requireActual('../../../models'),
+ useAuthClient: () => ({
+ mfaRequestOtp: jest.fn(),
+ }),
+}));
+
+// Mocks to suppress MFA Guard
+jest.mock('../../../lib/cache', () => ({
+ ...jest.requireActual('../../../lib/cache'),
+ JwtTokenCache: {
+ hasToken: jest.fn(),
+ getToken: jest.fn(),
+ getSnapshot: jest.fn(),
+ subscribe: jest.fn(),
+ },
+ sessionToken: () => 'session-123',
+}));
+
describe('UnitRowTwoStepAuth', () => {
+ beforeEach(() => {
+ (JwtTokenCache.hasToken as jest.Mock).mockReturnValue(true);
+ (JwtTokenCache.getToken as jest.Mock).mockReturnValue('jwt-123');
+ (JwtTokenCache.getSnapshot as jest.Mock).mockReturnValue(true);
+ });
+
it('renders when two-step authentication is enabled', async () => {
renderWithRouter(createSubject());
@@ -129,6 +155,17 @@ describe('UnitRowTwoStepAuth', () => {
);
});
+ it('renders MFAGuard when the disable totp modal is rendered and jwt is missing', async () => {
+ (JwtTokenCache.hasToken as jest.Mock).mockReturnValue(false);
+ const user = userEvent.setup();
+
+ renderWithRouter(createSubject());
+
+ await user.click(screen.getByRole('button', { name: 'Disable' }));
+
+ expect(screen.getByText('Enter confirmation code')).toBeInTheDocument();
+ });
+
it('renders with no backup codes and no recovery phone', () => {
renderWithRouter(
createSubject({
diff --git a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.tsx b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.tsx
index 3a317396126..dc2b705be0c 100644
--- a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.tsx
+++ b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.tsx
@@ -3,6 +3,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useEffect } from 'react';
+import * as Sentry from '@sentry/browser';
+import { useErrorHandler } from 'react-error-boundary';
import LinkExternal from 'fxa-react/components/LinkExternal';
import { useBooleanState } from 'fxa-react/lib/hooks';
import Modal from '../Modal';
@@ -22,6 +24,8 @@ import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery';
import { formatPhoneNumber } from '../../../lib/recovery-phone-utils';
import { RecoveryPhoneSetupReason } from '../../../lib/types';
import ModalVerifySession from '../ModalVerifySession';
+import { MfaGuard } from '../MfaGuard';
+import { isInvalidJwtError } from '../../../lib/mfa-guard-utils';
const route = `${SETTINGS_PATH}/two_step_authentication`;
const replaceCodesRoute = `${route}/replace_codes`;
@@ -209,7 +213,14 @@ export const UnitRowTwoStepAuth = () => {
/>
)}
{disable2FAModalRevealed && (
-
+ {
+ hideDisable2FAModal();
+ }}
+ >
+
+
)}
>
@@ -221,6 +232,7 @@ const DisableTwoStepAuthModal = ({
}: {
hideDisable2FAModal: () => void;
}) => {
+ const errorHandler = useErrorHandler();
const alertBar = useAlertBar();
const account = useAccount();
const ftlMsgResolver = useFtlMsgResolver();
@@ -243,6 +255,12 @@ const DisableTwoStepAuthModal = ({
() => GleanMetrics.accountPref.twoStepAuthDisableSuccessView()
);
} catch (e) {
+ if (isInvalidJwtError(e)) {
+ // JWT invalid/expired.
+ errorHandler(e);
+ return;
+ }
+
hideDisable2FAModal();
alertBar.error(
ftlMsgResolver.getMsg(
@@ -250,8 +268,10 @@ const DisableTwoStepAuthModal = ({
'Two-step authentication could not be disabled'
)
);
+
+ Sentry.captureException(e);
}
- }, [account, hideDisable2FAModal, alertBar, ftlMsgResolver]);
+ }, [account, hideDisable2FAModal, alertBar, ftlMsgResolver, errorHandler]);
return (