From ea6850f4315e6c581e9ac27bcbff36a141b46d8a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 26 Jan 2026 19:12:18 +0100 Subject: [PATCH 01/95] feat: add useMultifactorAuthenticationStatus hook --- .../MultifactorAuthentication/config/types.ts | 6 + .../MultifactorAuthentication/helpers.ts | 107 ++++++++++++++++++ .../MultifactorAuthentication/types.ts | 21 +++- .../useMultifactorAuthenticationStatus.ts | 91 +++++++++++++++ .../Biometrics/Challenge.ts | 3 +- .../Biometrics/VALUES.ts | 8 +- .../Biometrics/types.ts | 17 +++ 7 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 src/components/MultifactorAuthentication/helpers.ts create mode 100644 src/components/MultifactorAuthentication/useMultifactorAuthenticationStatus.ts diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 6e17c121c1a0f..3dbf737427a95 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -250,15 +250,21 @@ type MultifactorAuthenticationScenario = ValueOf({type}: MultifactorAuthenticationPartialStatus): AuthTypeName | undefined => + Object.values(SECURE_STORE_VALUES.AUTH_TYPE).find(({CODE}) => CODE === type)?.NAME; + +/** + * Creates an empty/initial authentication status object with provided UI text and default values. + * Used as the initial state for multifactor authentication flows. + * @param initialValue - The initial value for the status (typically a boolean or data object). + * @param config - Object containing UI text strings (headerTitle, title, description). + * @returns A complete MultifactorAuthenticationStatus object with default values. + */ +const createEmptyStatus = (initialValue: T, {headerTitle, title, description}: {headerTitle: string; title: string; description: string}): MultifactorAuthenticationStatus => ({ + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ACTION_MADE_YET, + headerTitle, + title, + description, + outcomePaths: { + successOutcome: 'biometrics-test-success', + failureOutcome: 'biometrics-test-failure', + }, + scenario: undefined, + value: initialValue, + step: { + wasRecentStepSuccessful: undefined, + requiredFactorForNextStep: undefined, + isRequestFulfilled: true, + }, +}); + +/** + * Constructs an outcome type string from scenario prefix and outcome suffix. + * Combines the lowercase scenario name with the kebab-cased suffix (e.g., 'biometrics-test-success'). + * @param scenarioPrefix - The lowercase scenario name or undefined to use default 'biometrics-test'. + * @param suffix - The outcome suffix (success/failure). + * @returns A fully qualified outcome type string. + */ +const getOutcomePath = ( + scenarioPrefix: Lowercase | undefined, + suffix: MultifactorAuthenticationOutcomeSuffixes, +): AllMultifactorAuthenticationOutcomeType => { + return `${scenarioPrefix ?? 'biometrics-test'}-${suffix}` as AllMultifactorAuthenticationOutcomeType; +}; + +/** + * Type guard function to validate whether a string is a known multifactor authentication scenario. + * Checks against the available scenarios defined in CONST. + * @param scenario - The string to validate as a scenario. + * @returns True if the scenario is valid, false otherwise. + */ +const isValidScenario = (scenario: string): scenario is MultifactorAuthenticationScenario => { + const scenarios = Object.values(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO); + return !!scenarios.find((sc) => sc === scenario); +}; + +/** + * Determines whether a scenario reason indicates the flow should be cleared/reset. + * Returns true for FULFILL and CANCEL scenarios which signal completion. + * @param scenario - The scenario or status reason to check. + * @returns True if the scenario should trigger a clear action, false otherwise. + */ +const shouldClearScenario = (scenario: MultifactorAuthenticationScenario | NoScenarioForStatusReason) => { + return scenario === CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.FULFILL || scenario === CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.CANCEL; +}; + +/** + * Generates success and failure outcome paths for a given scenario. + * Handles undefined scenarios by using default 'biometrics-test' prefix. + * @param scenario - The authentication scenario or undefined for defaults. + * @returns An object containing successOutcome and failureOutcome paths. + */ +const getOutcomePaths = (scenario: MultifactorAuthenticationScenario | undefined): OutcomePaths => { + const scenarioPrefix = scenario?.toLowerCase() as Lowercase | undefined; + const successOutcome = getOutcomePath(scenarioPrefix, 'success'); + const failureOutcome = getOutcomePath(scenarioPrefix, 'failure'); + + return { + successOutcome, + failureOutcome, + }; +}; + +/** + * Deletes both private and public keys for a given account from secure storage. + * Performs both deletions in parallel to reset the authentication state. + * @param accountID - The account ID whose keys should be deleted. + */ +async function resetKeys(accountID: number) { + await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); +} + +const Status = { + createEmptyStatus, +} as const; + +export {getAuthTypeName, getOutcomePaths, isValidScenario, shouldClearScenario, resetKeys, Status}; diff --git a/src/components/MultifactorAuthentication/types.ts b/src/components/MultifactorAuthentication/types.ts index 4130f888c0d3e..775ed5694eddd 100644 --- a/src/components/MultifactorAuthentication/types.ts +++ b/src/components/MultifactorAuthentication/types.ts @@ -3,11 +3,28 @@ */ import type {ValueOf} from 'type-fest'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; +import type {MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type CONST from '@src/CONST'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenario} from './config/types'; /** * Authentication type name derived from secure store values. */ type AuthTypeName = ValueOf['NAME']; -// eslint-disable-next-line import/prefer-default-export -export type {AuthTypeName}; +type UseMultifactorAuthenticationStatus = [MultifactorAuthenticationStatus, SetMultifactorAuthenticationStatus]; + +type NoScenarioForStatusReason = ValueOf; + +type SetMultifactorAuthenticationStatus = ( + partialStatus: MultifactorAuthenticationPartialStatus | ((prevStatus: MultifactorAuthenticationStatus) => MultifactorAuthenticationStatus), + scenario: MultifactorAuthenticationScenario | NoScenarioForStatusReason, + customOutcomePaths?: Partial, +) => MultifactorAuthenticationStatus; + +type OutcomePaths = { + successOutcome: AllMultifactorAuthenticationOutcomeType; + failureOutcome: AllMultifactorAuthenticationOutcomeType; +}; + +export type {SetMultifactorAuthenticationStatus, AuthTypeName, UseMultifactorAuthenticationStatus, OutcomePaths, NoScenarioForStatusReason}; diff --git a/src/components/MultifactorAuthentication/useMultifactorAuthenticationStatus.ts b/src/components/MultifactorAuthentication/useMultifactorAuthenticationStatus.ts new file mode 100644 index 0000000000000..3d5ec0c925171 --- /dev/null +++ b/src/components/MultifactorAuthentication/useMultifactorAuthenticationStatus.ts @@ -0,0 +1,91 @@ +import {useRef, useState} from 'react'; +import useLocalize from '@hooks/useLocalize'; +import type {MultifactorAuthenticationPartialStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {MultifactorAuthenticationTranslationParams} from '@src/languages/params'; +import type {TranslationPaths} from '@src/languages/types'; +import {MULTIFACTOR_AUTHENTICATION_DEFAULT_UI, MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP} from './config'; +import {getAuthTypeName, getOutcomePaths, isValidScenario, shouldClearScenario, Status} from './helpers'; +import type {SetMultifactorAuthenticationStatus, UseMultifactorAuthenticationStatus} from './types'; + +type MultifactorAuthenticationTranslate = (path: TPath, params: MultifactorAuthenticationTranslationParams) => string; + +const { + OUTCOMES: {failure}, +} = MULTIFACTOR_AUTHENTICATION_DEFAULT_UI; + +/** + * Custom hook for managing multifactor authentication status state. + * Handles status updates with scenario validation, outcome path generation, and success detection. + * Uses refs to track previous status and success selector for optimization. + * @param initialValue - The initial value for the status state. + * @param successSelector - Optional function to determine success; defaults to checking wasRecentStepSuccessful. + * @returns A tuple containing the current status and a function to update it. + */ +export default function useMultifactorAuthenticationStatus( + initialValue: T, + successSelector?: (prevStatus: MultifactorAuthenticationPartialStatus) => boolean, +): UseMultifactorAuthenticationStatus { + const {translate} = useLocalize(); + + const defaultText = { + headerTitle: translate(failure.headerTitle), + title: translate(failure.title), + description: translate(failure.description), + }; + + const [status, setStatusSource] = useState(() => Status.createEmptyStatus(initialValue, defaultText)); + + const previousStatus = useRef(Status.createEmptyStatus(initialValue, defaultText)); + + const successSource = useRef(successSelector); + + const setStatus: SetMultifactorAuthenticationStatus = (partialStatus, potentialScenario, customOutcomePaths) => { + const state = typeof partialStatus === 'function' ? partialStatus(previousStatus.current) : partialStatus; + + const success = successSource.current ? successSource.current(state) : !!state.step.wasRecentStepSuccessful; + + const typeName = getAuthTypeName(state); + const previousScenario = previousStatus.current.scenario; + + const newScenario = isValidScenario(potentialScenario) ? potentialScenario : undefined; + const scenario = newScenario ?? (shouldClearScenario(potentialScenario) ? undefined : previousScenario); + const defaultOutcomePaths = getOutcomePaths(scenario); + + const {successOutcome: customSuccessOutcome, failureOutcome: customFailureOutcome} = customOutcomePaths ?? {}; + + const outcomePaths = { + successOutcome: customSuccessOutcome ?? defaultOutcomePaths.successOutcome, + failureOutcome: customFailureOutcome ?? defaultOutcomePaths.failureOutcome, + }; + + const outcomeType = success ? 'successOutcome' : 'failureOutcome'; + + const {headerTitle: headerTitleTPath, title: titleTPath, description: descriptionTPath} = MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP[outcomePaths[outcomeType]]; + + const translateMFA = translate as MultifactorAuthenticationTranslate; + const translationParameters = { + authType: typeName, + }; + + const headerTitle = translateMFA(headerTitleTPath, translationParameters); + const title = translateMFA(titleTPath, translationParameters); + const description = translateMFA(descriptionTPath, translationParameters); + + const createdStatus = { + ...state, + scenario, + typeName, + headerTitle, + title, + description, + outcomePaths, + }; + + setStatusSource(createdStatus); + previousStatus.current = createdStatus; + + return createdStatus; + }; + + return [status, setStatus]; +} diff --git a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts index 72b60ef198590..46ac7e0d6618c 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts @@ -2,6 +2,7 @@ * Manages the multifactor authentication challenge flow including requesting, signing, and sending challenges. */ import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; +import {resetKeys} from '@components/MultifactorAuthentication/helpers'; import {requestAuthenticationChallenge} from '@libs/actions/MultifactorAuthentication'; import {signToken as signTokenED25519} from './ED25519'; import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; @@ -73,7 +74,7 @@ class MultifactorAuthenticationChallenge = MultifactorAu type?: MultifactorAuthenticationMethodCode; }; +type MultifactorAuthenticationStatus = MultifactorAuthenticationPartialStatus & { + typeName?: string; + + headerTitle: string; + + title: string; + + description: string; + + scenario: MultifactorAuthenticationScenario | undefined; + + outcomePaths: OutcomePaths; +}; + /** * Factors requirements configuration. */ @@ -151,6 +167,7 @@ export type { MultifactorAuthenticationResponseMap, MultifactorAuthenticationKeyType, AllMultifactorAuthenticationFactors, + MultifactorAuthenticationStatus, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationActionParams, MultifactorKeyStoreOptions, From 06d2fef1eba226f3bc056a9fc67cc3af2858bdcf Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 26 Jan 2026 19:36:44 +0100 Subject: [PATCH 02/95] feat: add useNativeBiometrics hook --- .../MultifactorAuthentication/helpers.ts | 173 +++++++++++++++++- .../MultifactorAuthentication/types.ts | 67 ++++++- .../useNativeBiometrics/index.ts | 90 +++++++++ .../useNativeBiometricsSetup.ts | 162 ++++++++++++++++ .../Biometrics/VALUES.ts | 1 + .../Biometrics/helpers.ts | 77 +++++++- 6 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 src/components/MultifactorAuthentication/useNativeBiometrics/index.ts create mode 100644 src/components/MultifactorAuthentication/useNativeBiometrics/useNativeBiometricsSetup.ts diff --git a/src/components/MultifactorAuthentication/helpers.ts b/src/components/MultifactorAuthentication/helpers.ts index 1e925741bd16b..d4f11d4b68ef2 100644 --- a/src/components/MultifactorAuthentication/helpers.ts +++ b/src/components/MultifactorAuthentication/helpers.ts @@ -1,9 +1,169 @@ import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import type {MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {MultifactorAuthenticationFactor, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import {requestAuthenticationChallenge} from '@userActions/MultifactorAuthentication'; import CONST from '@src/CONST'; import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationOutcomeSuffixes, MultifactorAuthenticationScenario} from './config/types'; -import type {AuthTypeName, NoScenarioForStatusReason, OutcomePaths} from './types'; +import type {AuthTypeName, BiometricsStatus, NoScenarioForStatusReason, OutcomePaths} from './types'; + +/** Default failed step state with unsuccessful result and fulfilled request. */ +const failedStep = { + wasRecentStepSuccessful: false, + isRequestFulfilled: true, + requiredFactorForNextStep: undefined, +}; + +/** + * Creates a status updater that merges an error status with a failed step state. + * Used to mark authorization attempts that failed due to errors. + * @param errorStatus - The error status containing reason and type information. + * @returns A function that takes previous status and returns updated status with failed step. + */ +const createAuthorizeErrorStatus = (errorStatus: MultifactorAuthenticationPartialStatus) => (prevStatus: MultifactorAuthenticationStatus) => ({ + ...prevStatus, + ...errorStatus, + step: { + ...failedStep, + }, +}); + +/** + * Checks if the device supports biometric authentication methods. + * Verifies both biometrics and credentials authentication capabilities. + * @returns True if biometrics or credentials authentication is supported on the device. + */ +function doesDeviceSupportBiometrics() { + const {biometrics, credentials} = PublicKeyStore.supportedAuthentication; + return biometrics || credentials; +} + +/** + * Determines if biometric authentication is configured for the current account. + * Checks both local key storage and backend registration status. + * @param accountID - The account ID to check biometric configuration for. + * @returns Object indicating whether any device is registered, if biometry is locally configured, and if local key is in auth. + */ +async function isBiometryConfigured(accountID: number) { + const {value: localPublicKey} = await PublicKeyStore.get(accountID); + const {publicKeys: authPublicKeys = []} = await requestAuthenticationChallenge(); + + const isAnyDeviceRegistered = !!authPublicKeys.length; + const isBiometryRegisteredLocally = !!localPublicKey; + const isLocalPublicKeyInAuth = isBiometryRegisteredLocally && authPublicKeys.includes(localPublicKey); + + return { + isAnyDeviceRegistered, + isBiometryRegisteredLocally, + isLocalPublicKeyInAuth, + }; +} + +/** + * Creates a step object indicating the result of an authentication attempt. + * @param wasSuccessful - Whether the authentication step was successful. + * @param isRequestFulfilled - Whether the request is complete and fulfilled. + * @param requiredFactor - The next required factor for multi-factor authentication, if any. + * @returns A step object with status flags and required factor information. + */ +const createBaseStep = (wasSuccessful: boolean, isRequestFulfilled: boolean, requiredFactor?: MultifactorAuthenticationFactor) => ({ + wasRecentStepSuccessful: wasSuccessful, + isRequestFulfilled, + requiredFactorForNextStep: requiredFactor, +}); + +/** + * Creates a status for unsupported devices that cannot use biometric authentication. + * Clears biometric configuration flags while preserving registration status. + * @param prevStatus - The previous authentication status. + * @returns Updated status indicating the device does not support biometrics. + */ +function createUnsupportedDeviceStatus(prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus { + return { + ...prevStatus, + value: { + isAnyDeviceRegistered: prevStatus.value.isAnyDeviceRegistered, + isLocalPublicKeyInAuth: false, + isBiometryRegisteredLocally: false, + }, + step: createBaseStep(false, true), + }; +} + +/** + * Creates a status indicating that the validate code is missing for biometric registration. + * Sets up the next required factor as VALIDATE_CODE. + * @param prevStatus - The previous authentication status. + * @returns Updated status with missing validate code requirement. + */ +function createValidateCodeMissingStatus(prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus { + return { + ...prevStatus, + step: createBaseStep(false, false, CONST.MULTIFACTOR_AUTHENTICATION.FACTORS.VALIDATE_CODE), + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.VALIDATE_CODE_MISSING, + }; +} + +/** + * Creates a status updater for key-related errors during biometric operations. + * Marks the step as failed and fulfilled with the provided error details. + * @param reason - The error reason code. + * @param type - The authentication type code for the error. + * @returns A function that takes previous status and returns status with error information. + */ +function createKeyErrorStatus({reason, type}: MultifactorAuthenticationPartialStatus) { + return (prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus => ({ + ...prevStatus, + reason, + type, + step: createBaseStep(false, true), + }); +} + +/** + * Creates a status updater for successful biometric registration results. + * Updates step status based on whether the registration was successful. + * @param partialStatus - Partial status containing the registration result information. + * @returns A function that merges registration results with previous status. + */ +function createRegistrationResultStatus(partialStatus: Partial>) { + return (prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus => ({ + ...prevStatus, + ...partialStatus, + step: createBaseStep(!!partialStatus.step?.wasRecentStepSuccessful, true), + }); +} + +/** + * Creates a status updater for when the biometric process is cancelled. + * Sets the request as fulfilled and optionally records if the step was successful before cancellation. + * @param wasRecentStepSuccessful - Optional flag indicating if the step was successful before cancellation. + * @returns A function that updates status to reflect the cancelled state. + */ +function createCancelStatus(wasRecentStepSuccessful?: boolean) { + return (prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus => ({ + ...prevStatus, + step: { + isRequestFulfilled: true, + wasRecentStepSuccessful, + requiredFactorForNextStep: undefined, + }, + }); +} + +/** + * Creates a status updater that refreshes the biometric setup status. + * Updates the value with new biometric configuration and optionally overwrites other status fields. + * @param setupStatus - The new biometric setup status with registration and local configuration details. + * @param overwriteStatus - Optional partial status fields to overwrite in the update. + * @returns A function that merges the new setup status with previous status. + */ +function createRefreshStatusStatus(setupStatus: BiometricsStatus, overwriteStatus?: Partial>) { + return (prevStatus: MultifactorAuthenticationStatus): MultifactorAuthenticationStatus => ({ + ...prevStatus, + ...overwriteStatus, + value: setupStatus, + }); +} /** * Retrieves the authentication type name from a status object by matching the type code. @@ -101,7 +261,14 @@ async function resetKeys(accountID: number) { } const Status = { + createUnsupportedDeviceStatus, + createValidateCodeMissingStatus, + createKeyErrorStatus, + createRegistrationResultStatus, + createCancelStatus, + createBaseStep, + createRefreshStatusStatus, createEmptyStatus, } as const; -export {getAuthTypeName, getOutcomePaths, isValidScenario, shouldClearScenario, resetKeys, Status}; +export {getAuthTypeName, doesDeviceSupportBiometrics, isBiometryConfigured, isValidScenario, shouldClearScenario, getOutcomePaths, createAuthorizeErrorStatus, resetKeys, Status}; diff --git a/src/components/MultifactorAuthentication/types.ts b/src/components/MultifactorAuthentication/types.ts index 775ed5694eddd..b62db1f99ead7 100644 --- a/src/components/MultifactorAuthentication/types.ts +++ b/src/components/MultifactorAuthentication/types.ts @@ -3,9 +3,60 @@ */ import type {ValueOf} from 'type-fest'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; -import type {MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type { + MultifactorAuthenticationPartialStatus, + MultifactorAuthenticationStatus, + MultifactorAuthenticationStep, + MultifactorKeyStoreOptions, +} from '@libs/MultifactorAuthentication/Biometrics/types'; import type CONST from '@src/CONST'; -import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenario} from './config/types'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from './config/types'; + +type MultifactorAuthorization = ( + scenario: T, + params: MultifactorAuthenticationScenarioParams & { + chainedPrivateKeyStatus?: MultifactorAuthenticationPartialStatus; + }, +) => Promise>; + +type RegisterFunction = ( + params: {validateCode?: number} & MultifactorKeyStoreOptions & T, + scenario?: MultifactorAuthenticationScenario, + outcomePaths?: Partial, + softPromptAccepted?: boolean, +) => Promise; + +type Register = RegisterFunction<{chainedWithAuthorization: true}, MultifactorAuthenticationPartialStatus> & + RegisterFunction<{chainedWithAuthorization?: false}, MultifactorAuthenticationStatus> & + RegisterFunction<{chainedWithAuthorization?: boolean}, MultifactorAuthenticationStatus | MultifactorAuthenticationPartialStatus>; + +type BiometricsStatus = { + isAnyDeviceRegistered: boolean; + isBiometryRegisteredLocally: boolean; + isLocalPublicKeyInAuth: boolean; +}; + +type MultifactorAuthenticationInfo = { + deviceSupportBiometrics: boolean; +} & BiometricsStatus; + +type MultifactorAuthenticationStatusMessage = { + description: string; + title: string; + headerTitle: string; +}; + +type UseBiometricsSetup = MultifactorAuthenticationStep & + MultifactorAuthenticationInfo & + MultifactorAuthenticationStatusMessage & { + /** Sets up multifactorial authentication by generating keys and registering with backend */ + register: Register; + + /** Completes current request and updates UI state accordingly */ + cancel: (wasRecentStepSuccessful?: boolean) => MultifactorAuthenticationStatus; + + refresh: () => Promise>; + }; /** * Authentication type name derived from secure store values. @@ -27,4 +78,14 @@ type OutcomePaths = { failureOutcome: AllMultifactorAuthenticationOutcomeType; }; -export type {SetMultifactorAuthenticationStatus, AuthTypeName, UseMultifactorAuthenticationStatus, OutcomePaths, NoScenarioForStatusReason}; +export type { + SetMultifactorAuthenticationStatus, + AuthTypeName, + UseMultifactorAuthenticationStatus, + UseBiometricsSetup, + Register, + MultifactorAuthorization, + BiometricsStatus, + OutcomePaths, + NoScenarioForStatusReason, +}; diff --git a/src/components/MultifactorAuthentication/useNativeBiometrics/index.ts b/src/components/MultifactorAuthentication/useNativeBiometrics/index.ts new file mode 100644 index 0000000000000..8ea15dbc0e49c --- /dev/null +++ b/src/components/MultifactorAuthentication/useNativeBiometrics/index.ts @@ -0,0 +1,90 @@ +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import type {MultifactorAuthenticationScenario} from '@components/MultifactorAuthentication/config/types'; +import {createAuthorizeErrorStatus} from '@components/MultifactorAuthentication/helpers'; +import type {MultifactorAuthorization} from '@components/MultifactorAuthentication/types'; +import useMultifactorAuthenticationStatus from '@components/MultifactorAuthentication/useMultifactorAuthenticationStatus'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import MultifactorAuthenticationChallenge from '@libs/MultifactorAuthentication/Biometrics/Challenge'; +import CONST from '@src/CONST'; +import useNativeBiometricsSetup from './useNativeBiometricsSetup'; + +/** + * Hook that manages native biometric authentication flow. + * Provides authorization capabilities and setup management for biometric-based multifactor authentication. + * Handles challenge generation, signing, and authorization verification. + * @returns Object containing authorization status, authorize function, cancel function, and setup utilities. + */ +function useNativeBiometrics() { + const [status, setStatus] = useMultifactorAuthenticationStatus(false); + const BiometricsSetup = useNativeBiometricsSetup(); + const {accountID} = useCurrentUserPersonalDetails(); + const {translate} = useLocalize(); + + /** + * Authorizes the user using biometric authentication for the specified scenario. + * Generates a challenge, signs it with the user's private key, and sends it to the backend. + * @param scenario - The authentication scenario to authorize for. + * @param params - Parameters including optional chained private key status. + * @returns Authentication status with success/failure indication. + */ + const authorize = async (scenario: T, params: Parameters>[1]): ReturnType> => { + const {chainedPrivateKeyStatus} = params; + + const {nativePromptTitle: nativePromptTitleTPath} = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario]; + + const nativePromptTitle = translate(nativePromptTitleTPath); + + const challenge = new MultifactorAuthenticationChallenge(scenario, params, { + nativePromptTitle, + }); + + const requestStatus = await challenge.request(); + if (!requestStatus.value) { + return setStatus(createAuthorizeErrorStatus(requestStatus), scenario); + } + + const signature = await challenge.sign(accountID, chainedPrivateKeyStatus); + if (!signature.value) { + return setStatus(createAuthorizeErrorStatus(signature), scenario); + } + + const result = await challenge.send(); + + return setStatus( + { + ...result, + step: { + wasRecentStepSuccessful: result.value, + isRequestFulfilled: true, + requiredFactorForNextStep: undefined, + }, + }, + scenario, + ); + }; + + /** + * Cancels the current biometric authorization flow. + * Marks the request as fulfilled and records the completion status. + * @param wasRecentStepSuccessful - Optional flag indicating the result before cancellation. + * @returns Updated authentication status with cancelled state. + */ + const cancel = (wasRecentStepSuccessful?: boolean) => { + return setStatus( + (prevStatus) => ({ + ...prevStatus, + step: { + isRequestFulfilled: true, + requiredFactorForNextStep: undefined, + wasRecentStepSuccessful, + }, + }), + CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.CANCEL, + ); + }; + + return {status, authorize, cancel, setup: BiometricsSetup}; +} + +export default useNativeBiometrics; diff --git a/src/components/MultifactorAuthentication/useNativeBiometrics/useNativeBiometricsSetup.ts b/src/components/MultifactorAuthentication/useNativeBiometrics/useNativeBiometricsSetup.ts new file mode 100644 index 0000000000000..399e6dc582f9f --- /dev/null +++ b/src/components/MultifactorAuthentication/useNativeBiometrics/useNativeBiometricsSetup.ts @@ -0,0 +1,162 @@ +import {useEffect} from 'react'; +import {doesDeviceSupportBiometrics, isBiometryConfigured, resetKeys, Status} from '@components/MultifactorAuthentication/helpers'; +import type {BiometricsStatus, Register, UseBiometricsSetup} from '@components/MultifactorAuthentication/types'; +import useMultifactorAuthenticationStatus from '@components/MultifactorAuthentication/useMultifactorAuthenticationStatus'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {generateKeyPair} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; +import {processRegistration} from '@libs/MultifactorAuthentication/Biometrics/helpers'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; +import type {MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import CONST from '@src/CONST'; + +/** + * Hook that manages biometric setup state and operations. + * Handles device capability detection, key pair generation, and registration with backend. + * @returns Setup object with registration, cancellation, and refresh capabilities. + */ +function useNativeBiometricsSetup(): UseBiometricsSetup { + const [status, setStatus] = useMultifactorAuthenticationStatus({ + isBiometryRegisteredLocally: false, + isAnyDeviceRegistered: false, + isLocalPublicKeyInAuth: false, + }); + const {accountID} = useCurrentUserPersonalDetails(); + + /** + * Cancels the biometric setup process and marks it as fulfilled. + * @param wasRecentStepSuccessful - Optional flag indicating if the step was successful before cancellation. + * @returns Updated status reflecting the cancelled setup. + */ + const cancel = (wasRecentStepSuccessful?: boolean) => + setStatus(Status.createCancelStatus(wasRecentStepSuccessful), CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.CANCEL); + + const deviceSupportBiometrics = doesDeviceSupportBiometrics(); + + /** + * Refreshes the biometric setup status by checking backend and local configuration. + * Queries the current biometric registration state and updates the status accordingly. + * @param overwriteStatus - Optional partial status fields to overwrite in the status update. + * @returns Updated authentication status with fresh biometric configuration. + */ + // useCallback should not be added to the code due to the React Compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + const refreshStatus = async (overwriteStatus?: Partial>) => { + const setupStatus = await isBiometryConfigured(accountID); + + const {isLocalPublicKeyInAuth, isAnyDeviceRegistered, isBiometryRegisteredLocally} = setupStatus; + + return setStatus( + Status.createRefreshStatusStatus( + { + isLocalPublicKeyInAuth, + isAnyDeviceRegistered, + isBiometryRegisteredLocally, + }, + overwriteStatus, + ), + CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.UPDATE, + ); + }; + + useEffect(() => { + refreshStatus(); + }, [refreshStatus]); + + /** + * Registers biometric authentication for the current account. + * Generates a key pair, stores it securely, and registers with the backend. + * Supports both standalone registration and chained registration with authorization. + * @param validateCode - One-time password for validation. + * @param chainedWithAuthorization - Whether this registration is part of an authorization flow. + * @param nativePromptTitle - Title to display in native biometric prompt. + * @param scenario - Optional scenario name for the registration. + * @param notificationPaths - Optional custom notification paths. + * @param softPromptAccepted - Whether the user accepted a soft prompt. + * @returns Registration result with key info or full authentication status. + */ + const register = (async ({validateCode, chainedWithAuthorization, nativePromptTitle}, scenario) => { + const statusReason = scenario ?? CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.REGISTER; + + if (!doesDeviceSupportBiometrics()) { + return setStatus(Status.createUnsupportedDeviceStatus, statusReason); + } + + if (!validateCode) { + return setStatus(Status.createValidateCodeMissingStatus, statusReason); + } + + const {privateKey, publicKey} = generateKeyPair(); + + const privateKeyResult = await PrivateKeyStore.set(accountID, privateKey, {nativePromptTitle}); + const privateKeyExists = privateKeyResult.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.EXPO.KEY_EXISTS; + + if (!privateKeyResult.value) { + if (privateKeyExists && !status.value) { + await PrivateKeyStore.delete(accountID); + } + return setStatus(Status.createKeyErrorStatus(privateKeyResult), statusReason); + } + + const publicKeyResult = await PublicKeyStore.set(accountID, publicKey); + if (!publicKeyResult.value) { + return setStatus(Status.createKeyErrorStatus(publicKeyResult), statusReason); + } + + const { + step: {wasRecentStepSuccessful, isRequestFulfilled}, + reason, + } = await processRegistration({ + publicKey, + validateCode, + }); + + const successMessage = CONST.MULTIFACTOR_AUTHENTICATION.REASON.KEYSTORE.KEY_PAIR_GENERATED; + const isCallSuccessful = wasRecentStepSuccessful && isRequestFulfilled; + + if (!isCallSuccessful) { + await resetKeys(accountID); + } + + const builtStatus = { + reason: isCallSuccessful ? successMessage : reason, + type: privateKeyResult.type, + step: { + wasRecentStepSuccessful: isCallSuccessful, + isRequestFulfilled: true, + requiredFactorForNextStep: undefined, + }, + }; + + const statusResult = setStatus(Status.createRegistrationResultStatus(builtStatus), statusReason); + + await refreshStatus(); + + if (chainedWithAuthorization && isCallSuccessful) { + return { + ...privateKeyResult, + step: { + wasRecentStepSuccessful: true, + isRequestFulfilled: false, + requiredFactorForNextStep: CONST.MULTIFACTOR_AUTHENTICATION.FACTORS.SIGNED_CHALLENGE, + }, + value: privateKey, + }; + } + + return statusResult; + }) as Register; + + return { + ...status.step, + ...status.value, + deviceSupportBiometrics, + headerTitle: status.headerTitle, + description: status.description, + title: status.title, + register, + cancel, + refresh: refreshStatus, + }; +} + +export default useNativeBiometricsSetup; diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 2f8c78409ffa2..69af0baeeb6cf 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -56,6 +56,7 @@ const REASON = { VALIDATE_CODE_MISSING: 'Validate code is missing', }, KEYSTORE: { + KEY_PAIR_GENERATED: 'Key pair generated successfully', KEY_DELETED: 'Key successfully deleted from SecureStore', REGISTRATION_REQUIRED: 'Key is stored locally but not found on server', KEY_MISSING: 'Key is missing', diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index 93b30a9953865..5fd558f5578b1 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -10,13 +10,16 @@ import type { MultifactorAuthenticationScenarioParams, MultifactorAuthenticationScenarioResponseWithSuccess, } from '@components/MultifactorAuthentication/config/types'; +import {registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; import type { AllMultifactorAuthenticationFactors, MultifactorAuthenticationFactor, + MultifactorAuthenticationKeyInfo, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationReason, MultifactorAuthenticationResponseMap, + ResponseDetails, } from './types'; import VALUES, {MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS} from './VALUES'; @@ -163,6 +166,72 @@ const transformMultifactorAuthenticationActionResponse = , + failedFactor?: MultifactorAuthenticationFactor, +): MultifactorAuthenticationPartialStatus => { + const {successful} = status.value; + + return { + ...status, + value: successful, + step: { + requiredFactorForNextStep: failedFactor, + wasRecentStepSuccessful: successful, + isRequestFulfilled: !failedFactor, + }, + reason: status.reason, + }; +}; + +function createKeyInfoObject({publicKey}: {publicKey: string}): MultifactorAuthenticationKeyInfo<'biometric'> { + // rawId should be the base64url-encoded public key itself, serving as a unique credential identifier + const rawId: Base64URLString = publicKey; + const type = VALUES.ED25519_TYPE; + const response: ResponseDetails<'biometric'> = { + biometric: { + publicKey, + algorithm: -8 as const, // ED25519 per COSE spec + }, + }; + + return { + rawId, + type, + response, + }; +} + +async function processMultifactorAuthenticationRegistration( + params: Partial & {publicKey: string}, +): Promise> { + const factorsCheckResult = areMultifactorAuthenticationFactorsSufficient(params, VALUES.FACTOR_COMBINATIONS.REGISTRATION); + + if (factorsCheckResult.value !== true) { + return registerMultifactorAuthenticationPostMethod( + { + ...factorsCheckResult, + value: {httpCode: undefined, successful: false}, + }, + factorsCheckResult.step.requiredFactorForNextStep, + ); + } + + const keyInfo = createKeyInfoObject(params); + + const {httpCode, reason} = await registerAuthenticationKey({ + keyInfo, + validateCode: params.validateCode, + }); + + const successful = String(httpCode).startsWith('2'); + + return registerMultifactorAuthenticationPostMethod({ + value: {successful, httpCode}, + reason, + }); +} + /** * Processes a multifactor authentication scenario by validating factors and calling the scenario action. */ @@ -231,4 +300,10 @@ function isChallengeSigned(challenge: MultifactorAuthenticationChallengeObject | return 'rawId' in challenge; } -export {processMultifactorAuthenticationScenario as processScenario, decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, isChallengeSigned, parseHttpRequest}; +export { + processMultifactorAuthenticationScenario as processScenario, + decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, + isChallengeSigned, + processMultifactorAuthenticationRegistration as processRegistration, + parseHttpRequest, +}; From f179dd1a754136ee60608eabedbd6c850619cf00 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 26 Jan 2026 20:03:53 +0100 Subject: [PATCH 03/95] feat: add the proper MFA context implementation --- src/CONST/index.ts | 4 - src/ROUTES.ts | 7 +- .../MultifactorAuthentication/Context.tsx | 452 ++++++++++++++++ .../TriggerCancelConfirmModal.tsx | 27 +- .../MultifactorAuthentication/config/types.ts | 7 +- .../MultifactorAuthentication/helpers.ts | 231 +++++++- .../MultifactorAuthentication/types.ts | 47 +- src/components/TestToolMenu.tsx | 13 +- .../Biometrics/VALUES.ts | 13 + .../Biometrics/types.ts | 27 +- .../Navigators/RightModalNavigator.tsx | 499 +++++++++--------- src/libs/Navigation/types.ts | 5 +- .../BiometricsTestPage.tsx | 12 +- .../MultifactorAuthentication/OutcomePage.tsx | 46 +- .../MultifactorAuthentication/PromptPage.tsx | 43 +- .../ValidateCodePage.tsx | 10 +- 16 files changed, 1088 insertions(+), 355 deletions(-) create mode 100644 src/components/MultifactorAuthentication/Context.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1d9db8c5138e5..9231e28143e06 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5505,10 +5505,6 @@ const CONST = { DISABLED: 'DISABLED', DISABLE: 'DISABLE', }, - MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE: { - SUCCESS: 'success', - FAILURE: 'failure', - }, MERGE_ACCOUNT_RESULTS: { SUCCESS: 'success', ERR_2FA: 'err_2fa', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a55fa3516ff52..92934176447fc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -5,6 +5,7 @@ */ import type {TupleToUnion, ValueOf} from 'type-fest'; import type {UpperCaseCharacters} from 'type-fest/source/internal'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationPromptType} from './components/MultifactorAuthentication/config/types'; import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types'; import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; @@ -3727,16 +3728,14 @@ const ROUTES = { MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.FACTOR}/magic-code`, MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test', - // The exact outcome & prompt type will be added as a part of Multifactor Authentication config in another PR, - // for now a string is accepted to avoid blocking this PR. MULTIFACTOR_AUTHENTICATION_OUTCOME: { route: 'multifactor-authentication/outcome/:outcomeType', - getRoute: (outcomeType: ValueOf) => `multifactor-authentication/outcome/${outcomeType}` as const, + getRoute: (outcomeType: AllMultifactorAuthenticationOutcomeType) => `multifactor-authentication/outcome/${outcomeType}` as const, }, MULTIFACTOR_AUTHENTICATION_PROMPT: { route: `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.PROMPT}/:promptType`, - getRoute: (promptType: string) => `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.PROMPT}/${promptType}` as const, + getRoute: (promptType: MultifactorAuthenticationPromptType) => `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.PROMPT}/${promptType}` as const, }, MULTIFACTOR_AUTHENTICATION_NOT_FOUND: 'multifactor-authentication/not-found', diff --git a/src/components/MultifactorAuthentication/Context.tsx b/src/components/MultifactorAuthentication/Context.tsx new file mode 100644 index 0000000000000..bded1cf5a656e --- /dev/null +++ b/src/components/MultifactorAuthentication/Context.tsx @@ -0,0 +1,452 @@ +import React, {createContext, useContext, useEffect, useRef} from 'react'; +import type {ReactNode} from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import {requestValidateCodeAction} from '@libs/actions/User'; +import type { + AllMultifactorAuthenticationFactors, + MultifactorAuthenticationPartialStatus, + MultifactorAuthenticationStatus, + MultifactorAuthenticationTrigger, +} from '@libs/MultifactorAuthentication/Biometrics/types'; +import {MultifactorAuthenticationCallbacks} from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import {normalizedConfigs} from '@navigation/linkingConfig/config'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from './config'; +import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from './config/types'; +import { + ContextStatus, + convertResultIntoMultifactorAuthenticationStatus, + doesDeviceSupportBiometrics, + EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS, + getCancelStatus, + getOutcomePath, + getOutcomePaths, + getOutcomeRoute, + isOnProtectedRoute, + isValidScenario, + resetKeys, + shouldAllowBiometrics, +} from './helpers'; +import type {MultifactorAuthenticationScenarioStatus, MultifactorTriggerArgument, OutcomePaths, Register, UseMultifactorAuthentication} from './types'; +import useMultifactorAuthenticationStatus from './useMultifactorAuthenticationStatus'; +import useNativeBiometrics from './useNativeBiometrics'; + +/** + * Context for multifactor authentication state and operations. + * Provides access to authentication info, proceed, update, and trigger functions. + * Default empty context value for use before provider is mounted. + */ +const MultifactorAuthenticationContext = createContext({ + info: { + isLocalPublicKeyInAuth: false, + isAnyDeviceRegistered: false, + isBiometryRegisteredLocally: false, + deviceSupportBiometrics: false, + description: EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS.description, + title: EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS.title, + headerTitle: EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS.headerTitle, + success: undefined, + scenario: undefined, + }, + proceed: () => Promise.resolve(EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS), + update: () => Promise.resolve(EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS), + trigger: () => Promise.resolve(EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS), +}); + +type MultifactorAuthenticationContextProviderProps = { + /** + * The children of the full-screen loader context provider. + */ + children: ReactNode; +}; + +/** + * Provider component that manages multifactor authentication context and state. + * Orchestrates biometric setup, authorization, and navigation flows. + * @param children - React components to provide context to. + */ +function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { + const NativeBiometrics = useNativeBiometrics(); + const [mergedStatus, setMergedStatus] = useMultifactorAuthenticationStatus({ + type: CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.NONE, + }); + const {translate} = useLocalize(); + + useEffect(() => { + Navigation.isNavigationReady().then(() => { + const shouldRedirect = !mergedStatus.scenario && isOnProtectedRoute(); + + if (shouldRedirect) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND, {forceReplace: true}); + } + }); + }, [mergedStatus.scenario]); + + const softStorePromptAccepted = useRef(undefined); + + const storedValidateCode = useRef(undefined); + + const {accountID} = useCurrentUserPersonalDetails(); + + /** + * Navigates to the appropriate screen based on authentication status and result. + * Handles required factors, callbacks, and result-specific routing. + * @param status - The current authentication status. + * @param softPrompt - Whether to navigate to soft prompt for biometric setup. + * @param noEligibleMethods - Whether no authentication methods are available. + */ + const navigate = (status: MultifactorAuthenticationStatus, softPrompt?: boolean, noEligibleMethods?: boolean) => { + const {step, scenario, outcomePaths} = status; + + const {requiredFactorForNextStep, isRequestFulfilled, wasRecentStepSuccessful} = step; + + if (softPrompt) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics')); + return; + } + + if (noEligibleMethods) { + const scenarioLowerCase = scenario?.toLowerCase() as Lowercase | undefined; + const outcome = getOutcomePath(scenarioLowerCase, 'no-eligible-methods'); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(outcome)); + return; + } + + // Handle required factors + if (requiredFactorForNextStep === CONST.MULTIFACTOR_AUTHENTICATION.FACTORS.VALIDATE_CODE && !Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE)) { + requestValidateCodeAction(); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE); + return; + } + + if (!isRequestFulfilled) { + return; + } + + // Execute callbacks when request is fulfilled + for (const callback of Object.values(MultifactorAuthenticationCallbacks.onFulfill)) { + callback(); + } + + const {screen} = scenario ? MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] : {}; + + // Navigate based on step result + const successRoute = getOutcomeRoute(outcomePaths.successOutcome); + const failureRoute = getOutcomeRoute(outcomePaths.failureOutcome); + + const scenarioRoute: Route = screen ? (normalizedConfigs[screen].path as Route) : ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND; + + if (wasRecentStepSuccessful === true && !Navigation.isActiveRoute(successRoute)) { + Navigation.navigate(successRoute); + return; + } + + if (wasRecentStepSuccessful === false && !Navigation.isActiveRoute(failureRoute)) { + Navigation.navigate(failureRoute); + return; + } + + if (wasRecentStepSuccessful === undefined && !Navigation.isActiveRoute(scenarioRoute)) { + Navigation.navigate(scenarioRoute); + } + }; + + /** + * Updates the merged authentication status and navigates based on new state. + * Wrapper around setMergedStatus that also triggers navigation. + * @returns The updated merged status. + */ + const setStatus = ( + ...args: [ + ...Parameters, + rest?: { + softPrompt?: boolean; + noEligibleMethods?: boolean; + }, + ] + ) => { + const [status, scenario, outcomePaths, rest] = args; + const {softPrompt, noEligibleMethods} = rest ?? {}; + const merged = setMergedStatus(status, scenario, outcomePaths); + + navigate(merged, softPrompt, noEligibleMethods); + return merged; + }; + + const allowedMethods = (scenario: T, softPromptAccepted?: boolean) => { + const {allowedAuthenticationMethods} = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario]; + const canUseBiometrics = shouldAllowBiometrics(allowedAuthenticationMethods); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isBiometricsAllowed = canUseBiometrics && (NativeBiometrics.setup.isLocalPublicKeyInAuth || softPromptAccepted); + return { + // passkeys: shouldAllowPasskeys(allowedAuthenticationMethods), + biometrics: isBiometricsAllowed, + }; + }; + + const register = (async ( + params: MultifactorAuthenticationScenarioParams & { + chainedWithAuthorization?: boolean; + nativePromptTitle: string; + }, + scenario: T, + outcomePaths?: Partial, + softPromptAccepted?: boolean, + ) => { + if (!allowedMethods(scenario, softPromptAccepted).biometrics) { + return setStatus(ContextStatus.createBiometricsNotAllowedStatus(params), scenario, outcomePaths); + } + + const {nativePromptTitle} = params; + + const result = await NativeBiometrics.setup.register({...params, nativePromptTitle}, scenario); + const status = convertResultIntoMultifactorAuthenticationStatus(result, scenario, CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.AUTHENTICATION, params); + const mergedResult = setStatus(status, scenario, outcomePaths); + + if (params.chainedWithAuthorization) { + return {...mergedResult, value: result.value} as MultifactorAuthenticationStatus; + } + return mergedResult; + }) as Register; + + const authorize = async ( + scenario: T, + params: MultifactorAuthenticationScenarioParams & { + chainedPrivateKeyStatus?: MultifactorAuthenticationPartialStatus | undefined; + }, + outcomePaths?: Partial, + softPromptAccepted?: boolean, + ) => { + if (!allowedMethods(scenario, softPromptAccepted).biometrics) { + return setStatus(ContextStatus.createBiometricsNotAllowedStatus(params), scenario, outcomePaths); + } + return setStatus( + convertResultIntoMultifactorAuthenticationStatus( + await NativeBiometrics.authorize(scenario, params), + scenario, + CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.AUTHORIZATION, + params, + ), + scenario, + outcomePaths, + ); + }; + + const cancel = ( + args?: { + wasRecentStepSuccessful?: boolean; + } & Partial, + ) => { + const {successOutcome, failureOutcome, wasRecentStepSuccessful} = args ?? {}; + + const {type} = mergedStatus.value; + const {scenario} = mergedStatus; + + softStorePromptAccepted.current = undefined; + storedValidateCode.current = undefined; + + const cancelStatus = getCancelStatus(type, wasRecentStepSuccessful, NativeBiometrics.cancel, NativeBiometrics.setup.cancel); + const scenarioType = type ?? CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.AUTHENTICATION; + + return setStatus( + convertResultIntoMultifactorAuthenticationStatus(cancelStatus, scenario, scenarioType, false), + scenario ?? CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.CANCEL, + { + successOutcome, + failureOutcome, + }, + ); + }; + + const proceed = async ( + scenario: T, + params?: MultifactorAuthenticationScenarioParams & Partial, + ): Promise> => { + const {successOutcome, failureOutcome} = params ?? {}; + const softPromptAccepted = softStorePromptAccepted.current; + + const outcomePaths = { + successOutcome, + failureOutcome, + }; + + if (!doesDeviceSupportBiometrics()) { + return setStatus((prevStatus) => prevStatus, scenario, outcomePaths, { + noEligibleMethods: true, + }); + } + + if (NativeBiometrics.setup.isBiometryRegisteredLocally && !NativeBiometrics.setup.isLocalPublicKeyInAuth) { + await resetKeys(accountID); + await NativeBiometrics.setup.refresh(); + } + + const shouldNavigateToSoftPrompt = !NativeBiometrics.setup.isLocalPublicKeyInAuth && softPromptAccepted === undefined && NativeBiometrics.setup.deviceSupportBiometrics; + + if (shouldNavigateToSoftPrompt) { + const validateCode = params?.validateCode ?? storedValidateCode.current; + storedValidateCode.current = validateCode; + + let stepUpdate = {}; + if (!validateCode) { + stepUpdate = { + isRequestFulfilled: false, + requiredFactorForNextStep: CONST.MULTIFACTOR_AUTHENTICATION.FACTORS.VALIDATE_CODE, + wasRecentStepSuccessful: undefined, + }; + } + + return setStatus( + (prevStatus) => + convertResultIntoMultifactorAuthenticationStatus( + {...prevStatus, step: {...prevStatus.step, ...stepUpdate}}, + scenario, + CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.NONE, + params ?? false, + ), + scenario, + outcomePaths, + { + softPrompt: !!validateCode, + }, + ); + } + + if (!NativeBiometrics.setup.isLocalPublicKeyInAuth) { + /** Multifactor authentication is not configured, let's do that first */ + /** Run the setup method */ + if (!params) { + return setStatus((prevStatus) => ContextStatus.badRequestStatus(prevStatus), scenario, outcomePaths); + } + + const {nativePromptTitle: nativePromptTitleTPath} = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario]; + + const nativePromptTitle = translate(nativePromptTitleTPath); + + const requestStatus = await register({...params, chainedWithAuthorization: true, nativePromptTitle}, scenario, outcomePaths, softPromptAccepted); + + if (!requestStatus.step.wasRecentStepSuccessful) { + return setStatus( + convertResultIntoMultifactorAuthenticationStatus(requestStatus, scenario, CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.AUTHORIZATION, params), + scenario, + outcomePaths, + ); + } + + return authorize(scenario, {...params, chainedPrivateKeyStatus: requestStatus}, outcomePaths, softPromptAccepted); + } + + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario]; + + // If the scenario is pure, the payload is not needed hence for the proceed call the params can be empty + if (!params && !('pure' in config)) { + return setStatus((prevStatus) => ContextStatus.badRequestStatus(prevStatus), scenario, outcomePaths); + } + + /** Multifactor authentication is configured already, let's do the challenge logic */ + const result = await authorize(scenario, {...(params as MultifactorAuthenticationScenarioParams), chainedPrivateKeyStatus: undefined}, outcomePaths, softPromptAccepted); + + if (result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.KEYSTORE.REGISTRATION_REQUIRED) { + await resetKeys(accountID); + } + + return result; + }; + + const update = async ( + params: Partial & { + softPromptDecision?: boolean; + }, + ) => { + const {payload} = mergedStatus.value; + const {isRequestFulfilled} = mergedStatus.step; + const {scenario} = mergedStatus; + + if (!scenario || isRequestFulfilled) { + return setStatus(ContextStatus.badRequestStatus(mergedStatus), scenario ?? CONST.MULTIFACTOR_AUTHENTICATION.NO_SCENARIO_FOR_STATUS_REASON.UPDATE); + } + + const validateCode = params.validateCode ?? storedValidateCode.current; + softStorePromptAccepted.current = params.softPromptDecision ?? softStorePromptAccepted.current; + + return proceed(scenario, { + ...payload, + ...params, + validateCode, + }); + }; + + const trigger = async (triggerType: T, argument?: MultifactorTriggerArgument) => { + const isScenarioArgument = argument && isValidScenario(argument); + const scenarioOutcomePaths = isScenarioArgument ? getOutcomePaths(argument) : {}; + + switch (triggerType) { + case CONST.MULTIFACTOR_AUTHENTICATION.TRIGGER.FULFILL: + return cancel({wasRecentStepSuccessful: true, ...(isScenarioArgument ? scenarioOutcomePaths : {successOutcome: argument ?? undefined})}); + case CONST.MULTIFACTOR_AUTHENTICATION.TRIGGER.CANCEL: + return cancel(isScenarioArgument ? scenarioOutcomePaths : {successOutcome: argument ?? undefined}); + case CONST.MULTIFACTOR_AUTHENTICATION.TRIGGER.FAILURE: + return cancel({wasRecentStepSuccessful: false, ...(isScenarioArgument ? scenarioOutcomePaths : {failureOutcome: argument ?? undefined})}); + default: + return mergedStatus; + } + }; + + const {isLocalPublicKeyInAuth, isBiometryRegisteredLocally, isAnyDeviceRegistered, deviceSupportBiometrics} = NativeBiometrics.setup; + const {scenario} = mergedStatus; + + const {wasRecentStepSuccessful, requiredFactorForNextStep, isRequestFulfilled} = mergedStatus.step; + + let success; + + if (!!requiredFactorForNextStep || !isRequestFulfilled) { + success = undefined; + } else { + success = wasRecentStepSuccessful; + } + + const {title, headerTitle, description} = mergedStatus; + + const info = { + title, + headerTitle, + description, + success, + deviceSupportBiometrics, + isLocalPublicKeyInAuth, + isBiometryRegisteredLocally, + isAnyDeviceRegistered, + scenario, + }; + + // The React compiler prohibits the use of useCallback and useMemo in new components + // eslint-disable-next-line react/jsx-no-constructed-context-values + const MultifactorAuthenticationData = {info, proceed, update, trigger}; + + return {children}; +} + +/** + * Hook to access multifactor authentication context. + * Must be used within a MultifactorAuthenticationContextProvider. + * @returns The multifactor authentication context with info, proceed, update, and trigger functions. + * @throws Error if used outside of the provider. + */ +function useMultifactorAuthenticationContext(): UseMultifactorAuthentication { + const context = useContext(MultifactorAuthenticationContext); + + if (!context) { + throw new Error('useMultifactorAuthenticationContext must be used within a MultifactorAuthenticationContextProvider'); + } + + return context; +} + +MultifactorAuthenticationContextProvider.displayName = 'MultifactorAuthenticationContextProvider'; + +export default MultifactorAuthenticationContextProvider; +export {MultifactorAuthenticationContext, useMultifactorAuthenticationContext}; diff --git a/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx index 819b385074972..7958a9f94c266 100644 --- a/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx +++ b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx @@ -1,40 +1,31 @@ import React from 'react'; import ConfirmModal from '@components/ConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import type {TranslationPaths} from '@src/languages/types'; +import type {MultifactorAuthenticationScenario} from './config/types'; +import {getMultifactorCancelConfirmModalConfig} from './helpers'; type MultifactorAuthenticationTriggerCancelConfirmModalProps = { isVisible: boolean; onConfirm: () => void; onCancel: () => void; + scenario?: MultifactorAuthenticationScenario; }; -// TODO: this config will be part of the scenario configuration, the current implementation is for testing purposes (https://github.com/Expensify/App/issues/79373) -const mockedConfig = { - title: 'common.areYouSure', - description: 'multifactorAuthentication.biometricsTest.areYouSureToReject', - confirmButtonText: 'multifactorAuthentication.biometricsTest.rejectAuthentication', - cancelButtonText: 'common.cancel', -} as const satisfies Record; - -function MultifactorAuthenticationTriggerCancelConfirmModal({isVisible, onConfirm, onCancel}: MultifactorAuthenticationTriggerCancelConfirmModalProps) { +function MultifactorAuthenticationTriggerCancelConfirmModal({isVisible, onConfirm, onCancel, scenario}: MultifactorAuthenticationTriggerCancelConfirmModalProps) { const {translate} = useLocalize(); - const title = translate(mockedConfig.title); - const description = translate(mockedConfig.description); - const confirmButtonText = translate(mockedConfig.confirmButtonText); - const cancelButtonText = translate(mockedConfig.cancelButtonText); + const {title, description, cancelButtonText, confirmButtonText} = getMultifactorCancelConfirmModalConfig(scenario); return ( ); diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 3dbf737427a95..b8a2a30c4a154 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -14,7 +14,7 @@ import type { 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'; +import type {MULTIFACTOR_AUTHENTICATION_PROMPT_UI, MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG, MultifactorAuthenticationScenarioPayload} from './index'; /** * Configuration for cancel confirmation modal in multifactor authentication. @@ -186,7 +186,7 @@ type MultifactorAuthenticationScenarioCustomConfig; +type MultifactorAuthenticationDefaultUIConfig = Pick, 'nativePromptTitle' | 'MODALS' | 'OUTCOMES'>; /** * Record mapping all scenarios to their configurations. @@ -220,6 +220,8 @@ type MultifactorAuthenticationScenarioResponseWithSuccess = { successful: boolean; }; +type MultifactorAuthenticationPromptType = keyof typeof MULTIFACTOR_AUTHENTICATION_PROMPT_UI; + /** * Parameters required for biometrics registration scenario. */ @@ -261,6 +263,7 @@ export type { MultifactorAuthenticationScenario, MultifactorAuthenticationOutcomeOptions, MultifactorAuthenticationScenarioParams, + MultifactorAuthenticationPromptType, MultifactorAuthenticationOutcomeType, AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenarioConfig, diff --git a/src/components/MultifactorAuthentication/helpers.ts b/src/components/MultifactorAuthentication/helpers.ts index d4f11d4b68ef2..c05ca71d3fe96 100644 --- a/src/components/MultifactorAuthentication/helpers.ts +++ b/src/components/MultifactorAuthentication/helpers.ts @@ -1,10 +1,21 @@ +import type {ValueOf} from 'type-fest'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; import type {MultifactorAuthenticationFactor, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus} from '@libs/MultifactorAuthentication/Biometrics/types'; +import Navigation from '@navigation/Navigation'; import {requestAuthenticationChallenge} from '@userActions/MultifactorAuthentication'; import CONST from '@src/CONST'; -import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationOutcomeSuffixes, MultifactorAuthenticationScenario} from './config/types'; -import type {AuthTypeName, BiometricsStatus, NoScenarioForStatusReason, OutcomePaths} from './types'; +import ROUTES, {MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import {MULTIFACTOR_AUTHENTICATION_DEFAULT_UI, MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from './config'; +import type { + AllMultifactorAuthenticationOutcomeType, + MultifactorAuthenticationOutcomeSuffixes, + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioAdditionalParams, + MultifactorAuthenticationScenarioParams, +} from './config/types'; +import type {AuthTypeName, BiometricsStatus, MultifactorAuthenticationScenarioStatus, MultifactorAuthenticationStatusKeyType, NoScenarioForStatusReason, OutcomePaths} from './types'; /** Default failed step state with unsuccessful result and fulfilled request. */ const failedStep = { @@ -13,6 +24,22 @@ const failedStep = { requiredFactorForNextStep: undefined, }; +const EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS: MultifactorAuthenticationStatus = { + value: {}, + outcomePaths: { + successOutcome: 'biometrics-test-success', + failureOutcome: 'biometrics-test-failure', + }, + scenario: undefined, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ACTION_MADE_YET, + headerTitle: 'Biometrics authentication', + title: 'You couldn’t be authenticated', + description: 'Your authentication attempt was unsuccessful.', + step: { + ...failedStep, + }, +}; + /** * Creates a status updater that merges an error status with a failed step state. * Used to mark authorization attempts that failed due to errors. @@ -174,6 +201,61 @@ function createRefreshStatusStatus(setupStatus: BiometricsStatus, overwriteStatu const getAuthTypeName = ({type}: MultifactorAuthenticationPartialStatus): AuthTypeName | undefined => Object.values(SECURE_STORE_VALUES.AUTH_TYPE).find(({CODE}) => CODE === type)?.NAME; +const additionalParametersToExclude = ['chainedWithAuthorization', 'chainedPrivateKeyStatus'] as const; + +/** + * Extracts additional scenario parameters by removing factor-related and special parameters. + * Used to isolate custom parameters passed to a scenario from authentication factors. + * @param params - The scenario parameters including factors and additional custom parameters. + * @returns Object containing only the additional custom parameters for the scenario. + */ +const extractAdditionalParameters = ( + params: MultifactorAuthenticationScenarioParams & Record, +): MultifactorAuthenticationScenarioAdditionalParams => { + const factorParams = Object.values(CONST.MULTIFACTOR_AUTHENTICATION.FACTORS_REQUIREMENTS).map(({parameter}) => parameter); + const newParams = {...params}; + for (const param of factorParams) { + if (param in newParams) { + delete newParams[param]; + } + } + for (const additionalParameter of additionalParametersToExclude) { + if (additionalParameter in newParams) { + delete newParams[additionalParameter]; + } + } + return newParams; +}; + +/** + * Determines if a biometric authentication method is allowed for a given authentication type. + * @param allowedAuthenticationMethods - The list of authentication types configuration to check. + * @returns True if biometrics authentication is allowed, false otherwise. + */ +const shouldAllowBiometrics = (allowedAuthenticationMethods: Array>) => + allowedAuthenticationMethods.includes(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); + +/** + * Creates a status indicating that biometric authentication is not allowed. + * Extracts additional scenario parameters for later use. + * @param params - Scenario parameters to extract custom payload from. + * @returns Partial status with biometrics not allowed reason and extracted payload. + */ +// eslint-disable-next-line rulesdir/no-negated-variables +const createBiometricsNotAllowedStatus = ( + params: MultifactorAuthenticationScenarioParams & Record, +): MultifactorAuthenticationPartialStatus => { + return { + step: { + ...failedStep, + }, + value: { + payload: extractAdditionalParameters(params), + }, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.BIOMETRICS_NOT_ALLOWED, + }; +}; + /** * Creates an empty/initial authentication status object with provided UI text and default values. * Used as the initial state for multifactor authentication flows. @@ -199,6 +281,19 @@ const createEmptyStatus = (initialValue: T, {headerTitle, title, description} }, }); +/** + * Checks if a given route is a protected multifactor authentication route. + * @param route - The route path to check. + * @returns True if the route is protected, false otherwise. + */ +const isProtectedRoute = (route: string) => Object.values(MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES).some((protectedRoute) => route.startsWith(`/${protectedRoute}`)); + +/** + * Determines if the currently active route is a protected multifactor authentication route. + * @returns True if currently on a protected route, false otherwise. + */ +const isOnProtectedRoute = () => isProtectedRoute(Navigation.getActiveRouteWithoutParams()); + /** * Constructs an outcome type string from scenario prefix and outcome suffix. * Combines the lowercase scenario name with the kebab-cased suffix (e.g., 'biometrics-test-success'). @@ -213,6 +308,111 @@ const getOutcomePath = ( return `${scenarioPrefix ?? 'biometrics-test'}-${suffix}` as AllMultifactorAuthenticationOutcomeType; }; +/** + * Converts an outcome path to a navigation route. + * Returns an outcome route if a path exists, otherwise returns the not found route. + * @param path - The outcome path (e.g., 'biometrics-test-success'). + * @returns The navigation route for the outcome or not found page. + */ +const getOutcomeRoute = (path: AllMultifactorAuthenticationOutcomeType | undefined): Route => { + if (!path) { + return ROUTES.MULTIFACTOR_AUTHENTICATION_NOT_FOUND; + } + return ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(path); +}; + +/** + * Creates a cancel status based on the current authentication scenario type. + * Delegates to either authorization or setup cancel depending on the type. + * @param type - The current multifactor authentication scenario type. + * @param wasRecentStepSuccessful - Whether the recent step was successful before cancellation. + * @param nativeBiometricsCancel - Cancel function for native biometrics authorization. + * @param setupCancel - Cancel function for biometric setup. + * @returns The appropriate cancel status for the scenario type. + */ +const getCancelStatus = ( + type: MultifactorAuthenticationScenarioStatus['type'], + wasRecentStepSuccessful: boolean | undefined, + nativeBiometricsCancel: (wasRecentStepSuccessful?: boolean) => MultifactorAuthenticationStatus, + setupCancel: (wasRecentStepSuccessful?: boolean) => MultifactorAuthenticationStatus, +): MultifactorAuthenticationStatus => { + if (type === CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO_TYPE.AUTHORIZATION) { + return nativeBiometricsCancel(wasRecentStepSuccessful); + } + return setupCancel(wasRecentStepSuccessful); +}; + +/** + * Converts any authentication result into a standardized status object. + * Overload signatures for type-safe full and partial status handling. + */ +function convertResultIntoMultifactorAuthenticationStatus( + status: MultifactorAuthenticationStatus, + scenario: T | undefined, + type: MultifactorAuthenticationStatusKeyType, + params: MultifactorAuthenticationScenarioParams | false, +): MultifactorAuthenticationStatus; +function convertResultIntoMultifactorAuthenticationStatus( + status: MultifactorAuthenticationPartialStatus, + scenario: T | undefined, + type: MultifactorAuthenticationStatusKeyType, + params: MultifactorAuthenticationScenarioParams | false, +): MultifactorAuthenticationPartialStatus; +/** + * Converts any authentication result status into a standardized scenario status. + * Extracts scenario parameters and attaches them as payload to the new status. + * @param status - The source status (can be full or partial). + * @param scenario - The authentication scenario (optional). + * @param type - The scenario type (authorization, authentication, etc.). + * @param params - Scenario parameters to extract into payload, or false if none. + * @returns The converted status with scenario-specific value structure. + */ +function convertResultIntoMultifactorAuthenticationStatus( + status: MultifactorAuthenticationStatus | MultifactorAuthenticationPartialStatus, + scenario: T | undefined, + type: MultifactorAuthenticationStatusKeyType, + params: MultifactorAuthenticationScenarioParams | false, +): MultifactorAuthenticationPartialStatus | MultifactorAuthenticationStatus { + return { + ...status, + value: { + payload: params ? extractAdditionalParameters(params) : undefined, + type, + }, + }; +} + +/** + * Retrieves the cancel confirmation modal configuration for a given scenario. + * Falls back to default UI configuration if scenario-specific config doesn't exist. + * @param scenario - The authentication scenario (optional). + * @returns The modal configuration for cancel confirmation. + */ +const getMultifactorCancelConfirmModalConfig = (scenario?: MultifactorAuthenticationScenario) => { + return (scenario ? MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] : MULTIFACTOR_AUTHENTICATION_DEFAULT_UI).MODALS.cancelConfirmation; +}; + +/** + * Creates a status indicating a bad request error occurred. + * Used when required parameters are missing or invalid. + * @param currentStatus - The current authentication status to update. + * @returns Updated status with bad request reason and failed step. + */ +const badRequestStatus = ( + currentStatus: MultifactorAuthenticationStatus, +): MultifactorAuthenticationStatus => { + return { + ...currentStatus, + value: { + ...currentStatus.value, + }, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.BAD_REQUEST, + step: { + ...failedStep, + }, + }; +}; + /** * Type guard function to validate whether a string is a known multifactor authentication scenario. * Checks against the available scenarios defined in CONST. @@ -271,4 +471,29 @@ const Status = { createEmptyStatus, } as const; -export {getAuthTypeName, doesDeviceSupportBiometrics, isBiometryConfigured, isValidScenario, shouldClearScenario, getOutcomePaths, createAuthorizeErrorStatus, resetKeys, Status}; +const ContextStatus = { + createBiometricsNotAllowedStatus, + badRequestStatus, +} as const; + +export { + getAuthTypeName, + doesDeviceSupportBiometrics, + isBiometryConfigured, + isValidScenario, + shouldClearScenario, + getOutcomePaths, + createAuthorizeErrorStatus, + shouldAllowBiometrics, + convertResultIntoMultifactorAuthenticationStatus, + getOutcomeRoute, + getOutcomePath, + resetKeys, + isOnProtectedRoute, + getMultifactorCancelConfirmModalConfig, + isProtectedRoute, + getCancelStatus, + EMPTY_MULTIFACTOR_AUTHENTICATION_STATUS, + ContextStatus, + Status, +}; diff --git a/src/components/MultifactorAuthentication/types.ts b/src/components/MultifactorAuthentication/types.ts index b62db1f99ead7..6b0369741eb47 100644 --- a/src/components/MultifactorAuthentication/types.ts +++ b/src/components/MultifactorAuthentication/types.ts @@ -4,13 +4,21 @@ import type {ValueOf} from 'type-fest'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; import type { + AllMultifactorAuthenticationFactors, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationStatus, MultifactorAuthenticationStep, + MultifactorAuthenticationTrigger, + MultifactorAuthenticationTriggerArgument, MultifactorKeyStoreOptions, } from '@libs/MultifactorAuthentication/Biometrics/types'; import type CONST from '@src/CONST'; -import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from './config/types'; +import type { + AllMultifactorAuthenticationOutcomeType, + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioAdditionalParams, + MultifactorAuthenticationScenarioParams, +} from './config/types'; type MultifactorAuthorization = ( scenario: T, @@ -58,6 +66,38 @@ type UseBiometricsSetup = MultifactorAuthenticationStep & refresh: () => Promise>; }; +type TriggerWithArgument = keyof MultifactorAuthenticationTriggerArgument; +type MultifactorTriggerArgument = T extends TriggerWithArgument ? MultifactorAuthenticationTriggerArgument[T] : void; + +type UseMultifactorAuthentication = { + info: MultifactorAuthenticationInfo & + MultifactorAuthenticationStatusMessage & { + success: undefined | boolean; + headerTitle: string; + scenario: MultifactorAuthenticationScenario | undefined; + }; + proceed: ( + scenario: T, + params?: MultifactorAuthenticationScenarioParams & Partial, + ) => Promise>; + update: ( + params: Partial & { + softPromptDecision?: boolean; + }, + ) => Promise>; + trigger: ( + triggerType: T, + argument?: MultifactorTriggerArgument, + ) => Promise>; +}; + +type MultifactorAuthenticationScenarioStatus = { + payload?: MultifactorAuthenticationScenarioAdditionalParams; + type?: MultifactorAuthenticationStatusKeyType; +}; + +type MultifactorAuthenticationStatusKeyType = ValueOf; + /** * Authentication type name derived from secure store values. */ @@ -80,12 +120,17 @@ type OutcomePaths = { export type { SetMultifactorAuthenticationStatus, + MultifactorAuthenticationStatusKeyType, AuthTypeName, UseMultifactorAuthenticationStatus, UseBiometricsSetup, Register, MultifactorAuthorization, + UseMultifactorAuthentication, + MultifactorAuthenticationScenarioStatus, + MultifactorAuthenticationStatusMessage, BiometricsStatus, OutcomePaths, + MultifactorTriggerArgument, NoScenarioForStatusReason, }; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index eadc8f7d0989d..d6b6723ac2cc4 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useLocalize from '@hooks/useLocalize'; @@ -8,6 +8,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {isUsingStagingApi} from '@libs/ApiUtils'; +import MultifactorAuthenticationObserver from '@libs/MultifactorAuthentication/Biometrics/Observer'; import Navigation from '@libs/Navigation/Navigation'; import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network'; import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session'; @@ -16,15 +17,13 @@ import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import Button from './Button'; +import useNativeBiometrics from './MultifactorAuthentication/useNativeBiometrics'; import SoftKillTestToolRow from './SoftKillTestToolRow'; import Switch from './Switch'; 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 +32,9 @@ function TestToolMenu() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {clearLHNCache} = useSidebarOrderedReports(); + const {setup} = useNativeBiometrics(); + + useEffect(() => MultifactorAuthenticationObserver.registerCallback('TestToolMenu', setup.refresh), [setup.refresh]); const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); @@ -50,8 +52,7 @@ function TestToolMenu() { // Check if the user is authenticated to show options that require authentication const isAuthenticated = useIsAuthenticated(); - // Temporary hardcoded false, expected behavior: status fetched from the MultifactorAuthenticationContext - const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: TEMP_BIOMETRICS_REGISTERED_STATUS}); + const biometricsTitle = translate('multifactorAuthentication.biometricsTest.troubleshootBiometricsStatus', {registered: setup.isLocalPublicKeyInAuth}); return ( <> diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 69af0baeeb6cf..b1b5596959829 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -51,9 +51,12 @@ const REASON = { SIGNATURE_INVALID: 'Signature is invalid', SIGNATURE_MISSING: 'Signature is missing', NO_ACTION_MADE_YET: 'No action has been made yet', + BIOMETRICS_NOT_ALLOWED: 'The biometrics actions are not allowed for this scenario', FACTORS_ERROR: 'Authentication factors error', FACTORS_VERIFIED: 'Authentication factors verified', VALIDATE_CODE_MISSING: 'Validate code is missing', + NO_ELIGIBLE_METHODS: 'No eligible methods available', + BAD_REQUEST: 'Bad request', }, KEYSTORE: { KEY_PAIR_GENERATED: 'Key pair generated successfully', @@ -194,6 +197,16 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { PUBLIC_KEY: '3DS_SCA_KEY_PUBLIC', PRIVATE_KEY: '3DS_SCA_KEY_PRIVATE', }, + SCENARIO_TYPE: { + NONE: 'None', + AUTHORIZATION: 'Authorization', + AUTHENTICATION: 'Authentication', + }, + TRIGGER: { + CANCEL: 'CANCEL', + FULFILL: 'FULFILL', + FAILURE: 'FAILURE', + }, EXPO_ERRORS, NO_SCENARIO_FOR_STATUS_REASON: { REGISTER: 'REGISTER', diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index 14e60035972da..4db133c20d779 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -2,7 +2,7 @@ * Type definitions for multifactor authentication biometrics operations. */ import type {EmptyObject, Simplify, ValueOf} from 'type-fest'; -import type {MultifactorAuthenticationScenario} from '@components/MultifactorAuthentication/config/types'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationScenario} from '@components/MultifactorAuthentication/config/types'; import type {OutcomePaths} from '@components/MultifactorAuthentication/types'; import type {SignedChallenge} from './ED25519/types'; import type {SECURE_STORE_VALUES} from './SecureStore'; @@ -16,13 +16,6 @@ type BasicMultifactorAuthenticationRequirementTypes = { [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]>; -}>; - type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** @@ -34,6 +27,20 @@ type MultifactorAuthenticationPartialStatusConditional = OmitStep exte } : EmptyObject; +type MultifactorAuthenticationTrigger = ValueOf; + +type MultifactorAuthenticationTriggerArgument = { + [VALUES.TRIGGER.FAILURE]: AllMultifactorAuthenticationOutcomeType | MultifactorAuthenticationScenario; + [VALUES.TRIGGER.FULFILL]: AllMultifactorAuthenticationOutcomeType | MultifactorAuthenticationScenario; +}; + +/** + * 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]>; +}>; + /** * Represents a partial status result of multifactor authentication operations. * Contains the operation result value, reason message, and optionally the authentication step state. @@ -169,10 +176,12 @@ export type { AllMultifactorAuthenticationFactors, MultifactorAuthenticationStatus, MultifactorAuthenticationPartialStatus, + MultifactorAuthenticationTrigger, + MultifactorAuthenticationKeyInfo, MultifactorAuthenticationActionParams, + MultifactorAuthenticationTriggerArgument, MultifactorKeyStoreOptions, MultifactorAuthenticationReason, - MultifactorAuthenticationKeyInfo, MultifactorAuthenticationMethodCode, ResponseDetails, ChallengeType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 0b1bf503ee637..1fbc033fe6a26 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react' // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; +import MultifactorAuthenticationContextProvider from '@components/MultifactorAuthentication/Context'; import { animatedWideRHPWidth, expandedRHPProgress, @@ -170,254 +171,256 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { return ( - - {!shouldUseNarrowLayout && ( - - )} - {/* This one is to limit the outer Animated.View and allow the background to be pressable */} - {/* Without it, the transparent half of the narrow format RHP card would cover the pressable part of the overlay */} - - - - { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => clearTwoFactorAuthData(true)); - }, - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - const options = modalStackScreenOptions(props); - return {...options, animation: animationEnabledOnSearchReport ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; - }} - /> - { - const options = modalStackScreenOptions(props); - return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; - }} - /> - { - const options = modalStackScreenOptions(props); - return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; - }} - /> - - - - - - {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */} - {/* Clicking on these overlays redirects you to the RHP screen below them. */} - {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */} - {!shouldUseNarrowLayout && } - {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( - - )} - + + + {!shouldUseNarrowLayout && ( + + )} + {/* This one is to limit the outer Animated.View and allow the background to be pressable */} + {/* Without it, the transparent half of the narrow format RHP card would cover the pressable part of the overlay */} + + + + { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => clearTwoFactorAuthData(true)); + }, + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + const options = modalStackScreenOptions(props); + return {...options, animation: animationEnabledOnSearchReport ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; + }} + /> + { + const options = modalStackScreenOptions(props); + return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; + }} + /> + { + const options = modalStackScreenOptions(props); + return {...options, animation: isSmallScreenWidth ? Animations.SLIDE_FROM_RIGHT : Animations.NONE}; + }} + /> + + + + + + {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */} + {/* Clicking on these overlays redirects you to the RHP screen below them. */} + {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */} + {!shouldUseNarrowLayout && } + {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( + + )} + + ); } diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fb0325e02755d..600a9d047f8e2 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -12,6 +12,7 @@ import type { } from '@react-navigation/native'; import type {TupleToUnion, ValueOf} from 'type-fest'; import type {UpperCaseCharacters} from 'type-fest/source/internal'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; import type {SearchQueryString} from '@components/Search/types'; import type {ReplacementReason} from '@libs/actions/Card'; import type {IOURequestType} from '@libs/actions/IOU'; @@ -3002,10 +3003,10 @@ type MultifactorAuthenticationParamList = { [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: undefined; [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: undefined; [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME]: { - outcomeType: ValueOf; + outcomeType: AllMultifactorAuthenticationOutcomeType; }; [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: { - promptType: string; + promptType: MultifactorAuthenticationPromptType; }; }; diff --git a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx index 048703a98ab6e..c763948a5eb53 100644 --- a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx +++ b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx @@ -1,18 +1,22 @@ import React, {useEffect} from 'react'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {useMultifactorAuthenticationContext} from '@components/MultifactorAuthentication/Context'; import ScreenWrapper from '@components/ScreenWrapper'; -import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; +import CONST from '@src/CONST'; const LOADING_DELAY_MS = 400; function MultifactorAuthenticationBiometricsTestPage() { + const {proceed} = useMultifactorAuthenticationContext(); + useEffect(() => { // Show a short loading state so the RHP transition feels smooth, then move to the magic code flow - const timeoutId = setTimeout(() => Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE), LOADING_DELAY_MS); + const timeoutId = setTimeout(() => { + proceed(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST); + }, LOADING_DELAY_MS); return () => clearTimeout(timeoutId); - }, []); + }, [proceed]); return ( diff --git a/src/pages/MultifactorAuthentication/OutcomePage.tsx b/src/pages/MultifactorAuthentication/OutcomePage.tsx index 0d505af02dea4..8dbe71f8f5c65 100644 --- a/src/pages/MultifactorAuthentication/OutcomePage.tsx +++ b/src/pages/MultifactorAuthentication/OutcomePage.tsx @@ -4,6 +4,8 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import {MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP} from '@components/MultifactorAuthentication/config'; +import {useMultifactorAuthenticationContext} from '@components/MultifactorAuthentication/Context'; import ScreenWrapper from '@components/ScreenWrapper'; import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -11,25 +13,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MultifactorAuthenticationParamList} from '@libs/Navigation/types'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import type SCREENS from '@src/SCREENS'; -// TODO: this config will be part of the scenario configuration, the current implementation is for testing purposes (https://github.com/Expensify/App/issues/79373) -const mockedConfigSuccess = { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', - title: 'multifactorAuthentication.biometricsTest.authenticationSuccessful', - description: 'multifactorAuthentication.biometricsTest.successfullyAuthenticatedUsing', -} as const satisfies Record; - -// TODO: this config will be part of the scenario configuration, the current implementation is for testing purposes (https://github.com/Expensify/App/issues/79373) -const mockedConfigFailure = { - headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', - title: 'multifactorAuthentication.oops', - description: 'multifactorAuthentication.biometricsTest.yourAttemptWasUnsuccessful', -} as const satisfies Record; - type MultifactorAuthenticationOutcomePageProps = PlatformStackScreenProps; function MultifactorAuthenticationOutcomePage({route}: MultifactorAuthenticationOutcomePageProps) { @@ -39,21 +25,20 @@ function MultifactorAuthenticationOutcomePage({route}: MultifactorAuthentication Navigation.dismissModal(); }; - const isSuccessOutcome = route.params.outcomeType === CONST.MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE.SUCCESS; + const {info} = useMultifactorAuthenticationContext(); + + const data = MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP[route.params.outcomeType]; + + const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(data?.illustration ?? 'HumptyDumpty')); - let headerTitle = translate(mockedConfigFailure.headerTitle); - let title = translate(mockedConfigFailure.title); - let description = translate(mockedConfigFailure.description); + const {headerTitle, title, description} = info; - if (isSuccessOutcome) { - headerTitle = translate(mockedConfigSuccess.headerTitle); - title = translate(mockedConfigSuccess.title); - // TODO: Replace hardcoded 'FaceID' with the actual authentication type (e.g., 'FaceID', 'TouchID', 'Fingerprint') - // once the MFA context provides the auth method used. This will require adding authType to route params and to CONST. - description = translate(mockedConfigSuccess.description, {authType: 'FaceID'}); + if (!data) { + return ; } - const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(isSuccessOutcome ? 'OpenPadlock' : 'HumptyDumpty')); + const {customDescription: CustomDescription} = data; + const CustomSubtitle = CustomDescription ? : undefined; return ( @@ -66,11 +51,12 @@ function MultifactorAuthenticationOutcomePage({route}: MultifactorAuthentication ; +type MultifactorAuthenticationPromptPageProps = PlatformStackScreenProps; -function MultifactorAuthenticationPromptPage() { +function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationPromptPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {update, trigger, info} = useMultifactorAuthenticationContext(); + + const contentData = MULTIFACTOR_AUTHENTICATION_PROMPT_UI[route.params.promptType]; const [isConfirmModalVisible, setConfirmModalVisibility] = useState(false); const onConfirm = () => { - // Temporary navigation, expected behavior: let the MultifactorAuthentication know that the soft prompt was accepted - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(CONST.MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE.SUCCESS)); + update({softPromptDecision: true}); }; const showConfirmModal = () => { @@ -39,13 +40,10 @@ function MultifactorAuthenticationPromptPage() { }; const cancelFlow = () => { - if (!isConfirmModalVisible) { - return; + if (isConfirmModalVisible) { + hideConfirmModal(); } - - hideConfirmModal(); - // Temporary navigation, expected behavior: trigger the failure from the MultifactorAuthenticationContext - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(CONST.MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE.FAILURE)); + trigger(CONST.MULTIFACTOR_AUTHENTICATION.TRIGGER.FAILURE); }; const focusTrapConfirmModal = () => { @@ -53,6 +51,10 @@ function MultifactorAuthenticationPromptPage() { return false; }; + if (!contentData) { + return ; + } + return (