Skip to content

Commit a34fe3e

Browse files
committed
task(settings): Create Mfa Guard and Error Boundary
Because: - We want to wrap certain pages with an MFA requirement This Commit: - Updates the cache to hold a JWT - Creates a guard component that invokes the MFA Modal if the JWT is missing - Creates an error boundary that clears invalid or expired JWTs
1 parent 3356b39 commit a34fe3e

15 files changed

Lines changed: 827 additions & 23 deletions

File tree

packages/functional-tests/tests/misc/mfa.spec.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,33 @@ test.describe('severity-2 #smoke', () => {
1919
}) => {
2020
const credentials = await testAccountTracker.signUpSync();
2121
const client = target.createAuthClient(2);
22-
let resp = await client.mfaRequestOtp(credentials.sessionToken, 'test');
23-
expect(resp.status).toBe('success');
22+
const resp1 = await client.mfaRequestOtp(credentials.sessionToken, 'test');
23+
expect(resp1.status).toBe('success');
2424

2525
// Verify the otp code
2626
const code = await target.emailClient.getVerifyAccountChangeCode(
2727
credentials.email
2828
);
2929

3030
// Try accessing the protected action test endpoint with jwt
31-
resp = await client.mfaOtpVerify(credentials.sessionToken, code, 'test');
32-
expect(resp.accessToken).toBeDefined();
33-
const jwtAccessToken = resp.accessToken;
31+
const resp2 = await client.mfaOtpVerify(
32+
credentials.sessionToken,
33+
code,
34+
'test'
35+
);
36+
expect(resp2.accessToken).toBeDefined();
37+
const jwtAccessToken = resp2.accessToken;
3438

3539
// Try accessing the protected action again
36-
resp = await client.mfaTestGet(jwtAccessToken);
37-
expect(resp.status).toBe('success');
40+
const resp3 = await client.mfaTestGet(jwtAccessToken);
41+
expect(resp3.status).toBe('success');
3842

39-
resp = await client.mfaTestPost(jwtAccessToken);
40-
expect(resp.status).toBe('success');
43+
const resp4 = await client.mfaTestPost(jwtAccessToken);
44+
expect(resp4.status).toBe('success');
4145

4246
let scopeError = undefined;
4347
try {
44-
resp = await client.mfaTestPost2(jwtAccessToken);
48+
await client.mfaTestPost2(jwtAccessToken);
4549
} catch (err) {
4650
scopeError = err;
4751
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,7 +1517,7 @@ export default class AuthClient {
15171517
sessionToken: hexstring,
15181518
action: string,
15191519
headers?: Headers
1520-
) {
1520+
): Promise<{ status: string }> {
15211521
return this.sessionPost(
15221522
'/mfa/otp/request',
15231523
sessionToken,
@@ -1533,7 +1533,7 @@ export default class AuthClient {
15331533
code: string,
15341534
action: string,
15351535
headers?: Headers
1536-
) {
1536+
): Promise<{ accessToken: string }> {
15371537
return this.sessionPost(
15381538
'/mfa/otp/verify',
15391539
sessionToken,
@@ -1545,7 +1545,10 @@ export default class AuthClient {
15451545
);
15461546
}
15471547

1548-
async mfaTestGet(jwt: string, headers?: Headers) {
1548+
async mfaTestGet(
1549+
jwt: string,
1550+
headers?: Headers
1551+
): Promise<{ status: string }> {
15491552
return this.jwtGet('/mfa/test', jwt, headers);
15501553
}
15511554

packages/fxa-auth-server/config/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,7 +2402,8 @@ const convictConf = convict({
24022402
},
24032403
ftlUrl: {
24042404
template: {
2405-
default: 'https://raw.githubusercontent.com/mozilla/fxa-cms-l10n/main/locales/{locale}/cms.ftl',
2405+
default:
2406+
'https://raw.githubusercontent.com/mozilla/fxa-cms-l10n/main/locales/{locale}/cms.ftl',
24062407
doc: 'URL template for FTL files. Use {locale} placeholder for locale substitution',
24072408
env: 'CMS_L10N_FTL_URL_TEMPLATE',
24082409
format: String,
@@ -2412,7 +2413,7 @@ const convictConf = convict({
24122413
doc: 'Timeout for FTL requests in milliseconds',
24132414
env: 'CMS_L10N_FTL_TIMEOUT',
24142415
format: Number,
2415-
}
2416+
},
24162417
},
24172418
ftlCache: {
24182419
memoryTtl: {
@@ -2520,7 +2521,7 @@ const convictConf = convict({
25202521
env: 'MFA__OTP__WINDOW',
25212522
},
25222523
digits: {
2523-
default: 8,
2524+
default: 6,
25242525
doc: 'Length of code',
25252526
format: Number,
25262527
env: 'MFA__OTP__DIGITS',

packages/fxa-auth-server/lib/routes/auth-schemes/mfa.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ export const strategy = (config: ConfigType) => {
3131
audience: config.mfa.jwt.audience,
3232
issuer: config.mfa.jwt.issuer,
3333
};
34-
const decoded = jwt.verify(token, key, opts) as {
35-
sub?: string;
36-
scope?: string[];
37-
};
34+
35+
let decoded;
36+
try {
37+
decoded = jwt.verify(token, key, opts) as {
38+
sub?: string;
39+
scope?: string[];
40+
};
41+
} catch (err) {
42+
throw AppError.invalidToken(err.message);
43+
}
3844

3945
// Ensure required state
4046
if (decoded.sub == null || decoded.scope == null) {

packages/fxa-auth-server/test/local/routes/mfa.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('mfa', () => {
3333
enabled: true,
3434
actions: ['test'],
3535
otp: {
36-
digits: 8,
36+
digits: 6,
3737
},
3838
jwt: {
3939
secretKey: 'foxes',
@@ -158,6 +158,7 @@ describe('mfa', () => {
158158
error = err;
159159
}
160160
assert.isDefined(error);
161+
assert.equal(error.errno, 110);
161162
assert.equal(error.message, 'jwt malformed');
162163
});
163164

@@ -172,6 +173,7 @@ describe('mfa', () => {
172173
error = err;
173174
}
174175
assert.isDefined(error);
176+
assert.equal(error.errno, 110);
175177
assert.equal(error.message, 'jwt expired');
176178
});
177179
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import { render, screen } from '@testing-library/react';
7+
import { MfaErrorBoundary } from './error-boundary';
8+
import { JwtTokenCache } from '../../../lib/cache';
9+
10+
const mockScope = 'test';
11+
const mockSessionToken = 'session-xyz';
12+
const mockJwt = 'jwt-123';
13+
14+
describe('MfaErrorBoundary', () => {
15+
let removeSpy: jest.SpyInstance;
16+
17+
beforeEach(() => {
18+
removeSpy = jest.spyOn(JwtTokenCache, 'removeToken');
19+
});
20+
21+
afterEach(() => {
22+
removeSpy.mockReset();
23+
});
24+
25+
it('renders children when no error occurs', () => {
26+
render(
27+
<MfaErrorBoundary
28+
requiredScope={mockScope}
29+
sessionToken={mockSessionToken}
30+
jwt={mockJwt}
31+
fallback={<div>fallback</div>}
32+
>
33+
<div>child</div>
34+
</MfaErrorBoundary>
35+
);
36+
37+
expect(screen.getByText('child')).toBeInTheDocument();
38+
});
39+
40+
it('renders fallback and removes JWT on auth error (401/110)', () => {
41+
const ref = React.createRef<MfaErrorBoundary>();
42+
const { rerender } = render(
43+
<MfaErrorBoundary
44+
ref={ref}
45+
requiredScope={mockScope}
46+
sessionToken={mockSessionToken}
47+
jwt="jwt-2"
48+
fallback={<div>fallback</div>}
49+
>
50+
<div>child</div>
51+
</MfaErrorBoundary>
52+
);
53+
54+
const authError: any = new Error('invalid jwt');
55+
authError.code = 401;
56+
authError.errno = 110;
57+
58+
// Simulate boundary catching the error
59+
ref.current?.componentDidCatch(authError, {} as any);
60+
61+
// Trigger a render so updated state is reflected in output
62+
rerender(
63+
<MfaErrorBoundary
64+
ref={ref}
65+
requiredScope={mockScope}
66+
sessionToken={mockSessionToken}
67+
jwt="jwt-2"
68+
fallback={<div>fallback</div>}
69+
>
70+
<div>child</div>
71+
</MfaErrorBoundary>
72+
);
73+
74+
expect(screen.getByText('fallback')).toBeInTheDocument();
75+
expect(removeSpy).toHaveBeenCalledWith(mockSessionToken, mockScope);
76+
});
77+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Component, ReactNode } from 'react';
2+
import { MfaScope } from '../../../lib/types';
3+
import { JwtTokenCache } from '../../../lib/cache';
4+
5+
/**
6+
* Error Boundary Implementation.
7+
*
8+
* This error boundary's only job is to handle errors that percolate up related to
9+
* invalid JWTs. This can happen if a user leaves a flow open for a while, and
10+
* the once-valid JWT expires. In this case, when they submit the JWT an invalid
11+
* state will be returned, and we should clear the JWT from our cache so the
12+
* user has an opportunity to get a new one via the MfaModalDialog.
13+
*
14+
* This error boundary is specifically tailored to the MfaGuard. Don't try
15+
* to export it or reuse it!
16+
*/
17+
type MfaErrorBoundaryProps = {
18+
requiredScope: MfaScope;
19+
sessionToken: string;
20+
jwt: string;
21+
children: ReactNode;
22+
fallback: ReactNode;
23+
};
24+
25+
type MfaErrorBoundaryState = { hasError: boolean; error: any | null };
26+
27+
export class MfaErrorBoundary extends Component<
28+
MfaErrorBoundaryProps,
29+
MfaErrorBoundaryState
30+
> {
31+
state: MfaErrorBoundaryState;
32+
33+
constructor(props: any) {
34+
super(props);
35+
this.state = { hasError: false, error: null };
36+
}
37+
38+
static getDerivedStateFromError(error: any) {
39+
if (error && error.code === 401 && error.errno === 110) {
40+
return { hasError: true };
41+
}
42+
43+
return undefined;
44+
}
45+
46+
componentDidCatch(error: any, info: any) {
47+
if (error && error.code === 401 && error.errno === 110) {
48+
this.setState({
49+
hasError: true,
50+
error: error,
51+
});
52+
53+
JwtTokenCache.removeToken(
54+
this.props.sessionToken,
55+
this.props.requiredScope
56+
);
57+
} else {
58+
// Causes error to bubble up to the next error boundary
59+
throw error;
60+
}
61+
}
62+
63+
componentDidUpdate(prevProps: MfaErrorBoundaryProps) {
64+
// Until a new code is provided, consider the error to still be valid.
65+
if (prevProps.jwt !== this.props.jwt) {
66+
this.setState({
67+
hasError: false,
68+
error: null,
69+
});
70+
}
71+
}
72+
73+
render() {
74+
if (this.state.hasError && this.state.error) {
75+
return this.props.fallback;
76+
}
77+
78+
return this.props.children;
79+
}
80+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import { Meta } from '@storybook/react';
7+
import { withLocalization, withLocation } from 'fxa-react/lib/storybooks';
8+
import { action } from '@storybook/addon-actions';
9+
import { AppContext } from '../../../models';
10+
import { mockAppContext } from '../../../models/mocks';
11+
import { MfaGuard } from './index';
12+
import { JwtTokenCache } from '../../../lib/cache';
13+
14+
const scope: 'test' = 'test';
15+
const session = 'session-xyz';
16+
17+
function initLocalAccount(sessionToken: string) {
18+
// Match Storage fullKey logic: '__fxa_storage.' prefix
19+
const NS = '__fxa_storage';
20+
const uid = 'abc123';
21+
const accounts = {
22+
[uid]: {
23+
uid,
24+
sessionToken,
25+
26+
verified: true,
27+
lastLogin: Date.now(),
28+
},
29+
};
30+
window.localStorage.setItem(`${NS}.accounts`, JSON.stringify(accounts));
31+
window.localStorage.setItem(`${NS}.currentAccountUid`, JSON.stringify(uid));
32+
}
33+
34+
const authClient = {
35+
mfaRequestOtp: async (st: string, sc: string) => {
36+
action('mfaRequestOtp')({ sessionToken: st, scope: sc });
37+
return undefined;
38+
},
39+
mfaOtpVerify: async (st: string, code: string, sc: string) => {
40+
action('mfaOtpVerify')({ sessionToken: st, code, scope: sc });
41+
return { accessToken: 'storybook-jwt' } as any;
42+
},
43+
} as any;
44+
45+
export default {
46+
title: 'Components/Settings/MfaGuard',
47+
component: MfaGuard,
48+
decorators: [withLocalization, withLocation('/settings')],
49+
} as Meta;
50+
51+
export const JwtMissingShowsModal = () => {
52+
initLocalAccount(session);
53+
// Ensure no token present so guard triggers OTP flow
54+
try {
55+
if (JwtTokenCache.hasToken(session, scope)) {
56+
JwtTokenCache.removeToken(session, scope);
57+
}
58+
} catch {}
59+
60+
return (
61+
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
62+
<MfaGuard requiredScope={scope}>
63+
<div>Secured content</div>
64+
</MfaGuard>
65+
</AppContext.Provider>
66+
);
67+
};
68+
69+
export const JwtPresentRendersChildren = () => {
70+
initLocalAccount(session);
71+
JwtTokenCache.setToken(session, scope, 'jwt-present');
72+
73+
return (
74+
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
75+
<MfaGuard requiredScope={scope}>
76+
<div>Secured content</div>
77+
</MfaGuard>
78+
</AppContext.Provider>
79+
);
80+
};

0 commit comments

Comments
 (0)