From 2449930285b3404c26833429251344f4bb217ca7 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 2 Jul 2026 17:02:22 +1000 Subject: [PATCH 1/2] AP-9459 # Added mfa_method JWT claim authorisation --- src/apps/mfa-service.ts | 6 +- src/apps/services/AWSCognitoClient.ts | 33 +++ src/apps/services/cognito.ts | 3 +- src/components/mfa/MfaMethodRow.tsx | 35 ++- .../mfa/MultiFactorAuthentication.tsx | 53 ++++- src/utils/mfa-requirement.ts | 136 ++++++++++-- tests/apps/services/AWSCognitoClient.test.ts | 36 +++- tests/utils/mfa-requirement.test.ts | 203 ++++++++++++------ 8 files changed, 395 insertions(+), 110 deletions(-) diff --git a/src/apps/mfa-service.ts b/src/apps/mfa-service.ts index 3bdef2de..4a0703d5 100644 --- a/src/apps/mfa-service.ts +++ b/src/apps/mfa-service.ts @@ -14,16 +14,16 @@ export { generateMfaAuthenticatorAppQrCodeUrl, DEFAULT_MFA_SETTINGS, } from './services/cognito' -export type { MfaMethod, MfaSettings } from './services/cognito' +export type { MfaMethod, MfaSettings, LoggedInMfaMethod } from './services/cognito' export { isMfaRequired, mfaRequirementToSelectedMethods, mfaSelectedMethodsToMfaRequirement, formatMfaRequirementLabel, - formatMfaRequirementMethodLabel, userMeetsMfaRequirement, formatMfaSetupRequiredMessage, - formatMfaMethodNotAcceptedMessage, + formatMfaMethodRequirementMessage, + isMfaMethodUsedForLogin, } from '../utils/mfa-requirement' export type { MfaRequirementMethod } from '../utils/mfa-requirement' diff --git a/src/apps/services/AWSCognitoClient.ts b/src/apps/services/AWSCognitoClient.ts index 3f975312..a102de3c 100644 --- a/src/apps/services/AWSCognitoClient.ts +++ b/src/apps/services/AWSCognitoClient.ts @@ -15,12 +15,19 @@ import { VerifySoftwareTokenCommand, VerifyUserAttributeCommand, } from '@aws-sdk/client-cognito-identity-provider' +import { jwtDecode } from 'jwt-decode' import Sentry from '../Sentry' import { OneBlinkAppsError } from '..' export type MfaMethod = 'authenticator' | 'sms' +export type LoggedInMfaMethod = + | 'SOFTWARE_TOKEN_MFA' + | 'SMS_MFA' + | 'NO_MFA_ENABLED' + export type MfaSettings = { + loggedInWithMfaMethod: LoggedInMfaMethod authenticator: { enabled: boolean preferred: boolean @@ -34,6 +41,7 @@ export type MfaSettings = { } export const DEFAULT_MFA_SETTINGS: MfaSettings = { + loggedInWithMfaMethod: 'NO_MFA_ENABLED', authenticator: { enabled: false, preferred: false }, sms: { enabled: false, @@ -43,6 +51,28 @@ export const DEFAULT_MFA_SETTINGS: MfaSettings = { }, } +export function resolveLoggedInMfaMethod( + idToken: string | undefined, +): LoggedInMfaMethod { + if (idToken) { + try { + const payload = jwtDecode<{ mfa_method?: unknown }>(idToken) + switch (payload.mfa_method) { + case 'SOFTWARE_TOKEN_MFA': + case 'SMS_MFA': + return payload.mfa_method + } + } catch (error) { + console.warn( + 'Error while decoding id token for logged in MFA method', + error, + ) + Sentry.captureException(error) + } + } + return 'NO_MFA_ENABLED' +} + export function resolveMfaPreferredFlags({ authenticatorEnabled, smsEnabled, @@ -630,7 +660,10 @@ export default class AWSCognitoClient { preferredMfaSetting, }) + const loggedInWithMfaMethod = resolveLoggedInMfaMethod(this._getIdToken()) + return { + loggedInWithMfaMethod, authenticator: { enabled: authenticatorEnabled, preferred: authenticatorPreferred, diff --git a/src/apps/services/cognito.ts b/src/apps/services/cognito.ts index 4d443836..41c5a8eb 100644 --- a/src/apps/services/cognito.ts +++ b/src/apps/services/cognito.ts @@ -3,6 +3,7 @@ import { jwtDecode } from 'jwt-decode' import AWSCognitoClient, { DEFAULT_MFA_SETTINGS, LoginAttemptResponse, + LoggedInMfaMethod, MfaMethod, MfaSettings, } from './AWSCognitoClient' @@ -572,4 +573,4 @@ export { generateMfaAuthenticatorAppQrCodeUrl, DEFAULT_MFA_SETTINGS, } -export type { LoginAttemptResponse, MfaMethod, MfaSettings } +export type { LoginAttemptResponse, LoggedInMfaMethod, MfaMethod, MfaSettings } diff --git a/src/components/mfa/MfaMethodRow.tsx b/src/components/mfa/MfaMethodRow.tsx index 827d02f9..f783e807 100644 --- a/src/components/mfa/MfaMethodRow.tsx +++ b/src/components/mfa/MfaMethodRow.tsx @@ -12,6 +12,7 @@ import { type Props = { isEnabled: boolean isPreferred: boolean + isUsedForLogin: boolean isSettingUp: boolean isSettingPreferredMfaMethod: boolean isSetupDisabled: boolean @@ -20,6 +21,7 @@ type Props = { description: string detail?: string mfaRequirementMessage?: string + mfaRequirementMessageIsWarning?: boolean cypressPrefix: string extraButtons?: React.ReactNode onSetup: () => void @@ -30,6 +32,7 @@ type Props = { function MfaMethodRow({ isEnabled, isPreferred, + isUsedForLogin, isSettingUp, isSettingPreferredMfaMethod, isSetupDisabled, @@ -38,6 +41,7 @@ function MfaMethodRow({ description, detail, mfaRequirementMessage, + mfaRequirementMessageIsWarning, cypressPrefix, extraButtons, onSetup, @@ -56,7 +60,7 @@ function MfaMethodRow({ @@ -65,11 +69,20 @@ function MfaMethodRow({ )} + {isUsedForLogin && ( + + )} {description} @@ -79,15 +92,6 @@ function MfaMethodRow({ {detail} )} - {!!mfaRequirementMessage && ( - - {mfaRequirementMessage} - - )} @@ -140,6 +144,15 @@ function MfaMethodRow({ + + {!!mfaRequirementMessage && ( + + {mfaRequirementMessage} + + )} ) } diff --git a/src/components/mfa/MultiFactorAuthentication.tsx b/src/components/mfa/MultiFactorAuthentication.tsx index 0f3c2f1e..902b70cf 100644 --- a/src/components/mfa/MultiFactorAuthentication.tsx +++ b/src/components/mfa/MultiFactorAuthentication.tsx @@ -18,14 +18,21 @@ import MfaMethodRow from './MfaMethodRow' import MfaSuccessSnackbar from './MfaSuccessSnackbar' import MfaErrorSnackbar from './MfaErrorSnackbar' import MfaStatusChip from './MfaStatusChip' -import { formatMfaMethodNotAcceptedMessage } from '../../utils/mfa-requirement' +import { + formatMfaMethodRequirementMessage, + isMfaMethodUsedForLogin, +} from '../../utils/mfa-requirement' type Props = { mfaRequirement: MiscTypes.MfaRequirement | undefined ssoSetupUrl?: string + accessScopeLabel?: string } -function MfaMethodList({ mfaRequirement }: Pick) { +function MfaMethodList({ + mfaRequirement, + accessScopeLabel = 'app', +}: Pick) { const { mfaSettings, loadingError, @@ -52,12 +59,22 @@ function MfaMethodList({ mfaRequirement }: Pick) { }, [mfaSettings]) const authenticatorMfaRequirementMessage = useMemo(() => { - return formatMfaMethodNotAcceptedMessage('authenticatorApp', mfaRequirement) - }, [mfaRequirement]) + return formatMfaMethodRequirementMessage( + 'authenticatorApp', + mfaRequirement, + mfaSettings, + accessScopeLabel, + ) + }, [accessScopeLabel, mfaRequirement, mfaSettings]) const smsMfaRequirementMessage = useMemo(() => { - return formatMfaMethodNotAcceptedMessage('sms', mfaRequirement) - }, [mfaRequirement]) + return formatMfaMethodRequirementMessage( + 'sms', + mfaRequirement, + mfaSettings, + accessScopeLabel, + ) + }, [accessScopeLabel, mfaRequirement, mfaSettings]) if (isLoading) { return ( @@ -81,6 +98,10 @@ function MfaMethodList({ mfaRequirement }: Pick) { ) { title="Authenticator App" description="Use an app like Google Authenticator or Microsoft Authenticator to generate 6-digit verification codes." mfaRequirementMessage={authenticatorMfaRequirementMessage} + mfaRequirementMessageIsWarning={ + !!mfaRequirement?.authenticatorApp && + !!authenticatorMfaRequirementMessage + } cypressPrefix="mfa-authenticator" onSetup={() => beginMfaSetup('authenticator')} onDisable={() => beginDisablingMfaMethod('authenticator')} @@ -97,6 +122,7 @@ function MfaMethodList({ mfaRequirement }: Pick) { ) { description="Receive a one-time verification code via SMS each time MFA is required." detail={phoneDetail} mfaRequirementMessage={smsMfaRequirementMessage} + mfaRequirementMessageIsWarning={ + !!mfaRequirement?.sms && !!smsMfaRequirementMessage + } cypressPrefix="mfa-sms" onSetup={() => beginMfaSetup('sms')} onDisable={() => beginDisablingMfaMethod('sms')} @@ -127,7 +156,7 @@ function MfaMethodList({ mfaRequirement }: Pick) { ) } -function MfaSetup({ ssoSetupUrl, mfaRequirement }: Props) { +function MfaSetup({ ssoSetupUrl, mfaRequirement, accessScopeLabel }: Props) { const { isExternalIdentityProviderUser } = useMfa() if (ssoSetupUrl) { @@ -164,7 +193,10 @@ function MfaSetup({ ssoSetupUrl, mfaRequirement }: Props) { return ( <> - + @@ -213,6 +245,7 @@ function MfaSetup({ ssoSetupUrl, mfaRequirement }: Props) { export default function MultiFactorAuthentication({ mfaRequirement, ssoSetupUrl, + accessScopeLabel = 'App', }: Props) { return ( @@ -222,7 +255,8 @@ export default function MultiFactorAuthentication({ - Multi Factor Authentication + Multi Factor Authentication{' '} + @@ -237,6 +271,7 @@ export default function MultiFactorAuthentication({ diff --git a/src/utils/mfa-requirement.ts b/src/utils/mfa-requirement.ts index 25b5a1c7..365bd68e 100644 --- a/src/utils/mfa-requirement.ts +++ b/src/utils/mfa-requirement.ts @@ -1,8 +1,11 @@ import { MiscTypes } from '@oneblink/types' -import type { MfaSettings } from '../apps/services/AWSCognitoClient' +import type { + LoggedInMfaMethod, + MfaSettings, +} from '../apps/services/AWSCognitoClient' import { joinArray } from './joinArray' -export type { MfaSettings } +export type { LoggedInMfaMethod, MfaSettings } export function isMfaRequired( mfaRequirement: MiscTypes.MfaRequirement | undefined, @@ -69,9 +72,7 @@ export function formatMfaRequirementLabel( return `MFA Required (${methodList} only)` } -export function formatMfaRequirementMethodLabel( - method: MfaRequirementMethod, -): string { +function formatMfaRequirementMethodLabel(method: MfaRequirementMethod): string { switch (method) { case 'sms': return 'SMS' @@ -84,6 +85,22 @@ export function formatMfaRequirementMethodLabel( } } +function mfaRequirementMethodToLoggedInMfaMethod( + method: MfaRequirementMethod, +): Exclude { + return method === 'sms' ? 'SMS_MFA' : 'SOFTWARE_TOKEN_MFA' +} + +export function isMfaMethodUsedForLogin( + mfaSettings: MfaSettings, + method: MfaRequirementMethod, +): boolean { + return ( + mfaSettings.loggedInWithMfaMethod === + mfaRequirementMethodToLoggedInMfaMethod(method) + ) +} + export function userMeetsMfaRequirement( mfaRequirement: MiscTypes.MfaRequirement | undefined, mfaSettings: MfaSettings, @@ -92,18 +109,13 @@ export function userMeetsMfaRequirement( return true } - if ( - mfaRequirement?.sms && - mfaSettings.sms.enabled && - mfaSettings.sms.preferred - ) { + if (mfaRequirement?.sms && mfaSettings.loggedInWithMfaMethod === 'SMS_MFA') { return true } if ( mfaRequirement?.authenticatorApp && - mfaSettings.authenticator.enabled && - mfaSettings.authenticator.preferred + mfaSettings.loggedInWithMfaMethod === 'SOFTWARE_TOKEN_MFA' ) { return true } @@ -111,7 +123,7 @@ export function userMeetsMfaRequirement( return false } -export function formatMfaMethodNotAcceptedMessage( +function formatMfaMethodNotAcceptedMessage( method: MfaRequirementMethod, mfaRequirement: MiscTypes.MfaRequirement | undefined, accessScopeLabel = 'app', @@ -134,12 +146,42 @@ export function formatMfaMethodNotAcceptedMessage( return `This MFA method is not sufficient for using this ${accessScopeLabel.toLowerCase()}. Your administrator requires ${methodList} multi factor authentication.` } +function isMfaMethodEnabled( + mfaSettings: MfaSettings, + method: MfaRequirementMethod, +): boolean { + return method === 'sms' + ? mfaSettings.sms.enabled + : mfaSettings.authenticator.enabled +} + +function isMfaMethodPreferred( + mfaSettings: MfaSettings, + method: MfaRequirementMethod, +): boolean { + return method === 'sms' + ? mfaSettings.sms.preferred + : mfaSettings.authenticator.preferred +} + +function hasPreferredEnabledRequiredMethod( + mfaRequirement: MiscTypes.MfaRequirement, + mfaSettings: MfaSettings, +): boolean { + return mfaRequirementToSelectedMethods(mfaRequirement).some( + (method) => + isMfaMethodEnabled(mfaSettings, method) && + isMfaMethodPreferred(mfaSettings, method), + ) +} + export function formatMfaSetupRequiredMessage( mfaRequirement: MiscTypes.MfaRequirement | undefined, mfaSettings: MfaSettings, accessScopeLabel = 'Account', ): string | undefined { if ( + !mfaRequirement || !isMfaRequired(mfaRequirement) || userMeetsMfaRequirement(mfaRequirement, mfaSettings) ) { @@ -147,27 +189,77 @@ export function formatMfaSetupRequiredMessage( } const requiredMethods = mfaRequirementToSelectedMethods(mfaRequirement) - const hasAnyMfaEnabled = - mfaSettings.authenticator.enabled || mfaSettings.sms.enabled if (requiredMethods.length === 1) { - const methodLabel = formatMfaRequirementMethodLabel(requiredMethods[0]) + const method = requiredMethods[0] + const methodLabel = formatMfaRequirementMethodLabel(method) + + if (!isMfaMethodEnabled(mfaSettings, method)) { + return `requires ${methodLabel} multi factor authentication to be configured before accessing this ${accessScopeLabel}.` + } - if (hasAnyMfaEnabled) { - return `requires ${methodLabel} multi factor authentication. You have a different MFA method configured, but that method is not accepted for this ${accessScopeLabel.toLowerCase()}.` + if (!isMfaMethodPreferred(mfaSettings, method)) { + return `requires ${methodLabel} multi factor authentication. Please change your preferred MFA method to ${methodLabel} to access this ${accessScopeLabel}.` } - return `requires ${methodLabel} multi factor authentication to be configured before accessing this ${accessScopeLabel}.` + return `requires ${methodLabel} multi factor authentication. Now that ${methodLabel} is your preferred MFA method, you must log out and log back in to access this ${accessScopeLabel}.` } const methodList = joinArray( requiredMethods.map(formatMfaRequirementMethodLabel), 'disjunction', ) + const hasAnyMfaEnabled = + mfaSettings.authenticator.enabled || mfaSettings.sms.enabled + + if (!hasAnyMfaEnabled) { + return `requires ${methodList} multi factor authentication to be configured before accessing this ${accessScopeLabel}.` + } + + if (!hasPreferredEnabledRequiredMethod(mfaRequirement, mfaSettings)) { + return `requires ${methodList} multi factor authentication. Please change your preferred MFA method to one of the accepted methods to access this ${accessScopeLabel}.` + } + + return `requires ${methodList} multi factor authentication. Now that your preferred MFA method meets this requirement, you must log out and log back in to access this ${accessScopeLabel}.` +} + +export function formatMfaMethodRequirementMessage( + method: MfaRequirementMethod, + mfaRequirement: MiscTypes.MfaRequirement | undefined, + mfaSettings: MfaSettings, + accessScopeLabel = 'app', +): string | undefined { + if (!mfaRequirement || !isMfaRequired(mfaRequirement)) { + return undefined + } + + const scope = accessScopeLabel.toLowerCase() + + if (!mfaRequirement[method]) { + return formatMfaMethodNotAcceptedMessage( + method, + mfaRequirement, + accessScopeLabel, + ) + } + + if (userMeetsMfaRequirement(mfaRequirement, mfaSettings)) { + return undefined + } + + const methodLabel = formatMfaRequirementMethodLabel(method) + const isMethodEnabled = isMfaMethodEnabled(mfaSettings, method) + const isMethodPreferred = isMfaMethodPreferred(mfaSettings, method) + + const firstSentence = `Your administrator requires ${methodLabel} multi factor authentication.` + + if (!isMethodEnabled) { + return `${firstSentence} Setup ${methodLabel} to access this ${scope}` + } - if (hasAnyMfaEnabled) { - return `requires ${methodList} multi factor authentication. Your current MFA method is not accepted for this ${accessScopeLabel.toLowerCase()}.` + if (!isMethodPreferred) { + return `${firstSentence} Set ${methodLabel} as your preferred MFA method to access this ${scope}.` } - return `requires ${methodList} multi factor authentication to be configured before accessing this ${accessScopeLabel}.` + return `${firstSentence} Now that ${methodLabel} is your preferred MFA method, you must log out and log back in to access this ${scope}.` } diff --git a/tests/apps/services/AWSCognitoClient.test.ts b/tests/apps/services/AWSCognitoClient.test.ts index 13800bc3..59ad1633 100644 --- a/tests/apps/services/AWSCognitoClient.test.ts +++ b/tests/apps/services/AWSCognitoClient.test.ts @@ -1,5 +1,39 @@ import { describe, expect, test } from 'vitest' -import { resolveMfaPreferredFlags } from '../../../src/apps/services/AWSCognitoClient' +import { + resolveLoggedInMfaMethod, + resolveMfaPreferredFlags, +} from '../../../src/apps/services/AWSCognitoClient' + +function createTestIdToken(payload: Record) { + const encode = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url') + + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature` +} + +describe('resolveLoggedInMfaMethod', () => { + test('returns valid Cognito MFA methods from the id token', () => { + expect( + resolveLoggedInMfaMethod( + createTestIdToken({ mfa_method: 'SOFTWARE_TOKEN_MFA' }), + ), + ).toBe('SOFTWARE_TOKEN_MFA') + expect( + resolveLoggedInMfaMethod(createTestIdToken({ mfa_method: 'SMS_MFA' })), + ).toBe('SMS_MFA') + }) + + test('falls back to NO_MFA_ENABLED for missing or invalid tokens', () => { + expect(resolveLoggedInMfaMethod(undefined)).toBe('NO_MFA_ENABLED') + expect(resolveLoggedInMfaMethod('not-a-jwt')).toBe('NO_MFA_ENABLED') + expect( + resolveLoggedInMfaMethod(createTestIdToken({ mfa_method: 'UNKNOWN' })), + ).toBe('NO_MFA_ENABLED') + expect(resolveLoggedInMfaMethod(createTestIdToken({}))).toBe( + 'NO_MFA_ENABLED', + ) + }) +}) describe('resolveMfaPreferredFlags', () => { test('returns no preferred methods when none are enabled', () => { diff --git a/tests/utils/mfa-requirement.test.ts b/tests/utils/mfa-requirement.test.ts index cfaeacd7..27d37c55 100644 --- a/tests/utils/mfa-requirement.test.ts +++ b/tests/utils/mfa-requirement.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' import { + formatMfaMethodRequirementMessage, formatMfaRequirementLabel, - formatMfaRequirementMethodLabel, - formatMfaMethodNotAcceptedMessage, formatMfaSetupRequiredMessage, + isMfaMethodUsedForLogin, isMfaRequired, mfaSelectedMethodsToMfaRequirement, mfaRequirementToSelectedMethods, @@ -12,6 +12,7 @@ import { } from '../../src/utils/mfa-requirement' const emptyMfaSettings: MfaSettings = { + loggedInWithMfaMethod: 'NO_MFA_ENABLED', authenticator: { enabled: false, preferred: false }, sms: { enabled: false, @@ -22,6 +23,7 @@ const emptyMfaSettings: MfaSettings = { } const authenticatorMfaSettings: MfaSettings = { + loggedInWithMfaMethod: 'SOFTWARE_TOKEN_MFA', authenticator: { enabled: true, preferred: true }, sms: { enabled: false, @@ -32,6 +34,29 @@ const authenticatorMfaSettings: MfaSettings = { } const smsMfaSettings: MfaSettings = { + loggedInWithMfaMethod: 'SMS_MFA', + authenticator: { enabled: false, preferred: false }, + sms: { + enabled: true, + preferred: true, + phoneNumber: '+61400000000', + isPhoneNumberVerified: true, + }, +} + +const smsEnabledButLoggedInWithAuthenticator: MfaSettings = { + loggedInWithMfaMethod: 'SOFTWARE_TOKEN_MFA', + authenticator: { enabled: true, preferred: true }, + sms: { + enabled: true, + preferred: false, + phoneNumber: '+61400000000', + isPhoneNumberVerified: true, + }, +} + +const smsConfiguredButNotUsedForLogin: MfaSettings = { + loggedInWithMfaMethod: 'NO_MFA_ENABLED', authenticator: { enabled: false, preferred: false }, sms: { enabled: true, @@ -98,91 +123,90 @@ describe('formatMfaRequirementLabel', () => { }) }) -describe('formatMfaRequirementMethodLabel', () => { - test('formats each method', () => { - expect(formatMfaRequirementMethodLabel('sms')).toBe('SMS') - expect(formatMfaRequirementMethodLabel('authenticatorApp')).toBe( - 'Authenticator App', - ) - }) -}) - -describe('userMeetsMfaRequirement', () => { - test('returns true when MFA is optional', () => { - expect(userMeetsMfaRequirement(undefined, emptyMfaSettings)).toBe(true) - }) - - test('returns true when any required method is enabled', () => { +describe('formatMfaMethodRequirementMessage', () => { + test('returns undefined when MFA is optional', () => { + expect(formatMfaMethodRequirementMessage('sms', undefined, emptyMfaSettings)).toBeUndefined() expect( - userMeetsMfaRequirement( - { sms: true, authenticatorApp: true }, - authenticatorMfaSettings, + formatMfaMethodRequirementMessage( + 'sms', + { sms: false, authenticatorApp: false }, + emptyMfaSettings, ), - ).toBe(true) + ).toBeUndefined() + }) + + test('returns undefined when the user meets the requirement', () => { expect( - userMeetsMfaRequirement( + formatMfaMethodRequirementMessage( + 'sms', { sms: true, authenticatorApp: false }, smsMfaSettings, ), - ).toBe(true) + ).toBeUndefined() }) - test('returns false when no required method is enabled', () => { + test('describes when a method is not accepted', () => { expect( - userMeetsMfaRequirement( + formatMfaMethodRequirementMessage( + 'authenticatorApp', { sms: true, authenticatorApp: false }, emptyMfaSettings, ), - ).toBe(false) - expect( - userMeetsMfaRequirement( - { sms: true, authenticatorApp: false }, - authenticatorMfaSettings, - ), - ).toBe(false) + ).toBe( + 'This MFA method is not sufficient for using this app. Your administrator requires SMS multi factor authentication.', + ) }) -}) -describe('formatMfaMethodNotAcceptedMessage', () => { - test('returns undefined when MFA is optional', () => { - expect(formatMfaMethodNotAcceptedMessage('sms', undefined)).toBeUndefined() + test('describes when a required method needs to be set as preferred', () => { expect( - formatMfaMethodNotAcceptedMessage('sms', { - sms: false, - authenticatorApp: false, - }), - ).toBeUndefined() + formatMfaMethodRequirementMessage( + 'sms', + { sms: true, authenticatorApp: false }, + smsEnabledButLoggedInWithAuthenticator, + ), + ).toBe( + 'Your administrator requires SMS multi factor authentication. Set SMS as your preferred MFA method to access this app.', + ) }) - test('returns undefined when the method is accepted', () => { + test('describes when a required preferred method needs a new login session', () => { expect( - formatMfaMethodNotAcceptedMessage('sms', { - sms: true, - authenticatorApp: false, - }), - ).toBeUndefined() + formatMfaMethodRequirementMessage( + 'sms', + { sms: true, authenticatorApp: false }, + smsConfiguredButNotUsedForLogin, + ), + ).toBe( + 'Your administrator requires SMS multi factor authentication. Now that SMS is your preferred MFA method, you must log out and log back in to access this app.', + ) }) - test('describes a single required method', () => { + test('describes when a required method needs to be set up', () => { expect( - formatMfaMethodNotAcceptedMessage('authenticatorApp', { - sms: true, - authenticatorApp: false, - }), + formatMfaMethodRequirementMessage( + 'sms', + { sms: true, authenticatorApp: false }, + emptyMfaSettings, + ), ).toBe( - 'This MFA method is not sufficient for using this app. Your administrator requires SMS multi factor authentication.', + 'Your administrator requires SMS multi factor authentication. Setup SMS to access this app', ) }) +}) - test('describes multiple required methods', () => { +describe('isMfaMethodUsedForLogin', () => { + test('returns true when the login method matches', () => { + expect(isMfaMethodUsedForLogin(smsMfaSettings, 'sms')).toBe(true) expect( - formatMfaMethodNotAcceptedMessage('sms', { - sms: false, - authenticatorApp: true, - }), - ).toBe( - 'This MFA method is not sufficient for using this app. Your administrator requires Authenticator App multi factor authentication.', + isMfaMethodUsedForLogin(authenticatorMfaSettings, 'authenticatorApp'), + ).toBe(true) + }) + + test('returns false when the login method does not match', () => { + expect(isMfaMethodUsedForLogin(smsMfaSettings, 'authenticatorApp')).toBe( + false, ) + expect(isMfaMethodUsedForLogin(emptyMfaSettings, 'sms')).toBe(false) }) }) @@ -207,14 +231,25 @@ describe('formatMfaSetupRequiredMessage', () => { ) }) - test('describes an unaccepted method when another MFA method is enabled', () => { + test('describes when the preferred MFA method needs to change', () => { expect( formatMfaSetupRequiredMessage( { sms: true, authenticatorApp: false }, - authenticatorMfaSettings, + smsEnabledButLoggedInWithAuthenticator, + ), + ).toBe( + 'requires SMS multi factor authentication. Please change your preferred MFA method to SMS to access this Account.', + ) + }) + + test('describes when MFA is configured but the preferred method needs to change', () => { + expect( + formatMfaSetupRequiredMessage( + { sms: true, authenticatorApp: false }, + smsConfiguredButNotUsedForLogin, ), ).toBe( - 'requires SMS multi factor authentication. You have a different MFA method configured, but that method is not accepted for this account.', + 'requires SMS multi factor authentication. Now that SMS is your preferred MFA method, you must log out and log back in to access this Account.', ) }) @@ -241,3 +276,45 @@ describe('formatMfaSetupRequiredMessage', () => { ) }) }) + +describe('userMeetsMfaRequirement', () => { + test('returns true when MFA is optional', () => { + expect(userMeetsMfaRequirement(undefined, emptyMfaSettings)).toBe(true) + }) + + test('returns true when the user logged in with a required method', () => { + expect( + userMeetsMfaRequirement( + { sms: true, authenticatorApp: true }, + authenticatorMfaSettings, + ), + ).toBe(true) + expect( + userMeetsMfaRequirement( + { sms: true, authenticatorApp: false }, + smsMfaSettings, + ), + ).toBe(true) + }) + + test('returns false when the user did not log in with a required method', () => { + expect( + userMeetsMfaRequirement( + { sms: true, authenticatorApp: false }, + emptyMfaSettings, + ), + ).toBe(false) + expect( + userMeetsMfaRequirement( + { sms: true, authenticatorApp: false }, + authenticatorMfaSettings, + ), + ).toBe(false) + expect( + userMeetsMfaRequirement( + { sms: true, authenticatorApp: false }, + smsConfiguredButNotUsedForLogin, + ), + ).toBe(false) + }) +}) From da511b675df1741dbfbfd3050547c5955339355c Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 3 Jul 2026 10:10:01 +1000 Subject: [PATCH 2/2] Update src/utils/mfa-requirement.ts Co-authored-by: Zac Turner <39893564+Zaxist@users.noreply.github.com> --- src/utils/mfa-requirement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/mfa-requirement.ts b/src/utils/mfa-requirement.ts index 365bd68e..a1bb8744 100644 --- a/src/utils/mfa-requirement.ts +++ b/src/utils/mfa-requirement.ts @@ -244,7 +244,7 @@ export function formatMfaMethodRequirementMessage( } if (userMeetsMfaRequirement(mfaRequirement, mfaSettings)) { - return undefined + return } const methodLabel = formatMfaRequirementMethodLabel(method)