From acb9f51ae93b93fe6bab7f06d5d2102161a22209 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 19 Jan 2026 19:11:28 +0100 Subject: [PATCH 01/11] chore: add biometrics test scenario config & actions --- cspell.json | 1 + src/CONST/index.ts | 3 + .../config/helpers.ts | 62 ++++++++ .../MultifactorAuthentication/config/index.ts | 12 ++ .../config/scenarios/BiometricsTest.ts | 25 ++++ .../config/scenarios/DefaultUserInterface.ts | 104 +++++++++++++ .../config/scenarios/index.ts | 22 +++ .../config/scenarios/names.ts | 6 + .../config/scenarios/prompts.ts | 16 ++ .../MultifactorAuthentication/config/types.ts | 138 ++++++++++++++++++ .../MultifactorAuthentication/types.ts | 13 ++ src/languages/params.ts | 3 +- .../API/parameters/BiometricsTestParams.ts | 5 + .../parameters/RegisterBiometricsParams.ts | 5 + .../RequestBiometricChallengeParams.ts | 8 + src/libs/API/parameters/index.ts | 3 + src/libs/API/types.ts | 7 + .../Biometrics/ED25519/index.ts | 19 ++- .../Biometrics/ED25519/types.ts | 39 ++++- .../Biometrics/VALUES.ts | 104 ++++++++++++- .../Biometrics/helpers.ts | 28 ++++ .../Biometrics/types.ts | 87 +++++++++++ src/libs/actions/MultifactorAuthentication.ts | 59 ++++++++ src/types/onyx/Response.ts | 7 + 24 files changed, 762 insertions(+), 14 deletions(-) create mode 100644 src/components/MultifactorAuthentication/config/helpers.ts create mode 100644 src/components/MultifactorAuthentication/config/index.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/index.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/names.ts create mode 100644 src/components/MultifactorAuthentication/config/scenarios/prompts.ts create mode 100644 src/components/MultifactorAuthentication/config/types.ts create mode 100644 src/components/MultifactorAuthentication/types.ts create mode 100644 src/libs/API/parameters/BiometricsTestParams.ts create mode 100644 src/libs/API/parameters/RegisterBiometricsParams.ts create mode 100644 src/libs/API/parameters/RequestBiometricChallengeParams.ts create mode 100644 src/libs/MultifactorAuthentication/Biometrics/helpers.ts create mode 100644 src/libs/MultifactorAuthentication/Biometrics/types.ts create mode 100644 src/libs/actions/MultifactorAuthentication.ts diff --git a/cspell.json b/cspell.json index 5cc365f37ff12..a25fee56fe73f 100644 --- a/cspell.json +++ b/cspell.json @@ -756,6 +756,7 @@ "WarchoĊ‚", "WDYR", "webapps", + "webauthn", "webcredentials", "webrtc", "welldone", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2b8f37da52a4a..e0c8478a6fca0 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7,6 +7,7 @@ import type {ValueOf} from 'type-fest'; import type {SearchFilterKey} from '@components/Search/types'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import type {MileageRate} from '@libs/DistanceRequestUtils'; +import MULTIFACTOR_AUTHENTICATION_VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; import addTrailingForwardSlash from '@libs/UrlUtils'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -422,6 +423,8 @@ const CONST = { MAX_AGE: 150, }, + MULTIFACTOR_AUTHENTICATION: MULTIFACTOR_AUTHENTICATION_VALUES, + DESKTOP_SHORTCUT_ACCELERATOR: { PASTE_AND_MATCH_STYLE: 'Option+Shift+CmdOrCtrl+V', PASTE_AS_PLAIN_TEXT: 'CmdOrCtrl+Shift+V', diff --git a/src/components/MultifactorAuthentication/config/helpers.ts b/src/components/MultifactorAuthentication/config/helpers.ts new file mode 100644 index 0000000000000..8e3df020ff230 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/helpers.ts @@ -0,0 +1,62 @@ +import type {KebabCase} from 'type-fest'; +import type { + MultifactorAuthenticationNotificationMap, + MultifactorAuthenticationNotificationOptions, + MultifactorAuthenticationNotificationRecord, + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioConfigRecord, +} from './types'; + +/** + * Converts a string to lowercase. + */ +function toLowerCase(str: T) { + return str.toLowerCase() as Lowercase; +} + +/** + * Converts camelCase string to kebab-case format. + */ +function camelToKebabCase(str: T) { + return str.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as KebabCase; +} + +/** + * Creates a notification record from multifactor authentication scenario configuration. + */ +const createNotificationRecord = (mfaConfig: MultifactorAuthenticationScenarioConfigRecord): MultifactorAuthenticationNotificationRecord => { + const entries = Object.entries({...mfaConfig}); + return entries.reduce((record, [key, {NOTIFICATIONS}]) => { + // eslint-disable-next-line no-param-reassign + record[key as MultifactorAuthenticationScenario] = {...NOTIFICATIONS}; + return record; + }, {} as MultifactorAuthenticationNotificationRecord); +}; + +/** + * Creates a notification key by combining scenario and notification name in kebab-case format. + */ +const createNotificationKey = (key: string, name: string) => { + const scenarioKebabCase = toLowerCase(key as MultifactorAuthenticationScenario); + const notificationName = camelToKebabCase(name as MultifactorAuthenticationNotificationOptions); + + return `${scenarioKebabCase}-${notificationName}` as const; +}; + +/** + * Maps multifactor authentication scenario configuration to a notification map with kebab-case keys. + */ +const mapMultifactorAuthenticationNotification = (mfaConfig: MultifactorAuthenticationScenarioConfigRecord) => { + const recordEntries = Object.entries(createNotificationRecord({...mfaConfig})); + + const notifications: Partial = {}; + + for (const [key, config] of recordEntries) { + for (const [name, ui] of Object.entries(config)) { + notifications[createNotificationKey(key, name)] = {...ui}; + } + } + + return notifications as MultifactorAuthenticationNotificationMap; +}; +export {mapMultifactorAuthenticationNotification, toLowerCase}; diff --git a/src/components/MultifactorAuthentication/config/index.ts b/src/components/MultifactorAuthentication/config/index.ts new file mode 100644 index 0000000000000..a097fff9dd978 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/index.ts @@ -0,0 +1,12 @@ +/** + * Configuration exports for multifactor authentication UI components and scenarios. + */ +import {mapMultifactorAuthenticationNotification} from './helpers'; +import MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG from './scenarios'; + +const MULTIFACTOR_AUTHENTICATION_NOTIFICATION_MAP = mapMultifactorAuthenticationNotification(MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG); + +export {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG, MULTIFACTOR_AUTHENTICATION_NOTIFICATION_MAP}; +export {default as MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from './scenarios/prompts'; +export {default as MULTIFACTOR_AUTHENTICATION_DEFAULT_UI} from './scenarios/DefaultUserInterface'; +export type {Payloads as MultifactorAuthenticationScenarioPayload} from './scenarios'; diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts new file mode 100644 index 0000000000000..9e7482762f54c --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts @@ -0,0 +1,25 @@ +import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; +import {troubleshootMultifactorAuthentication} from '@userActions/MultifactorAuthentication'; +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; + +/** + * Configuration for the biometrics test multifactor authentication scenario. + */ +export default { + allowedAuthentication: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, + action: troubleshootMultifactorAuthentication, + screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST, + pure: true, + NOTIFICATIONS: { + success: { + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', + }, + failure: { + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', + }, + outOfTime: { + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', + }, + }, +} as const satisfies MultifactorAuthenticationScenarioCustomConfig; diff --git a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts new file mode 100644 index 0000000000000..1301de7b8d51c --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts @@ -0,0 +1,104 @@ +import type {MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; +import NoEligibleMethodsDescription from '@components/MultifactorAuthentication/NoEligibleMethodsDescription'; +// eslint-disable-next-line no-restricted-imports +import spacing from '@styles/utils/spacing'; +import variables from '@styles/variables'; + +/** + * Default UI configuration for all multifactor authentication scenarios with modals and notifications. + */ +const DEFAULT_CONFIG = { + NOTIFICATIONS: { + success: { + illustration: 'OpenPadlock', + iconWidth: variables.openPadlockWidth, + iconHeight: variables.openPadlockHeight, + padding: spacing.p2, + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', + title: 'multifactorAuthentication.biometricsTest.authenticationSuccessful', + description: 'multifactorAuthentication.biometricsTest.successfullyAuthenticatedUsing', + }, + failure: { + illustration: 'HumptyDumpty', + iconWidth: variables.humptyDumptyWidth, + iconHeight: variables.humptyDumptyHeight, + padding: spacing.p0, + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', + title: 'multifactorAuthentication.oops', + description: 'multifactorAuthentication.biometricsTest.yourAttemptWasUnsuccessful', + }, + outOfTime: { + illustration: 'RunOutOfTime', + iconWidth: variables.runOutOfTimeWidth, + iconHeight: variables.runOutOfTimeHeight, + padding: spacing.p0, + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', + title: 'multifactorAuthentication.youRanOutOfTime', + description: 'multifactorAuthentication.looksLikeYouRanOutOfTime', + }, + noEligibleMethods: { + illustration: 'HumptyDumpty', + iconWidth: variables.humptyDumptyWidth, + iconHeight: variables.humptyDumptyHeight, + padding: spacing.p0, + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', + title: 'multifactorAuthentication.biometricsTest.youCouldNotBeAuthenticated', + description: 'multifactorAuthentication.biometricsTest.youCouldNotBeAuthenticated', + customDescription: NoEligibleMethodsDescription, + }, + }, + MODALS: { + cancelConfirmation: { + title: 'common.areYouSure', + description: 'multifactorAuthentication.biometricsTest.areYouSureToReject', + confirmButtonText: 'multifactorAuthentication.biometricsTest.rejectAuthentication', + cancelButtonText: 'common.cancel', + }, + }, + nativePromptTitle: 'multifactorAuthentication.letsVerifyItsYou', +} as const satisfies MultifactorAuthenticationDefaultUIConfig; + +/** + * Merges custom scenario configuration with default UI configuration for modals and notifications. + */ +function customConfig>(config: T) { + const MODALS = { + ...DEFAULT_CONFIG.MODALS, + ...config.MODALS, + cancelConfirmation: { + ...DEFAULT_CONFIG.MODALS.cancelConfirmation, + ...config.MODALS?.cancelConfirmation, + }, + } as const; + + const NOTIFICATIONS = { + ...DEFAULT_CONFIG.NOTIFICATIONS, + ...config.NOTIFICATIONS, + success: { + ...DEFAULT_CONFIG.NOTIFICATIONS.success, + ...config.NOTIFICATIONS?.success, + }, + failure: { + ...DEFAULT_CONFIG.NOTIFICATIONS.failure, + ...config.NOTIFICATIONS?.failure, + }, + outOfTime: { + ...DEFAULT_CONFIG.NOTIFICATIONS.outOfTime, + ...config.NOTIFICATIONS?.outOfTime, + }, + noEligibleMethods: { + ...DEFAULT_CONFIG.NOTIFICATIONS.noEligibleMethods, + ...config.NOTIFICATIONS?.noEligibleMethods, + }, + } as const; + + return { + ...DEFAULT_CONFIG, + ...config, + MODALS, + NOTIFICATIONS, + } as const; +} + +export default DEFAULT_CONFIG; +export {customConfig}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/index.ts b/src/components/MultifactorAuthentication/config/scenarios/index.ts new file mode 100644 index 0000000000000..2df65d17a3576 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/index.ts @@ -0,0 +1,22 @@ +import type {EmptyObject} from 'type-fest'; +import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/MultifactorAuthentication/config/types'; +import CONST from '@src/CONST'; +import BiometricsTest from './BiometricsTest'; +import {customConfig} from './DefaultUserInterface'; + +/** + * Payload types for multifactor authentication scenarios. + * Since the BiometricsTest does not require any payload, it is an empty object for now. + * The AuthorizeTransaction Scenario will change it. + */ +type Payloads = EmptyObject; + +/** + * Configuration records for all multifactor authentication scenarios. + */ +const Configs = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: customConfig(BiometricsTest), +} as const satisfies MultifactorAuthenticationScenarioConfigRecord; + +export default Configs; +export type {Payloads}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts new file mode 100644 index 0000000000000..26dcd16dbd67e --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -0,0 +1,6 @@ +/** + * Multifactor authentication scenario names. + */ +export default { + BIOMETRICS_TEST: 'BIOMETRICS-TEST', +} as const; diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts new file mode 100644 index 0000000000000..b25ba9f8cae86 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -0,0 +1,16 @@ +import LottieAnimations from '@components/LottieAnimations'; +import type {MultifactorAuthenticationPrompt} from '@components/MultifactorAuthentication/config/types'; + +/** + * Configuration for multifactor authentication prompt UI with animations and translations. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ +export default { + 'enable-biometrics': { + animation: LottieAnimations.Fingerprint, + title: 'multifactorAuthentication.verifyYourself.biometrics', + subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', + }, +} as const satisfies MultifactorAuthenticationPrompt; +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts new file mode 100644 index 0000000000000..15e95679fd72f --- /dev/null +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -0,0 +1,138 @@ +import type {ViewStyle} from 'react-native'; +import type {EmptyObject, KebabCase, Replace, ValueOf} from 'type-fest'; +import type {IllustrationName} from '@components/Icon/chunks/illustrations.chunk'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import type {MultifactorAuthenticationActionParams, MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type SCREENS from '@src/SCREENS'; +import type {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG, MultifactorAuthenticationScenarioPayload} from './index'; + +type MultifactorAuthenticationCancelConfirm = { + description?: TranslationPaths; + cancelButtonText?: TranslationPaths; + confirmButtonText?: TranslationPaths; + title?: TranslationPaths; +}; + +type MultifactorAuthenticationPromptConfig = { + animation: DotLottieAnimation; + title: TranslationPaths; + subtitle: TranslationPaths; +}; + +type MultifactorAuthenticationNotificationConfig = { + illustration: IllustrationName; + iconWidth: number; + iconHeight: number; + padding: ViewStyle; + headerTitle: TranslationPaths; + title: TranslationPaths; + description: TranslationPaths; + customDescription?: React.FunctionComponent; +}; + +type MultifactorAuthenticationPrompt = Record; + +type MultifactorAuthenticationNotification = Record; + +type MultifactorAuthenticationModal = { + cancelConfirmation: MultifactorAuthenticationCancelConfirm; +}; + +type MultifactorAuthenticationModalOptional = { + cancelConfirmation?: Partial; +}; + +type MultifactorAuthenticationNotificationOptional = Record>; + +type MultifactorAuthenticationConfigRecordConst = typeof MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG; + +type MultifactorAuthenticationScenarioNotificationConst = { + [K in MultifactorAuthenticationScenario]: MultifactorAuthenticationConfigRecordConst[K]['NOTIFICATIONS']; +}; + +type MultifactorAuthenticationNotificationScenarioOptions = { + [K in MultifactorAuthenticationScenario]: keyof MultifactorAuthenticationScenarioNotificationConst[K]; +}; + +type MultifactorAuthenticationNotificationRecord = Record; + +type MultifactorAuthenticationNotificationType = `${Lowercase}-${KebabCase}`; + +type MultifactorAuthenticationUI = { + MODALS: MultifactorAuthenticationModal; + NOTIFICATIONS: MultifactorAuthenticationNotification; +}; + +type AllMultifactorAuthenticationNotificationType = MultifactorAuthenticationNotificationType; + +type MultifactorAuthenticationNotificationMap = Record; + +type MultifactorAuthenticationNotificationOptions = keyof MultifactorAuthenticationScenarioNotificationConst[MultifactorAuthenticationScenario]; + +type MultifactorAuthenticationNotificationSuffixes = Replace}-`, ''>; + +type MultifactorAuthenticationScenarioResponse = { + httpCode: number; + reason: MultifactorAuthenticationReason; +}; + +type MultifactorAuthenticationScreen = ValueOf; + +type MultifactorAuthenticationScenarioPureMethod> = ( + params: MultifactorAuthenticationActionParams, +) => Promise; + +type MultifactorAuthenticationScenarioConfig = EmptyObject> = { + action: MultifactorAuthenticationScenarioPureMethod; + allowedAuthentication: ValueOf; + screen: MultifactorAuthenticationScreen; + pure?: true; + nativePromptTitle: TranslationPaths; +} & MultifactorAuthenticationUI; + +type MultifactorAuthenticationScenarioCustomConfig = EmptyObject> = Omit< + MultifactorAuthenticationScenarioConfig, + 'MODALS' | 'NOTIFICATIONS' | 'nativePromptTitle' +> & { + nativePromptTitle?: TranslationPaths; + MODALS?: MultifactorAuthenticationModalOptional; + NOTIFICATIONS: MultifactorAuthenticationNotificationOptional; +}; + +type MultifactorAuthenticationDefaultUIConfig = Pick; + +type MultifactorAuthenticationScenarioConfigRecord = Record>; + +type RegisterBiometricsParams = MultifactorAuthenticationActionParams< + { + keyInfo: MultifactorAuthenticationKeyInfo<'biometric'>; + }, + 'validateCode' +>; + +type MultifactorAuthenticationScenarioParameters = { + [key in MultifactorAuthenticationScenario]: MultifactorAuthenticationActionParams< + key extends keyof MultifactorAuthenticationScenarioPayload ? MultifactorAuthenticationScenarioPayload[key] : EmptyObject, + 'signedChallenge' + >; +} & { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'REGISTER-BIOMETRICS': RegisterBiometricsParams; +}; + +type MultifactorAuthenticationScenario = ValueOf; + +export type { + MultifactorAuthenticationPrompt, + MultifactorAuthenticationNotificationRecord, + MultifactorAuthenticationNotificationMap, + MultifactorAuthenticationScenarioParameters, + MultifactorAuthenticationScenario, + MultifactorAuthenticationNotificationOptions, + MultifactorAuthenticationScenarioConfigRecord, + MultifactorAuthenticationDefaultUIConfig, + MultifactorAuthenticationNotificationSuffixes, + MultifactorAuthenticationScenarioCustomConfig, +}; diff --git a/src/components/MultifactorAuthentication/types.ts b/src/components/MultifactorAuthentication/types.ts new file mode 100644 index 0000000000000..4130f888c0d3e --- /dev/null +++ b/src/components/MultifactorAuthentication/types.ts @@ -0,0 +1,13 @@ +/** + * Type definitions for multifactor authentication components. + */ +import type {ValueOf} from 'type-fest'; +import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; + +/** + * Authentication type name derived from secure store values. + */ +type AuthTypeName = ValueOf['NAME']; + +// eslint-disable-next-line import/prefer-default-export +export type {AuthTypeName}; diff --git a/src/languages/params.ts b/src/languages/params.ts index 8315e3025da7b..82296d24a9879 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {AuthTypeName} from '@components/MultifactorAuthentication/types'; import type CONST from '@src/CONST'; import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; import type {DelegateRole} from '@src/types/onyx/Account'; @@ -6,7 +7,7 @@ import type {AllConnectionName, ConnectionName, PolicyConnectionSyncStage, SageI import type {ViolationDataType} from '@src/types/onyx/TransactionViolation'; type MultifactorAuthenticationTranslationParams = { - authType?: string; + authType?: AuthTypeName; registered?: boolean; }; diff --git a/src/libs/API/parameters/BiometricsTestParams.ts b/src/libs/API/parameters/BiometricsTestParams.ts new file mode 100644 index 0000000000000..7843b5898e1f3 --- /dev/null +++ b/src/libs/API/parameters/BiometricsTestParams.ts @@ -0,0 +1,5 @@ +import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; + +type BiometricsTestParams = MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']; + +export default BiometricsTestParams; diff --git a/src/libs/API/parameters/RegisterBiometricsParams.ts b/src/libs/API/parameters/RegisterBiometricsParams.ts new file mode 100644 index 0000000000000..267a5ab75a5ff --- /dev/null +++ b/src/libs/API/parameters/RegisterBiometricsParams.ts @@ -0,0 +1,5 @@ +import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; + +type RegisterBiometricsParams = MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']; + +export default RegisterBiometricsParams; diff --git a/src/libs/API/parameters/RequestBiometricChallengeParams.ts b/src/libs/API/parameters/RequestBiometricChallengeParams.ts new file mode 100644 index 0000000000000..7236b2f330ff3 --- /dev/null +++ b/src/libs/API/parameters/RequestBiometricChallengeParams.ts @@ -0,0 +1,8 @@ +import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; + +type RequestBiometricChallengeParams = { + /** Challenge type: 'authentication' for signing existing keys, 'registration' for new key registration */ + challengeType: ChallengeType; +}; + +export default RequestBiometricChallengeParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 13cfd31718221..c02b3adf84710 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -463,3 +463,6 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; +export type {default as RegisterBiometricsParams} from './RegisterBiometricsParams'; +export type {default as BiometricsTestParams} from './BiometricsTestParams'; +export type {default as RequestBiometricChallengeParams} from './RequestBiometricChallengeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a4f0123a3d7d5..03799fc6c4412 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1295,6 +1295,10 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { ACCEPT_SPOTNANA_TERMS: 'AcceptSpotnanaTerms', COMPLETE_GUIDED_SETUP: 'CompleteGuidedSetup', + + REGISTER_AUTHENTICATION_KEY: 'RegisterAuthenticationKey', + TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION: 'TroubleshootMultifactorAuthentication', + REQUEST_AUTHENTICATION_CHALLENGE: 'RequestAuthenticationChallenge', } as const; type SideEffectRequestCommand = ValueOf; @@ -1324,6 +1328,9 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: Parameters.AcceptSpotnanaTermsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN]: Parameters.GetScimTokenParams; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; + [SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY]: Parameters.RegisterBiometricsParams; + [SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION]: Parameters.BiometricsTestParams; + [SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE]: Parameters.RequestBiometricChallengeParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts b/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts index f5ba93e3e18a4..88fe959bdccf4 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/ED25519/index.ts @@ -109,13 +109,22 @@ function createAuthenticatorData(rpId: string): Bytes { /** * Signs a multifactor authentication challenge for the given account identifier and key. - * Returns a WebAuthn-compatible signed challenge structure. + * Returns a WebAuthn-compatible signed challenge structure using ED25519. + * + * @param credentialRequestOptions Challenge object from server (must be AuthenticationChallenge format) + * @param privateKey ED25519 private key in hex format + * @param publicKey ED25519 public key in base64url format (used as rawId) + * @returns SignedChallenge with ED25519 signature */ -function signToken(credentialRequestOptions: MultifactorAuthenticationChallengeObject, privateKey: string): SignedChallenge { - const rawId: Base64URLString = Base64URL.encode(VALUES.KEY_ALIASES.PUBLIC_KEY); - const type = VALUES.ED25519_TYPE; +function signToken(credentialRequestOptions: MultifactorAuthenticationChallengeObject, privateKey: string, publicKey: Base64URLString): SignedChallenge { + // rawId should be the base64url-encoded public key, serving as credential identifier + const rawId: Base64URLString = publicKey; + const type = VALUES.ED25519_TYPE; // "biometric" + + // Extract rpId from challenge - handle both authentication and registration formats + const rpId = 'rpId' in credentialRequestOptions ? credentialRequestOptions.rpId : credentialRequestOptions.rp.id; - const authenticatorDataBytes = createAuthenticatorData(credentialRequestOptions.rpId); + const authenticatorDataBytes = createAuthenticatorData(rpId); const authenticatorData: Base64URLString = Base64URL.encode(authenticatorDataBytes); const clientDataJSON = JSON.stringify({challenge: credentialRequestOptions.challenge}); diff --git a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts b/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts index ec3e65af97df5..6cfe08509a9ac 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/ED25519/types.ts @@ -6,7 +6,8 @@ import type {Base64URLString} from '@src/utils/Base64URL'; type ChallengeFlags = number; /** - * Signed multifactor authentication challenge matching the WebAuthn response shape. + * Signed multifactor authentication challenge for biometric authentication. + * Uses ED25519 signature format with authenticatorData and signature. */ type SignedChallenge = { rawId: Base64URLString; @@ -19,21 +20,45 @@ type SignedChallenge = { }; /** - * Challenge parameters required to initiate a multifactor authentication request. + * Registration challenge for biometric key registration. + * Full WebAuthn format that specifies allowed credential types. + * Per spec: When registering a new biometric key, webauthn specification requires a challenge be supplied to sign the newly generated key. */ -type MultifactorAuthenticationChallengeObject = { +type RegistrationChallenge = { challenge: string; + rp: {id: string}; + user: { + id: string; + displayName: string; + }; + pubKeyCredParams: Array<{ + type: string; + alg: number; + }>; + timeout: number; +}; - rpId: string; +/** + * Challenge object can be either authentication or registration format. + * The backend sends different structures depending on the challengeType parameter. + */ +type MultifactorAuthenticationChallengeObject = AuthenticationChallenge | RegistrationChallenge; +/** + * Authentication challenge for biometric authentication flow. + * This is a simplified nonce-based challenge used for ED25519 biometric signing. + * Per spec: Used when a MultifactorAuthenticationCommand requires public-key authentication. + */ +type AuthenticationChallenge = { + challenge: string; + rpId: string; allowCredentials: Array<{ type: string; id: string; }>; - userVerification: string; - timeout: number; + expires?: string; }; -export type {MultifactorAuthenticationChallengeObject, ChallengeFlags, SignedChallenge}; +export type {MultifactorAuthenticationChallengeObject, ChallengeFlags, SignedChallenge, AuthenticationChallenge, RegistrationChallenge}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 975ea9e9ae948..e7787fd8db134 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -1,13 +1,115 @@ +/** + * Constants for multifactor authentication biometrics flow and API responses. + */ +import SCENARIO from '@components/MultifactorAuthentication/config/scenarios/names'; + +/** + * Backend reason messages for multifactor authentication responses. + */ +const REASON = { + BACKEND: { + REGISTRATION_REQUIRED: 'Registration is required', + CHALLENGE_GENERATED: 'Challenge generated successfully', + KEY_INFO_MISSING: 'Key info not provided', + KEY_ALREADY_REGISTERED: 'This public key is already registered', + VALIDATE_CODE_MISSING: 'Validate code is missing', + VALIDATE_CODE_INVALID: 'Validate code is invalid', + BIOMETRICS_REGISTERED: 'Biometrics registration successful', + UNABLE_TO_AUTHORIZE: 'Authorization failed with provided credentials', + AUTHORIZATION_SUCCESSFUL: 'User authorized successfully', + BAD_REQUEST: 'Bad request', + UNKNOWN_RESPONSE: 'Unrecognized response type', + }, +} as const; + +/** + * Maps API endpoints to their HTTP status codes and corresponding reason messages. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +const API_RESPONSE_MAP = { + UNKNOWN: REASON.BACKEND.UNKNOWN_RESPONSE, + REQUEST_AUTHENTICATION_CHALLENGE: { + 401: REASON.BACKEND.REGISTRATION_REQUIRED, + 200: REASON.BACKEND.CHALLENGE_GENERATED, + }, + REGISTER_AUTHENTICATION_KEY: { + 422: REASON.BACKEND.KEY_INFO_MISSING, + 409: REASON.BACKEND.KEY_ALREADY_REGISTERED, + 401: REASON.BACKEND.VALIDATE_CODE_MISSING, + 400: REASON.BACKEND.VALIDATE_CODE_INVALID, + 200: REASON.BACKEND.BIOMETRICS_REGISTERED, + }, + TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION: { + 401: REASON.BACKEND.REGISTRATION_REQUIRED, + 409: REASON.BACKEND.UNABLE_TO_AUTHORIZE, + 200: REASON.BACKEND.AUTHORIZATION_SUCCESSFUL, + 400: REASON.BACKEND.BAD_REQUEST, + }, +} as const; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Factor origin types for multifactor authentication. + */ +const MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN = { + BIOMETRICS: 'Biometrics', + ADDITIONAL: 'Additional', +} as const; + +/** + * Available multifactor authentication factors. + */ +const MULTIFACTOR_AUTHENTICATION_FACTORS = { + SIGNED_CHALLENGE: 'SIGNED_CHALLENGE', + VALIDATE_CODE: 'VALIDATE_CODE', +} as const; + /** * Centralized constants used by the multifactor authentication biometrics flow. * It is stored here instead of the CONST file to avoid circular dependencies. */ const MULTIFACTOR_AUTHENTICATION_VALUES = { - /** Referred to as the EdDSA in the Auth */ + /** + * EdDSA key type identifier referred to as EdDSA in the Auth system. + */ ED25519_TYPE: 'biometric', + /** + * Key alias identifiers for secure storage. + */ KEY_ALIASES: { PUBLIC_KEY: '3DS_SCA_KEY_PUBLIC', }, + /** + * Defines the requirements and configuration for each authentication factor. + */ + FACTORS_REQUIREMENTS: { + SIGNED_CHALLENGE: { + id: MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE, + name: 'Signed Challenge', + parameter: 'signedChallenge', + length: undefined, + origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.BIOMETRICS, + }, + VALIDATE_CODE: { + id: MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE, + name: 'Email One-Time Password', + parameter: 'validateCode', + length: 6, + origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.ADDITIONAL, + }, + }, + FACTORS_ORIGIN: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN, + SCENARIO, + TYPE: { + BIOMETRICS: 'BIOMETRICS', + }, + CHALLENGE_TYPE: { + REGISTRATION: 'registration', + AUTHENTICATION: 'authentication', + }, + FACTORS: MULTIFACTOR_AUTHENTICATION_FACTORS, + API_RESPONSE_MAP, + REASON, } as const; export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts new file mode 100644 index 0000000000000..c3e4178f8f09e --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -0,0 +1,28 @@ +/** + * Helper utilities for multifactor authentication biometrics operations. + */ +import type {ValueOf} from 'type-fest'; +import type {MultifactorAuthenticationReason, MultifactorAuthenticationResponseMap} from './types'; +import VALUES from './VALUES'; + +/** + * Parses an HTTP response code and returns the corresponding HTTP code and reason. + */ +function parseHttpCode( + jsonCode: string | number | undefined, + source: ValueOf>, +): { + httpCode: number; + reason: MultifactorAuthenticationReason; +} { + const httpCode = Number(jsonCode) || 0; + const reason = source[httpCode as keyof typeof source] ?? VALUES.API_RESPONSE_MAP.UNKNOWN; + + return { + httpCode, + reason, + }; +} + +// eslint-disable-next-line import/prefer-default-export +export {parseHttpCode}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts new file mode 100644 index 0000000000000..7ef9559adfb20 --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -0,0 +1,87 @@ +/** + * Type definitions for multifactor authentication biometrics operations. + */ +import type {Simplify, ValueOf} from 'type-fest'; +import type {SignedChallenge} from './ED25519/types'; +import type VALUES from './VALUES'; + +/** + * Basic authentication requirement types for signed challenge and validation code. + */ +type BasicMultifactorAuthenticationRequirementTypes = { + [VALUES.FACTORS.SIGNED_CHALLENGE]: SignedChallenge; + [VALUES.FACTORS.VALIDATE_CODE]: number; +}; + +/** + * Represents the reason for a multifactor authentication response from the backend. + */ +type MultifactorAuthenticationReason = ValueOf<{ + [K in keyof typeof VALUES.REASON]: ValueOf<(typeof VALUES.REASON)[K]>; +}>; + +/** + * Factors requirements configuration. + */ +type MultifactorAuthenticationFactorsRequirements = ValueOf; + +/** + * Main authentication factors excluding additional factors. + */ +type MultifactorAuthenticationFactors = { + [K in MultifactorAuthenticationFactorsRequirements as K extends { + origin: typeof VALUES.FACTORS_ORIGIN.ADDITIONAL; + } + ? never + : K['parameter']]: BasicMultifactorAuthenticationRequirementTypes[K['id']]; +}; + +/** + * Maps scenarios to their additional factors + */ +type MultifactorAuthorizationAdditionalFactors = { + [K in MultifactorAuthenticationFactorsRequirements as K extends { + origin: typeof VALUES.FACTORS_ORIGIN.ADDITIONAL; + } + ? K['parameter'] + : never]?: BasicMultifactorAuthenticationRequirementTypes[K['id']]; +}; + +type AllMultifactorAuthenticationFactors = Simplify; + +type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; + +type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationFactors> = T & Pick; + +type KeyInfoType = 'biometric' | 'public-key'; + +type ResponseDetails = T extends 'biometric' + ? { + biometric: { + publicKey: Base64URLString; + /** ED25519 algorithm identifier per COSE spec: -8 */ + algorithm: -8; + }; + } + : { + clientDataJSON: Base64URLString; + attestationObject: Base64URLString; + }; + +type MultifactorAuthenticationKeyInfo = { + rawId: Base64URLString; + type: T; + response: ResponseDetails; +}; + +type ChallengeType = ValueOf; + +export type { + MultifactorAuthenticationResponseMap, + AllMultifactorAuthenticationFactors, + MultifactorAuthenticationActionParams, + MultifactorAuthenticationReason, + MultifactorAuthenticationKeyInfo, + ResponseDetails, + ChallengeType, +}; diff --git a/src/libs/actions/MultifactorAuthentication.ts b/src/libs/actions/MultifactorAuthentication.ts new file mode 100644 index 0000000000000..0f0a03bbd54fe --- /dev/null +++ b/src/libs/actions/MultifactorAuthentication.ts @@ -0,0 +1,59 @@ +/* eslint-disable rulesdir/no-api-side-effects-method */ +import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; +import {makeRequestWithSideEffects} from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import {parseHttpCode} from '@libs/MultifactorAuthentication/Biometrics/helpers'; +import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; +import CONST from '@src/CONST'; + +/** + * To keep the code clean and readable, these functions return parsed data in order to: + * + * - Check whether multifactorial authentication scenario was successful as we need to know it as fast as possible + * to make the usage of authentication seamless and to tell if we should abort the process + * if an error occurred. + * + * - To avoid storing challenge in the persistent memory for security reasons. + * + * - As there is a certain short time frame in which the challenge needs to be signed, + * we should not delay the possibility to do so for the user. + * + * This is not a standard practice in the code base. + * Please consult before using this pattern. + */ + +async function registerAuthenticationKey({keyInfo, validateCode}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { + if (!validateCode) { + return parseHttpCode(401, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); + } + + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo, validateCode}, {}); + + const {jsonCode} = response ?? {}; + return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); +} + +async function requestAuthenticationChallenge(challengeType: ChallengeType = 'authentication') { + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, {challengeType}, {}); + const {jsonCode, challenge, publicKeys} = response ?? {}; + + return { + ...parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE), + challenge, + publicKeys, + }; +} + +async function troubleshootMultifactorAuthentication({signedChallenge}: MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']) { + if (!signedChallenge) { + return parseHttpCode(400, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); + } + + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION, {signedChallenge}, {}); + + const {jsonCode} = response ?? {}; + + return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); +} + +export {registerAuthenticationKey, requestAuthenticationChallenge, troubleshootMultifactorAuthentication}; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index ab93314762b2d..1f74eb49d18dd 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -1,4 +1,5 @@ import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; /** Model of commands data */ type Data = { @@ -41,6 +42,12 @@ type Response = { /** Used to load resources like attachment videos and images */ encryptedAuthToken?: string; + /** Registered multifactor public keys */ + publicKeys?: string[]; + + /** Multifactor authentication challenge object */ + challenge?: MultifactorAuthenticationChallengeObject; + /** User session auth token when connecting as a delegate */ restrictedToken?: string; From 175b5a3c19524e2c30abb620305e49886d2ab223 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 19 Jan 2026 19:12:19 +0100 Subject: [PATCH 02/11] chore: add MFA auth keys & challenge wrappers --- .../MultifactorAuthentication/config/types.ts | 123 ++++++++++- .../Biometrics/Challenge.ts | 130 ++++++++++++ .../Biometrics/KeyStore.ts | 129 ++++++++++++ .../Biometrics/Observer.ts | 19 ++ .../Biometrics/VALUES.ts | 105 ++++++++++ .../Biometrics/helpers.ts | 193 +++++++++++++++++- .../Biometrics/types.ts | 72 ++++++- 7 files changed, 765 insertions(+), 6 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/Biometrics/Challenge.ts create mode 100644 src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts create mode 100644 src/libs/MultifactorAuthentication/Biometrics/Observer.ts diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 15e95679fd72f..ad9ec8600ae72 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -1,13 +1,24 @@ +/** + * Configuration types for multifactor authentication UI and scenarios. + */ import type {ViewStyle} from 'react-native'; import type {EmptyObject, KebabCase, Replace, ValueOf} from 'type-fest'; import type {IllustrationName} from '@components/Icon/chunks/illustrations.chunk'; import type DotLottieAnimation from '@components/LottieAnimations/types'; -import type {MultifactorAuthenticationActionParams, MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type { + AllMultifactorAuthenticationFactors, + MultifactorAuthenticationActionParams, + MultifactorAuthenticationKeyInfo, + MultifactorAuthenticationReason, +} from '@libs/MultifactorAuthentication/Biometrics/types'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type SCREENS from '@src/SCREENS'; import type {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG, MultifactorAuthenticationScenarioPayload} from './index'; +/** + * Configuration for cancel confirmation modal in multifactor authentication. + */ type MultifactorAuthenticationCancelConfirm = { description?: TranslationPaths; cancelButtonText?: TranslationPaths; @@ -15,12 +26,18 @@ type MultifactorAuthenticationCancelConfirm = { title?: TranslationPaths; }; +/** + * Configuration for multifactor authentication prompt display with animation and translations. + */ type MultifactorAuthenticationPromptConfig = { animation: DotLottieAnimation; title: TranslationPaths; subtitle: TranslationPaths; }; +/** + * Configuration for displaying multifactor authentication notifications with illustrations and text. + */ type MultifactorAuthenticationNotificationConfig = { illustration: IllustrationName; iconWidth: number; @@ -32,32 +49,62 @@ type MultifactorAuthenticationNotificationConfig = { customDescription?: React.FunctionComponent; }; +/** + * Collection of prompts keyed by prompt identifier. + */ type MultifactorAuthenticationPrompt = Record; +/** + * Collection of notifications keyed by notification type. + */ type MultifactorAuthenticationNotification = Record; +/** + * Configuration for modals in multifactor authentication flows. + */ type MultifactorAuthenticationModal = { cancelConfirmation: MultifactorAuthenticationCancelConfirm; }; +/** + * Optional modal configuration for scenarios that may not have custom modals. + */ type MultifactorAuthenticationModalOptional = { cancelConfirmation?: Partial; }; +/** + * Optional notification configuration with partial properties for scenario overrides. + */ type MultifactorAuthenticationNotificationOptional = Record>; +/** + * Type representation of the scenario configuration constant. + */ type MultifactorAuthenticationConfigRecordConst = typeof MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG; +/** + * Maps each scenario to its notifications configuration type. + */ type MultifactorAuthenticationScenarioNotificationConst = { [K in MultifactorAuthenticationScenario]: MultifactorAuthenticationConfigRecordConst[K]['NOTIFICATIONS']; }; +/** + * Available notification options for each scenario. + */ type MultifactorAuthenticationNotificationScenarioOptions = { [K in MultifactorAuthenticationScenario]: keyof MultifactorAuthenticationScenarioNotificationConst[K]; }; +/** + * Maps scenarios to their notification configurations. + */ type MultifactorAuthenticationNotificationRecord = Record; +/** + * Constructs a kebab-case notification type string from scenario and notification name. + */ type MultifactorAuthenticationNotificationType = `${Lowercase}-${KebabCase}`; type MultifactorAuthenticationUI = { @@ -65,25 +112,49 @@ type MultifactorAuthenticationUI = { NOTIFICATIONS: MultifactorAuthenticationNotification; }; +/** + * All possible notification type keys across all scenarios. + */ type AllMultifactorAuthenticationNotificationType = MultifactorAuthenticationNotificationType; +/** + * Maps all notification type keys to their configurations. + */ type MultifactorAuthenticationNotificationMap = Record; +/** + * All available notification options across scenarios. + */ type MultifactorAuthenticationNotificationOptions = keyof MultifactorAuthenticationScenarioNotificationConst[MultifactorAuthenticationScenario]; +/** + * Notification type suffixes for a specific scenario (removes the scenario prefix). + */ type MultifactorAuthenticationNotificationSuffixes = Replace}-`, ''>; +/** + * Response from a multifactor authentication scenario action. + */ type MultifactorAuthenticationScenarioResponse = { httpCode: number; reason: MultifactorAuthenticationReason; }; +/** + * Multifactor authentication screen identifiers. + */ type MultifactorAuthenticationScreen = ValueOf; +/** + * Pure function type for scenario actions that return HTTP response and reason. + */ type MultifactorAuthenticationScenarioPureMethod> = ( params: MultifactorAuthenticationActionParams, ) => Promise; +/** + * Complete scenario configuration including action, UI, and metadata. + */ type MultifactorAuthenticationScenarioConfig = EmptyObject> = { action: MultifactorAuthenticationScenarioPureMethod; allowedAuthentication: ValueOf; @@ -92,6 +163,9 @@ type MultifactorAuthenticationScenarioConfig = nativePromptTitle: TranslationPaths; } & MultifactorAuthenticationUI; +/** + * Scenario configuration for custom scenarios with optional overrides. + */ type MultifactorAuthenticationScenarioCustomConfig = EmptyObject> = Omit< MultifactorAuthenticationScenarioConfig, 'MODALS' | 'NOTIFICATIONS' | 'nativePromptTitle' @@ -101,10 +175,46 @@ type MultifactorAuthenticationScenarioCustomConfig; +/** + * Record mapping all scenarios to their configurations. + */ type MultifactorAuthenticationScenarioConfigRecord = Record>; +/** + * Additional parameters specific to a scenario. + */ +type MultifactorAuthenticationScenarioAdditionalParams = T extends keyof MultifactorAuthenticationScenarioPayload + ? MultifactorAuthenticationScenarioPayload[T] + : EmptyObject; + +/** + * Optional authentication factors with scenario-specific parameters. + */ +type MultifactorAuthenticationScenarioParams = Partial & + MultifactorAuthenticationScenarioAdditionalParams; + +/** + * All required authentication factors with scenario-specific parameters. + */ +type MultifactorAuthenticationProcessScenarioParameters = AllMultifactorAuthenticationFactors & + MultifactorAuthenticationScenarioAdditionalParams; + +/** + * Scenario response with success status indicator. + */ +type MultifactorAuthenticationScenarioResponseWithSuccess = { + httpCode: number | undefined; + successful: boolean; +}; + +/** + * Parameters required for biometrics registration scenario. + */ type RegisterBiometricsParams = MultifactorAuthenticationActionParams< { keyInfo: MultifactorAuthenticationKeyInfo<'biometric'>; @@ -112,6 +222,9 @@ type RegisterBiometricsParams = MultifactorAuthenticationActionParams< 'validateCode' >; +/** + * Type-safe parameters for each multifactor authentication scenario. + */ type MultifactorAuthenticationScenarioParameters = { [key in MultifactorAuthenticationScenario]: MultifactorAuthenticationActionParams< key extends keyof MultifactorAuthenticationScenarioPayload ? MultifactorAuthenticationScenarioPayload[key] : EmptyObject, @@ -122,16 +235,24 @@ type MultifactorAuthenticationScenarioParameters = { 'REGISTER-BIOMETRICS': RegisterBiometricsParams; }; +/** + * Identifier for different multifactor authentication scenarios. + */ type MultifactorAuthenticationScenario = ValueOf; export type { MultifactorAuthenticationPrompt, MultifactorAuthenticationNotificationRecord, MultifactorAuthenticationNotificationMap, + MultifactorAuthenticationScenarioResponseWithSuccess, + MultifactorAuthenticationScenarioAdditionalParams, MultifactorAuthenticationScenarioParameters, MultifactorAuthenticationScenario, MultifactorAuthenticationNotificationOptions, + MultifactorAuthenticationScenarioParams, + MultifactorAuthenticationScenarioConfig, MultifactorAuthenticationScenarioConfigRecord, + MultifactorAuthenticationProcessScenarioParameters, MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationNotificationSuffixes, MultifactorAuthenticationScenarioCustomConfig, diff --git a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts new file mode 100644 index 0000000000000..af47e49e1cdff --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts @@ -0,0 +1,130 @@ +/** + * Manages the multifactor authentication challenge flow including requesting, signing, and sending challenges. + */ +import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; +import {requestAuthenticationChallenge} from '@libs/actions/MultifactorAuthentication'; +import {signToken as signTokenED25519} from './ED25519'; +import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; +import {isChallengeSigned, processScenario} from './helpers'; +import {PrivateKeyStore, PublicKeyStore} from './KeyStore'; +import type {ChallengeType, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationReason, MultifactorKeyStoreOptions} from './types'; +import VALUES from './VALUES'; + +/** + * Handles the complete lifecycle of a multifactor authentication challenge for a specific scenario. + * Manages requesting challenges from the server, signing them with the private key, and sending signed challenges back. + */ +class MultifactorAuthenticationChallenge { + private auth: MultifactorAuthenticationPartialStatus = { + value: undefined, + reason: VALUES.REASON.GENERIC.NO_ACTION_MADE_YET, + }; + + private publicKeys: string[] = []; + + constructor( + private readonly scenario: T, + private readonly params: MultifactorAuthenticationScenarioAdditionalParams, + private readonly options?: MultifactorKeyStoreOptions, + private readonly challengeType: ChallengeType = 'authentication', + ) {} + + /** + * Creates an error return value with the given reason. + */ + private createErrorReturnValue(reasonKey: MultifactorAuthenticationReason): MultifactorAuthenticationPartialStatus { + return {value: false, reason: reasonKey}; + } + + /** + * Requests a new authentication challenge from the server and stores public keys. + * Sends the appropriate challengeType (authentication or registration) to the backend. + */ + public async request(): Promise> { + const {challenge, publicKeys: authPublicKeys, reason: apiReason} = await requestAuthenticationChallenge(this.challengeType); + this.publicKeys = authPublicKeys ?? []; + + const reason = apiReason === VALUES.REASON.BACKEND.UNKNOWN_RESPONSE ? VALUES.REASON.CHALLENGE.BAD_TOKEN : apiReason; + + this.auth = { + value: challenge, + reason: challenge ? VALUES.REASON.CHALLENGE.CHALLENGE_RECEIVED : reason, + }; + + return {...this.auth, value: true}; + } + + /** + * Signs the challenge with the user's private key and validates against public keys from the server. + */ + public async sign( + accountID: number, + chainedPrivateKeyStatus?: MultifactorAuthenticationPartialStatus, + ): Promise> { + if (!this.auth.value) { + return this.createErrorReturnValue(VALUES.REASON.CHALLENGE.CHALLENGE_MISSING); + } + + if (isChallengeSigned(this.auth.value)) { + return this.createErrorReturnValue(VALUES.REASON.CHALLENGE.CHALLENGE_ALREADY_SIGNED); + } + + const {value, type, reason} = chainedPrivateKeyStatus?.value ? chainedPrivateKeyStatus : await PrivateKeyStore.get(accountID, this.options); + + if (!value) { + return this.createErrorReturnValue(reason || VALUES.REASON.KEYSTORE.KEY_MISSING); + } + + const {value: publicKey} = await PublicKeyStore.get(accountID); + + if (!publicKey || !this.publicKeys.includes(publicKey)) { + await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); + return this.createErrorReturnValue(VALUES.REASON.KEYSTORE.KEY_MISSING_ON_THE_BACKEND); + } + + this.auth = { + value: signTokenED25519(this.auth.value, value, publicKey), + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + type, + }; + + return {...this.auth, value: true}; + } + + /** + * Sends the signed challenge to the server for the specific scenario. + */ + public async send(): Promise> { + const {value} = this.auth; + + if (!value || !isChallengeSigned(value)) { + return this.createErrorReturnValue(VALUES.REASON.GENERIC.SIGNATURE_MISSING); + } + + const authorizationResult = processScenario( + this.scenario, + { + ...this.params, + signedChallenge: value, + }, + VALUES.FACTOR_COMBINATIONS.BIOMETRICS_AUTHENTICATION, + ); + + const { + reason, + step: {wasRecentStepSuccessful, isRequestFulfilled}, + } = await authorizationResult; + + if (!wasRecentStepSuccessful || !isRequestFulfilled) { + return this.createErrorReturnValue(reason === VALUES.REASON.BACKEND.UNKNOWN_RESPONSE ? VALUES.REASON.GENERIC.SIGNATURE_INVALID : reason); + } + + return { + value: true, + reason: VALUES.REASON.BACKEND.AUTHORIZATION_SUCCESSFUL, + type: this.auth.type, + }; + } +} + +export default MultifactorAuthenticationChallenge; diff --git a/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts b/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts new file mode 100644 index 0000000000000..852b26cdfacd4 --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts @@ -0,0 +1,129 @@ +/** + * Manages secure storage and retrieval of cryptographic keys for multifactor authentication. + */ +import {decodeExpoMessage} from './helpers'; +import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from './SecureStore'; +import type {SecureStoreOptions} from './SecureStore'; +import type {MultifactorAuthenticationKeyType, MultifactorAuthenticationPartialStatus, MultifactorKeyStoreOptions} from './types'; +import VALUES from './VALUES'; + +/** + * Static options for secure store operations. + */ +const STATIC_OPTIONS = { + keychainService: VALUES.KEYCHAIN_SERVICE, + keychainAccessible: SECURE_STORE_VALUES.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, + enableDeviceFallback: true, + returnUsedAuthenticationType: true, +} as const; + +/** + * Configures secure store options based on the key type and provided options. + * Private keys require additional authentication and are protected from updates. + */ +const secureStoreOptions = (key: string, KSOptions?: MultifactorKeyStoreOptions): SecureStoreOptions => { + const isPrivateKey = key.endsWith(VALUES.KEY_ALIASES.PRIVATE_KEY); + + return { + failOnUpdate: isPrivateKey, + requireAuthentication: isPrivateKey, + forceAuthenticationOnSave: isPrivateKey, + forceReadAuthenticationOnSimulators: isPrivateKey, + authenticationPrompt: KSOptions?.nativePromptTitle ?? 'Approve transaction', + }; +}; + +/** + * Unified interface for secure storage operations with proper error handling and authentication. + */ +const MultifactorAuthenticationStore = { + get: (key: string, KSOptions?: MultifactorKeyStoreOptions) => SECURE_STORE_METHODS.getItemAsync(key, {...secureStoreOptions(key, KSOptions), ...STATIC_OPTIONS}), + set: (key: string, value: string, KSOptions?: MultifactorKeyStoreOptions) => SECURE_STORE_METHODS.setItemAsync(key, value, {...secureStoreOptions(key, KSOptions), ...STATIC_OPTIONS}), + delete: (key: string) => + SECURE_STORE_METHODS.deleteItemAsync(key, { + keychainService: VALUES.KEYCHAIN_SERVICE, + }), +}; + +/** + * Manages storage and retrieval of a specific key type (public or private) in secure storage. + * Handles encryption, authentication, and error management for cryptographic keys. + */ +class MultifactorAuthenticationKeyStore { + constructor(private readonly key: MultifactorAuthenticationKeyType) {} + + /** + * Saves a key to secure storage for the given account. + */ + public async set(accountID: number, value: string, KSOptions?: MultifactorKeyStoreOptions): Promise> { + try { + const type = await MultifactorAuthenticationStore.set(`${accountID}_${this.key}`, value, KSOptions); + return { + value: true, + reason: VALUES.REASON.KEYSTORE.KEY_SAVED, + type, + }; + } catch (error) { + return { + value: false, + reason: decodeExpoMessage(error, VALUES.REASON.KEYSTORE.UNABLE_TO_SAVE_KEY), + }; + } + } + + /** + * Deletes a key from secure storage for the given account. + */ + public async delete(accountID: number): Promise> { + try { + await MultifactorAuthenticationStore.delete(`${accountID}_${this.key}`); + return { + value: true, + reason: VALUES.REASON.KEYSTORE.KEY_DELETED, + }; + } catch (error) { + return { + value: false, + reason: decodeExpoMessage(error, VALUES.REASON.KEYSTORE.UNABLE_TO_DELETE_KEY), + }; + } + } + + /** + * Retrieves a key from secure storage for the given account with optional authentication. + */ + public async get(accountID: number, KSOptions?: MultifactorKeyStoreOptions): Promise> { + try { + const [key, type] = await MultifactorAuthenticationStore.get(`${accountID}_${this.key}`, KSOptions); + return { + value: key, + reason: key ? VALUES.REASON.KEYSTORE.KEY_RETRIEVED : VALUES.REASON.KEYSTORE.KEY_NOT_FOUND, + type, + }; + } catch (error) { + return { + value: null, + reason: decodeExpoMessage(error, VALUES.REASON.KEYSTORE.UNABLE_TO_RETRIEVE_KEY), + }; + } + } + + /** + * Checks what authentication methods are supported on this device. + */ + get supportedAuthentication() { + return {biometrics: SECURE_STORE_METHODS.canUseBiometricAuthentication(), credentials: SECURE_STORE_METHODS.canUseDeviceCredentialsAuthentication()}; + } +} + +/** + * Store instance for managing private keys. + */ +const MultifactorAuthenticationPrivateKeyStore = new MultifactorAuthenticationKeyStore(VALUES.KEY_ALIASES.PRIVATE_KEY); + +/** + * Store instance for managing public keys. + */ +const MultifactorAuthenticationPublicKeyStore = new MultifactorAuthenticationKeyStore(VALUES.KEY_ALIASES.PUBLIC_KEY); + +export {MultifactorAuthenticationPrivateKeyStore as PrivateKeyStore, MultifactorAuthenticationPublicKeyStore as PublicKeyStore}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/Observer.ts b/src/libs/MultifactorAuthentication/Biometrics/Observer.ts new file mode 100644 index 0000000000000..d6624a223de1d --- /dev/null +++ b/src/libs/MultifactorAuthentication/Biometrics/Observer.ts @@ -0,0 +1,19 @@ +/** + * Observer pattern implementation for multifactor authentication callbacks. + */ +import {MultifactorAuthenticationCallbacks} from './VALUES'; + +/** + * Manages registration and storage of multifactor authentication callback functions. + * Used for subscribing to authentication flow events. + */ +const MultifactorAuthenticationObserver = { + /** + * Registers a callback function for a specific event ID. + */ + registerCallback: (id: string, callback: () => unknown) => { + MultifactorAuthenticationCallbacks.onFulfill[id] = callback; + }, +}; + +export default MultifactorAuthenticationObserver; diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index e7787fd8db134..4920ed86273c4 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -3,6 +3,15 @@ */ import SCENARIO from '@components/MultifactorAuthentication/config/scenarios/names'; +/** + * Callback registry for multifactor authentication flow events. + */ +const MultifactorAuthenticationCallbacks: { + onFulfill: Record void>; +} = { + onFulfill: {}, +}; + /** * Backend reason messages for multifactor authentication responses. */ @@ -20,6 +29,40 @@ const REASON = { BAD_REQUEST: 'Bad request', UNKNOWN_RESPONSE: 'Unrecognized response type', }, + CHALLENGE: { + BAD_TOKEN: 'Bad token', + CHALLENGE_MISSING: 'Challenge is missing', + CHALLENGE_ALREADY_SIGNED: 'Challenge is already signed', + CHALLENGE_RECEIVED: 'Challenge received successfully', + CHALLENGE_SIGNED: 'Challenge signed successfully', + }, + EXPO: { + CANCELED: 'Authentication canceled by user', + IN_PROGRESS: 'Authentication already in progress', + NOT_IN_FOREGROUND: 'Application must be in the foreground', + KEY_EXISTS: 'This key already exists', + NO_METHOD_AVAILABLE: 'No authentication methods available', + NOT_SUPPORTED: 'This feature is not supported on the device', + GENERIC: 'An error occurred', + }, + GENERIC: { + SIGNATURE_INVALID: 'Signature is invalid', + SIGNATURE_MISSING: 'Signature is missing', + NO_ACTION_MADE_YET: 'No action has been made yet', + FACTORS_ERROR: 'Authentication factors error', + FACTORS_VERIFIED: 'Authentication factors verified', + }, + KEYSTORE: { + KEY_DELETED: 'Key successfully deleted from SecureStore', + KEY_MISSING_ON_THE_BACKEND: 'Key is stored locally but not found on server', + KEY_MISSING: 'Key is missing', + KEY_SAVED: 'Key successfully saved in SecureStore', + UNABLE_TO_SAVE_KEY: 'Failed to save key in SecureStore', + UNABLE_TO_DELETE_KEY: 'Failed to delete key from SecureStore', + KEY_RETRIEVED: 'Key successfully retrieved from SecureStore', + KEY_NOT_FOUND: 'Key not found in SecureStore', + UNABLE_TO_RETRIEVE_KEY: 'Failed to retrieve key from SecureStore', + }, } as const; /** @@ -64,11 +107,54 @@ const MULTIFACTOR_AUTHENTICATION_FACTORS = { VALIDATE_CODE: 'VALIDATE_CODE', } as const; +/** + * Expo error message search strings and separator. + */ +const EXPO_ERRORS = { + SEPARATOR: 'Caused by:', + SEARCH_STRING: { + NOT_IN_FOREGROUND: 'not in the foreground', + IN_PROGRESS: 'in progress', + CANCELED: 'canceled', + EXISTS: 'already exists', + NO_AUTHENTICATION: 'No authentication method available', + OLD_ANDROID: 'NoSuchMethodError', + }, +} as const; + +/** + * Maps authentication factors and Expo errors to appropriate reason messages. + */ +const MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS = { + /** Maps authentication factors to their missing error translation paths */ + FACTOR_MISSING_REASONS: { + [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.BACKEND.VALIDATE_CODE_MISSING, + [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE]: REASON.GENERIC.SIGNATURE_MISSING, + }, + /** Maps authentication factors to their invalid error translation paths */ + FACTOR_INVALID_REASONS: { + [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.BACKEND.VALIDATE_CODE_INVALID, + [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE]: REASON.GENERIC.SIGNATURE_INVALID, + }, + EXPO_ERROR_MAPPINGS: { + [EXPO_ERRORS.SEARCH_STRING.CANCELED]: REASON.EXPO.CANCELED, + [EXPO_ERRORS.SEARCH_STRING.IN_PROGRESS]: REASON.EXPO.IN_PROGRESS, + [EXPO_ERRORS.SEARCH_STRING.NOT_IN_FOREGROUND]: REASON.EXPO.NOT_IN_FOREGROUND, + [EXPO_ERRORS.SEARCH_STRING.EXISTS]: REASON.EXPO.KEY_EXISTS, + [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, + [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, + }, +} as const; + /** * Centralized constants used by the multifactor authentication biometrics flow. * It is stored here instead of the CONST file to avoid circular dependencies. */ const MULTIFACTOR_AUTHENTICATION_VALUES = { + /** + * Keychain service name for secure key storage. + */ + KEYCHAIN_SERVICE: 'Expensify', /** * EdDSA key type identifier referred to as EdDSA in the Auth system. */ @@ -78,7 +164,9 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { */ KEY_ALIASES: { PUBLIC_KEY: '3DS_SCA_KEY_PUBLIC', + PRIVATE_KEY: '3DS_SCA_KEY_PRIVATE', }, + EXPO_ERRORS, /** * Defines the requirements and configuration for each authentication factor. */ @@ -98,8 +186,24 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.ADDITIONAL, }, }, + /** + * Valid authentication factor combinations for different scenarios. + */ + FACTOR_COMBINATIONS: { + REGISTRATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE], + BIOMETRICS_AUTHENTICATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE], + }, + /** + * Factor origin classifications. + */ FACTORS_ORIGIN: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN, + /** + * Scenario name mappings. + */ SCENARIO, + /** + * Authentication type identifiers. + */ TYPE: { BIOMETRICS: 'BIOMETRICS', }, @@ -112,4 +216,5 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { REASON, } as const; +export {MultifactorAuthenticationCallbacks, MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS}; export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index c3e4178f8f09e..83670c6edb9bd 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -2,8 +2,23 @@ * Helper utilities for multifactor authentication biometrics operations. */ import type {ValueOf} from 'type-fest'; -import type {MultifactorAuthenticationReason, MultifactorAuthenticationResponseMap} from './types'; -import VALUES from './VALUES'; +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import type { + MultifactorAuthenticationProcessScenarioParameters, + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioConfig, + MultifactorAuthenticationScenarioParams, + MultifactorAuthenticationScenarioResponseWithSuccess, +} from '@components/MultifactorAuthentication/config/types'; +import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; +import type { + AllMultifactorAuthenticationFactors, + MultifactorAuthenticationFactor, + MultifactorAuthenticationPartialStatus, + MultifactorAuthenticationReason, + MultifactorAuthenticationResponseMap, +} from './types'; +import VALUES, {MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS} from './VALUES'; /** * Parses an HTTP response code and returns the corresponding HTTP code and reason. @@ -24,5 +39,175 @@ function parseHttpCode( }; } -// eslint-disable-next-line import/prefer-default-export -export {parseHttpCode}; +/** + * Returns the appropriate error reason when a required authentication factor is missing. + */ +function factorMissingReason(factor: MultifactorAuthenticationFactor): MultifactorAuthenticationReason { + return MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.FACTOR_MISSING_REASONS[factor] ?? VALUES.REASON.GENERIC.FACTORS_ERROR; +} + +/** + * Returns the appropriate error reason when an authentication factor is invalid. + */ +function factorInvalidReason(factor: MultifactorAuthenticationFactor): MultifactorAuthenticationReason { + return MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.FACTOR_INVALID_REASONS[factor] ?? VALUES.REASON.GENERIC.FACTORS_ERROR; +} + +/** + * Creates an unsuccessful step result with the required factor for the next step. + */ +function createUnsuccessfulStep(requiredFactor: MultifactorAuthenticationFactor) { + return { + requiredFactorForNextStep: requiredFactor, + wasRecentStepSuccessful: false, + isRequestFulfilled: false, + }; +} + +/** + * Validates that all required authentication factors are present and valid. + * Checks factor existence and validates factor length if applicable. + */ +function areMultifactorAuthenticationFactorsSufficient( + factors: Partial, + factorsCombination: ValueOf, +): MultifactorAuthenticationPartialStatus { + const requiredFactors = factorsCombination.map((id) => VALUES.FACTORS_REQUIREMENTS[id]); + + for (const {id, parameter, name, length} of requiredFactors) { + const value = factors[parameter]; + + // Check if factor is missing + if (value === undefined) { + return { + value: `Missing required factor: ${name} (${parameter})`, + step: createUnsuccessfulStep(id), + reason: factorMissingReason(id), + }; + } + + // Check if factor length is valid (if length requirement exists) + if (typeof length === 'number' && (typeof value === 'string' || typeof value === 'number')) { + const valueLength = String(value).length; + if (valueLength !== length) { + return { + value: `Invalid length for factor: ${name} (${parameter}). Expected length ${length}, got length ${valueLength}`, + step: createUnsuccessfulStep(id), + reason: factorInvalidReason(id), + }; + } + } + } + + return { + value: true, + step: { + requiredFactorForNextStep: undefined, + wasRecentStepSuccessful: undefined, + isRequestFulfilled: false, + }, + reason: VALUES.REASON.GENERIC.FACTORS_VERIFIED, + }; +} + +/** + * Processes the authorization response and determines the next step in the authentication flow. + */ +const authorizeMultifactorAuthenticationPostMethod = ( + status: MultifactorAuthenticationPartialStatus, + params: MultifactorAuthenticationScenarioParams, + failedFactor?: MultifactorAuthenticationFactor, +) => { + const {successful} = status.value; + const {validateCode} = params; + + // Determine the appropriate error reason + let reason = status.reason; + + if (status.reason !== VALUES.REASON.BACKEND.UNABLE_TO_AUTHORIZE) { + reason = status.reason; + } else if (!validateCode) { + reason = VALUES.REASON.BACKEND.VALIDATE_CODE_INVALID; + } + + return { + ...status, + value: validateCode && successful ? validateCode : undefined, + step: { + requiredFactorForNextStep: failedFactor, + wasRecentStepSuccessful: successful, + isRequestFulfilled: !failedFactor, + }, + reason, + }; +}; + +/** + * Processes a multifactor authentication scenario by validating factors and calling the scenario action. + */ +async function processMultifactorAuthenticationScenario( + scenario: T, + params: MultifactorAuthenticationProcessScenarioParameters, + factorsCombination: ValueOf, +): Promise> { + const factorsCheckResult = areMultifactorAuthenticationFactorsSufficient(params, factorsCombination); + + const currentScenario = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] as MultifactorAuthenticationScenarioConfig; + + if (factorsCheckResult.value !== true) { + return authorizeMultifactorAuthenticationPostMethod( + { + ...factorsCheckResult, + value: {httpCode: undefined, successful: false}, + }, + params, + factorsCheckResult.step.requiredFactorForNextStep, + ); + } + + // We can safely make this assertion because the factors check method guarantees that the necessary conditions are met + const {httpCode, reason} = await currentScenario.action(params); + const successful = String(httpCode).startsWith('2'); + + return authorizeMultifactorAuthenticationPostMethod( + { + value: {successful, httpCode}, + reason, + }, + params, + ); +} + +/** + * Decodes Expo error messages and maps them to authentication error reasons. + */ +function decodeExpoMessage(error: unknown): MultifactorAuthenticationReason { + const errorString = String(error); + const parts = errorString.split(VALUES.EXPO_ERRORS.SEPARATOR); + const searchString = parts.length > 1 ? parts.slice(1).join(';').trim() : errorString; + + for (const [searchKey, errorValue] of Object.entries(MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.EXPO_ERROR_MAPPINGS)) { + if (searchString.includes(searchKey)) { + return errorValue; + } + } + + return VALUES.REASON.EXPO.GENERIC; +} + +/** + * Decodes an Expo error message with optional fallback for generic errors. + */ +const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: MultifactorAuthenticationReason): MultifactorAuthenticationReason => { + const decodedMessage = decodeExpoMessage(message); + return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; +}; + +/** + * Type guard to check if a challenge has been signed by verifying the presence of rawId property. + */ +function isChallengeSigned(challenge: MultifactorAuthenticationChallengeObject | SignedChallenge): challenge is SignedChallenge { + return 'rawId' in challenge; +} + +export {processMultifactorAuthenticationScenario as processScenario, decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, isChallengeSigned, parseHttpCode}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index 7ef9559adfb20..5f0b4627a1787 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -1,8 +1,9 @@ /** * Type definitions for multifactor authentication biometrics operations. */ -import type {Simplify, ValueOf} from 'type-fest'; +import type {EmptyObject, Simplify, ValueOf} from 'type-fest'; import type {SignedChallenge} from './ED25519/types'; +import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; /** @@ -20,11 +21,37 @@ type MultifactorAuthenticationReason = ValueOf<{ [K in keyof typeof VALUES.REASON]: ValueOf<(typeof VALUES.REASON)[K]>; }>; +/** + * Conditional type for including or omitting the step field in partial status. + */ +type MultifactorAuthenticationPartialStatusConditional = OmitStep extends false + ? { + step: MultifactorAuthenticationStep; + } + : EmptyObject; + +/** + * Represents a partial status result of multifactor authentication operations. + * Contains the operation result value, reason message, and optionally the authentication step state. + */ +type MultifactorAuthenticationPartialStatus = MultifactorAuthenticationPartialStatusConditional & { + value: T; + + reason: MultifactorAuthenticationReason; + + type?: ValueOf['CODE']; +}; + /** * Factors requirements configuration. */ type MultifactorAuthenticationFactorsRequirements = ValueOf; +/** + * Individual authentication factor types. + */ +type MultifactorAuthenticationFactor = ValueOf; + /** * Main authentication factors excluding additional factors. */ @@ -47,12 +74,40 @@ type MultifactorAuthorizationAdditionalFactors = { : never]?: BasicMultifactorAuthenticationRequirementTypes[K['id']]; }; +/** + * Combined type representing all possible authentication factors (required and additional). + */ type AllMultifactorAuthenticationFactors = Simplify; +/** + * Represents the state of a step in the multifactor authentication flow. + */ +type MultifactorAuthenticationStep = { + wasRecentStepSuccessful: boolean | undefined; + + requiredFactorForNextStep: MultifactorAuthenticationFactor | undefined; + + isRequestFulfilled: boolean; +}; + +/** + * Maps API endpoints to their HTTP status codes and reason messages. + */ type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; +/** + * Identifier for different types of cryptographic keys. + */ +type MultifactorAuthenticationKeyType = ValueOf; + +/** + * Parameters for a multifactor authentication action with required authentication factor. + */ type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationFactors> = T & Pick; +/** + * Supported key types for multifactor authentication. + */ type KeyInfoType = 'biometric' | 'public-key'; type ResponseDetails = T extends 'biometric' @@ -68,18 +123,33 @@ type ResponseDetails = T extends 'biometric' attestationObject: Base64URLString; }; +/** + * Information about a cryptographic key including its raw ID, type, and response details. + */ type MultifactorAuthenticationKeyInfo = { rawId: Base64URLString; type: T; response: ResponseDetails; }; +/** + * Configuration options for multifactor key store operations. + */ +type MultifactorKeyStoreOptions = { + nativePromptTitle?: string; +}; + type ChallengeType = ValueOf; export type { + MultifactorAuthenticationFactor, + MultifactorAuthenticationStep, MultifactorAuthenticationResponseMap, + MultifactorAuthenticationKeyType, AllMultifactorAuthenticationFactors, + MultifactorAuthenticationPartialStatus, MultifactorAuthenticationActionParams, + MultifactorKeyStoreOptions, MultifactorAuthenticationReason, MultifactorAuthenticationKeyInfo, ResponseDetails, From e5df7c6e05cb085312c767f43a944ad81895b8de Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 19 Jan 2026 19:11:39 +0100 Subject: [PATCH 03/11] tests: add biometrics test scenario config & actions --- .../MultifactorAuthentication/ED25519.test.ts | 8 +- .../config/scenarios/index.test.ts | 95 +++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts diff --git a/tests/unit/MultifactorAuthentication/ED25519.test.ts b/tests/unit/MultifactorAuthentication/ED25519.test.ts index 3b55d7eb2ce31..11f4d11c0c045 100644 --- a/tests/unit/MultifactorAuthentication/ED25519.test.ts +++ b/tests/unit/MultifactorAuthentication/ED25519.test.ts @@ -1,4 +1,3 @@ -import {Buffer} from 'buffer'; import {TextEncoder} from 'util'; import {concatBytes, createAuthenticatorData, generateKeyPair, randomBytes, sha256, signToken, utf8ToBytes} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; @@ -47,13 +46,12 @@ describe('MultifactorAuthentication Biometrics ED25519 helpers', () => { timeout: 60000, }; - const result = signToken(challengeObject, privateKey); + const result = signToken(challengeObject, privateKey, publicKey); expect(result.type).toBe(VALUES.ED25519_TYPE); - // Decode base64URL-encoded rawId to verify it contains the accountID - const decodedRawId = Buffer.from(result.rawId.replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(); - expect(decodedRawId).toContain(VALUES.KEY_ALIASES.PUBLIC_KEY); + // Verify rawId matches the public key + expect(result.rawId).toBe(publicKey); expect(result.response.authenticatorData).toEqual(expect.any(String)); expect(result.response.clientDataJSON).toEqual(expect.any(String)); expect(result.response.signature).toEqual(expect.any(String)); diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts new file mode 100644 index 0000000000000..be7e398c78497 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -0,0 +1,95 @@ +import MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG from '@components/MultifactorAuthentication/config/scenarios'; +import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/MultifactorAuthentication/config/types'; +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; + +describe('MultifactorAuthentication Scenarios Config', () => { + /** + * Verifies that every scenario config has all required properties via customConfig + */ + it('should have all required properties for every scenario config', () => { + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; + + for (const scenarioConfig of Object.values(config)) { + // Verify MODALS exists and has cancelConfirmation + expect(scenarioConfig).toHaveProperty('MODALS'); + expect(scenarioConfig.MODALS).toHaveProperty('cancelConfirmation'); + + const cancelConfirmation = scenarioConfig.MODALS.cancelConfirmation; + expect(cancelConfirmation).toHaveProperty('title'); + expect(cancelConfirmation).toHaveProperty('description'); + expect(cancelConfirmation).toHaveProperty('confirmButtonText'); + expect(cancelConfirmation).toHaveProperty('cancelButtonText'); + + // Verify NOTIFICATIONS exists and has all required notification types + expect(scenarioConfig).toHaveProperty('NOTIFICATIONS'); + expect(scenarioConfig.NOTIFICATIONS).toHaveProperty('success'); + expect(scenarioConfig.NOTIFICATIONS).toHaveProperty('failure'); + expect(scenarioConfig.NOTIFICATIONS).toHaveProperty('outOfTime'); + expect(scenarioConfig.NOTIFICATIONS).toHaveProperty('noEligibleMethods'); + } + }); + + /** + * Verifies that each notification in every scenario has all required properties + */ + it('should have all required notification properties for each notification type', () => { + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; + + const requiredNotificationProps = ['illustration', 'iconWidth', 'iconHeight', 'padding', 'headerTitle', 'title', 'description']; + + for (const scenarioConfig of Object.values(config)) { + for (const notification of Object.values(scenarioConfig.NOTIFICATIONS)) { + for (const prop of requiredNotificationProps) { + expect(notification).toHaveProperty(prop); + } + } + } + }); + + /** + * Verifies that every scenario config has required action-related properties + */ + it('should have all required action properties for each scenario', () => { + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; + + for (const scenarioConfig of Object.values(config)) { + expect(scenarioConfig).toHaveProperty('action'); + expect(scenarioConfig).toHaveProperty('allowedAuthentication'); + expect(scenarioConfig).toHaveProperty('screen'); + expect(scenarioConfig).toHaveProperty('nativePromptTitle'); + } + }); + + /** + * Verifies that BIOMETRICS_TEST scenario config is properly configured + */ + it('should have BIOMETRICS_TEST scenario properly configured', () => { + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; + const biometricsTestScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]; + + expect(biometricsTestScenario).toBeDefined(); + expect(biometricsTestScenario.allowedAuthentication).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); + expect(biometricsTestScenario.screen).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST); + expect(biometricsTestScenario.pure).toBe(true); + expect(biometricsTestScenario.action).toBeDefined(); + }); + + /** + * Verifies that the customConfig properly merges defaults with custom overrides + */ + it('should properly merge default and custom notification configurations', () => { + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; + const biometricsTestConfig = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]; + + // Verify that custom success headerTitle is applied + expect(biometricsTestConfig.NOTIFICATIONS.success.headerTitle).toBe('multifactorAuthentication.biometricsTest.biometricsTest'); + + // Verify that default illustration is preserved + expect(biometricsTestConfig.NOTIFICATIONS.success).toHaveProperty('illustration'); + + // Verify that other notifications still have defaults + expect(biometricsTestConfig.NOTIFICATIONS.failure.illustration).toBe('HumptyDumpty'); + expect(biometricsTestConfig.NOTIFICATIONS.outOfTime.illustration).toBe('RunOutOfTime'); + }); +}); From a5dccda49fbcc99952acceac6b2b36c9dcb1c782 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 19 Jan 2026 19:12:29 +0100 Subject: [PATCH 04/11] tests: add MFA auth keys & challenge wrappers --- .../Biometrics/Challenge.test.ts | 229 ++++++++++++++++++ .../Biometrics/KeyStore.test.ts | 164 +++++++++++++ .../Biometrics/Observer.test.ts | 109 +++++++++ 3 files changed, 502 insertions(+) create mode 100644 tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts create mode 100644 tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts create mode 100644 tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts b/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts new file mode 100644 index 0000000000000..4614bd42d35a7 --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts @@ -0,0 +1,229 @@ +import type {MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; +import * as MFAActions from '@libs/actions/MultifactorAuthentication'; +import MultifactorAuthenticationChallenge from '@libs/MultifactorAuthentication/Biometrics/Challenge'; +import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import * as helpers from '@libs/MultifactorAuthentication/Biometrics/helpers'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import CONST from '@src/CONST'; + +// Mock dependencies +jest.mock('@libs/actions/MultifactorAuthentication'); +jest.mock('@libs/MultifactorAuthentication/Biometrics/ED25519'); +jest.mock('@libs/MultifactorAuthentication/Biometrics/helpers'); +jest.mock('@libs/MultifactorAuthentication/Biometrics/KeyStore'); + +const mockedChallengeObject: MultifactorAuthenticationChallengeObject = { + allowCredentials: [], + rpId: 'example.com', + challenge: 'test-challenge', + userVerification: 'required', + timeout: 7000, +}; + +const mockHelpers = jest.mocked(helpers); +const mockPrivateKeyStore = jest.mocked(PrivateKeyStore); +const mockPublicKeyStore = jest.mocked(PublicKeyStore); +const mockMFAActions = jest.mocked(MFAActions); + +describe('MultifactorAuthenticationChallenge', () => { + const mockScenario = CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST; + const mockParams: MultifactorAuthenticationScenarioAdditionalParams = {}; + const mockAccountID = 12345; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize challenge instance with scenario and params', () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + expect(challenge).toBeDefined(); + }); + + it('should accept optional keystore options', () => { + const options = {nativePromptTitle: 'Test Prompt'}; + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams, options); + expect(challenge).toBeDefined(); + }); + }); + + describe('request method', () => { + it('should successfully request a challenge from the server', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + // Mock the requestAuthenticationChallenge function + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: mockedChallengeObject, + httpCode: 200, + publicKeys: ['key1', 'key2'], + reason: VALUES.REASON.BACKEND.CHALLENGE_GENERATED, + }); + + const result = await challenge.request(); + + expect(result).toEqual({ + value: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_RECEIVED, + }); + }); + + it('should handle bad token error', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: undefined, + publicKeys: undefined, + httpCode: 400, + reason: VALUES.REASON.BACKEND.UNKNOWN_RESPONSE, + }); + + const result = await challenge.request(); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.CHALLENGE.BAD_TOKEN); + }); + }); + + describe('sign method', () => { + it('should return error if challenge is not requested yet', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + const result = await challenge.sign(mockAccountID); + + expect(result.value).toBe(false); + expect(result.reason).toBe(VALUES.REASON.CHALLENGE.CHALLENGE_MISSING); + }); + + it('should return error if challenge is already signed', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + // Mock request + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: mockedChallengeObject, + publicKeys: ['key1'], + httpCode: 200, + reason: VALUES.REASON.BACKEND.CHALLENGE_GENERATED, + }); + + await challenge.request(); + + // Mock isChallengeSigned to return true + mockHelpers.isChallengeSigned.mockReturnValueOnce(true); + + const result = await challenge.sign(mockAccountID); + + expect(result.value).toBe(false); + expect(result.reason).toBe(VALUES.REASON.CHALLENGE.CHALLENGE_ALREADY_SIGNED); + }); + + it('should handle missing private key', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + // Mock request + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: mockedChallengeObject, + publicKeys: ['key1'], + httpCode: 200, + reason: VALUES.REASON.BACKEND.CHALLENGE_GENERATED, + }); + + await challenge.request(); + + // Mock isChallengeSigned to return false + mockHelpers.isChallengeSigned.mockReturnValueOnce(false); + + // Mock private key retrieval failure + mockPrivateKeyStore.get.mockResolvedValueOnce({ + value: null, + reason: VALUES.REASON.KEYSTORE.KEY_NOT_FOUND, + }); + + const result = await challenge.sign(mockAccountID); + + expect(result.value).toBe(false); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + }); + + it('should delete keys if public key is missing from backend', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + // Mock request + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: mockedChallengeObject, + httpCode: 200, + publicKeys: ['backend-key'], + reason: VALUES.REASON.BACKEND.CHALLENGE_GENERATED, + }); + + await challenge.request(); + + // Mock isChallengeSigned to return false + mockHelpers.isChallengeSigned.mockReturnValueOnce(false); + + // Mock private key retrieval success + mockPrivateKeyStore.get.mockResolvedValueOnce({ + value: 'private-key-value', + reason: VALUES.REASON.KEYSTORE.KEY_RETRIEVED, + }); + + // Mock public key retrieval with different key + mockPublicKeyStore.get.mockResolvedValueOnce({ + value: 'different-key', + reason: VALUES.REASON.KEYSTORE.KEY_RETRIEVED, + }); + + const result = await challenge.sign(mockAccountID); + + expect(result.value).toBe(false); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_MISSING_ON_THE_BACKEND); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockPrivateKeyStore.delete).toHaveBeenCalledWith(mockAccountID); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockPublicKeyStore.delete).toHaveBeenCalledWith(mockAccountID); + }); + }); + + describe('send method', () => { + it('should return error if challenge is not signed', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + const result = await challenge.send(); + + expect(result.value).toBe(false); + expect(result.reason).toBe(VALUES.REASON.GENERIC.SIGNATURE_MISSING); + }); + + it('should process scenario with signed challenge', async () => { + const challenge = new MultifactorAuthenticationChallenge(mockScenario, mockParams); + + // Mock request and helpers + mockMFAActions.requestAuthenticationChallenge.mockResolvedValueOnce({ + challenge: mockedChallengeObject, + httpCode: 200, + publicKeys: ['key1'], + reason: VALUES.REASON.BACKEND.CHALLENGE_GENERATED, + }); + + mockHelpers.isChallengeSigned.mockReturnValueOnce(true); + + // Mock processScenario + mockHelpers.processScenario.mockResolvedValueOnce({ + reason: VALUES.REASON.BACKEND.AUTHORIZATION_SUCCESSFUL, + value: undefined, + step: { + wasRecentStepSuccessful: true, + isRequestFulfilled: true, + requiredFactorForNextStep: undefined, + }, + }); + + await challenge.request(); + + const result = await challenge.send(); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.BACKEND.AUTHORIZATION_SUCCESSFUL); + }); + }); +}); diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts b/tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts new file mode 100644 index 0000000000000..c992a3f8f5803 --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/Biometrics/KeyStore.test.ts @@ -0,0 +1,164 @@ +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; +import {SECURE_STORE_METHODS} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; + +jest.mock('@libs/MultifactorAuthentication/Biometrics/SecureStore'); + +const mockedSecureStoreMethods = jest.mocked(SECURE_STORE_METHODS); + +// Mock the SECURE_STORE_METHODS +jest.mock('@libs/MultifactorAuthentication/Biometrics/SecureStore', () => ({ + SECURE_STORE_METHODS: { + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), + canUseBiometricAuthentication: jest.fn(() => true), + canUseDeviceCredentialsAuthentication: jest.fn(() => true), + }, + SECURE_STORE_VALUES: { + WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'WHEN_PASSCODE_SET_THIS_DEVICE_ONLY', + AUTH_TYPE: { + BIOMETRIC: {CODE: 'biometric', NAME: 'Biometric'}, + DEVICE_CREDENTIAL: {CODE: 'device_credential', NAME: 'Device Credential'}, + }, + }, +})); + +jest.mock('@libs/MultifactorAuthentication/Biometrics/helpers', () => ({ + decodeExpoMessage: jest.fn(() => 'decoded-error-reason'), +})); + +describe('MultifactorAuthentication KeyStore', () => { + const mockAccountID = 12345; + const mockKey = 'test-key-value'; + const mockOptions = {nativePromptTitle: 'Test Prompt'}; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('PrivateKeyStore', () => { + describe('set method', () => { + it('should save a private key successfully', async () => { + mockedSecureStoreMethods.setItemAsync.mockResolvedValueOnce('biometric'); + + const result = await PrivateKeyStore.set(mockAccountID, mockKey); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_SAVED); + expect(result.type).toBe('biometric'); + expect(mockedSecureStoreMethods.setItemAsync).toHaveBeenCalled(); + }); + + it('should handle save errors', async () => { + mockedSecureStoreMethods.setItemAsync.mockRejectedValueOnce(new Error('Save failed')); + + const result = await PrivateKeyStore.set(mockAccountID, mockKey); + + expect(result.value).toBe(false); + }); + + it('should pass options to secure store', async () => { + mockedSecureStoreMethods.setItemAsync.mockResolvedValueOnce('biometric'); + + await PrivateKeyStore.set(mockAccountID, mockKey, mockOptions); + + expect(mockedSecureStoreMethods.setItemAsync).toHaveBeenCalled(); + }); + }); + + describe('get method', () => { + it('should retrieve a stored private key', async () => { + mockedSecureStoreMethods.getItemAsync.mockResolvedValueOnce([mockKey, 'biometric']); + + const result = await PrivateKeyStore.get(mockAccountID); + + expect(result.value).toBe(mockKey); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_RETRIEVED); + expect(result.type).toBe('biometric'); + }); + + it('should return KEY_NOT_FOUND when key is null', async () => { + mockedSecureStoreMethods.getItemAsync.mockResolvedValueOnce([null, undefined]); + + const result = await PrivateKeyStore.get(mockAccountID); + + expect(result.value).toBeNull(); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + }); + + it('should handle retrieval errors', async () => { + mockedSecureStoreMethods.getItemAsync.mockRejectedValueOnce(new Error('Retrieval failed')); + + const result = await PrivateKeyStore.get(mockAccountID); + + expect(result.value).toBeNull(); + }); + }); + + describe('delete method', () => { + it('should delete a private key successfully', async () => { + mockedSecureStoreMethods.deleteItemAsync.mockResolvedValueOnce(undefined); + + const result = await PrivateKeyStore.delete(mockAccountID); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_DELETED); + }); + + it('should handle deletion errors', async () => { + mockedSecureStoreMethods.deleteItemAsync.mockRejectedValueOnce(new Error('Deletion failed')); + + const result = await PrivateKeyStore.delete(mockAccountID); + + expect(result.value).toBe(false); + }); + }); + + describe('supportedAuthentication property', () => { + it('should return available authentication methods', () => { + const supported = PrivateKeyStore.supportedAuthentication; + + expect(supported).toEqual({ + biometrics: true, + credentials: true, + }); + }); + }); + }); + + describe('PublicKeyStore', () => { + describe('set method', () => { + it('should save a public key successfully', async () => { + mockedSecureStoreMethods.setItemAsync.mockResolvedValueOnce('device_credential'); + + const result = await PublicKeyStore.set(mockAccountID, mockKey); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_SAVED); + }); + }); + + describe('get method', () => { + it('should retrieve a stored public key', async () => { + mockedSecureStoreMethods.getItemAsync.mockResolvedValueOnce([mockKey, 'device_credential']); + + const result = await PublicKeyStore.get(mockAccountID); + + expect(result.value).toBe(mockKey); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_RETRIEVED); + }); + }); + + describe('delete method', () => { + it('should delete a public key successfully', async () => { + mockedSecureStoreMethods.deleteItemAsync.mockResolvedValueOnce(undefined); + + const result = await PublicKeyStore.delete(mockAccountID); + + expect(result.value).toBe(true); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_DELETED); + }); + }); + }); +}); diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts b/tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts new file mode 100644 index 0000000000000..6da4d177c9509 --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/Biometrics/Observer.test.ts @@ -0,0 +1,109 @@ +import MultifactorAuthenticationObserver from '@libs/MultifactorAuthentication/Biometrics/Observer'; +import {MultifactorAuthenticationCallbacks} from '@libs/MultifactorAuthentication/Biometrics/VALUES'; + +describe('MultifactorAuthenticationObserver', () => { + beforeEach(() => { + // Clear all callbacks before each test + MultifactorAuthenticationCallbacks.onFulfill = {}; + }); + + describe('registerCallback', () => { + it('should register a callback with an ID', () => { + const testId = 'test-callback-id'; + const testCallback = jest.fn(); + + MultifactorAuthenticationObserver.registerCallback(testId, testCallback); + + expect(MultifactorAuthenticationCallbacks.onFulfill[testId]).toBe(testCallback); + }); + + it('should allow multiple callbacks to be registered', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + MultifactorAuthenticationObserver.registerCallback('id-1', callback1); + MultifactorAuthenticationObserver.registerCallback('id-2', callback2); + + expect(MultifactorAuthenticationCallbacks.onFulfill['id-1']).toBe(callback1); + expect(MultifactorAuthenticationCallbacks.onFulfill['id-2']).toBe(callback2); + }); + + it('should overwrite existing callback with same ID', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const testId = 'same-id'; + + MultifactorAuthenticationObserver.registerCallback(testId, callback1); + MultifactorAuthenticationObserver.registerCallback(testId, callback2); + + expect(MultifactorAuthenticationCallbacks.onFulfill[testId]).toBe(callback2); + }); + + it('should handle callback function execution', () => { + const testId = 'executable-callback'; + const testCallback = jest.fn(() => 'test-result'); + + MultifactorAuthenticationObserver.registerCallback(testId, testCallback); + + const storedCallback = MultifactorAuthenticationCallbacks.onFulfill[testId]; + const result = storedCallback(); + + expect(testCallback).toHaveBeenCalled(); + expect(result).toBe('test-result'); + }); + + it('should accept callbacks that return different types', () => { + const stringCallback = jest.fn(() => 'string-result'); + const numberCallback = jest.fn(() => 42); + const objectCallback = jest.fn(() => ({result: 'object'})); + + MultifactorAuthenticationObserver.registerCallback('string-id', stringCallback); + MultifactorAuthenticationObserver.registerCallback('number-id', numberCallback); + MultifactorAuthenticationObserver.registerCallback('object-id', objectCallback); + + expect(MultifactorAuthenticationCallbacks.onFulfill['string-id']()).toBe('string-result'); + expect(MultifactorAuthenticationCallbacks.onFulfill['number-id']()).toBe(42); + expect(MultifactorAuthenticationCallbacks.onFulfill['object-id']()).toEqual({result: 'object'}); + }); + + it('should handle callbacks with side effects', () => { + const state = {counter: 0}; + const incrementCallback = jest.fn(() => { + state.counter++; + }); + + MultifactorAuthenticationObserver.registerCallback('increment', incrementCallback); + + MultifactorAuthenticationCallbacks.onFulfill.increment(); + + expect(state.counter).toBe(1); + }); + }); + + describe('callback storage', () => { + it('should maintain callback registry across multiple operations', () => { + const callbacks = [ + {id: 'cb-1', fn: jest.fn()}, + {id: 'cb-2', fn: jest.fn()}, + {id: 'cb-3', fn: jest.fn()}, + ]; + + for (const {id, fn} of callbacks) { + MultifactorAuthenticationObserver.registerCallback(id, fn); + } + + for (const {id, fn} of callbacks) { + expect(MultifactorAuthenticationCallbacks.onFulfill[id]).toBe(fn); + } + }); + + it('should allow querying registered callbacks', () => { + const testId = 'query-test'; + const testCallback = jest.fn(); + + MultifactorAuthenticationObserver.registerCallback(testId, testCallback); + + expect(testId in MultifactorAuthenticationCallbacks.onFulfill).toBe(true); + }); + }); +}); From f321b9918ec7f40aadc79e0f459f13ceaedf26e6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 20 Jan 2026 17:44:23 +0100 Subject: [PATCH 05/11] fix: address PR comments pt.1 --- .../MultifactorAuthentication/config/index.ts | 2 +- ...MultifactorAuthenticationNotifications.ts} | 65 ++++++++++++++----- .../config/scenarios/BiometricsTest.ts | 2 +- .../config/scenarios/DefaultUserInterface.ts | 1 + .../config/scenarios/index.ts | 8 ++- .../config/scenarios/names.ts | 5 ++ .../MultifactorAuthentication/config/types.ts | 8 ++- .../API/parameters/BiometricsTestParams.ts | 5 -- .../RegisterAuthenticationKeyParams.ts | 5 ++ .../parameters/RegisterBiometricsParams.ts | 5 -- ...> RequestAuthenticationChallengeParams.ts} | 4 +- ...bleshootMultifactorAuthenticationParams.ts | 5 ++ src/libs/API/parameters/index.ts | 6 +- src/libs/API/types.ts | 6 +- .../Biometrics/Challenge.ts | 4 +- .../Biometrics/KeyStore.ts | 4 +- .../Biometrics/Observer.ts | 6 ++ .../Biometrics/VALUES.ts | 10 ++- .../Biometrics/helpers.ts | 6 +- src/libs/StringUtils/index.ts | 19 ++++++ src/libs/actions/MultifactorAuthentication.ts | 62 ++++++++++++------ .../config/scenarios/index.test.ts | 4 +- .../Biometrics/Challenge.test.ts | 2 +- 23 files changed, 177 insertions(+), 67 deletions(-) rename src/components/MultifactorAuthentication/config/{helpers.ts => mapMultifactorAuthenticationNotifications.ts} (55%) delete mode 100644 src/libs/API/parameters/BiometricsTestParams.ts create mode 100644 src/libs/API/parameters/RegisterAuthenticationKeyParams.ts delete mode 100644 src/libs/API/parameters/RegisterBiometricsParams.ts rename src/libs/API/parameters/{RequestBiometricChallengeParams.ts => RequestAuthenticationChallengeParams.ts} (70%) create mode 100644 src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts diff --git a/src/components/MultifactorAuthentication/config/index.ts b/src/components/MultifactorAuthentication/config/index.ts index a097fff9dd978..345161822c125 100644 --- a/src/components/MultifactorAuthentication/config/index.ts +++ b/src/components/MultifactorAuthentication/config/index.ts @@ -1,7 +1,7 @@ /** * Configuration exports for multifactor authentication UI components and scenarios. */ -import {mapMultifactorAuthenticationNotification} from './helpers'; +import mapMultifactorAuthenticationNotification from './mapMultifactorAuthenticationNotifications'; import MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG from './scenarios'; const MULTIFACTOR_AUTHENTICATION_NOTIFICATION_MAP = mapMultifactorAuthenticationNotification(MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG); diff --git a/src/components/MultifactorAuthentication/config/helpers.ts b/src/components/MultifactorAuthentication/config/mapMultifactorAuthenticationNotifications.ts similarity index 55% rename from src/components/MultifactorAuthentication/config/helpers.ts rename to src/components/MultifactorAuthentication/config/mapMultifactorAuthenticationNotifications.ts index 8e3df020ff230..408f1c7752747 100644 --- a/src/components/MultifactorAuthentication/config/helpers.ts +++ b/src/components/MultifactorAuthentication/config/mapMultifactorAuthenticationNotifications.ts @@ -1,4 +1,4 @@ -import type {KebabCase} from 'type-fest'; +import StringUtils from '@libs/StringUtils'; import type { MultifactorAuthenticationNotificationMap, MultifactorAuthenticationNotificationOptions, @@ -8,21 +8,54 @@ import type { } from './types'; /** - * Converts a string to lowercase. + * This utility module provides functions to map multifactor authentication scenario configurations + * to a notification map with kebab-case keys. + * + * This allows notification pages to reference the config based on its NotificationType in url. + * + * e.g. + * + * { + * "BIOMETRICS-TEST": { + * // ... + * NOTIFICATIONS: { + * success: { + * title: "...", + * // ... + * }, + * failure: { + * title: "...", + * // ... + * }, + * // ... + * } + * }, + * "AUTHORIZE-TRANSACTION": { + * // ... + * } + * } + * + * is mapped to: + * + * { + * "biometrics-test-success": { + * title: "...", + * // ... + * }, + * "biometrics-test-failure": { + * title: "...", + * // ... + * }, + * "authorize-transaction-success": { + * // ... + * } + * // ... + * } */ -function toLowerCase(str: T) { - return str.toLowerCase() as Lowercase; -} - -/** - * Converts camelCase string to kebab-case format. - */ -function camelToKebabCase(str: T) { - return str.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as KebabCase; -} /** * Creates a notification record from multifactor authentication scenario configuration. + * For details refer to the example above. */ const createNotificationRecord = (mfaConfig: MultifactorAuthenticationScenarioConfigRecord): MultifactorAuthenticationNotificationRecord => { const entries = Object.entries({...mfaConfig}); @@ -35,10 +68,11 @@ const createNotificationRecord = (mfaConfig: MultifactorAuthenticationScenarioCo /** * Creates a notification key by combining scenario and notification name in kebab-case format. + * e.g. a scenario key of "BIOMETRICS-TEST" and notification name of "success" will produce "biometrics-test-success". */ const createNotificationKey = (key: string, name: string) => { - const scenarioKebabCase = toLowerCase(key as MultifactorAuthenticationScenario); - const notificationName = camelToKebabCase(name as MultifactorAuthenticationNotificationOptions); + const scenarioKebabCase = StringUtils.toLowerCase(key as MultifactorAuthenticationScenario); + const notificationName = StringUtils.camelToKebabCase(name as MultifactorAuthenticationNotificationOptions); return `${scenarioKebabCase}-${notificationName}` as const; }; @@ -59,4 +93,5 @@ const mapMultifactorAuthenticationNotification = (mfaConfig: MultifactorAuthenti return notifications as MultifactorAuthenticationNotificationMap; }; -export {mapMultifactorAuthenticationNotification, toLowerCase}; + +export default mapMultifactorAuthenticationNotification; diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts index 9e7482762f54c..451f3114c0e11 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts @@ -7,7 +7,7 @@ import SCREENS from '@src/SCREENS'; * Configuration for the biometrics test multifactor authentication scenario. */ export default { - allowedAuthentication: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS], action: troubleshootMultifactorAuthentication, screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST, pure: true, diff --git a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts index 1301de7b8d51c..6c44524c82d46 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts @@ -1,5 +1,6 @@ import type {MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; import NoEligibleMethodsDescription from '@components/MultifactorAuthentication/NoEligibleMethodsDescription'; +// Spacing utilities are needed for icon padding configuration in notification defaults // eslint-disable-next-line no-restricted-imports import spacing from '@styles/utils/spacing'; import variables from '@styles/variables'; diff --git a/src/components/MultifactorAuthentication/config/scenarios/index.ts b/src/components/MultifactorAuthentication/config/scenarios/index.ts index 2df65d17a3576..b89521d273681 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/index.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/index.ts @@ -7,7 +7,13 @@ import {customConfig} from './DefaultUserInterface'; /** * Payload types for multifactor authentication scenarios. * Since the BiometricsTest does not require any payload, it is an empty object for now. - * The AuthorizeTransaction Scenario will change it. + * The AuthorizeTransaction Scenario will change it, as it needs the transactionID to be provided as well. + * + * { + * "AUTHORIZE-TRANSACTION": { + * transactionID: string; + * } + * } */ type Payloads = EmptyObject; diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index 26dcd16dbd67e..ce06cd3b0a442 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -1,5 +1,10 @@ /** * Multifactor authentication scenario names. + * + * The names need to be a kebab-case string to satisfy the requirements of the URL schema. + * Moreover, they are exported to a separate file to avoid circular dependencies + * as the Multifactor Authentication configs imports SCREENS, actions, and other shared modules, + * and at the same time the config is imported in the CONSTs. */ export default { BIOMETRICS_TEST: 'BIOMETRICS-TEST', diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index ad9ec8600ae72..01a8314c3b7a1 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -157,8 +157,14 @@ type MultifactorAuthenticationScenarioPureMethod = EmptyObject> = { action: MultifactorAuthenticationScenarioPureMethod; - allowedAuthentication: ValueOf; + allowedAuthenticationMethods: Array>; screen: MultifactorAuthenticationScreen; + + /** + * Whether the scenario does not require any additional parameters except for the native biometrics data. + * If it is the case, the scenario needs to be defined as such + * so the absence of payload will be tolerated at the run-time. + */ pure?: true; nativePromptTitle: TranslationPaths; } & MultifactorAuthenticationUI; diff --git a/src/libs/API/parameters/BiometricsTestParams.ts b/src/libs/API/parameters/BiometricsTestParams.ts deleted file mode 100644 index 7843b5898e1f3..0000000000000 --- a/src/libs/API/parameters/BiometricsTestParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; - -type BiometricsTestParams = MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']; - -export default BiometricsTestParams; diff --git a/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts b/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts new file mode 100644 index 0000000000000..7bf85d9844394 --- /dev/null +++ b/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts @@ -0,0 +1,5 @@ +import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; + +type RegisterAuthenticationKeyParams = MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']; + +export default RegisterAuthenticationKeyParams; diff --git a/src/libs/API/parameters/RegisterBiometricsParams.ts b/src/libs/API/parameters/RegisterBiometricsParams.ts deleted file mode 100644 index 267a5ab75a5ff..0000000000000 --- a/src/libs/API/parameters/RegisterBiometricsParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; - -type RegisterBiometricsParams = MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']; - -export default RegisterBiometricsParams; diff --git a/src/libs/API/parameters/RequestBiometricChallengeParams.ts b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts similarity index 70% rename from src/libs/API/parameters/RequestBiometricChallengeParams.ts rename to src/libs/API/parameters/RequestAuthenticationChallengeParams.ts index 7236b2f330ff3..a11a0f9f1ea30 100644 --- a/src/libs/API/parameters/RequestBiometricChallengeParams.ts +++ b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts @@ -1,8 +1,8 @@ import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; -type RequestBiometricChallengeParams = { +type RequestAuthenticationChallengeParams = { /** Challenge type: 'authentication' for signing existing keys, 'registration' for new key registration */ challengeType: ChallengeType; }; -export default RequestBiometricChallengeParams; +export default RequestAuthenticationChallengeParams; diff --git a/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts b/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts new file mode 100644 index 0000000000000..86434a2072f87 --- /dev/null +++ b/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts @@ -0,0 +1,5 @@ +import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; + +type TroubleshootMultifactorAuthenticationParams = MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']; + +export default TroubleshootMultifactorAuthenticationParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index c02b3adf84710..99d03d7a1bc81 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -463,6 +463,6 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; -export type {default as RegisterBiometricsParams} from './RegisterBiometricsParams'; -export type {default as BiometricsTestParams} from './BiometricsTestParams'; -export type {default as RequestBiometricChallengeParams} from './RequestBiometricChallengeParams'; +export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; +export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; +export type {default as RequestAuthenticationChallengeParams} from './RequestAuthenticationChallengeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 03799fc6c4412..52698a4ab3836 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1328,9 +1328,9 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: Parameters.AcceptSpotnanaTermsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN]: Parameters.GetScimTokenParams; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; - [SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY]: Parameters.RegisterBiometricsParams; - [SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION]: Parameters.BiometricsTestParams; - [SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE]: Parameters.RequestBiometricChallengeParams; + [SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY]: Parameters.RegisterAuthenticationKeyParams; + [SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION]: Parameters.TroubleshootMultifactorAuthenticationParams; + [SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE]: Parameters.RequestAuthenticationChallengeParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts index af47e49e1cdff..17c6146f2bfbc 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts @@ -55,7 +55,7 @@ class MultifactorAuthenticationChallenge unknown) => { MultifactorAuthenticationCallbacks.onFulfill[id] = callback; }, + unregisterCallback: (id: string) => { + delete MultifactorAuthenticationCallbacks.onFulfill[id]; + }, + clearAllCallbacks: () => { + MultifactorAuthenticationCallbacks.onFulfill = {}; + }, }; export default MultifactorAuthenticationObserver; diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 4920ed86273c4..2c250ce43bc19 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -54,7 +54,7 @@ const REASON = { }, KEYSTORE: { KEY_DELETED: 'Key successfully deleted from SecureStore', - KEY_MISSING_ON_THE_BACKEND: 'Key is stored locally but not found on server', + REGISTRATION_REQUIRED: 'Key is stored locally but not found on server', KEY_MISSING: 'Key is missing', KEY_SAVED: 'Key successfully saved in SecureStore', UNABLE_TO_SAVE_KEY: 'Failed to save key in SecureStore', @@ -131,6 +131,7 @@ const MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS = { [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.BACKEND.VALIDATE_CODE_MISSING, [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE]: REASON.GENERIC.SIGNATURE_MISSING, }, + /** Maps authentication factors to their invalid error translation paths */ FACTOR_INVALID_REASONS: { [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.BACKEND.VALIDATE_CODE_INVALID, @@ -155,10 +156,12 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { * Keychain service name for secure key storage. */ KEYCHAIN_SERVICE: 'Expensify', + /** * EdDSA key type identifier referred to as EdDSA in the Auth system. */ ED25519_TYPE: 'biometric', + /** * Key alias identifiers for secure storage. */ @@ -167,6 +170,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { PRIVATE_KEY: '3DS_SCA_KEY_PRIVATE', }, EXPO_ERRORS, + /** * Defines the requirements and configuration for each authentication factor. */ @@ -186,6 +190,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.ADDITIONAL, }, }, + /** * Valid authentication factor combinations for different scenarios. */ @@ -193,14 +198,17 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { REGISTRATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE], BIOMETRICS_AUTHENTICATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE], }, + /** * Factor origin classifications. */ FACTORS_ORIGIN: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN, + /** * Scenario name mappings. */ SCENARIO, + /** * Authentication type identifiers. */ diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index 83670c6edb9bd..be7fc6362e8d2 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -113,7 +113,7 @@ function areMultifactorAuthenticationFactorsSufficient( /** * Processes the authorization response and determines the next step in the authentication flow. */ -const authorizeMultifactorAuthenticationPostMethod = ( +const transformMultifactorAuthenticationActionResponse = ( status: MultifactorAuthenticationPartialStatus, params: MultifactorAuthenticationScenarioParams, failedFactor?: MultifactorAuthenticationFactor, @@ -155,7 +155,7 @@ async function processMultifactorAuthenticationScenario `-${letter.toLowerCase()}`); } +/** + * Converts a string to lowercase. + * In addition to the standard toLowerCase behavior, this function ensures + * that if a const string is provided, it is not converted to a primitive type. + */ +function toLowerCase(str: T) { + return str.toLowerCase() as Lowercase; +} + +/** + * Converts camelCase string to kebab-case format. + */ +function camelToKebabCase(str: T) { + return str.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as KebabCase; +} + export default { sanitizeString, isEmptyString, @@ -205,4 +222,6 @@ export default { countWhiteSpaces, startsWithVowel, camelToHyphenCase, + camelToKebabCase, + toLowerCase, }; diff --git a/src/libs/actions/MultifactorAuthentication.ts b/src/libs/actions/MultifactorAuthentication.ts index 0f0a03bbd54fe..f9f53f20f78f8 100644 --- a/src/libs/actions/MultifactorAuthentication.ts +++ b/src/libs/actions/MultifactorAuthentication.ts @@ -1,7 +1,10 @@ /* eslint-disable rulesdir/no-api-side-effects-method */ +// These functions use makeRequestWithSideEffects because challenge data must be returned immediately +// for security and timing requirements (see detailed explanation below) import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; import {makeRequestWithSideEffects} from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; import {parseHttpCode} from '@libs/MultifactorAuthentication/Biometrics/helpers'; import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; import CONST from '@src/CONST'; @@ -23,37 +26,56 @@ import CONST from '@src/CONST'; */ async function registerAuthenticationKey({keyInfo, validateCode}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { - if (!validateCode) { - return parseHttpCode(401, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); - } + try { + if (!validateCode) { + return parseHttpCode(401, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); + } - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo, validateCode}, {}); + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo, validateCode}, {}); - const {jsonCode} = response ?? {}; - return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); + const {jsonCode} = response ?? {}; + return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); + } catch (error) { + Log.hmmm('[MultifactorAuthentication] Failed to register an authentication key', {error}); + return parseHttpCode(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY); + } } async function requestAuthenticationChallenge(challengeType: ChallengeType = 'authentication') { - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, {challengeType}, {}); - const {jsonCode, challenge, publicKeys} = response ?? {}; - - return { - ...parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE), - challenge, - publicKeys, - }; + try { + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, {challengeType}, {}); + const {jsonCode, challenge, publicKeys} = response ?? {}; + + return { + ...parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE), + challenge, + publicKeys, + }; + } catch (error) { + Log.hmmm('[MultifactorAuthentication] Failed to request an authentication challenge', {error}); + return { + ...parseHttpCode(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE), + challenge: undefined, + publicKeys: undefined, + }; + } } async function troubleshootMultifactorAuthentication({signedChallenge}: MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']) { - if (!signedChallenge) { - return parseHttpCode(400, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); - } + try { + if (!signedChallenge) { + return parseHttpCode(400, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); + } - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION, {signedChallenge}, {}); + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION, {signedChallenge}, {}); - const {jsonCode} = response ?? {}; + const {jsonCode} = response ?? {}; - return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); + return parseHttpCode(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); + } catch (error) { + Log.hmmm('[MultifactorAuthentication] Failed to troubleshoot multifactor authentication', {error}); + return parseHttpCode(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION); + } } export {registerAuthenticationKey, requestAuthenticationChallenge, troubleshootMultifactorAuthentication}; diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index be7e398c78497..a31397546a9b8 100644 --- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -55,7 +55,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { for (const scenarioConfig of Object.values(config)) { expect(scenarioConfig).toHaveProperty('action'); - expect(scenarioConfig).toHaveProperty('allowedAuthentication'); + expect(scenarioConfig).toHaveProperty('allowedAuthenticationMethods'); expect(scenarioConfig).toHaveProperty('screen'); expect(scenarioConfig).toHaveProperty('nativePromptTitle'); } @@ -69,7 +69,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { const biometricsTestScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]; expect(biometricsTestScenario).toBeDefined(); - expect(biometricsTestScenario.allowedAuthentication).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); + expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS]); expect(biometricsTestScenario.screen).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST); expect(biometricsTestScenario.pure).toBe(true); expect(biometricsTestScenario.action).toBeDefined(); diff --git a/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts b/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts index 4614bd42d35a7..73c27f7ebf6c0 100644 --- a/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/Biometrics/Challenge.test.ts @@ -176,7 +176,7 @@ describe('MultifactorAuthenticationChallenge', () => { const result = await challenge.sign(mockAccountID); expect(result.value).toBe(false); - expect(result.reason).toBe(VALUES.REASON.KEYSTORE.KEY_MISSING_ON_THE_BACKEND); + expect(result.reason).toBe(VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED); // eslint-disable-next-line @typescript-eslint/unbound-method expect(mockPrivateKeyStore.delete).toHaveBeenCalledWith(mockAccountID); // eslint-disable-next-line @typescript-eslint/unbound-method From 450020fa7885d72a07dd4243cd843cd1c5ef977d Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 26 Jan 2026 12:19:45 +0100 Subject: [PATCH 06/11] fix: address PR comments pt.2 --- .../config/scenarios/names.ts | 11 ++++- .../config/scenarios/prompts.ts | 3 +- src/components/TestToolMenu.tsx | 12 +++++- .../Biometrics/SecureStore/MQValues.ts | 42 +++++++++++++++++++ .../Biometrics/SecureStore/index.ts | 8 ++++ .../Biometrics/SecureStore/index.web.ts | 8 ++++ .../Biometrics/SecureStore/types.ts | 1 + .../Biometrics/VALUES.ts | 9 +++- .../ValidateCodePage.tsx | 4 +- 9 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/Biometrics/SecureStore/MQValues.ts diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index ce06cd3b0a442..c305a7313369c 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -6,6 +6,15 @@ * as the Multifactor Authentication configs imports SCREENS, actions, and other shared modules, * and at the same time the config is imported in the CONSTs. */ -export default { +const SCENARIO_NAMES = { BIOMETRICS_TEST: 'BIOMETRICS-TEST', } as const; + +/** + * Prompt identifiers for multifactor authentication scenarios. + */ +const PROMPT_NAMES = { + ENABLE_BIOMETRICS: 'enable-biometrics', +}; + +export {SCENARIO_NAMES, PROMPT_NAMES}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts index b25ba9f8cae86..3f56948fd335b 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -1,5 +1,6 @@ import LottieAnimations from '@components/LottieAnimations'; import type {MultifactorAuthenticationPrompt} from '@components/MultifactorAuthentication/config/types'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; /** * Configuration for multifactor authentication prompt UI with animations and translations. @@ -7,7 +8,7 @@ import type {MultifactorAuthenticationPrompt} from '@components/MultifactorAuthe /* eslint-disable @typescript-eslint/naming-convention */ export default { - 'enable-biometrics': { + [VALUES.PROMPT.ENABLE_BIOMETRICS]: { animation: LottieAnimations.Fingerprint, title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index f4f83bfab0008..eadc8f7d0989d 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -22,6 +22,9 @@ import TestCrash from './TestCrash'; import TestToolRow from './TestToolRow'; import Text from './Text'; +// Temporary hardcoded value until MultifactorAuthenticationContext is implemented +const TEMP_BIOMETRICS_REGISTERED_STATUS = false; + function TestToolMenu() { const [network] = useOnyx(ONYXKEYS.NETWORK, {canBeMissing: true}); const [isUsingImportedState] = useOnyx(ONYXKEYS.IS_USING_IMPORTED_STATE, {canBeMissing: true}); @@ -33,6 +36,11 @@ function TestToolMenu() { const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); + + /** + * The wrapper is needed to prevent rapid double‑taps on native from triggering multiple navigations. + * Context: https://github.com/Expensify/App/pull/79475#discussion_r2708230681 + */ const navigateToBiometricsTestPage = singleExecution( waitForNavigate(() => { Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST); @@ -43,7 +51,7 @@ function TestToolMenu() { const isAuthenticated = useIsAuthenticated(); // Temporary hardcoded false, expected behavior: status fetched from the MultifactorAuthenticationContext - const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: false}); + const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: TEMP_BIOMETRICS_REGISTERED_STATUS}); return ( <> @@ -100,7 +108,7 @@ function TestToolMenu() { /> - {/* Allows you to test the Biometrics flow */} + {/* Allows testing the biometric multifactor authentication flow */}