From b6d8321f0392f44fc1da475c780b1258f2cd54a6 Mon Sep 17 00:00:00 2001 From: Nicholas Shirley Date: Wed, 17 Sep 2025 12:32:08 -0600 Subject: [PATCH] chore(mfa): Add mfa helper utility for checking invalid JWT Because: - There are a few cases where a page cannot directly rely on MFA error-boundary and needs to check the JWT reponse directly This Commit: - Adds a mfa-guard-utils to check for a bad JWT server response - Adds a helper to clear cache when JWT is invalid --- .../src/lib/mfa-guard-utils.test.ts | 129 ++++++++++++++++++ .../fxa-settings/src/lib/mfa-guard-utils.ts | 42 ++++++ 2 files changed, 171 insertions(+) create mode 100644 packages/fxa-settings/src/lib/mfa-guard-utils.test.ts create mode 100644 packages/fxa-settings/src/lib/mfa-guard-utils.ts diff --git a/packages/fxa-settings/src/lib/mfa-guard-utils.test.ts b/packages/fxa-settings/src/lib/mfa-guard-utils.test.ts new file mode 100644 index 0000000000..de00ef15cb --- /dev/null +++ b/packages/fxa-settings/src/lib/mfa-guard-utils.test.ts @@ -0,0 +1,129 @@ +/* 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 { JwtTokenCache, MfaOtpRequestCache } from './cache'; +import { + clearMfaAndJwtCacheOnInvalidJwt, + isInvalidJwtError, +} from './mfa-guard-utils'; + +const defaultSessionToken = 'you-get-a-session-token'; +const jwt = 'and-you-get-a-jwt'; +const scope = 'test'; + +jest.mock('./cache', () => { + const actual = jest.requireActual('./cache'); + return { + __esModule: true, + ...actual, + sessionToken: jest.fn(() => defaultSessionToken), + }; +}); + +describe('mfa-guard-utils', () => { + let sessionTokenSpy: jest.SpyInstance; + + beforeEach(() => { + sessionTokenSpy = jest.mocked(require('./cache').sessionToken); + }); + + afterEach(() => { + sessionTokenSpy.mockReturnValue(defaultSessionToken); + }); + + describe('isInvalidJwtError', () => { + it('should return true if the error is an invalid JWT error', () => { + expect(isInvalidJwtError({ code: 401, errno: 110 })).toBe(true); + }); + + it('should return false if the error is not an invalid JWT error', () => { + expect(isInvalidJwtError({ code: 401, errno: 100 })).toBe(false); + }); + + it('should return false if the error is not an object', () => { + expect(isInvalidJwtError('not-an-object')).toBe(false); + }); + + it('should return false if the error is not an object with code and errno properties', () => { + expect(isInvalidJwtError({ code: 401 })).toBe(false); + }); + }); + + describe('clearMfaAndJwtCacheOnInvalidJwt', () => { + let removeJwtSpy: jest.SpyInstance; + let removeOtpSpy: jest.SpyInstance; + + beforeEach(() => { + removeJwtSpy = jest.spyOn(JwtTokenCache, 'removeToken'); + removeOtpSpy = jest.spyOn(MfaOtpRequestCache, 'remove'); + }); + + afterEach(() => { + removeJwtSpy.mockReset(); + removeOtpSpy.mockReset(); + }); + + it('should clear the MFA and JWT cache if the error is an invalid JWT error', () => { + const e = { code: 401, errno: 110 }; + + clearMfaAndJwtCacheOnInvalidJwt(e, scope); + + expect(removeOtpSpy).toHaveBeenCalledWith(defaultSessionToken, scope); + expect(removeJwtSpy).toHaveBeenCalledWith(defaultSessionToken, scope); + }); + + it('should not clear the MFA and JWT cache if the error is not an invalid JWT error', () => { + MfaOtpRequestCache.set(defaultSessionToken, scope); + JwtTokenCache.setToken(defaultSessionToken, scope, jwt); + const e = { code: 401, errno: 100 }; + + clearMfaAndJwtCacheOnInvalidJwt(e, scope); + + expect(MfaOtpRequestCache.get(defaultSessionToken, scope)).toBeDefined(); + expect(JwtTokenCache.getToken(defaultSessionToken, scope)).toBeDefined(); + }); + + it('should not clear the MFA and JWT cache if the session token is not set', () => { + MfaOtpRequestCache.set(defaultSessionToken, scope); + JwtTokenCache.setToken(defaultSessionToken, scope, jwt); + + // Override sessionToken to return undefined + sessionTokenSpy.mockReturnValue(undefined); + + const e = { code: 401, errno: 110 }; + + clearMfaAndJwtCacheOnInvalidJwt(e, scope); + + expect(removeOtpSpy).not.toHaveBeenCalled(); + expect(removeJwtSpy).not.toHaveBeenCalled(); + }); + + it('should not clear the MFA and JWT cache if the session token is null', () => { + MfaOtpRequestCache.set(defaultSessionToken, scope); + JwtTokenCache.setToken(defaultSessionToken, scope, jwt); + + // Override sessionToken to return null + sessionTokenSpy.mockReturnValue(null); + + const e = { code: 401, errno: 110 }; + + clearMfaAndJwtCacheOnInvalidJwt(e, scope); + + expect(removeOtpSpy).not.toHaveBeenCalled(); + expect(removeJwtSpy).not.toHaveBeenCalled(); + }); + + it('does not throw if token does not exist by scope', () => { + // Set a token in cache with a different scope + JwtTokenCache.setToken(defaultSessionToken, 'email', jwt); + + const e = { code: 401, errno: 110 }; + + expect(() => clearMfaAndJwtCacheOnInvalidJwt(e, scope)).not.toThrow(); + + expect(removeOtpSpy).toHaveBeenCalledWith(defaultSessionToken, scope); + expect(removeJwtSpy).toHaveBeenCalledWith(defaultSessionToken, scope); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/mfa-guard-utils.ts b/packages/fxa-settings/src/lib/mfa-guard-utils.ts new file mode 100644 index 0000000000..72bd7d8d9f --- /dev/null +++ b/packages/fxa-settings/src/lib/mfa-guard-utils.ts @@ -0,0 +1,42 @@ +/* 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 { + JwtTokenCache, + MfaOtpRequestCache, + sessionToken as getSessionToken, +} from './cache'; +import { MfaScope } from './types'; + +/** + * Clears the MFA and JWT cache for the given scope if the error is an invalid JWT. + * + * Use this when checking the response from the auth server for an invalid JWT. + * @param e - The error to check, must have code and errno properties. + * @param scope - The scope to clear from the MFA and JWT cache. + * @returns + */ +export const clearMfaAndJwtCacheOnInvalidJwt = ( + e: any, + scope: MfaScope +): void => { + const sessionToken = getSessionToken(); + if (!sessionToken) { + // noop - we can't do anything without a session token + return; + } + if (isInvalidJwtError(e)) { + MfaOtpRequestCache.remove(sessionToken, scope); + JwtTokenCache.removeToken(sessionToken, scope); + } +}; + +/** + * Checks if the error is an invalid JWT error. + * @param e - The error to check, must have code and errno properties. + * @returns + */ +export const isInvalidJwtError = (e: any): boolean => { + return e && e.code === 401 && e.errno === 110; +};