fix(totp): Use otplib/preset-browser for consistent 2FA setup, handle server OTP reject

Because:
* There's an error case some users experience where it appears a client-side OTP code check is valid but our server then rejects it. We mishandle the error state and tell the user 2FA setup was successful

This commit:
* Updates our front-end OTP check in fxa-settings to use the same library our backend uses (otplib, but for the browser)
* Has auth-server throw an error if the TOTP code is invalid during set up, and handles it properly in the front-end by checking for an error, not updating apollo cache to show a successful TOTP setup if there is an error, and displays an error for the user

closes FXA-12035
This commit is contained in:
Lauren Zugai
2025-07-10 12:19:42 -05:00
parent d6b244bb16
commit f9528dbd99
19 changed files with 423 additions and 95 deletions

View File

@@ -319,6 +319,9 @@ export function gleanMetrics(config: ConfigType) {
replaceCodeComplete: createEventFn(
'two_factor_auth_replace_code_complete'
),
setupInvalidCodeError: createEventFn(
'two_factor_auth_setup_invalid_code_error'
),
},
twoStepAuthPhoneCode: {
sent: createEventFn('two_step_auth_phone_code_sent'),

View File

@@ -4904,6 +4904,88 @@ class EventsServerEventLogger {
event,
});
}
/**
* Record and submit a two_factor_auth_setup_invalid_code_error event:
* Edge case error that indicates the user verified their OTP code client-side during setup, but the server rejected it
* Event is logged using internal mozlog logger.
*
* @param {string} user_agent - The user agent.
* @param {string} ip_address - The IP address. Will be used to decode Geo
* information and scrubbed at ingestion.
* @param {string} account_user_id - The firefox/mozilla account id.
* @param {string} account_user_id_sha256 - A hex string of a sha256 hash of the account's uid.
* @param {string} relying_party_oauth_client_id - The client id of the relying party.
* @param {string} relying_party_service - The service name of the relying party.
* @param {string} session_device_type - one of 'mobile', 'tablet', or ''.
* @param {string} session_entrypoint - Entrypoint to the service.
* @param {string} session_entrypoint_experiment - Identifier for the experiment the user is part of at the entrypoint.
* @param {string} session_entrypoint_variation - Identifier for the experiment variation the user is part of at the entrypoint.
* @param {string} session_flow_id - an ID generated by FxA for its flow metrics.
* @param {string} utm_campaign - A marketing campaign. For example, if a user signs into FxA from selecting a Mozilla VPN plan on Mozilla VPN's product site, then the value of this metric could be 'vpn-product-page'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters. The special value of 'page+referral+-+not+part+of+a+campaign' is also allowed..
* @param {string} utm_content - The content on which the user acted. For example, if the user clicked on the (previously available) "Get started here" link in "Looking for Firefox Sync? Get started here", then the value for this metric would be 'fx-sync-get-started'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
* @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
* @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
* @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters..
*/
recordTwoFactorAuthSetupInvalidCodeError({
user_agent,
ip_address,
account_user_id,
account_user_id_sha256,
relying_party_oauth_client_id,
relying_party_service,
session_device_type,
session_entrypoint,
session_entrypoint_experiment,
session_entrypoint_variation,
session_flow_id,
utm_campaign,
utm_content,
utm_medium,
utm_source,
utm_term,
}: {
user_agent: string;
ip_address: string;
account_user_id: string;
account_user_id_sha256: string;
relying_party_oauth_client_id: string;
relying_party_service: string;
session_device_type: string;
session_entrypoint: string;
session_entrypoint_experiment: string;
session_entrypoint_variation: string;
session_flow_id: string;
utm_campaign: string;
utm_content: string;
utm_medium: string;
utm_source: string;
utm_term: string;
}) {
const event = {
category: 'two_factor_auth',
name: 'setup_invalid_code_error',
};
this.#record({
user_agent,
ip_address,
account_user_id,
account_user_id_sha256,
relying_party_oauth_client_id,
relying_party_service,
session_device_type,
session_entrypoint,
session_entrypoint_experiment,
session_entrypoint_variation,
session_flow_id,
utm_campaign,
utm_content,
utm_medium,
utm_source,
utm_term,
event,
});
}
/**
* Record and submit a two_step_auth_phone_code_complete event:
* User successfully entered and submitted an authentication code

View File

@@ -261,9 +261,7 @@ module.exports = (
log.error('totp.destroy.remove_phone_number.error');
}
} catch (error) {
if (
error instanceof RecoveryNumberNotExistsError
) {
if (error instanceof RecoveryNumberNotExistsError) {
statsd.increment('totp.destroy.remove_phone_number.fail');
} else {
statsd.increment('totp.destroy.remove_phone_number.error');
@@ -567,29 +565,37 @@ module.exports = (
const isSetup = !tokenVerified;
// Once a valid TOTP code has been detected, the token becomes verified
// and enabled for the user.
if (isValidCode && isSetup) {
await db.replaceTotpToken({
uid,
sharedSecret,
verified: true,
enabled: true,
epoch: 0,
});
await authServerCacheRedis.del(toRedisTotpSecretKey(uid));
if (isSetup) {
// We currently check for code validity client-side, and then check again
// server-side at the end of the flow with this request. This guards against
// an edgecase where the client may accept a code that the server rejects.
if (!isValidCode) {
glean.twoFactorAuth.setupInvalidCodeError(request, { uid });
throw errors.invalidTokenVerficationCode();
} else {
// Once a valid TOTP code has been detected, the token becomes verified
// and enabled for the user.
await db.replaceTotpToken({
uid,
sharedSecret,
verified: true,
enabled: true,
epoch: 0,
});
await authServerCacheRedis.del(toRedisTotpSecretKey(uid));
recordSecurityEvent('account.two_factor_added', {
db,
request,
});
recordSecurityEvent('account.two_factor_added', {
db,
request,
});
glean.twoFactorAuth.codeComplete(request, { uid });
glean.twoFactorAuth.codeComplete(request, { uid });
await profileClient.deleteCache(uid);
await log.notifyAttachedServices('profileDataChange', request, {
uid,
});
await profileClient.deleteCache(uid);
await log.notifyAttachedServices('profileDataChange', request, {
uid,
});
}
}
// If a valid code was sent, this verifies the session using the `totp-2fa` method.

View File

@@ -46,6 +46,7 @@ const recordPasswordResetEmailConfirmationSuccessStub = sinon.stub();
const recordTwoFactorAuthCodeCompleteStub = sinon.stub();
const recordTwoFactorAuthReplaceCodeCompleteStub = sinon.stub();
const recordTwoFactorAuthSetCodesCompleteStub = sinon.stub();
const recordTwoFactorAuthSetupInvalidCodeErrorStub = sinon.stub();
const recordTwoStepAuthPhoneCodeSentStub = sinon.stub();
const recordTwoStepAuthPhoneCodeSendErrorStub = sinon.stub();
const recordTwoStepAuthPhoneCodeCompleteStub = sinon.stub();
@@ -126,6 +127,8 @@ const gleanProxy = proxyquire('../../../lib/metrics/glean', {
recordTwoFactorAuthReplaceCodeCompleteStub,
recordTwoFactorAuthSetCodesComplete:
recordTwoFactorAuthSetCodesCompleteStub,
recordTwoFactorAuthSetupInvalidCodeError:
recordTwoFactorAuthSetupInvalidCodeErrorStub,
recordTwoStepAuthPhoneCodeSent: recordTwoStepAuthPhoneCodeSentStub,
recordTwoStepAuthPhoneCodeSendError:
recordTwoStepAuthPhoneCodeSendErrorStub,
@@ -537,6 +540,17 @@ describe('Glean server side events', () => {
);
sinon.assert.calledOnce(recordTwoFactorAuthReplaceCodeCompleteStub);
});
it('logs a "two_factor_auth_setup_invalid_code_error" event', async () => {
await glean.twoFactorAuth.setupInvalidCodeError(request);
sinon.assert.calledOnce(recordStub);
const metrics = recordStub.args[0][0];
assert.equal(
metrics['event_name'],
'two_factor_auth_setup_invalid_code_error'
);
sinon.assert.calledOnce(recordTwoFactorAuthSetupInvalidCodeErrorStub);
});
});
describe('registration', () => {

View File

@@ -387,6 +387,38 @@ describe('totp', () => {
});
});
it('should throw errors.invalidTokenVerficationCode for invalid code during setup', async () => {
// Simulate setup (isSetup = true) by making totpTokenVerified false
requestOptions.credentials.tokenVerified = false;
requestOptions.payload = {
code: 'INVALID_CODE',
};
try {
await setup(
{
db: { email: TEST_EMAIL },
redis: { secret },
totpTokenVerified: false,
totpTokenEnabled: false,
},
{},
'/session/verify/totp',
requestOptions
);
assert.fail('Invalid token verification code error was not thrown');
} catch (err) {
assert.equal(
err.errno,
authErrors.ERRNO.INVALID_TOKEN_VERIFICATION_CODE
);
assert.equal(
err.message,
authErrors.invalidTokenVerficationCode().message
);
assert.calledOnce(glean.twoFactorAuth.setupInvalidCodeError);
}
});
it('should verify session with TOTP token - sync', () => {
const authenticator = new otplib.authenticator.Authenticator();
authenticator.options = Object.assign({}, otplib.authenticator.options, {

View File

@@ -4,6 +4,7 @@
const { resolve } = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const webpack = require('webpack');
const allFxa = resolve(__dirname, '../../');
const allLibs = resolve(__dirname, '../../libs/');
@@ -19,6 +20,16 @@ const additionalJSImports = {
const customizeWebpackConfig = ({ config }) => ({
...config,
plugins: [
...config.plugins,
new webpack.ProvidePlugin({
process: 'process/browser',
}),
new webpack.ProvidePlugin({
buffer: ['buffer', 'Buffer'],
Buffer: ['buffer', 'Buffer'],
}),
],
resolve: {
...config.resolve,
plugins: [
@@ -42,6 +53,8 @@ const customizeWebpackConfig = ({ config }) => ({
...config.fallback,
fs: false,
path: false,
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
},
},
module: {

View File

@@ -623,6 +623,10 @@ module.exports = function (webpackEnv) {
new webpack.ProvidePlugin({
process: 'process/browser',
}),
new webpack.ProvidePlugin({
buffer: ['buffer', 'Buffer'], // otplib/preset-browser expects a global 'buffer'
Buffer: ['buffer', 'Buffer'],
}),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(

View File

@@ -126,6 +126,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@material-ui/core": "v5.0.0-alpha.24",
"@otplib/preset-browser": "^12.0.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
"@reach/router": "^1.3.4",
"@react-pdf/renderer": "3.2.1",

View File

@@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useEffect, useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import { Link, RouteComponentProps } from '@reach/router';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
@@ -28,6 +28,8 @@ import FlowSetup2faBackupCodeConfirm from '../FlowSetup2faBackupCodeConfirm';
import FlowSetupRecoveryPhoneSubmitNumber from '../FlowSetupRecoveryPhoneSubmitNumber';
import FlowSetupRecoveryPhoneConfirmCode from '../FlowSetupRecoveryPhoneConfirmCode';
import VerifiedSessionGuard from '../VerifiedSessionGuard';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { FtlMsg } from 'fxa-react/lib/utils';
const Page2faSetup = (_: RouteComponentProps) => {
const account = useAccount();
@@ -186,14 +188,43 @@ const Page2faSetup = (_: RouteComponentProps) => {
await account.setRecoveryCodes(backupCodes);
await enable2fa();
} catch (error) {
// If this throws, the initial client-side code check succeeded but the
// back-end rejected it. The user needs to begin the flow again.
if (error.errno === AuthUiErrors.INVALID_OTP_CODE.errno) {
const startOverLink = (
<Link
className="link-blue"
to="/settings/two_step_authentication"
onClick={() => {
navigateWithQuery('/settings/two_step_authentication');
alertBar.hide();
}}
>
start over
</Link>
);
alertBar.error(
<FtlMsg
id="two-factor-auth-setup-token-verification-error"
elems={{ a: startOverLink }}
>
<>
There was a problem enabling two-step authentication. Check that
your devices clock is set to update automatically and{' '}
{startOverLink}.
</>
</FtlMsg>
);
} else {
showGenericError();
}
// for any error other than an incorrect backup code
// return to main settings page with generic error message
// there is no action the user can take at this step
showGenericError();
goHome();
return;
}
showSuccess();
goHome();
return;

View File

@@ -3,3 +3,7 @@
# Variables:
# $lastFourPhoneNumber (Number) - The last 4 digits of the user's recovery phone number
recovery-phone-number-ending-digits = Number ending in { $lastFourPhoneNumber }
# This error is shown when there is a particular kind of error at the very end of the 2FA flow
# and the user should begin it again. A system/device clock not being synced to the internet time is
# a common problem when using 2FA.
two-factor-auth-setup-token-verification-error = There was a problem enabling two-step authentication. Check that your devices clock is set to update automatically and <a>start over</a>.

View File

@@ -2,58 +2,65 @@
* 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 base32Decode from 'base32-decode';
import random from './random';
function trimOrPad(num: number, digits: number): string {
const str = num.toString().substr(-digits);
if (str.length === digits) {
return str;
let authenticatorInstance: any = null;
// Polyfill for Playwright tests because they run in a Node context where some
// browser globals are undefined, which `@otplib/preset-browser` uses.
function setupTestPolyfills() {
if (typeof window === 'undefined') {
(global as any).window = {
crypto: (global as any).crypto || {
getRandomValues: (arr: any) => {
const crypto = require('crypto');
const bytes = crypto.randomBytes(arr.length);
arr.set(bytes);
return arr;
},
},
};
if (typeof (global as any).buffer === 'undefined') {
(global as any).buffer = require('buffer');
}
if (typeof (global as any).Buffer === 'undefined') {
(global as any).Buffer = require('buffer').Buffer;
}
}
return new Array(digits - str.length + 1).join('0') + str;
}
export async function getCode(
secret: string,
digits: number = 6,
timestamp: number = Date.now()
): Promise<string> {
const secretKey = base32Decode(secret, 'RFC4648');
const counter = new ArrayBuffer(8);
const cv = new DataView(counter);
cv.setUint32(4, Math.floor(timestamp / 30000), false);
// Lazy import and configure authenticator only when needed. This is required
// because when importing this lib top-level, Playwright will error.
async function getAuthenticator() {
if (authenticatorInstance) {
return authenticatorInstance;
}
setupTestPolyfills();
const key = await crypto.subtle.importKey(
'raw',
secretKey,
{
name: 'HMAC',
hash: { name: 'SHA-1' },
},
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, counter);
const hmac = new DataView(signature);
const offset = hmac.getUint8(hmac.byteLength - 1) & 0x0f;
return trimOrPad(hmac.getInt32(offset, false) & 0x7fffffff, digits);
const { authenticator } = await import('@otplib/preset-browser');
// Configure otplib to match auth-server settings
authenticator.options = {
...authenticator.options,
step: 30,
window: 1,
};
authenticatorInstance = authenticator;
return authenticator;
}
export async function getCode(secret: string): Promise<string> {
const authenticator = await getAuthenticator();
return authenticator.generate(secret);
}
export async function checkCode(
secret: string,
code: string,
timestamp: number = Date.now(),
tries = 2
code: string
): Promise<boolean> {
for (; tries > 0; tries--, timestamp -= 30000) {
const x = await getCode(secret, 6, timestamp);
if (x === code) {
return true;
}
}
return false;
const authenticator = await getAuthenticator();
return authenticator.verify({ token: code, secret });
}
export function copyRecoveryCodes(event: React.ClipboardEvent<HTMLElement>) {

View File

@@ -1244,22 +1244,28 @@ export class Account implements AccountData {
}
async verifyTotp(code: string) {
await this.withLoadingStatus(
this.authClient.verifyTotpCode(sessionToken()!, code)
);
// We must requery for this because this endpoint checks
// for if recovery codes exist
await this.refresh('recoveryPhone');
const cache = this.apolloClient.cache;
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
totp(currentTotp) {
return { ...currentTotp, verified: true };
try {
await this.withLoadingStatus(
this.authClient.verifyTotpCode(sessionToken()!, code)
);
// We must requery for this because this endpoint checks
// for if recovery codes exist
await this.refresh('recoveryPhone');
const cache = this.apolloClient.cache;
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
totp(currentTotp) {
return { ...currentTotp, verified: true };
},
},
},
});
await this.refresh('backupCodes');
});
await this.refresh('backupCodes');
} catch (e) {
// Re-throw and allow the presentation layer to display the error.
// We don't want to set `totp.verified` to `true` if there's an error.
throw e;
}
}
async uploadAvatar(file: Blob) {

View File

@@ -1,9 +1,10 @@
import { makeVar } from '@apollo/client';
import { ReactNode } from 'react';
import { consumeAlertTextExternal } from '../lib/cache';
export type NotificationType = 'success' | 'info' | 'error' | 'warning';
export const alertContent = makeVar(consumeAlertTextExternal() || '');
export const alertContent = makeVar<string | ReactNode>(consumeAlertTextExternal() || '');
export const alertType = makeVar<NotificationType>('success');
export const alertVisible = makeVar(!!alertContent());
@@ -24,24 +25,24 @@ export class AlertBarInfo {
alertType(type);
}
setContent(text: string) {
setContent(text: string | ReactNode) {
alertContent(text);
}
success(message: string, gleanEvent?: () => void) {
success(message: string | ReactNode, gleanEvent?: () => void) {
this.setType('success');
this.setContent(message);
gleanEvent && gleanEvent();
this.show();
}
error(message: string, error?: Error) {
error(message: string | ReactNode, error?: Error) {
this.setType('error');
this.setContent(message);
this.show();
}
info(message: string) {
info(message: string | ReactNode) {
this.setType('success');
this.setContent(message);
this.show();

View File

@@ -2,7 +2,7 @@
* 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 { act, screen, waitFor } from '@testing-library/react';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent, { UserEvent } from '@testing-library/user-event';
import InlineRecoverySetup from '.';
import { MozServices } from '../../lib/types';
@@ -10,6 +10,7 @@ import { renderWithRouter } from '../../models/mocks';
import { MOCK_BACKUP_CODES, MOCK_EMAIL } from '../mocks';
import GleanMetrics from '../../lib/glean';
import { OAUTH_ERRORS, OAuthError } from '../../lib/oauth';
import { AuthUiErrors } from '../../lib/auth-errors/auth-errors';
jest.mock('../../lib/metrics', () => ({
logViewEvent: jest.fn(),
@@ -225,4 +226,39 @@ describe('InlineRecoverySetup', () => {
'There was a problem confirming your backup authentication code'
);
});
it('shows a banner error when AuthUiErrors.INVALID_OTP_CODE.errno is thrown', async () => {
const verifyTotpHandler = jest.fn().mockImplementation(() => {
const error: any = new Error();
error.errno = AuthUiErrors.INVALID_OTP_CODE.errno;
throw error;
});
renderWithRouter(
<InlineRecoverySetup
email={MOCK_EMAIL}
serviceName={MozServices.MozillaVPN}
{...props}
{...{ verifyTotpHandler }}
/>
);
await waitFor(async () =>
user.click(screen.getByRole('button', { name: 'Continue' }))
);
await waitFor(async () =>
user.type(
screen.getByLabelText('Backup authentication code'),
MOCK_BACKUP_CODES[0]
)
);
await waitFor(async () =>
user.click(screen.getByRole('button', { name: 'Confirm' }))
);
const errorEl = await screen.findByText(
/There was a problem enabling two-step authentication\./i
);
const startOverLink = within(errorEl).getByRole('link', {
name: /start over/i,
});
expect(startOverLink).toBeInTheDocument();
});
});

View File

@@ -3,9 +3,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useEffect, useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import { Link, RouteComponentProps, useLocation } from '@reach/router';
import { FtlMsg } from 'fxa-react/lib/utils';
import { useFtlMsgResolver } from '../../models';
import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery';
import DataBlock from '../../components/DataBlock';
import { BackupCodesImage } from '../../components/images';
import CardHeader from '../../components/CardHeader';
@@ -15,8 +16,15 @@ import FormVerifyCode, {
commonBackupCodeFormAttributes,
} from '../../components/FormVerifyCode';
import { AuthUiErrors } from '../../lib/auth-errors/auth-errors';
import { InlineRecoverySetupProps } from './interfaces';
import { getErrorFtlId, getLocalizedErrorMessage } from '../../lib/error-utils';
import {
InlineRecoverySetupProps,
SigninRecoveryLocationState,
} from './interfaces';
import {
getErrorFtlId,
getHandledError,
getLocalizedErrorMessage,
} from '../../lib/error-utils';
import GleanMetrics from '../../lib/glean';
import { GleanClickEventType2FA } from '../../lib/types';
import Banner from '../../components/Banner';
@@ -31,6 +39,12 @@ const InlineRecoverySetup = ({
email,
}: InlineRecoverySetupProps & RouteComponentProps) => {
const ftlMsgResolver = useFtlMsgResolver();
const navigateWithQuery = useNavigateWithQuery();
const location = useLocation() as ReturnType<typeof useLocation> & {
state?: SigninRecoveryLocationState;
};
const signinRecoveryLocationState = location.state;
const { totp, ...signinLocationState } = signinRecoveryLocationState || {};
const localizedIncorrectBackupCodeError = ftlMsgResolver.getMsg(
'tfa-incorrect-recovery-code-1',
'Incorrect backup authentication code'
@@ -44,6 +58,8 @@ const InlineRecoverySetup = ({
const [successfulTotpSetup, setSuccessfulTotpSetup] =
useState<boolean>(false);
const [recoveryCodeError, setRecoveryCodeError] = useState<string>('');
const [bannerErrorLocalized, setBannerErrorLocalized] =
useState<React.ReactNode>(null);
const showBannerSuccess = useCallback(
() =>
@@ -61,7 +77,7 @@ const InlineRecoverySetup = ({
[ftlMsgResolver, successfulTotpSetup]
);
const showBannerError = useCallback(
const showBannerOAuthError = useCallback(
() =>
oAuthError && (
<Banner
@@ -87,6 +103,8 @@ const InlineRecoverySetup = ({
const completeSetup = useCallback(
async (code: string) => {
setBannerErrorLocalized(null);
if (!recoveryCodes.includes(code.trim())) {
setRecoveryCodeError(localizedIncorrectBackupCodeError);
return;
@@ -100,11 +118,12 @@ const InlineRecoverySetup = ({
setSuccessfulTotpSetup(true);
setTimeout(successfulSetupHandler, 500);
} else {
// Some server side error occurred. Generic error message in catch
// Some server side error occurred. Generic error message in catch
// block.
throw new Error('cannot enable TOTP');
}
} catch (error) {
} catch (err) {
const { error } = getHandledError(err);
if (error.errno === AuthUiErrors.TOTP_TOKEN_NOT_FOUND.errno) {
setRecoveryCodeError(
ftlMsgResolver.getMsg(
@@ -112,6 +131,34 @@ const InlineRecoverySetup = ({
AuthUiErrors.TOTP_TOKEN_NOT_FOUND.message
)
);
} else if (error.errno === AuthUiErrors.INVALID_OTP_CODE.errno) {
const startOverLink = (
<Link
className="link-blue"
to="/inline_totp_setup"
state={signinLocationState}
onClick={() => {
navigateWithQuery('/inline_totp_setup', {
state: signinLocationState,
});
}}
>
start over
</Link>
);
setBannerErrorLocalized(
<FtlMsg
id="two-factor-auth-setup-token-verification-error"
elems={{ a: startOverLink }}
>
<>
There was a problem enabling two-step authentication. Check that
your devices clock is set to update automatically and{' '}
{startOverLink}.
</>
</FtlMsg>
);
} else {
setRecoveryCodeError(
ftlMsgResolver.getMsg(
@@ -128,6 +175,8 @@ const InlineRecoverySetup = ({
recoveryCodes,
successfulSetupHandler,
verifyTotpHandler,
navigateWithQuery,
signinLocationState,
]
);
@@ -156,7 +205,19 @@ const InlineRecoverySetup = ({
{...{ serviceName }}
/>
{showBannerSuccess()}
{showBannerError()}
{!bannerErrorLocalized && showBannerOAuthError()}
{/* Only show one banner-style error at a time. At the time of writing the
* only non-oauth error banner tells the user to start the flow again,
* so allow it to take precedence.
*/}
{bannerErrorLocalized && (
<Banner
type="error"
customContent={
<p className="font-bold">{bannerErrorLocalized}</p>
}
/>
)}
<section>
<div>
<BackupCodesImage />

