Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/functional-tests/pages/inlineTotpSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,34 @@ export class InlineTotpSetupPage extends BaseLayout {
get continueButton() {
return this.page.getByRole('button', { name: 'Continue' });
}

/**
* The MFA email-OTP guard modal now protects inline TOTP enrolment
* (FXA-14311). Its heading matches the Settings MfaGuard modal.
*/
get mfaGuardHeading() {
return this.page.getByRole('heading', { name: 'Enter confirmation code' });
}

/**
* Satisfies the MFA email-OTP guard that gates inline TOTP enrolment:
* fetches the code from the inbox and submits it. On entry to
* `/inline_totp_setup` the guard has no cached JWT, so the modal is shown.
*/
async confirmMfaGuard(email: string) {
await this.mfaGuardHeading.waitFor();
const code =
await this.target.emailClient.getVerifyAccountChangeCode(email);
await this.page
.getByRole('textbox', { name: 'Enter 6-digit code' })
.fill(code);
await this.page.getByRole('button', { name: 'Confirm' }).click();
}

/** Confirms the MFA guard only if its modal is currently displayed. */
async confirmMfaGuardIfVisible(email: string) {
if (await this.mfaGuardHeading.isVisible()) {
await this.confirmMfaGuard(email);
}
}
}
2 changes: 2 additions & 0 deletions packages/functional-tests/pages/settings/totp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ export class TotpPage extends SettingsLayout {
recoveryPhoneAvailable: boolean
): Promise<string> {
await this.page.waitForURL(/inline_totp_setup/);
// Inline TOTP enrolment is now gated by an MFA email-OTP (FXA-14311).
await inlineTotpSetup.confirmMfaGuard(credentials.email);
await expect(inlineTotpSetup.introHeading).toBeVisible();
await inlineTotpSetup.continueButton.click();
const secret = await this.setUp2faAppWithManualCode(credentials);
Expand Down
4 changes: 4 additions & 0 deletions packages/functional-tests/tests/oauth/totp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ test.describe('severity-1 #smoke', () => {

await page.waitForURL(/inline_totp_setup/);

// Inline TOTP enrolment is gated by an MFA email-OTP (FXA-14311).
await inlineTotpSetup.confirmMfaGuard(credentials.email);
await expect(inlineTotpSetup.introHeading).toBeVisible();
await inlineTotpSetup.continueButton.click();
await expect(totp.setup2faAppHeading).toBeVisible();
Expand Down Expand Up @@ -118,6 +120,8 @@ test.describe('OAuth totp with recovery phone (local only)', () => {

await page.waitForURL(/inline_totp_setup/);

// Inline TOTP enrolment is gated by an MFA email-OTP (FXA-14311).
await inlineTotpSetup.confirmMfaGuard(credentials.email);
await expect(inlineTotpSetup.introHeading).toBeVisible();
await inlineTotpSetup.continueButton.click();
await expect(totp.setup2faAppHeading).toBeVisible();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ test.describe('severity-1 #smoke', () => {

test('forces /inline_totp_setup when password sign-in on an account with a passkey enrolled hits an AAL2 RP', async ({
target,
pages: { page, relier, settings, settingsPasskeyAdd, signin },
pages: { page, relier, settings, settingsPasskeyAdd, signin, inlineTotpSetup },
testAccountTracker,
}) => {
// Passkey enrolment alone doesn't satisfy AAL2 for a password session,
Expand All @@ -576,6 +576,9 @@ test.describe('severity-1 #smoke', () => {
await signin.fillOutPasswordForm(credentials.password);

await page.waitForURL(/inline_totp_setup/);
// Inline TOTP enrolment is gated by an MFA email-OTP (FXA-14311); the
// setup UI only renders once the guard is satisfied.
await inlineTotpSetup.confirmMfaGuard(credentials.email);
await expect(
page.getByRole('heading', { name: /Set up two-step authentication/i })
).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-client/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2668,7 +2668,7 @@ export default class AuthClient {
*/
async completeTotpSetupWithJwt(
jwt: string,
options: { metricsContext?: MetricsContext } = {},
options: { service?: string; metricsContext?: MetricsContext } = {},
headers?: Headers
): Promise<{ success: boolean }> {
return this.jwtPost(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { screen } from '@testing-library/react';
import { mockAppContext, renderWithRouter } from '../../../models/mocks';
import { MfaGuardCore } from './MfaGuardCore';
import { JwtTokenCache } from '../../../lib/cache';
import { AppContext } from '../../../models';
import { MfaReason } from '../../../lib/types';

const mockSessionToken = 'session-core';
const mockScope = 'test';
const mockAuthClient = {
mfaRequestOtp: jest.fn().mockResolvedValue(undefined),
mfaOtpVerify: jest.fn(),
};

jest.mock('../../../models', () => ({
...jest.requireActual('../../../models'),
useAuthClient: () => mockAuthClient,
}));

const noop = () => {};

function renderCore() {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuardCore
requiredScope={mockScope}
reason={MfaReason.test}
email="user@example.com"
sessionToken={mockSessionToken}
onDismiss={noop}
onSessionInvalid={noop}
onFatalError={noop}
>
<div>secured content</div>
</MfaGuardCore>
</AppContext.Provider>
);
}

describe('MfaGuardCore', () => {
beforeEach(() => {
JwtTokenCache.removeToken(mockSessionToken, mockScope);
jest.clearAllMocks();
});

it('blocks children and requests an OTP when no JWT is cached', async () => {
renderCore();

// The security invariant: children (the sensitive action) must not render
// until an email OTP has produced a JWT.
expect(screen.queryByText('secured content')).not.toBeInTheDocument();
expect(
await screen.findByText('Enter confirmation code')
).toBeInTheDocument();
expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalledWith(
mockSessionToken,
mockScope
);
});

it('renders children (no OTP request) when a JWT is already cached', () => {
JwtTokenCache.setToken(mockSessionToken, mockScope, 'jwt-present');

renderCore();

expect(screen.getByText('secured content')).toBeInTheDocument();
expect(
screen.queryByText('Enter confirmation code')
).not.toBeInTheDocument();
expect(mockAuthClient.mfaRequestOtp).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React, {
ReactNode,
useCallback,
useEffect,
useState,
useSyncExternalStore,
} from 'react';

import { useAuthClient, useConfig, useFtlMsgResolver } from '../../../models';
import Modal from '../ModalMfaProtected';
import { JwtTokenCache, MfaOtpRequestCache } from '../../../lib/cache';
import { MfaReason, MfaScope } from '../../../lib/types';
import { ERRNO } from '@fxa/accounts/errors';
import * as Sentry from '@sentry/react';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
import GleanMetrics from '../../../lib/glean';
import { MfaContext } from './context';

/**
* Host-agnostic core of the MFA email-OTP guard. It blocks its children behind
* an email-OTP → JWT exchange (scope `mfa:<requiredScope>`), caching the JWT in
* `JwtTokenCache`, and provides the scope via `MfaContext` so children can use
* `useMfaErrorHandler`.
*
* Everything host-specific is injected via props so this can be used both inside
* Settings (see the `MfaGuard` wrapper in ./index) and in the Signin inline
* enrolment flow (FXA-14311). It deliberately does NOT read `useAccount`,
* `useAlertBar`, the global cached session token, or navigate directly.
*/
export const MfaGuardCore = ({
children,
requiredScope,
reason,
email,
sessionToken,
onDismiss,
onSessionInvalid,
onFatalError,
debounceIntervalMs = 3000,
}: {
children: ReactNode;
requiredScope: MfaScope;
reason: MfaReason;
/** Address shown in the OTP modal (host supplies it; not read from a model). */
email: string;
/** Session token used to request/verify the OTP and key the JWT cache. */
sessionToken: string;
/** Called when the user dismisses the modal or after a fatal error — host decides where to go. */
onDismiss: () => void;
/** Called when the session token is rejected as invalid/expired — host redirects to sign-in. */
onSessionInvalid: () => void;
/** Called with an already-localized message for an unrecoverable OTP-request error. */
onFatalError: (localizedMessage: string) => void;
debounceIntervalMs?: number;
}) => {
const config = useConfig();
const authClient = useAuthClient();
const ftlMsgResolver = useFtlMsgResolver();

const [localizedErrorBannerMessage, setLocalizedErrorBannerMessage] =
useState<string | undefined>(undefined);
const [resendCodeLoading, setResendCodeLoading] = useState(false);
const [showResendSuccessBanner, setShowResendSuccessBanner] = useState(false);

const resetStates = useCallback(() => {
setLocalizedErrorBannerMessage(undefined);
setShowResendSuccessBanner(false);
}, []);

// Reactive state: if the store state changes, a re-render is triggered
const jwtState = useSyncExternalStore(
JwtTokenCache.subscribe,
JwtTokenCache.getSnapshot
);

const dismiss = useCallback(() => {
resetStates();
onDismiss();
}, [resetStates, onDismiss]);

// jwtState should always return
if (!jwtState) {
throw new Error('Invalid state. Missing jwt cache.');
}

const debounce = useCallback(
(limitInMs: number) => {
const lastRequest = MfaOtpRequestCache.get(sessionToken, requiredScope);
return lastRequest != null && Date.now() - lastRequest < limitInMs;
},
[sessionToken, requiredScope]
);

// Modal Setup
useEffect(() => {
(async () => {
// To avoid requesting multiple OTPs on mount
if (JwtTokenCache.hasToken(sessionToken, requiredScope)) {
return;
}

// Avoid bombarding the user with emails just because they open
// the dialog
const limitInMs = config.mfa.otp.expiresInMinutes * 60 * 1000;
if (debounce(limitInMs)) {
return;
}

try {
MfaOtpRequestCache.set(sessionToken, requiredScope);
await authClient.mfaRequestOtp(sessionToken, requiredScope);
} catch (err) {
MfaOtpRequestCache.remove(sessionToken, requiredScope);

// If session token is invalid (destroyed/expired), redirect to signin
if (err?.errno === ERRNO.INVALID_TOKEN) {
onSessionInvalid();
return;
}

if (err.code === 429) {
setShowResendSuccessBanner(false);
setLocalizedErrorBannerMessage(
getLocalizedErrorMessage(ftlMsgResolver, err)
);
return;
}

Sentry.captureException(err);
onFatalError(getLocalizedErrorMessage(ftlMsgResolver, err));
dismiss();
}
})();
}, [
jwtState,
sessionToken,
requiredScope,
authClient,
ftlMsgResolver,
dismiss,
onSessionInvalid,
onFatalError,
config.mfa.otp.expiresInMinutes,
debounce,
]);

const onSubmitOtp = async (code: string) => {
try {
const result = await authClient.mfaOtpVerify(
sessionToken,
code,
requiredScope
);
GleanMetrics.accountPref.mfaGuardSubmitSuccess({
event: { reason },
});
JwtTokenCache.setToken(sessionToken, requiredScope, result.accessToken);
resetStates();
} catch (err) {
setShowResendSuccessBanner(false);
setLocalizedErrorBannerMessage(
getLocalizedErrorMessage(ftlMsgResolver, err)
);
}
};

const handleResendCode = async () => {
// Stop users from hammering the resend button...
if (debounce(debounceIntervalMs)) {
return;
}

setResendCodeLoading(true);
try {
MfaOtpRequestCache.set(sessionToken, requiredScope);
await authClient.mfaRequestOtp(sessionToken, requiredScope);
setLocalizedErrorBannerMessage(undefined);
setShowResendSuccessBanner(true);
} catch (err) {
MfaOtpRequestCache.remove(sessionToken, requiredScope);
setShowResendSuccessBanner(false);
setLocalizedErrorBannerMessage(
getLocalizedErrorMessage(ftlMsgResolver, err)
);
} finally {
setResendCodeLoading(false);
}
};

const expirationTime = config.mfa.otp.expiresInMinutes;

const getModal = () => (
<Modal
{...{
email,
expirationTime,
onSubmit: onSubmitOtp,
onDismiss: dismiss,
handleResendCode,
clearErrorMessage: () => setLocalizedErrorBannerMessage(undefined),
resendCodeLoading,
showResendSuccessBanner,
localizedErrorBannerMessage,
reason,
}}
>
<p>Re-verify Account!</p>
</Modal>
);

// If we don't have a JWT, we need to open the modal to prompt for it.
if (!JwtTokenCache.hasToken(sessionToken, requiredScope)) {
return getModal();
}

// Provide the scope via context so child components can use useMfaErrorHandler
return (
<MfaContext.Provider value={requiredScope}>{children}</MfaContext.Provider>
);
};
Loading
Loading