Skip to content

Commit b16b84a

Browse files
committed
bug(settings): Add pre-check for JWT expiration
Because: - If a flow is started with an expired token, the MFA dialog will not render until a web request is made that invalidates the token. Once the token is invalidated, the flow must be restarted... This Commit: - Adds the ability to decode the JWT's payload - Adds a check to determine if the JWT is expired to the hasToken check. - If the token is expired, hasToken returns false, and the MFA dialog will render, so the user can get a valid JWT before the flow starts.
1 parent 6ebe636 commit b16b84a

3 files changed

Lines changed: 95 additions & 3 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ describe('MfaGuard', () => {
106106
expect(mockAuthClient.mfaRequestOtp).not.toHaveBeenCalled();
107107
});
108108

109+
it('renders dialog when JWT has expired', async () => {
110+
// Set an expired token...
111+
JwtTokenCache.setToken(
112+
mockSessionToken,
113+
mockScope,
114+
// The middle part of the string is the payload. The other parts don't matter for this test.
115+
'__.eyJzdWIiOiI4YjMzNmZlNGE5MmM0ZTk5YmMyNGIyMjFmOTUzMzk0MiIsInNjb3BlIjpbIm1mYTpyZWNvdmVyeV9rZXkiXSwiaWF0IjoxNzU5MjQ3MTE3LCJqdGkiOiJjYmY1N2M2MC1hYzcwLTRhNGEtYTdkMy0wN2U0NTdlM2E4MWYiLCJzdGlkIjoiMzI5ZjQzNTFiMDUwN2QwNDVmNDYxZWQxNWY4MzZmNDM3MDBhMmM0YTk5NmVlMWM1ODMxZTQzNGIxZjc4ZjFhNCIsImV4cCI6MTc1OTI0NzE0NywiYXVkIjoiZnhhIiwiaXNzIjoiYWNjb3VudHMuZmlyZWZveC5jb20ifQ.__'
116+
);
117+
118+
renderWithRouter(
119+
<AppContext.Provider value={mockAppContext()}>
120+
<MfaGuard requiredScope={mockScope}>
121+
<div>secured</div>
122+
</MfaGuard>
123+
</AppContext.Provider>
124+
);
125+
126+
await waitFor(() => {
127+
expect(screen.queryByText('Enter confirmation code')).toBeInTheDocument();
128+
expect(mockAuthClient.mfaRequestOtp).toHaveBeenCalled();
129+
});
130+
});
131+
109132
it('shows error banner on invalid OTP', async () => {
110133
renderWithRouter(
111134
<AppContext.Provider value={mockAppContext()}>

packages/fxa-settings/src/lib/cache.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,18 @@ export function isInReactExperiment() {
268268
}
269269
}
270270

271+
/** Decoded JWT payload state */
272+
export type JwtPayload = {
273+
aud: string;
274+
exp: number;
275+
iat: number;
276+
iss: string;
277+
jti: string;
278+
stid: string;
279+
sub: string;
280+
scope: Array<string>;
281+
};
282+
271283
/**
272284
* External Container for holding JWTs.
273285
*
@@ -335,7 +347,63 @@ export class JwtTokenCache {
335347
*/
336348
static hasToken(sessionToken: string, scope: MfaScope) {
337349
const key = JwtTokenCache.getKey(sessionToken, scope);
338-
return this.state[key] != null;
350+
const jwt = this.state[key];
351+
return jwt != null && !this.isExpired(jwt);
352+
}
353+
354+
/**
355+
* Checks if a token is expired. If the token cannot be decoded or the exp
356+
* claim cannot be found in the payload, then we will also return false.
357+
* @param token A valid JWT
358+
* @returns True if the token's exp claim is greater than now.
359+
*/
360+
static isExpired(token: string) {
361+
const decodedJwt = this.decodeTokenPayload(token);
362+
363+
// Under real use this shouldn't happen. If a token could be decode
364+
// we can't tell if it expired so return false. We get a little fast
365+
// and loose with some our mocks, and this helps keep them simple...
366+
if (!decodedJwt) {
367+
return false;
368+
}
369+
370+
return decodedJwt.exp * 1000 < Date.now();
371+
}
372+
373+
/**
374+
* Decodes a jwt's payload
375+
* @param token
376+
* @returns
377+
*/
378+
static decodeTokenPayload(token: string) {
379+
try {
380+
const [, payload] = token.split('.');
381+
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
382+
const jsonPayload = decodeURIComponent(
383+
atob(base64)
384+
.split('')
385+
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
386+
.join('')
387+
);
388+
const decoded = JSON.parse(jsonPayload);
389+
390+
// Type guard for JwtPayload
391+
if (
392+
decoded &&
393+
typeof decoded.aud === 'string' &&
394+
typeof decoded.exp === 'number' &&
395+
typeof decoded.iat === 'number' &&
396+
typeof decoded.iss === 'string' &&
397+
typeof decoded.jti === 'string' &&
398+
typeof decoded.stid === 'string' &&
399+
typeof decoded.sub === 'string' &&
400+
decoded.scope instanceof Array
401+
) {
402+
return decoded as JwtPayload;
403+
}
404+
} catch {}
405+
406+
return null;
339407
}
340408

341409
/**

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getStoredAccountData,
2020
sessionToken,
2121
JwtTokenCache,
22+
JwtNotFoundError,
2223
} from '../lib/cache';
2324
import firefox from '../lib/channels/firefox';
2425
import Storage from '../lib/storage';
@@ -1631,11 +1632,11 @@ export class Account implements AccountData {
16311632
getCachedJwtByScope(scope: MfaScope) {
16321633
const token = sessionToken();
16331634
if (!token) {
1634-
throw AuthUiErrors.INVALID_TOKEN;
1635+
throw new JwtNotFoundError('Missing parent session token.');
16351636
}
16361637
const hasJwt = JwtTokenCache.hasToken(token, scope);
16371638
if (!hasJwt) {
1638-
throw AuthUiErrors.INVALID_TOKEN;
1639+
throw new JwtNotFoundError('Missing or expired jwt.');
16391640
}
16401641

16411642
return JwtTokenCache.getToken(token, scope);

0 commit comments

Comments
 (0)