View File

@@ -1075,6 +1075,23 @@ two_factor_auth:
expires: never
data_sensitivity:
- interaction
setup_invalid_code_error:
type: event
description: |
Edge case error that indicates the user verified their OTP code client-side during setup, but the server rejected it
lifetime: ping
send_in_pings:
- events
notification_emails:
- vzare@mozilla.com
- fxa-staff@mozilla.com
bugs:
- https://mozilla-hub.atlassian.net/browse/FXA-11087
data_reviews:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1830504
expires: never
data_sensitivity:
- interaction
two_step_auth_phone_code:
sent:

1
types/@otplib/index.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '@otplib';

View File

@@ -0,0 +1 @@
declare module '@otplib/preset-browser';

View File

@@ -11102,6 +11102,13 @@ __metadata:
languageName: node
linkType: hard
"@otplib/preset-browser@npm:^12.0.1":
version: 12.0.1
resolution: "@otplib/preset-browser@npm:12.0.1"
checksum: 10c0/ec0f61905d0e337fc68e0717b85fcdfb1b42263b1b7a7e96f89379a86d004c88a9ff5cf82a37de50696d6f3ecb219bdb1992f85a69320f8bccf38b49c492e1e2
languageName: node
linkType: hard
"@oxc-resolver/binding-darwin-arm64@npm:1.12.0":
version: 1.12.0
resolution: "@oxc-resolver/binding-darwin-arm64@npm:1.12.0"
@@ -33219,6 +33226,7 @@ __metadata:
"@emotion/react": "npm:^11.13.3"
"@emotion/styled": "npm:^11.13.0"
"@material-ui/core": "npm:v5.0.0-alpha.24"
"@otplib/preset-browser": "npm:^12.0.1"
"@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.3"
"@reach/router": "npm:^1.3.4"
"@react-pdf/renderer": "npm:3.2.1"