Skip to content

Commit f8be6b1

Browse files
authored
Merge pull request #19472 from mozilla/FXA-12398
task(settings,auth): Wrap remove account recovery key with MFA guard
2 parents 9e4ad95 + c2a0bdf commit f8be6b1

6 files changed

Lines changed: 136 additions & 44 deletions

File tree

packages/fxa-auth-client/lib/client.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,21 @@ export default class AuthClient {
348348
return this.request('POST', path, payload, headers);
349349
}
350350

351+
private async jwtDelete(
352+
path: string,
353+
jwt: string,
354+
payload: any,
355+
headers?: Headers
356+
) {
357+
const authorization = 'Bearer ' + jwt;
358+
if (!headers) {
359+
headers = new Headers({ authorization });
360+
} else {
361+
headers.set('authorization', authorization);
362+
}
363+
return this.request('DELETE', path, payload, headers);
364+
}
365+
351366
private async jwtPut(
352367
path: string,
353368
jwt: string,
@@ -2410,6 +2425,9 @@ export default class AuthClient {
24102425
return accountData;
24112426
}
24122427

2428+
/**
2429+
* @deprecated Use deleteRecoveryKeyWithJwt instead
2430+
*/
24132431
async deleteRecoveryKey(sessionToken: hexstring, headers?: Headers) {
24142432
return this.hawkRequest(
24152433
'DELETE',
@@ -2421,6 +2439,16 @@ export default class AuthClient {
24212439
);
24222440
}
24232441

2442+
/**
2443+
* Removes the recovery key from the account
2444+
* @param jwt The MFA jwt for auth
2445+
* @param headers
2446+
* @returns
2447+
*/
2448+
async deleteRecoveryKeyWithJwt(jwt: string, headers?: Headers) {
2449+
return this.jwtDelete('/mfa/recoveryKey', jwt, {}, headers);
2450+
}
2451+
24242452
async recoveryKeyExists(
24252453
sessionToken: hexstring | undefined,
24262454
email: string | undefined,

packages/fxa-auth-server/docs/swagger/recovery-key-api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ const RECOVERYKEY_DELETE = {
6060
],
6161
};
6262

63+
const MFA_RECOVERY_KEY_DELETE = {
64+
...TAGS_RECOVERY_KEY,
65+
description: '/recoveryKey',
66+
notes: [
67+
'🔒 Authenticated with MFA JWT (scope: mfa:recovery_key)',
68+
"This route remove an account's account recovery key. When the key is removed, it can no longer be used to restore an account's kB.",
69+
],
70+
};
71+
6372
const RECOVERYKEY_VERIFY_POST = {
6473
...TAGS_RECOVERY_KEY,
6574
description: '/recoveryKey/verify',
@@ -92,6 +101,7 @@ const RECOVERYKEY_HINT_POST = {
92101

93102
const API_DOCS = {
94103
RECOVERYKEY_DELETE,
104+
MFA_RECOVERY_KEY_DELETE,
95105
RECOVERYKEY_EXISTS_POST,
96106
RECOVERYKEY_POST,
97107
MFA_RECOVERY_KEY_POST,

packages/fxa-auth-server/lib/routes/recovery-key.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,26 @@ module.exports = (
470470
return {};
471471
},
472472
},
473+
{
474+
method: 'DELETE',
475+
path: '/mfa/recoveryKey',
476+
options: {
477+
...RECOVERY_KEY_DOCS.MFA_RECOVERY_KEY_DELETE,
478+
auth: {
479+
strategy: 'mfa',
480+
scope: ['mfa:recovery_key'],
481+
payload: false,
482+
},
483+
},
484+
async handler(request) {
485+
return routes
486+
.find(
487+
(route) =>
488+
route.path === '/v1/recoveryKey' && route.method === 'DELETE'
489+
)
490+
.handler(request);
491+
},
492+
},
473493
];
474494

475495
return routes;

packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@ import { mockAppContext, renderWithRouter } from '../../../models/mocks';
99
import { Account, AppContext } from '../../../models';
1010
import * as Metrics from '../../../lib/metrics';
1111
import { Config } from '../../../lib/config';
12+
import AuthClient from 'fxa-auth-client/browser';
13+
import * as cache from '../../../lib/cache';
1214

1315
jest.mock('../../../lib/metrics', () => ({
1416
logViewEvent: jest.fn(),
1517
}));
1618

19+
jest.mock('../../../lib/cache', () => ({
20+
...jest.requireActual('../../../lib/cache'),
21+
sessionToken: jest.fn(),
22+
currentAccount: jest.fn(),
23+
}));
24+
25+
const sessionToken = 'session-123';
26+
const jwt = 'jwt-123';
27+
1728
const accountHasRecoveryKey = {
1829
hasPassword: true,
1930
recoveryKey: { exists: true },
@@ -29,11 +40,22 @@ const accountWithoutPassword = {
2940
recoveryKey: { exists: false },
3041
} as unknown as Account;
3142

43+
const authClient = {} as unknown as AuthClient;
44+
3245
const renderWithContext = (
3346
account: Partial<Account>,
3447
config?: Partial<Config>
3548
) => {
36-
const context = { account: account as Account, config: config as Config };
49+
const context = {
50+
authClient,
51+
account: account as Account,
52+
config: config as Config,
53+
};
54+
55+
(cache.currentAccount as jest.Mock).mockReturnValue(account);
56+
(cache.sessionToken as jest.Mock).mockReturnValue(sessionToken);
57+
cache.JwtTokenCache.getToken = jest.fn().mockReturnValue(jwt);
58+
cache.JwtTokenCache.hasToken = jest.fn().mockReturnValue(true);
3759

3860
renderWithRouter(
3961
<AppContext.Provider value={mockAppContext(context)}>
@@ -103,7 +125,7 @@ describe('UnitRowRecoveryKey', () => {
103125
const accountHasRecoveryKeyWithDeleteSuccess = {
104126
hasPassword: true,
105127
recoveryKey: { exists: true },
106-
deleteRecoveryKey: jest.fn().mockResolvedValue(true),
128+
deleteRecoveryKeyWithJwt: jest.fn().mockResolvedValue(true),
107129
} as unknown as Account;
108130

109131
renderWithContext(accountHasRecoveryKeyWithDeleteSuccess);
@@ -124,7 +146,7 @@ describe('UnitRowRecoveryKey', () => {
124146
const accountHasRecoveryKeyWithDeleteFailure = {
125147
hasPassword: true,
126148
recoveryKey: { exists: true },
127-
deleteRecoveryKey: jest.fn().mockRejectedValue(false),
149+
deleteRecoveryKeyWithJwt: jest.fn().mockRejectedValue(false),
128150
} as unknown as Account;
129151

130152
renderWithContext(accountHasRecoveryKeyWithDeleteFailure);

packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.tsx

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import React, { useCallback, useState } from 'react';
6+
import { useErrorHandler } from 'react-error-boundary';
67
import { useBooleanState } from 'fxa-react/lib/hooks';
78
import { useAccount, useAlertBar, useFtlMsgResolver } from '../../../models';
89
import { logViewEvent } from '../../../lib/metrics';
@@ -13,9 +14,12 @@ import { ButtonIconTrash } from '../ButtonIcon';
1314
import { SETTINGS_PATH } from '../../../constants';
1415
import { FtlMsg } from 'fxa-react/lib/utils';
1516
import GleanMetrics from '../../../lib/glean';
17+
import { isInvalidJwtError } from '../../../lib/mfa-guard-utils';
18+
import { MfaGuard } from '../MfaGuard';
1619

1720
export const UnitRowRecoveryKey = () => {
1821
const account = useAccount();
22+
const errorHandler = useErrorHandler();
1923

2024
const recoveryKey = account.recoveryKey.exists;
2125
const alertBar = useAlertBar();
@@ -25,7 +29,7 @@ export const UnitRowRecoveryKey = () => {
2529

2630
const deleteRecoveryKey = useCallback(async () => {
2731
try {
28-
await account.deleteRecoveryKey();
32+
await account.deleteRecoveryKeyWithJwt();
2933
hideModal();
3034
alertBar.success(
3135
ftlMsgResolver.getMsg(
@@ -36,6 +40,13 @@ export const UnitRowRecoveryKey = () => {
3640
logViewEvent('flow.settings.account-recovery', 'confirm-revoke.success');
3741
} catch (e) {
3842
hideModal();
43+
44+
if (isInvalidJwtError(e)) {
45+
// JWT invalid/expired
46+
errorHandler(e);
47+
return;
48+
}
49+
3950
alertBar.error(
4051
ftlMsgResolver.getMsg(
4152
'rk-remove-error-2',
@@ -46,7 +57,7 @@ export const UnitRowRecoveryKey = () => {
4657
} finally {
4758
setIsDeleting(false);
4859
}
49-
}, [account, hideModal, alertBar, ftlMsgResolver]);
60+
}, [account, hideModal, alertBar, ftlMsgResolver, errorHandler]);
5061

5162
const localizedDeleteRKIconButton = ftlMsgResolver.getMsg(
5263
'unit-row-recovery-key-delete-icon-button-title',
@@ -120,40 +131,42 @@ export const UnitRowRecoveryKey = () => {
120131
);
121132
}}
122133
>
123-
<Modal
124-
onDismiss={() => {
125-
hideModal();
126-
}}
127-
onConfirm={() => {
128-
setIsDeleting(true);
129-
deleteRecoveryKey();
130-
logViewEvent(
131-
'flow.settings.account-recovery',
132-
'confirm-revoke.submit'
133-
);
134-
}}
135-
confirmBtnClassName="cta-caution cta-base-p"
136-
confirmText={ftlMsgResolver.getMsg('rk-action-remove', 'Remove')}
137-
headerId="recovery-key-header"
138-
descId="recovery-key-desc"
139-
isLoading={isDeleting}
140-
>
141-
<FtlMsg id="rk-remove-modal-heading-1">
142-
<h2
143-
id="recovery-key-header"
144-
className="font-bold text-xl text-center mb-2"
145-
>
146-
Remove account recovery key?
147-
</h2>
148-
</FtlMsg>
149-
<FtlMsg id="rk-remove-modal-content-1">
150-
<p className="my-6 text-center">
151-
In the event you reset your password, you wonʼt be able to use
152-
your account recovery key to access your data. You canʼt undo
153-
this action.
154-
</p>
155-
</FtlMsg>
156-
</Modal>
134+
<MfaGuard requiredScope="recovery_key">
135+
<Modal
136+
onDismiss={() => {
137+
hideModal();
138+
}}
139+
onConfirm={() => {
140+
setIsDeleting(true);
141+
deleteRecoveryKey();
142+
logViewEvent(
143+
'flow.settings.account-recovery',
144+
'confirm-revoke.submit'
145+
);
146+
}}
147+
confirmBtnClassName="cta-caution cta-base-p"
148+
confirmText={ftlMsgResolver.getMsg('rk-action-remove', 'Remove')}
149+
headerId="recovery-key-header"
150+
descId="recovery-key-desc"
151+
isLoading={isDeleting}
152+
>
153+
<FtlMsg id="rk-remove-modal-heading-1">
154+
<h2
155+
id="recovery-key-header"
156+
className="font-bold text-xl text-center mb-2"
157+
>
158+
Remove account recovery key?
159+
</h2>
160+
</FtlMsg>
161+
<FtlMsg id="rk-remove-modal-content-1">
162+
<p className="my-6 text-center">
163+
In the event you reset your password, you wonʼt be able to use
164+
your account recovery key to access your data. You canʼt undo
165+
this action.
166+
</p>
167+
</FtlMsg>
168+
</Modal>
169+
</MfaGuard>
157170
</VerifiedSessionGuard>
158171
)}
159172
</UnitRow>

packages/fxa-settings/src/models/Account.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,10 +1143,9 @@ export class Account implements AccountData {
11431143
await this.refresh('backupCodes');
11441144
}
11451145

1146-
async deleteRecoveryKey() {
1147-
await this.withLoadingStatus(
1148-
this.authClient.deleteRecoveryKey(sessionToken()!)
1149-
);
1146+
async deleteRecoveryKeyWithJwt() {
1147+
const jwt = this.getCachedJwtByScope('recovery_key');
1148+
await this.withLoadingStatus(this.authClient.deleteRecoveryKeyWithJwt(jwt));
11501149
const cache = this.apolloClient.cache;
11511150
cache.modify({
11521151
id: cache.identify({ __typename: 'Account' }),
@@ -1613,7 +1612,7 @@ export class Account implements AccountData {
16131612
* @param scope MfaScope
16141613
* @returns JWT token string
16151614
*/
1616-
private getCachedJwtByScope(scope: MfaScope) {
1615+
getCachedJwtByScope(scope: MfaScope) {
16171616
const token = sessionToken();
16181617
if (!token) {
16191618
throw AuthUiErrors.INVALID_TOKEN;

0 commit comments

Comments
 (0)