diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 148807ae21499..e694993e1269d 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -301,7 +301,7 @@ jobs: - name: Rock Remote Build - Android id: rock-remote-build-android - uses: callstackincubator/android@0bbc1b7c2e1a8be1ecb4d6c744c211869823fd65 + uses: callstackincubator/android@2177ac62cabe662aa49a2bc6f3154070705dd8f5 env: GITHUB_TOKEN: ${{ github.token }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -320,6 +320,7 @@ jobs: keystore-path: '../tools/buildtools/upload-key.keystore' comment-bot: false rock-build-extra-params: '--extra-params -PreactNativeArchitectures=arm64-v8a,x86_64' + custom-ref: ${{ needs.prep.outputs.APP_REF }} - name: Set artifact URL output id: set-artifact-url @@ -414,7 +415,7 @@ jobs: - name: Rock Remote Build - iOS id: rock-remote-build-ios - uses: callstackincubator/ios@8dcef6cc275e0cf3299f5a97cde5ebd635c887d7 + uses: callstackincubator/ios@d638bd25c764655baeeced3232c692f1698cf72b env: GITHUB_TOKEN: ${{ github.token }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -442,6 +443,7 @@ jobs: } ] comment-bot: false + custom-ref: ${{ needs.prep.outputs.APP_REF }} - name: Set artifact URL output id: set-artifact-url diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d245d12368015..05b9cb4131c5a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5567,10 +5567,6 @@ const CONST = { DISABLED: 'DISABLED', DISABLE: 'DISABLE', }, - MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE: { - SUCCESS: 'success', - FAILURE: 'failure', - }, MERGE_ACCOUNT_RESULTS: { SUCCESS: 'success', ERR_2FA: 'err_2fa', @@ -7470,7 +7466,7 @@ const CONST = { CASH_BACK: 'earnedCashback', }, - EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT, SCREENS.MONEY_REQUEST.STEP_SCAN] as string[], + EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN, SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT, SCREENS.MONEY_REQUEST.STEP_SCAN, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION)] as string[], CANCELLATION_TYPE: { MANUAL: 'manual', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a3f1d16a9a8a5..f0c4b83ad2de1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -29,6 +29,14 @@ const ONYXKEYS = { /** A unique ID for the device */ DEVICE_ID: 'deviceID', + /** Holds information about device-specific biometrics which: + * - does need to be persisted + * - does not need to be kept in secure storage + * - does not persist across uninstallations + * (secure storage persists across uninstallation) + */ + DEVICE_BIOMETRICS: 'deviceBiometrics', + /** Boolean flag set whenever the sidebar has loaded */ IS_SIDEBAR_LOADED: 'isSidebarLoaded', @@ -1417,6 +1425,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean; [ONYXKEYS.PERSONAL_POLICY_ID]: string; [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; + [ONYXKEYS.DEVICE_BIOMETRICS]: OnyxTypes.DeviceBiometrics; }; type OnyxDerivedValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a952a6d80935b..2011daa88b2b2 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, OdometerImageType} from './CONST'; @@ -84,11 +85,6 @@ const DYNAMIC_ROUTES = { }, } as const satisfies DynamicRoutes; -const MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES = { - FACTOR: 'multifactor-authentication/factor', - PROMPT: 'multifactor-authentication/prompt', -} as const; - const ROUTES = { ...PUBLIC_SCREENS_ROUTES, // This route renders the list of reports. @@ -3870,19 +3866,17 @@ const ROUTES = { getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/invite` as const, }, - MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `${MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES.FACTOR}/magic-code`, + MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `multifactor-authentication/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, + route: `multifactor-authentication/prompt/:promptType`, + getRoute: (promptType: MultifactorAuthenticationPromptType) => `multifactor-authentication/prompt/${promptType}` as const, }, MULTIFACTOR_AUTHENTICATION_NOT_FOUND: 'multifactor-authentication/not-found', @@ -3903,7 +3897,7 @@ const SHARED_ROUTE_PARAMS: Partial> = { [SCREENS.WORKSPACE.INITIAL]: ['backTo'], } as const; -export {PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS, VERIFY_ACCOUNT, MULTIFACTOR_AUTHENTICATION_PROTECTED_ROUTES, DYNAMIC_ROUTES}; +export {PUBLIC_SCREENS_ROUTES, SHARED_ROUTE_PARAMS, VERIFY_ACCOUNT, DYNAMIC_ROUTES}; export default ROUTES; type ReportAttachmentsRoute = typeof ROUTES.REPORT_ATTACHMENTS.route; diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx new file mode 100644 index 0000000000000..71f63037c3c64 --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -0,0 +1,413 @@ +import React, {createContext, useCallback, useContext, useEffect, useMemo} from 'react'; +import type {ReactNode} from 'react'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import {getOutcomePaths} from '@components/MultifactorAuthentication/config/outcomePaths'; +import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types'; +import useNetwork from '@hooks/useNetwork'; +import {requestValidateCodeAction} from '@libs/actions/User'; +import getPlatform from '@libs/getPlatform'; +import type {ChallengeType, MultifactorAuthenticationReason, OutcomePaths} from '@libs/MultifactorAuthentication/Biometrics/types'; +import Navigation from '@navigation/Navigation'; +import {requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; +import {processRegistration, processScenario} from '@userActions/MultifactorAuthentication/processing'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {DeviceBiometrics} from '@src/types/onyx'; +import {useMultifactorAuthenticationState} from './State'; +import useNativeBiometrics from './useNativeBiometrics'; +import type {AuthorizeResult, RegisterResult} from './useNativeBiometrics'; + +let deviceBiometricsState: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.DEVICE_BIOMETRICS, + callback: (data) => { + deviceBiometricsState = data; + }, +}); + +type ExecuteScenarioParams = MultifactorAuthenticationScenarioParams & Partial; + +type MultifactorAuthenticationContextValue = { + /** Execute a multifactor authentication scenario */ + executeScenario: (scenario: T, params?: ExecuteScenarioParams) => Promise; + + /** Cancel the current authentication flow and navigate to failure outcome */ + cancel: () => void; +}; + +const MultifactorAuthenticationContext = createContext(undefined); + +type MultifactorAuthenticationContextProviderProps = { + children: ReactNode; +}; + +/** + * Identifies the challenge type based on its properties. + * Registration challenges (require prior validateCode verification) have 'user' and 'rp'. + * Authorization challenges (no prior verification) have 'allowCredentials' and 'rpId'. + */ +function getChallengeType(challenge: unknown): ChallengeType | undefined { + if (typeof challenge === 'object' && challenge !== null) { + if ('user' in challenge && 'rp' in challenge) { + return CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.REGISTRATION; + } + if ('allowCredentials' in challenge && 'rpId' in challenge) { + return CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.AUTHENTICATION; + } + } + return undefined; +} + +function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { + const {state, dispatch} = useMultifactorAuthenticationState(); + + const biometrics = useNativeBiometrics(); + const {isOffline} = useNetwork(); + const platform = getPlatform(); + const isWeb = useMemo(() => platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.MOBILE_WEB, [platform]); + + /** + * Internal process function that runs after each step. + * Uses if statements to determine and execute the next step in the flow. + */ + const process = useCallback(async () => { + const { + error, + continuableError, + scenario, + softPromptApproved, + validateCode, + registrationChallenge, + authorizationChallenge, + payload, + outcomePaths, + isRegistrationComplete, + isAuthorizationComplete, + isFlowComplete, + } = state; + + // 0. Check if one of the early exit conditions applies: + // - Flow is already complete, + // - User is offline, + // - Scenario is not set, + // - There's a continuable error: + // Pause flow and wait for the user to fix it. + // Continuable errors (like invalid validate code) are displayed on the current screen + // and don't stop the entire flow - the user can retry without restarting + if (isFlowComplete || !scenario || isOffline || continuableError) { + return; + } + + const paths = outcomePaths ?? getOutcomePaths(scenario); + + // 1. Check if there's an error - stop processing + if (error) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(paths.failureOutcome), {forceReplace: true}); + dispatch({type: 'SET_FLOW_COMPLETE', payload: true}); + return; + } + + // 2. Check if device is compatible + if (!biometrics.doesDeviceSupportBiometrics()) { + const {allowedAuthenticationMethods = [] as string[]} = scenario ? MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] : {}; + + let reason: MultifactorAuthenticationReason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE; + + // If the user is using mobile app and the scenario allows native biometrics as a form of authentication, + // then they need to enable it in the system settings as well for doesDeviceSupportBiometrics to return true. + if (!isWeb && allowedAuthenticationMethods.includes(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS)) { + reason = CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS; + } + + dispatch({ + type: 'SET_ERROR', + payload: { + reason, + }, + }); + return; + } + + // 3. Check if registration is required (local credentials not known to server yet) + const isRegistrationRequired = !(await biometrics.areLocalCredentialsKnownToServer()) && !isRegistrationComplete; + + if (isRegistrationRequired) { + // Need validate code before registration + if (!validateCode) { + requestValidateCodeAction(); + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_MAGIC_CODE, {forceReplace: true}); + return; + } + + // Request registration challenge after validateCode is set + if (!registrationChallenge) { + const {challenge, reason: challengeReason} = await requestRegistrationChallenge(validateCode); + + if (!challenge) { + dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); + return; + } + + // IMPORTANT: Validate that we received a registration challenge. + // This check is safe here because the backend only issues registration challenges AFTER + // validateCode verification. The prior validation gate guarantees that if we receive + // a challenge of type 'registration', it's genuinely from the registration path. This security guarantee + // does NOT apply to authorization challenges (which skip validateCode verification). If the WebAuthN spec + // ever changes the structure of these challenges, update getChallengeType() accordingly. + const challengeType = getChallengeType(challenge); + if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.REGISTRATION) { + dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); + return; + } + + dispatch({type: 'SET_REGISTRATION_CHALLENGE', payload: challenge}); + return; + } + + // Check if a soft prompt is needed + if (!softPromptApproved) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.ENABLE_BIOMETRICS), {forceReplace: true}); + return; + } + + await biometrics.register(async (result: RegisterResult) => { + if (!result.success) { + dispatch({ + type: 'SET_ERROR', + payload: { + reason: result.reason, + }, + }); + return; + } + + // Call backend to register the public key + const registrationResponse = await processRegistration({ + publicKey: result.publicKey, + authenticationMethod: result.authenticationMethod.marqetaValue, + challenge: registrationChallenge.challenge, + }); + + if (!registrationResponse.success) { + dispatch({ + type: 'SET_ERROR', + payload: { + reason: registrationResponse.reason, + }, + }); + return; + } + + dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true}); + }); + return; + } + + // Registration isn't required, but they have never seen the soft prompt + // this happens on ios if they delete and reinstall the app. Their keys are preserved in the secure store, but + // they'll be shown the "do you want to enable FaceID again" system prompt, so we want to show them the soft prompt + if (!deviceBiometricsState?.hasAcceptedSoftPrompt) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute(CONST.MULTIFACTOR_AUTHENTICATION.PROMPT.ENABLE_BIOMETRICS), {forceReplace: true}); + return; + } + + // 4. Authorize the user if that has not already been done + if (!isAuthorizationComplete) { + if (!Navigation.isActiveRoute(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'))) { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_PROMPT.getRoute('enable-biometrics'), {forceReplace: true}); + } + + // Request authorization challenge if not already fetched + if (!authorizationChallenge) { + const {challenge, reason: challengeReason} = await requestAuthorizationChallenge(); + + if (!challenge) { + dispatch({type: 'SET_ERROR', payload: {reason: challengeReason}}); + return; + } + + // Validate that we received an authentication challenge + const challengeType = getChallengeType(challenge); + if (challengeType !== CONST.MULTIFACTOR_AUTHENTICATION.CHALLENGE_TYPE.AUTHENTICATION) { + dispatch({type: 'SET_ERROR', payload: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_CHALLENGE_TYPE}}); + return; + } + + dispatch({type: 'SET_AUTHORIZATION_CHALLENGE', payload: challenge}); + return; + } + + await biometrics.authorize( + { + scenario, + challenge: authorizationChallenge, + }, + async (result: AuthorizeResult) => { + if (!result.success) { + // Re-registration may be needed even though we checked credentials above, because: + // - The local public key was deleted between the check and authorization + // - The server no longer accepts the local public key (not in allowCredentials) + if (result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.KEYSTORE.REGISTRATION_REQUIRED) { + await biometrics.resetKeysForAccount(); + dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: false}); + dispatch({type: 'SET_AUTHORIZATION_CHALLENGE', payload: undefined}); + } else { + dispatch({ + type: 'SET_ERROR', + payload: { + reason: result.reason, + }, + }); + } + return; + } + + // Call backend with signed challenge + const scenarioAPIResponse = await processScenario(scenario, { + signedChallenge: result.signedChallenge, + authenticationMethod: result.authenticationMethod.marqetaValue, + ...payload, + }); + + if (!scenarioAPIResponse.success) { + dispatch({ + type: 'SET_ERROR', + payload: { + reason: scenarioAPIResponse.reason, + }, + }); + return; + } + + dispatch({type: 'SET_AUTHENTICATION_METHOD', payload: result.authenticationMethod}); + dispatch({type: 'SET_AUTHORIZATION_COMPLETE', payload: true}); + }, + ); + return; + } + + // 5. All steps completed - success + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(paths.successOutcome), {forceReplace: true}); + dispatch({type: 'SET_FLOW_COMPLETE', payload: true}); + }, [biometrics, dispatch, isOffline, state, isWeb]); + + /** + * Drives the MFA state machine forward whenever relevant state changes occur. + * This effect acts as the "engine" that progresses through the authentication flow: + * - Waits for a scenario to be set via executeScenario() before running + * - Re-evaluates the flow whenever key state fields change (e.g., validateCode entered, challenge received) + * - Each run of process() checks current state and advances to the next step or completes the flow + * + * TODO: This pattern will likely be refactored to address React rules violations and race condition risks. + * See: https://github.com/Expensify/App/issues/81197 + */ + useEffect(() => { + // Don't run until a scenario has been initiated + if (!state.scenario) { + return; + } + + process(); + // We intentionally omit `process` and `state` from dependencies. + // Including them would cause infinite re-renders since `process` is recreated on every state change. + // Instead, we list only the specific state fields that should trigger a re-run of the MFA flow. + // https://github.com/Expensify/App/issues/81197 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + // Error states - need to handle failures and navigate to outcome screens + state.error, + + // Core flow state - which scenario is active + state.scenario, + + // User interactions - soft prompt approval triggers biometric registration + state.softPromptApproved, + + // Magic code entry - required before registration challenge can be requested + state.validateCode, + + // Challenge responses from backend - trigger next steps in registration/authorization + state.registrationChallenge, + state.authorizationChallenge, + + // Completion flags - determine whether to continue or finish the flow + state.isRegistrationComplete, + state.isAuthorizationComplete, + state.isFlowComplete, + ]); + + /** + * Initiates a multifactor authentication scenario. + * Dispatches the initial state setup with the specified scenario and optional parameters. + * The flow will automatically progress through registration (if needed) and authorization steps. + * + * @template T - The type of the multifactor authentication scenario + * @param scenario - The MFA scenario to process + * @param {ExecuteScenarioParams} [params] - Optional parameters including: + * - successOutcome: Navigation route for successful authentication (overrides default) + * - failureOutcome: Navigation route for failed authentication (overrides default) + * - Additional payload data to pass through the authentication flow + * @returns {Promise} A promise that resolves when the scenario has been initialized + */ + const executeScenario = useCallback( + async (scenario: T, params?: ExecuteScenarioParams): Promise => { + const {successOutcome, failureOutcome, ...payload} = params ?? {}; + const paths = getOutcomePaths(scenario); + + dispatch({ + type: 'INIT', + payload: { + scenario, + payload: Object.keys(payload).length > 0 ? payload : undefined, + outcomePaths: { + successOutcome: successOutcome ?? paths.successOutcome, + failureOutcome: failureOutcome ?? paths.failureOutcome, + }, + }, + }); + }, + [dispatch], + ); + + /** + * Cancel the current authentication flow. + * Sets an error state which triggers navigation to the failure outcome. + */ + const cancel = useCallback(() => { + // Set error to trigger failure navigation + dispatch({ + type: 'SET_ERROR', + payload: { + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.EXPO.CANCELED, + }, + }); + }, [dispatch]); + + const contextValue: MultifactorAuthenticationContextValue = useMemo( + () => ({ + executeScenario, + cancel, + }), + [cancel, executeScenario], + ); + + return {children}; +} + +function useMultifactorAuthentication(): MultifactorAuthenticationContextValue { + const context = useContext(MultifactorAuthenticationContext); + + if (!context) { + throw new Error('useMultifactorAuthentication must be used within a MultifactorAuthenticationContextProviders'); + } + + return context; +} + +MultifactorAuthenticationContextProvider.displayName = 'MultifactorAuthenticationContextProvider'; + +export {useMultifactorAuthentication, MultifactorAuthenticationContext, MultifactorAuthenticationContextProvider}; +export type {MultifactorAuthenticationContextValue, ExecuteScenarioParams}; diff --git a/src/components/MultifactorAuthentication/Context/Provider.tsx b/src/components/MultifactorAuthentication/Context/Provider.tsx new file mode 100644 index 0000000000000..81a1824297c18 --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/Provider.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import type {ReactNode} from 'react'; +import ComposeProviders from '@components/ComposeProviders'; +import {MultifactorAuthenticationContextProvider} from './Main'; +import MultifactorAuthenticationStateProvider from './State'; + +type MultifactorAuthenticationProviderProps = { + children: ReactNode; +}; + +function MultifactorAuthenticationContextProviders({children}: MultifactorAuthenticationProviderProps) { + return {children}; +} + +MultifactorAuthenticationContextProviders.displayName = 'MultifactorAuthenticationContextProviders'; + +export default MultifactorAuthenticationContextProviders; diff --git a/src/components/MultifactorAuthentication/Context/State.tsx b/src/components/MultifactorAuthentication/Context/State.tsx new file mode 100644 index 0000000000000..5846ea3354e5a --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/State.tsx @@ -0,0 +1,219 @@ +import React, {createContext, useContext, useMemo, useReducer} from 'react'; +import type {ReactNode} from 'react'; +import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import type {AuthTypeInfo, MultifactorAuthenticationReason, OutcomePaths} from '@libs/MultifactorAuthentication/Biometrics/types'; +import CONST from '@src/CONST'; + +type ErrorState = { + reason: MultifactorAuthenticationReason; + message?: string; +}; + +type MultifactorAuthenticationState = { + /** Current error state - stops the flow and navigates to failure outcome */ + error: ErrorState | undefined; + + /** Continuable error - displayed on current screen without stopping the flow */ + continuableError: ErrorState | undefined; + + /** Validate code entered by user */ + validateCode: string | undefined; + + /** Challenge received from backend for registration (full object with user, rp, challenge) */ + registrationChallenge: RegistrationChallenge | undefined; + + /** Challenge received from backend for authorization (full object with allowCredentials, rpId, challenge) */ + authorizationChallenge: AuthenticationChallenge | undefined; + + /** Whether user approved the soft prompt for biometric setup */ + softPromptApproved: boolean; + + /** Current scenario being executed */ + scenario: MultifactorAuthenticationScenario | undefined; + + /** Additional parameters for the current scenario */ + payload: MultifactorAuthenticationScenarioAdditionalParams | undefined; + + /** Outcome paths for navigation after authentication completes */ + outcomePaths: OutcomePaths | undefined; + + /** Whether registration step has been completed */ + isRegistrationComplete: boolean; + + /** Whether authorization step has been completed */ + isAuthorizationComplete: boolean; + + /** Whether the entire flow has been completed */ + isFlowComplete: boolean; + + /** Authentication method used (e.g., 'BIOMETRIC_FACE', 'BIOMETRIC_FINGERPRINT') */ + authenticationMethod: AuthTypeInfo | undefined; +}; + +type MultifactorAuthenticationStateContextValue = { + state: MultifactorAuthenticationState; + dispatch: (action: Action) => void; +}; + +const DEFAULT_STATE: MultifactorAuthenticationState = { + error: undefined, + continuableError: undefined, + validateCode: undefined, + registrationChallenge: undefined, + authorizationChallenge: undefined, + softPromptApproved: false, + scenario: undefined, + payload: undefined, + outcomePaths: undefined, + isRegistrationComplete: false, + isAuthorizationComplete: false, + isFlowComplete: false, + authenticationMethod: undefined, +}; + +type InitPayload = { + scenario: MultifactorAuthenticationScenario; + payload: MultifactorAuthenticationScenarioAdditionalParams | undefined; + outcomePaths: OutcomePaths; +}; + +type Action = + | {type: 'SET_ERROR'; payload: ErrorState | undefined} + | {type: 'CLEAR_CONTINUABLE_ERROR'} + | {type: 'SET_VALIDATE_CODE'; payload: string | undefined} + | {type: 'SET_REGISTRATION_CHALLENGE'; payload: RegistrationChallenge | undefined} + | {type: 'SET_AUTHORIZATION_CHALLENGE'; payload: AuthenticationChallenge | undefined} + | {type: 'SET_SOFT_PROMPT_APPROVED'; payload: boolean} + | {type: 'SET_SCENARIO'; payload: MultifactorAuthenticationScenario | undefined} + | {type: 'SET_PAYLOAD'; payload: MultifactorAuthenticationScenarioAdditionalParams | undefined} + | {type: 'SET_OUTCOME_PATHS'; payload: OutcomePaths | undefined} + | {type: 'SET_REGISTRATION_COMPLETE'; payload: boolean} + | {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean} + | {type: 'SET_FLOW_COMPLETE'; payload: boolean} + | {type: 'SET_AUTHENTICATION_METHOD'; payload: AuthTypeInfo | undefined} + | {type: 'INIT'; payload: InitPayload} + | {type: 'RESET'}; + +/** + * Reducer function that manages the multifactor authentication state machine. + * Handles all state transitions based on dispatched actions, including: + * - Error handling (fatal errors and continuable errors like invalid codes) + * - Challenge management (registration and authorization) + * - Flow progression tracking + * - Scenario and payload management + * + * @param state - The current state + * @param action - The action to process with type-specific payload + * @returns The new state after applying the action + */ +function stateReducer(state: MultifactorAuthenticationState, action: Action): MultifactorAuthenticationState { + switch (action.type) { + case 'SET_ERROR': { + if (action.payload === undefined) { + return {...state, error: undefined, continuableError: undefined}; + } + // Invalid validate code is a continuable error - it doesn't fail the entire MFA flow, + // instead it's displayed on the current screen and the user can retry + if (action.payload.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.INVALID_VALIDATE_CODE) { + return {...state, continuableError: action.payload, error: undefined}; + } + return {...state, error: action.payload, continuableError: undefined}; + } + case 'CLEAR_CONTINUABLE_ERROR': + return {...state, continuableError: undefined}; + case 'SET_VALIDATE_CODE': + return {...state, validateCode: action.payload}; + case 'SET_REGISTRATION_CHALLENGE': + return {...state, registrationChallenge: action.payload}; + case 'SET_AUTHORIZATION_CHALLENGE': + return {...state, authorizationChallenge: action.payload}; + case 'SET_SOFT_PROMPT_APPROVED': + return {...state, softPromptApproved: action.payload}; + case 'SET_SCENARIO': + return {...state, scenario: action.payload}; + case 'SET_PAYLOAD': + return {...state, payload: action.payload}; + case 'SET_OUTCOME_PATHS': + return {...state, outcomePaths: action.payload}; + case 'SET_REGISTRATION_COMPLETE': + return {...state, isRegistrationComplete: action.payload}; + case 'SET_AUTHORIZATION_COMPLETE': + return {...state, isAuthorizationComplete: action.payload}; + case 'SET_FLOW_COMPLETE': + return {...state, isFlowComplete: action.payload}; + case 'SET_AUTHENTICATION_METHOD': + return {...state, authenticationMethod: action.payload}; + case 'INIT': + return { + ...DEFAULT_STATE, + scenario: action.payload.scenario, + payload: action.payload.payload, + outcomePaths: action.payload.outcomePaths, + }; + case 'RESET': + return DEFAULT_STATE; + default: + return state; + } +} + +const MultifactorAuthenticationStateContext = createContext(undefined); + +type MultifactorAuthenticationStateProviderProps = { + children: ReactNode; +}; + +/** + * Provider component that manages the global multifactor authentication state. + * Uses a reducer pattern to handle complex state transitions and provides + * the state and dispatch function to all consuming components. + * Must be placed high in the component tree to wrap all MFA-related screens. + * + * @param props - Component props + * @param props.children - Child components that will have access to MFA state + * @returns The provider component wrapping children + */ +function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationStateProviderProps) { + const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE); + + const contextValue: MultifactorAuthenticationStateContextValue = useMemo( + () => ({ + state, + dispatch, + }), + [state], + ); + + return {children}; +} + +/** + * Hook to access the multifactor authentication state and dispatch function. + * Provides access to the current state and a dispatch function for triggering state updates. + * Must be called within a MultifactorAuthenticationStateProvider tree. + * + * @returns Object containing: + * - state: The current MultifactorAuthenticationState + * - dispatch: Function to dispatch actions and update state + * @throws {Error} If used outside of MultifactorAuthenticationStateProvider + * + * @example + * const {state, dispatch} = useMultifactorAuthenticationState(); + * dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'}); + */ +function useMultifactorAuthenticationState(): MultifactorAuthenticationStateContextValue { + const context = useContext(MultifactorAuthenticationStateContext); + + if (!context) { + throw new Error('useMultifactorAuthenticationState must be used within a MultifactorAuthenticationStateProvider'); + } + + return context; +} + +MultifactorAuthenticationStateProvider.displayName = 'MultifactorAuthenticationStateProvider'; + +export default MultifactorAuthenticationStateProvider; +export {useMultifactorAuthenticationState, MultifactorAuthenticationStateContext, DEFAULT_STATE}; +export type {MultifactorAuthenticationState, MultifactorAuthenticationStateContextValue, ErrorState, Action}; diff --git a/src/components/MultifactorAuthentication/Context/index.ts b/src/components/MultifactorAuthentication/Context/index.ts new file mode 100644 index 0000000000000..ea60883367b2d --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/index.ts @@ -0,0 +1,8 @@ +export {default as MultifactorAuthenticationContextProviders} from './Provider'; +export {useMultifactorAuthentication} from './Main'; +export type {MultifactorAuthenticationContextValue, ExecuteScenarioParams} from './Main'; + +export {useMultifactorAuthenticationState} from './State'; +export type {MultifactorAuthenticationState, MultifactorAuthenticationStateContextValue, ErrorState} from './State'; + +export {default as usePromptContent, serverHasRegisteredCredentials} from './usePromptContent'; diff --git a/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts new file mode 100644 index 0000000000000..e1d828b5cda68 --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/useNativeBiometrics.ts @@ -0,0 +1,273 @@ +import {useCallback, useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {MultifactorAuthenticationScenario} from '@components/MultifactorAuthentication/config/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {generateKeyPair, signToken as signTokenED25519} from '@libs/MultifactorAuthentication/Biometrics/ED25519'; +import type {AuthenticationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/Biometrics/KeyStore'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/Biometrics/SecureStore'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; + +type BaseRegisterResult = { + privateKey: string; + publicKey: string; + authenticationMethod: AuthTypeInfo; +}; + +type RegisterResult = + | ({ + success: true; + reason: MultifactorAuthenticationReason; + } & BaseRegisterResult) + | ({ + success: false; + reason: MultifactorAuthenticationReason; + } & Partial); + +type AuthorizeParams = { + scenario: T; + challenge: AuthenticationChallenge; +}; + +type AuthorizeResultSuccess = { + success: true; + reason: MultifactorAuthenticationReason; + signedChallenge: SignedChallenge; + authenticationMethod: AuthTypeInfo; +}; + +type AuthorizeResultFailure = { + success: false; + reason: MultifactorAuthenticationReason; +}; + +type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; + +// In the 4th release of the Multifactor Authentication this interface will not focus on the Onyx/Auth values. +// Instead, the providers abstraction will be added. +// For context, see: https://github.com/Expensify/App/pull/79473#discussion_r2747993460 +type UseNativeBiometricsReturn = { + /** Whether server has any registered credentials for this account */ + serverHasAnyCredentials: boolean; + + /** List of credential IDs known to server (from Onyx) */ + serverKnownCredentialIDs: string[]; + + /** Check if device supports biometrics */ + doesDeviceSupportBiometrics: () => boolean; + + /** Check if device has biometric credentials stored locally */ + hasLocalCredentials: () => Promise; + + /** Check if local credentials are known to server (local credential exists in server's list) */ + areLocalCredentialsKnownToServer: () => Promise; + + /** Register biometrics on device */ + register: (onResult: (result: RegisterResult) => Promise | void) => Promise; + + /** Authorize using biometrics */ + authorize: (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => Promise; + + /** Reset keys for account */ + resetKeysForAccount: () => Promise; +}; + +/** + * Selector to get multifactor authentication public key IDs from Account Onyx state. + */ +function getMultifactorAuthenticationPublicKeyIDs(data: OnyxEntry) { + return data?.multifactorAuthenticationPublicKeyIDs; +} + +/** + * Clears local credentials to allow re-registration. + * Should only be used in response to server indicating credentials were removed. + */ +async function resetKeys(accountID: number) { + await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); +} + +/** + * Determines if biometric authentication is configured locally for the current account. + * Checks local public key storage and compares with the provided auth public keys. + * Note: We only check public key here because checking private key requires biometric authentication. + * If private key is missing, it will be detected during authorize() and trigger re-registration. + * @param accountID - The account ID to check biometric configuration for. + * @param authPublicKeys - The list of public keys registered in auth backend (from Onyx). + * @returns Object indicating if biometry is locally configured and if local key is in auth. + */ +async function isBiometryConfigured(accountID: number, authPublicKeys: string[] = []) { + const {value: localPublicKey} = await PublicKeyStore.get(accountID); + + const isBiometryRegisteredLocally = !!localPublicKey; + const isLocalPublicKeyInAuth = isBiometryRegisteredLocally && authPublicKeys.includes(localPublicKey); + + return { + isBiometryRegisteredLocally, + isLocalPublicKeyInAuth, + }; +} + +function useNativeBiometrics(): UseNativeBiometricsReturn { + const {accountID} = useCurrentUserPersonalDetails(); + const {translate} = useLocalize(); + + const [multifactorAuthenticationPublicKeyIDs] = useOnyx(ONYXKEYS.ACCOUNT, {selector: getMultifactorAuthenticationPublicKeyIDs, canBeMissing: true}); + const serverKnownCredentialIDs = useMemo(() => multifactorAuthenticationPublicKeyIDs ?? [], [multifactorAuthenticationPublicKeyIDs]); + const serverHasAnyCredentials = serverKnownCredentialIDs.length > 0; + + /** + * 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. + */ + const doesDeviceSupportBiometrics = useCallback(() => { + const {biometrics, credentials} = PublicKeyStore.supportedAuthentication; + return biometrics || credentials; + }, []); + + const hasLocalCredentials = useCallback(async () => { + const config = await isBiometryConfigured(accountID); + return config.isBiometryRegisteredLocally; + }, [accountID]); + + const areLocalCredentialsKnownToServer = useCallback(async () => { + const config = await isBiometryConfigured(accountID, serverKnownCredentialIDs); + return config.isLocalPublicKeyInAuth; + }, [accountID, serverKnownCredentialIDs]); + + const resetKeysForAccount = useCallback(async () => { + await resetKeys(accountID); + }, [accountID]); + + const register = async (onResult: (result: RegisterResult) => Promise | void) => { + // Generate key pair + const {privateKey, publicKey} = generateKeyPair(); + + // Delete existing keys before storing new ones to avoid "key already exists" errors + await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); + + // Store private key + const privateKeyResult = await PrivateKeyStore.set(accountID, privateKey, {nativePromptTitle: translate('multifactorAuthentication.letsVerifyItsYou')}); + const authTypeEntry = Object.values(SECURE_STORE_VALUES.AUTH_TYPE).find(({CODE}) => CODE === privateKeyResult.type); + + const authType = authTypeEntry + ? { + code: authTypeEntry.CODE, + name: authTypeEntry.NAME, + marqetaValue: authTypeEntry.MARQETA_VALUE, + } + : undefined; + + if (!privateKeyResult.value || authType === undefined) { + onResult({ + success: false, + reason: privateKeyResult.reason, + }); + return; + } + + // Store public key + const publicKeyResult = await PublicKeyStore.set(accountID, publicKey); + if (!publicKeyResult.value) { + // Delete the private key if public key storage fails to maintain a consistent key pair state. + // If only the private key exists without a matching public key, the device will be unable to + // complete authorization later (public key mismatch with server). Clean up to force re-registration + // on the next attempt when both keys can be successfully stored. + await PrivateKeyStore.delete(accountID); + onResult({ + success: false, + reason: publicKeyResult.reason, + }); + return; + } + + // Return success with keys - challenge is passed from Main.tsx + await onResult({ + success: true, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, + privateKey, + publicKey, + authenticationMethod: authType, + }); + }; + + const authorize = async (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => { + const {challenge} = params; + + // Extract public keys from challenge.allowCredentials + const authPublicKeys = challenge.allowCredentials?.map((cred: {id: string; type: string}) => cred.id) ?? []; + + // Get private key from SecureStore + const privateKeyData = await PrivateKeyStore.get(accountID, {nativePromptTitle: translate('multifactorAuthentication.letsVerifyItsYou')}); + + if (!privateKeyData.value) { + onResult({ + success: false, + reason: privateKeyData.reason || VALUES.REASON.KEYSTORE.KEY_MISSING, + }); + return; + } + + // Get public key + const {value: publicKey} = await PublicKeyStore.get(accountID); + + if (!publicKey || !authPublicKeys.includes(publicKey)) { + await resetKeys(accountID); + onResult({ + success: false, + reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED, + }); + return; + } + + // Sign the challenge + const signedChallenge = signTokenED25519(challenge, privateKeyData.value, publicKey); + const authenticationMethodCode = privateKeyData.type; + const authTypeEntry = Object.values(SECURE_STORE_VALUES.AUTH_TYPE).find(({CODE}) => CODE === authenticationMethodCode); + + const authType = authTypeEntry + ? { + code: authTypeEntry.CODE, + name: authTypeEntry.NAME, + marqetaValue: authTypeEntry.MARQETA_VALUE, + } + : undefined; + + if (!authType) { + onResult({ + success: false, + reason: VALUES.REASON.GENERIC.BAD_REQUEST, + }); + return; + } + + // Return signed challenge - let callback handle backend authorization + await onResult({ + success: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + signedChallenge, + authenticationMethod: authType, + }); + }; + + return { + serverHasAnyCredentials, + serverKnownCredentialIDs, + doesDeviceSupportBiometrics, + hasLocalCredentials, + areLocalCredentialsKnownToServer, + register, + authorize, + resetKeysForAccount, + }; +} + +export default useNativeBiometrics; +export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseNativeBiometricsReturn}; diff --git a/src/components/MultifactorAuthentication/Context/usePromptContent.ts b/src/components/MultifactorAuthentication/Context/usePromptContent.ts new file mode 100644 index 0000000000000..e98831fb569f6 --- /dev/null +++ b/src/components/MultifactorAuthentication/Context/usePromptContent.ts @@ -0,0 +1,78 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import {MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from '@components/MultifactorAuthentication/config'; +import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; +import useOnyx from '@hooks/useOnyx'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; +import {useMultifactorAuthenticationState} from './State'; + +type PromptContent = { + animation: DotLottieAnimation; + title: TranslationPaths; + subtitle: TranslationPaths | undefined; + shouldDisplayConfirmButton: boolean; +}; + +/** + * Selector to check if server has any registered credentials for this account. + * Note: This checks server state only, not device-local credentials. + */ +function serverHasRegisteredCredentials(data: OnyxEntry) { + const credentialIDs = data?.multifactorAuthenticationPublicKeyIDs; + return credentialIDs && credentialIDs.length > 0; +} + +/** + * Hook to get the prompt content (animation, title, subtitle) for the MFA prompt page. + * Handles the logic for determining the correct title and subtitle based on: + * - Whether the user is a returning user (already has biometrics registered) + * - Whether registration has just been completed + * - The default content from the prompt configuration + * + * Uses context state instead of Onyx for state changes during the flow to avoid + * timing issues with optimistic updates. + */ +function usePromptContent(promptType: MultifactorAuthenticationPromptType): PromptContent { + const {state} = useMultifactorAuthenticationState(); + const [serverHasCredentials = false] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true, selector: serverHasRegisteredCredentials}); + const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS, {canBeMissing: true}); + const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false; + + const contentData = MULTIFACTOR_AUTHENTICATION_PROMPT_UI[promptType]; + + // Returning user: server has credentials, but user hasn't approved soft prompt yet + const isReturningUser = hasEverAcceptedSoftPrompt && serverHasCredentials && !state.softPromptApproved; + + let title: TranslationPaths = contentData.title; + let subtitle: TranslationPaths | undefined = contentData.subtitle; + + // Customize title and subtitle based on the user's scenario: + // 1. Returning User (isReturningUser): User already has biometrics registered on server and just opened the app. + // Show "Let's authenticate you" to guide into the authorization flow. + // 2. New User Registration Complete (isRegistrationComplete): User just finished registering biometrics in this session. + // Show "Now let's authenticate you" to transition from registration to authorization. + if (isReturningUser) { + title = 'multifactorAuthentication.letsAuthenticateYou'; + subtitle = undefined; + } else if (state.isRegistrationComplete && hasEverAcceptedSoftPrompt) { + title = 'multifactorAuthentication.nowLetsAuthenticateYou'; + subtitle = undefined; + } + + // Display confirm button only for new users during their first biometric registration. + // Hide it for: users who already approved the soft prompt, users who finished registration, + // or returning users with existing server credentials. The button prompts users to enable biometrics. + const shouldDisplayConfirmButton = !hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials); + + return { + animation: contentData.animation, + title, + subtitle, + shouldDisplayConfirmButton, + }; +} + +export default usePromptContent; +export {serverHasRegisteredCredentials}; diff --git a/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx b/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx index b1dcab2c7dcad..fc31ba84cfb96 100644 --- a/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx +++ b/src/components/MultifactorAuthentication/NoEligibleMethodsDescription.tsx @@ -1,8 +1,11 @@ +import React from 'react'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; import goToSettings from '@libs/goToSettings'; +import CONST from '@src/CONST'; const baseTranslationPath = 'multifactorAuthentication.pleaseEnableInSystemSettings' as const; @@ -20,10 +23,13 @@ function NoEligibleMethodsDescription() { const link = translate(translationPaths.link); const end = translate(translationPaths.end); + const platform = getPlatform(); + const isWeb = platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.MOBILE_WEB; + return ( {start} - {link} + {isWeb ? link : {link}} {end} ); diff --git a/src/components/MultifactorAuthentication/PromptContent.tsx b/src/components/MultifactorAuthentication/PromptContent.tsx index 2d91ff96d1d4a..455caa5f85ffa 100644 --- a/src/components/MultifactorAuthentication/PromptContent.tsx +++ b/src/components/MultifactorAuthentication/PromptContent.tsx @@ -1,7 +1,8 @@ import React from 'react'; import {View} from 'react-native'; -import BlockingView from '@components/BlockingViews/BlockingView'; +import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; @@ -9,7 +10,7 @@ import type {TranslationPaths} from '@src/languages/types'; type MultifactorAuthenticationPromptContentProps = { animation: DotLottieAnimation; title: TranslationPaths; - subtitle: TranslationPaths; + subtitle?: TranslationPaths; }; function MultifactorAuthenticationPromptContent({title, subtitle, animation}: MultifactorAuthenticationPromptContentProps) { @@ -17,18 +18,23 @@ function MultifactorAuthenticationPromptContent({title, subtitle, animation}: Mu const {translate} = useLocalize(); return ( - - + + + + + + {translate(title)} + {!!subtitle && {translate(subtitle)}} + ); } diff --git a/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx index 819b385074972..007ce875f22a8 100644 --- a/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx +++ b/src/components/MultifactorAuthentication/TriggerCancelConfirmModal.tsx @@ -1,40 +1,36 @@ import React from 'react'; import ConfirmModal from '@components/ConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import type {TranslationPaths} from '@src/languages/types'; +import {MULTIFACTOR_AUTHENTICATION_DEFAULT_UI, MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from './config'; +import type {MultifactorAuthenticationScenario} from './config/types'; 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); + /** + * Retrieves the cancel confirmation modal configuration for a given scenario. + * Falls back to default UI configuration if scenario-specific config doesn't exist. + */ + const {title, description, cancelButtonText, confirmButtonText} = (scenario ? MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] : MULTIFACTOR_AUTHENTICATION_DEFAULT_UI).MODALS + .cancelConfirmation; return ( ); diff --git a/src/components/MultifactorAuthentication/UnsupportedDeviceDescription.tsx b/src/components/MultifactorAuthentication/UnsupportedDeviceDescription.tsx new file mode 100644 index 0000000000000..022399015fef0 --- /dev/null +++ b/src/components/MultifactorAuthentication/UnsupportedDeviceDescription.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {View} from 'react-native'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function UnsupportedDeviceDescription() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + ); +} + +UnsupportedDeviceDescription.displayName = 'UnsupportedDeviceDescription'; + +export default UnsupportedDeviceDescription; diff --git a/src/components/MultifactorAuthentication/config/outcomePaths.ts b/src/components/MultifactorAuthentication/config/outcomePaths.ts new file mode 100644 index 0000000000000..1924abccd253e --- /dev/null +++ b/src/components/MultifactorAuthentication/config/outcomePaths.ts @@ -0,0 +1,35 @@ +import type {OutcomePaths} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {AllMultifactorAuthenticationOutcomeType, MultifactorAuthenticationOutcomeSuffixes, MultifactorAuthenticationScenario} from './types'; + +/** + * 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 scenario - The authentication scenario or undefined for defaults. + * @param suffix - The outcome suffix (success/failure). + * @returns A fully qualified outcome type string. + */ +const getOutcomePath = ( + scenario: T | undefined, + suffix: MultifactorAuthenticationOutcomeSuffixes, +): AllMultifactorAuthenticationOutcomeType => { + const scenarioPrefix = scenario?.toLowerCase() as Lowercase | undefined; + return `${scenarioPrefix ?? 'biometrics-test'}-${suffix}` as AllMultifactorAuthenticationOutcomeType; +}; + +/** + * 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 successOutcome = getOutcomePath(scenario, 'success'); + const failureOutcome = getOutcomePath(scenario, 'failure'); + + return { + successOutcome, + failureOutcome, + }; +}; + +export {getOutcomePath, getOutcomePaths}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts index 6c9f0f44af50d..bb44eb9c3f3d8 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.ts @@ -21,5 +21,11 @@ export default { outOfTime: { headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', }, + noEligibleMethods: { + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', + }, + unsupportedDevice: { + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsTest', + }, }, } as const satisfies MultifactorAuthenticationScenarioCustomConfig; diff --git a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts index 2c827de8d53ef..93b03fe83cd54 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/DefaultUserInterface.ts @@ -1,5 +1,6 @@ import type {MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; import NoEligibleMethodsDescription from '@components/MultifactorAuthentication/NoEligibleMethodsDescription'; +import UnsupportedDeviceDescription from '@components/MultifactorAuthentication/UnsupportedDeviceDescription'; // Spacing utilities are needed for icon padding configuration in outcomes defaults // eslint-disable-next-line no-restricted-imports import spacing from '@styles/utils/spacing'; @@ -47,6 +48,16 @@ const DEFAULT_CONFIG = { description: 'multifactorAuthentication.biometricsTest.youCouldNotBeAuthenticated', customDescription: NoEligibleMethodsDescription, }, + unsupportedDevice: { + illustration: 'HumptyDumpty', + iconWidth: variables.humptyDumptyWidth, + iconHeight: variables.humptyDumptyHeight, + padding: spacing.p0, + headerTitle: 'multifactorAuthentication.biometricsTest.biometricsAuthentication', + title: 'multifactorAuthentication.unsupportedDevice.unsupportedDevice', + description: 'multifactorAuthentication.biometricsTest.youCouldNotBeAuthenticated', + customDescription: UnsupportedDeviceDescription, + }, }, MODALS: { cancelConfirmation: { @@ -56,7 +67,6 @@ const DEFAULT_CONFIG = { cancelButtonText: 'common.cancel', }, }, - nativePromptTitle: 'multifactorAuthentication.letsVerifyItsYou', } as const satisfies MultifactorAuthenticationDefaultUIConfig; /** @@ -91,6 +101,10 @@ function customConfig = * so the absence of payload will be tolerated at the run-time. */ pure?: true; - nativePromptTitle: TranslationPaths; } & MultifactorAuthenticationUI; /** * Scenario configuration for custom scenarios with optional overrides. */ -type MultifactorAuthenticationScenarioCustomConfig = EmptyObject> = Omit< - MultifactorAuthenticationScenarioConfig, - 'MODALS' | 'OUTCOMES' | 'nativePromptTitle' -> & { - nativePromptTitle?: TranslationPaths; +type MultifactorAuthenticationScenarioCustomConfig = EmptyObject> = Omit, 'MODALS' | 'OUTCOMES'> & { MODALS?: MultifactorAuthenticationModalOptional; OUTCOMES: MultifactorAuthenticationOutcomeOptional; }; @@ -186,7 +181,7 @@ type MultifactorAuthenticationScenarioCustomConfig; +type MultifactorAuthenticationDefaultUIConfig = Pick, 'MODALS' | 'OUTCOMES'>; /** * Record mapping all scenarios to their configurations. @@ -203,29 +198,23 @@ type MultifactorAuthenticationScenarioAdditionalParams = Partial & +type MultifactorAuthenticationScenarioParams = Partial & MultifactorAuthenticationScenarioAdditionalParams; /** * All required authentication factors with scenario-specific parameters. */ -type MultifactorAuthenticationProcessScenarioParameters = AllMultifactorAuthenticationFactors & +type MultifactorAuthenticationProcessScenarioParameters = AllMultifactorAuthenticationBaseParameters & MultifactorAuthenticationScenarioAdditionalParams; -/** - * Scenario response with success status indicator. - */ -type MultifactorAuthenticationScenarioResponseWithSuccess = { - httpCode: number | undefined; - successful: boolean; -}; +type MultifactorAuthenticationPromptType = keyof typeof MULTIFACTOR_AUTHENTICATION_PROMPT_UI; /** * Parameters required for biometrics registration scenario. */ type RegisterBiometricsParams = MultifactorAuthenticationActionParams< { - keyInfo: MultifactorAuthenticationKeyInfo<'biometric'>; + keyInfo: MultifactorAuthenticationKeyInfo; }, 'validateCode' >; @@ -250,15 +239,21 @@ type MultifactorAuthenticationScenario = ValueOf['NAME']; - -// eslint-disable-next-line import/prefer-default-export -export type {AuthTypeName}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 0095adbf84fcc..14357132b2318 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -736,6 +736,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'Anscheinend ist deine Zeit abgelaufen! Bitte versuche es noch einmal beim Händler.', youRanOutOfTime: 'Die Zeit ist abgelaufen', letsVerifyItsYou: 'Lass uns bestätigen, dass du es bist', + nowLetsAuthenticateYou: 'Lassen Sie uns Sie jetzt authentifizieren …', + letsAuthenticateYou: 'Lass uns dich authentifizieren …', verifyYourself: { biometrics: 'Bestätige dich mit deinem Gesicht oder Fingerabdruck', }, @@ -753,6 +755,10 @@ const translations: TranslationDeepObject = { dismiss: 'Verstanden', error: 'Anfrage fehlgeschlagen. Versuche es später noch einmal.', }, + unsupportedDevice: { + unsupportedDevice: 'Nicht unterstütztes Gerät', + pleaseDownloadMobileApp: ` Diese Aktion wird auf deinem Gerät nicht unterstützt. Bitte lade die Expensify-App aus dem App Store oder dem Google Play Store herunter und versuche es erneut.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/en.ts b/src/languages/en.ts index 0194288859567..e5105fdfdf757 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -731,6 +731,10 @@ const translations = { signIn: 'Please sign in again.', }, multifactorAuthentication: { + unsupportedDevice: { + unsupportedDevice: 'Unsupported device', + pleaseDownloadMobileApp: ` This action is not supported on your device. Please download the Expensify app from the App Store or Google Play Store and try again.`, + }, biometricsTest: { biometricsTest: 'Biometrics test', authenticationSuccessful: 'Authentication successful', @@ -741,7 +745,7 @@ const translations = { areYouSureToReject: 'Are you sure? The authentication attempt will be rejected if you close this screen.', rejectAuthentication: 'Reject authentication', test: 'Test', - biometricsAuthentication: 'Biometrics authentication', + biometricsAuthentication: 'Biometric authentication', }, pleaseEnableInSystemSettings: { start: 'Please enable face/fingerprint verification or set a device passcode in your ', @@ -752,6 +756,8 @@ const translations = { looksLikeYouRanOutOfTime: 'Looks like you ran out of time! Please try again at the merchant.', youRanOutOfTime: 'You ran out of time', letsVerifyItsYou: 'Let’s verify it’s you', + nowLetsAuthenticateYou: "Now, let's authenticate you...", + letsAuthenticateYou: "Let's authenticate you...", verifyYourself: { biometrics: 'Verify yourself with your face or fingerprint', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 03178c8533b58..6cc2b3bda21c3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -493,6 +493,10 @@ const translations: TranslationDeepObject = { signIn: 'Por favor, inicia sesión de nuevo.', }, multifactorAuthentication: { + unsupportedDevice: { + unsupportedDevice: 'Dispositivo no compatible', + pleaseDownloadMobileApp: ` Esta acción no está soportada en tu dispositivo. Por favor descarga la aplicación de Expensify desde la App Store o Google Play Store e inténtalo de nuevo.`, + }, biometricsTest: { biometricsTest: 'Prueba de biometría', authenticationSuccessful: 'Autenticación exitosa', @@ -514,6 +518,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: '¡Parece que se te acabó el tiempo! Por favor, inténtalo de nuevo en el comercio.', youRanOutOfTime: 'Se te acabó el tiempo', letsVerifyItsYou: 'Verifiquemos que eres tú', + nowLetsAuthenticateYou: 'Vamos a validarte...', + letsAuthenticateYou: 'Validando...', verifyYourself: { biometrics: 'Verifícate con tu rostro o huella dactilar', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d8b9d96084afd..25b81f4eaeb62 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -739,6 +739,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'On dirait que votre temps est écoulé ! Veuillez réessayer chez le commerçant.', youRanOutOfTime: 'Vous n’avez plus de temps', letsVerifyItsYou: 'Vérifions qu’il s’agit bien de vous', + nowLetsAuthenticateYou: 'Maintenant, procédons à votre authentification…', + letsAuthenticateYou: 'Authentifions votre identité…', verifyYourself: { biometrics: 'Vérifiez votre identité avec votre visage ou votre empreinte digitale', }, @@ -757,6 +759,10 @@ const translations: TranslationDeepObject = { dismiss: 'Compris', error: 'La requête a échoué. Réessayez plus tard.', }, + unsupportedDevice: { + unsupportedDevice: 'Appareil non pris en charge', + pleaseDownloadMobileApp: ` Cette action n'est pas prise en charge sur votre appareil. Veuillez télécharger l'application Expensify depuis l'App Store ou le Google Play Store et réessayer.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/it.ts b/src/languages/it.ts index e20f92296bdf8..e60ae65e8cdd1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -736,6 +736,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'Sembra che il tempo sia scaduto! Riprova presso l’esercente.', youRanOutOfTime: 'Il tempo è scaduto', letsVerifyItsYou: 'Verifichiamo che sia tu', + nowLetsAuthenticateYou: 'Ora procediamo con l’autenticazione...', + letsAuthenticateYou: 'Autentichiamo la tua identità...', verifyYourself: { biometrics: 'Verificati con il volto o l’impronta digitale', }, @@ -754,6 +756,10 @@ const translations: TranslationDeepObject = { dismiss: 'Ho capito', error: 'Richiesta non riuscita. Riprova più tardi.', }, + unsupportedDevice: { + unsupportedDevice: 'Dispositivo non supportato', + pleaseDownloadMobileApp: ` Questa azione non è supportata sul tuo dispositivo. Scarica l'app Expensify dall'App Store o da Google Play Store e riprova.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c54d3d385d419..0543ec445608b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -736,6 +736,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: '時間切れになったようです。加盟店で再度お試しください。', youRanOutOfTime: '時間切れです', letsVerifyItsYou: 'ご本人確認を行いましょう', + nowLetsAuthenticateYou: 'では、ご本人確認を行いましょう…', + letsAuthenticateYou: '認証を行っています…', verifyYourself: { biometrics: '顔または指紋で本人確認を行ってください', }, @@ -752,6 +754,10 @@ const translations: TranslationDeepObject = { dismiss: '了解しました', error: 'リクエストに失敗しました。後でもう一度お試しください。', }, + unsupportedDevice: { + unsupportedDevice: '未対応のデバイス', + pleaseDownloadMobileApp: ` この操作はお使いのデバイスではサポートされていません。App Store または Google Playストア からExpensifyアプリをダウンロードして、もう一度お試しください。`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4ce08959a1201..8ba638a771290 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -736,6 +736,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'Het lijkt erop dat je tijd op is! Probeer het alsjeblieft opnieuw bij de handelaar.', youRanOutOfTime: 'Je tijd is op', letsVerifyItsYou: 'Laten we controleren of jij het bent', + nowLetsAuthenticateYou: 'Laten we je nu verifiëren...', + letsAuthenticateYou: 'We gaan je authenticeren...', verifyYourself: { biometrics: 'Verifieer jezelf met je gezicht of vingerafdruk', }, @@ -753,6 +755,10 @@ const translations: TranslationDeepObject = { dismiss: 'Begrepen', error: 'Aanvraag mislukt. Probeer het later opnieuw.', }, + unsupportedDevice: { + unsupportedDevice: 'Niet-ondersteund apparaat', + pleaseDownloadMobileApp: ` Deze actie wordt niet ondersteund op jouw apparaat. Download de Expensify-app uit de App Store of de Google Play Store en probeer het opnieuw.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/params.ts b/src/languages/params.ts index c244af1842bf6..ab071a00da892 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -1,5 +1,5 @@ import type {ValueOf} from 'type-fest'; -import type {AuthTypeName} from '@components/MultifactorAuthentication/types'; +import type {AuthTypeName} from '@libs/MultifactorAuthentication/Biometrics/types'; import type CONST from '@src/CONST'; import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; import type {DelegateRole} from '@src/types/onyx/Account'; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 99f6908e541ba..175a45bc7eaa9 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -736,6 +736,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'Wygląda na to, że skończył ci się czas! Spróbuj ponownie u sprzedawcy.', youRanOutOfTime: 'Czas się skończył', letsVerifyItsYou: 'Zweryfikujmy, czy to na pewno Ty', + nowLetsAuthenticateYou: 'Teraz Cię uwierzytelnimy…', + letsAuthenticateYou: 'Uwierzytelnijmy Cię…', verifyYourself: { biometrics: 'Zweryfikuj się za pomocą twarzy lub odcisku palca', }, @@ -754,6 +756,10 @@ const translations: TranslationDeepObject = { dismiss: 'Rozumiem', error: 'Żądanie nie powiodło się. Spróbuj ponownie później.', }, + unsupportedDevice: { + unsupportedDevice: 'Nieobsługiwane urządzenie', + pleaseDownloadMobileApp: ` Ta akcja nie jest obsługiwana na Twoim urządzeniu. Pobierz aplikację Expensify z App Store lub Sklepu Google Play i spróbuj ponownie.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c6edd85708ccc..dd6e404edc256 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -735,6 +735,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: 'Parece que o tempo acabou! Tente novamente no estabelecimento.', youRanOutOfTime: 'Seu tempo acabou', letsVerifyItsYou: 'Vamos verificar se é você', + nowLetsAuthenticateYou: 'Agora, vamos autenticar você...', + letsAuthenticateYou: 'Vamos autenticar você...', verifyYourself: { biometrics: 'Verifique sua identidade com seu rosto ou impressão digital', }, @@ -752,6 +754,10 @@ const translations: TranslationDeepObject = { dismiss: 'Entendi', error: 'Falha na solicitação. Tente novamente mais tarde.', }, + unsupportedDevice: { + unsupportedDevice: 'Dispositivo não compatível', + pleaseDownloadMobileApp: ` Esta ação não é compatível com seu dispositivo. Baixe o app do Expensify na App Store ou na Google Play Store e tente novamente.`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5e94a0a04f7d7..b23ebb15b1db0 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -720,7 +720,7 @@ const translations: TranslationDeepObject = { areYouSureToReject: '您确定吗?如果您关闭此界面,此次身份验证尝试将被拒绝。', rejectAuthentication: '拒绝认证', test: '测试', - biometricsAuthentication: '生物识别身份验证', + biometricsAuthentication: '生物识别认证', }, pleaseEnableInSystemSettings: { start: '请在您的设备中启用面部/指纹验证或设置设备密码', @@ -731,6 +731,8 @@ const translations: TranslationDeepObject = { looksLikeYouRanOutOfTime: '看起来您的操作已超时!请在商家处重试。', youRanOutOfTime: '你的时间用完了', letsVerifyItsYou: '让我们验证一下您的身份', + nowLetsAuthenticateYou: '现在,让我们为你进行身份验证…', + letsAuthenticateYou: '正在验证您的身份…', verifyYourself: { biometrics: '使用面部或指纹验证您的身份', }, @@ -747,6 +749,10 @@ const translations: TranslationDeepObject = { dismiss: '明白了', error: '请求失败。请稍后重试。', }, + unsupportedDevice: { + unsupportedDevice: '不支持的设备', + pleaseDownloadMobileApp: ` 您的设备不支持此操作。请从App StoreGoogle Play 商店下载 Expensify 应用,然后重试。`, + }, }, validateCodeModal: { successfulSignInTitle: dedent(` diff --git a/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts b/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts index 7bf85d9844394..22a33bcfb1125 100644 --- a/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts +++ b/src/libs/API/parameters/RegisterAuthenticationKeyParams.ts @@ -1,5 +1,12 @@ import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; -type RegisterAuthenticationKeyParams = MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']; +/** + * The keyInfo type is changed because we want to validate the structure when the action is called, + * but it needs to be stringified when sent to the API. + */ + +type RegisterAuthenticationKeyParams = Omit & { + keyInfo: string; +}; export default RegisterAuthenticationKeyParams; diff --git a/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts index a11a0f9f1ea30..fabf40dc13ed0 100644 --- a/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts +++ b/src/libs/API/parameters/RequestAuthenticationChallengeParams.ts @@ -3,6 +3,9 @@ import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/typ type RequestAuthenticationChallengeParams = { /** Challenge type: 'authentication' for signing existing keys, 'registration' for new key registration */ challengeType: ChallengeType; + + /** Validate code required for registration challenge type */ + validateCode?: string; }; export default RequestAuthenticationChallengeParams; diff --git a/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts b/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts index 86434a2072f87..cce931a2698fe 100644 --- a/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts +++ b/src/libs/API/parameters/TroubleshootMultifactorAuthenticationParams.ts @@ -1,5 +1,11 @@ import type {MultifactorAuthenticationScenarioParameters} from '@components/MultifactorAuthentication/config/types'; -type TroubleshootMultifactorAuthenticationParams = MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']; +/** + * The signedChallenge type is changed because we want to validate the structure when the action is called, + * but it needs to be stringified when sent to the API. + */ +type TroubleshootMultifactorAuthenticationParams = Omit & { + signedChallenge: string; +}; export default TroubleshootMultifactorAuthenticationParams; diff --git a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts b/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts deleted file mode 100644 index 72b60ef198590..0000000000000 --- a/src/libs/MultifactorAuthentication/Biometrics/Challenge.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Manages the multifactor authentication challenge flow including requesting, signing, and sending challenges. - */ -import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; -import {requestAuthenticationChallenge} from '@libs/actions/MultifactorAuthentication'; -import {signToken as signTokenED25519} from './ED25519'; -import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; -import {isChallengeSigned, processScenario} from './helpers'; -import {PrivateKeyStore, PublicKeyStore} from './KeyStore'; -import type {ChallengeType, MultifactorAuthenticationMethodCode, MultifactorAuthenticationPartialStatus, MultifactorAuthenticationReason, MultifactorKeyStoreOptions} from './types'; -import VALUES from './VALUES'; - -/** - * Handles the complete lifecycle of a multifactor authentication challenge for a specific scenario. - * Manages requesting challenges from the server, signing them with the private key, and sending signed challenges back. - */ -class MultifactorAuthenticationChallenge { - private challenge: MultifactorAuthenticationChallengeObject | SignedChallenge | undefined = undefined; - - private authenticationMethod: MultifactorAuthenticationMethodCode | undefined = undefined; - - private publicKeys: string[] = []; - - constructor( - private readonly scenario: T, - private readonly params: MultifactorAuthenticationScenarioAdditionalParams, - private readonly options: MultifactorKeyStoreOptions, - private readonly challengeType: ChallengeType = 'authentication', - ) {} - - /** - * Creates an error return value with the given reason. - */ - private createErrorReturnValue(reasonKey: MultifactorAuthenticationReason): MultifactorAuthenticationPartialStatus { - return {value: false, reason: reasonKey}; - } - - /** - * Requests a new authentication challenge from the server and stores public keys. - */ - public async request(): Promise> { - const {challenge, publicKeys: authPublicKeys, reason: apiReason} = await requestAuthenticationChallenge(this.challengeType); - this.publicKeys = authPublicKeys ?? []; - - const reason = apiReason === VALUES.REASON.BACKEND.UNKNOWN_RESPONSE ? VALUES.REASON.CHALLENGE.COULD_NOT_RETRIEVE_A_CHALLENGE : apiReason; - - this.challenge = challenge; - - return {reason: challenge ? VALUES.REASON.CHALLENGE.CHALLENGE_RECEIVED : reason, value: true}; - } - - /** - * Checks if the secure store contains a public key known to the server. If so, a challenge is signed using that key. - */ - public async sign( - accountID: number, - chainedPrivateKeyStatus?: MultifactorAuthenticationPartialStatus, - ): Promise> { - if (!this.challenge) { - return this.createErrorReturnValue(VALUES.REASON.CHALLENGE.CHALLENGE_MISSING); - } - - if (isChallengeSigned(this.challenge)) { - return this.createErrorReturnValue(VALUES.REASON.CHALLENGE.CHALLENGE_ALREADY_SIGNED); - } - - const {value, type, reason} = chainedPrivateKeyStatus?.value ? chainedPrivateKeyStatus : await PrivateKeyStore.get(accountID, this.options); - - if (!value) { - return this.createErrorReturnValue(reason || VALUES.REASON.KEYSTORE.KEY_MISSING); - } - - const {value: publicKey} = await PublicKeyStore.get(accountID); - - if (!publicKey || !this.publicKeys.includes(publicKey)) { - await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); - return this.createErrorReturnValue(VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED); - } - - this.challenge = signTokenED25519(this.challenge, value, publicKey); - this.authenticationMethod = type; - - return {value: true, reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, type}; - } - - /** - * Sends the signed challenge to the server for the specific scenario. - */ - public async send(): Promise> { - if (!this.challenge || !isChallengeSigned(this.challenge)) { - return this.createErrorReturnValue(VALUES.REASON.GENERIC.SIGNATURE_MISSING); - } - - const authorizationResult = processScenario( - this.scenario, - { - ...this.params, - signedChallenge: this.challenge, - }, - VALUES.FACTOR_COMBINATIONS.BIOMETRICS_AUTHENTICATION, - ); - - const { - reason, - step: {wasRecentStepSuccessful, isRequestFulfilled}, - } = await authorizationResult; - - if (!wasRecentStepSuccessful || !isRequestFulfilled) { - return this.createErrorReturnValue(reason === VALUES.REASON.BACKEND.UNKNOWN_RESPONSE ? VALUES.REASON.GENERIC.SIGNATURE_INVALID : reason); - } - - return { - value: true, - reason: VALUES.REASON.BACKEND.AUTHORIZATION_SUCCESSFUL, - type: this.authenticationMethod, - }; - } -} - -export default MultifactorAuthenticationChallenge; diff --git a/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts b/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts index 7082388ad3ac7..f9c1bc3510d26 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/KeyStore.ts @@ -4,7 +4,7 @@ import {decodeExpoMessage} from './helpers'; import {SECURE_STORE_METHODS, SECURE_STORE_VALUES} from './SecureStore'; import type {SecureStoreOptions} from './SecureStore'; -import type {MultifactorAuthenticationKeyType, MultifactorAuthenticationPartialStatus, MultifactorKeyStoreOptions} from './types'; +import type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions} from './types'; import VALUES from './VALUES'; /** @@ -43,7 +43,7 @@ class MultifactorAuthenticationKeyStore): Promise> { + public async set(accountID: number, value: string, KSOptions: MultifactorKeyStoreOptions): Promise> { try { const alias = `${accountID}_${this.key}`; const type = await SECURE_STORE_METHODS.setItemAsync(alias, value, {...secureStoreOptions(this.key, KSOptions), ...STATIC_OPTIONS}); @@ -63,7 +63,7 @@ class MultifactorAuthenticationKeyStore> { + public async delete(accountID: number): Promise> { try { const alias = `${accountID}_${this.key}`; await SECURE_STORE_METHODS.deleteItemAsync(alias, { @@ -84,7 +84,7 @@ class MultifactorAuthenticationKeyStore): Promise> { + public async get(accountID: number, KSOptions: MultifactorKeyStoreOptions): Promise> { try { const alias = `${accountID}_${this.key}`; const [key, type] = await SECURE_STORE_METHODS.getItemAsync(alias, {...secureStoreOptions(this.key, KSOptions), ...STATIC_OPTIONS}); diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MQValues.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts similarity index 95% rename from src/libs/MultifactorAuthentication/Biometrics/SecureStore/MQValues.ts rename to src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts index a0b531d958a9f..d1d8c3d9a8462 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MQValues.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/MarqetaValues.ts @@ -38,8 +38,8 @@ const AUTHENTICATION_METHOD = { NONE: '', } as const; -const MQ_VALUES = { +const MARQETA_VALUES = { AUTHENTICATION_METHOD, } as const; -export default MQ_VALUES; +export default MARQETA_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts index 761437dd2cdee..bdb87bbf4d451 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.ts @@ -1,5 +1,5 @@ import * as SecureStore from 'expo-secure-store'; -import MQ_VALUES from './MQValues'; +import MARQETA_VALUES from './MarqetaValues'; import type {SecureStoreMethods, SecureStoreValues} from './types'; /** @@ -13,32 +13,32 @@ const SECURE_STORE_VALUES = { UNKNOWN: { CODE: SecureStore.AUTH_TYPE.UNKNOWN, NAME: 'Unknown', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, NONE: { CODE: SecureStore.AUTH_TYPE.NONE, NAME: 'None', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.NONE, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.NONE, }, CREDENTIALS: { CODE: SecureStore.AUTH_TYPE.CREDENTIALS, NAME: 'Credentials', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, BIOMETRICS: { CODE: SecureStore.AUTH_TYPE.BIOMETRICS, NAME: 'Biometrics', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, FACE_ID: { CODE: SecureStore.AUTH_TYPE.FACE_ID, NAME: 'FaceID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, TOUCH_ID: { CODE: SecureStore.AUTH_TYPE.TOUCH_ID, NAME: 'TouchID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, /** * OpticID is reserved by apple, used on Apple Vision Pro and not iOS. @@ -47,7 +47,7 @@ const SECURE_STORE_VALUES = { OPTIC_ID: { CODE: SecureStore.AUTH_TYPE.OPTIC_ID, NAME: 'OpticID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, }, /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts index ae6f32913a459..6040c2083bdb9 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/index.web.ts @@ -1,4 +1,4 @@ -import MQ_VALUES from './MQValues'; +import MARQETA_VALUES from './MarqetaValues'; import type {SecureStoreMethods, SecureStoreValues} from './types'; /** @@ -10,37 +10,37 @@ const SECURE_STORE_VALUES = { UNKNOWN: { CODE: -1, NAME: 'Unknown', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.OTHER, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.OTHER, }, NONE: { CODE: 0, NAME: 'None', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.OTHER, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.OTHER, }, CREDENTIALS: { CODE: 1, NAME: 'Credentials', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, BIOMETRICS: { CODE: 2, NAME: 'Biometrics', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, FACE_ID: { CODE: 3, NAME: 'FaceID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, TOUCH_ID: { CODE: 4, NAME: 'TouchID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, OPTIC_ID: { CODE: 5, NAME: 'OpticID', - MQ_VALUE: MQ_VALUES.AUTHENTICATION_METHOD.OTHER, + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.OTHER, }, }, WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: -1, diff --git a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts index 355b08394085a..c042ac5aa6f09 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/SecureStore/types.ts @@ -9,7 +9,7 @@ type AuthTypeInfo = { CODE: number; NAME: string; - MQ_VALUE: string; + MARQETA_VALUE: string; }; /** diff --git a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts index 5ce9716fd9cd1..1e8278f720254 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/VALUES.ts @@ -52,9 +52,11 @@ const REASON = { SIGNATURE_INVALID: 'Signature is invalid', SIGNATURE_MISSING: 'Signature is missing', NO_ACTION_MADE_YET: 'No action has been made yet', - FACTORS_ERROR: 'Authentication factors error', - FACTORS_VERIFIED: 'Authentication factors verified', VALIDATE_CODE_MISSING: 'Validate code is missing', + NO_ELIGIBLE_METHODS: 'No eligible methods available', + UNSUPPORTED_DEVICE: 'Unsupported device', + BAD_REQUEST: 'Bad request', + LOCAL_REGISTRATION_COMPLETE: 'Local registration complete', }, KEYSTORE: { KEY_DELETED: 'Key successfully deleted from SecureStore', @@ -92,6 +94,7 @@ const API_RESPONSE_MAP = { }, 401: { TOO_MANY_ATTEMPTS: REASON.BACKEND.TOO_MANY_ATTEMPTS, + INVALID_VALIDATE_CODE: REASON.BACKEND.INVALID_VALIDATE_CODE, }, 402: { MISSING_CHALLENGE_TYPE: REASON.BACKEND.MISSING_CHALLENGE_TYPE, @@ -121,22 +124,6 @@ const API_RESPONSE_MAP = { } as const; /* eslint-enable @typescript-eslint/naming-convention */ -/** - * Factor origin types for multifactor authentication. - */ -const MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN = { - BIOMETRICS: 'Biometrics', - ADDITIONAL: 'Additional', -} as const; - -/** - * Available multifactor authentication factors. - */ -const MULTIFACTOR_AUTHENTICATION_FACTORS = { - SIGNED_CHALLENGE: 'SIGNED_CHALLENGE', - VALIDATE_CODE: 'VALIDATE_CODE', -} as const; - /** * Expo error message search strings and separator. */ @@ -152,31 +139,6 @@ const EXPO_ERRORS = { }, } as const; -/** - * Maps authentication factors and Expo errors to appropriate reason messages. - */ -const MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS = { - /** Maps authentication factors to their missing error translation paths */ - FACTOR_MISSING_REASONS: { - [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.GENERIC.VALIDATE_CODE_MISSING, - [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE]: REASON.GENERIC.SIGNATURE_MISSING, - }, - - /** Maps authentication factors to their invalid error translation paths */ - FACTOR_INVALID_REASONS: { - [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE]: REASON.BACKEND.INVALID_VALIDATE_CODE, - [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE]: REASON.GENERIC.SIGNATURE_INVALID, - }, - EXPO_ERROR_MAPPINGS: { - [EXPO_ERRORS.SEARCH_STRING.CANCELED]: REASON.EXPO.CANCELED, - [EXPO_ERRORS.SEARCH_STRING.IN_PROGRESS]: REASON.EXPO.IN_PROGRESS, - [EXPO_ERRORS.SEARCH_STRING.NOT_IN_FOREGROUND]: REASON.EXPO.NOT_IN_FOREGROUND, - [EXPO_ERRORS.SEARCH_STRING.EXISTS]: REASON.EXPO.KEY_EXISTS, - [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, - [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, - }, -} as const; - /** * Centralized constants used by the multifactor authentication biometrics flow. * It is stored here instead of the CONST file to avoid circular dependencies. @@ -188,7 +150,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { KEYCHAIN_SERVICE: 'Expensify', /** - * EdDSA key type identifier referred to as EdDSA in the Auth system. + * EdDSA key type identifier referred to as EdDSA in the Auth. */ ED25519_TYPE: 'biometric', @@ -202,38 +164,17 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { EXPO_ERRORS, /** - * Defines the requirements and configuration for each authentication factor. + * Maps authentication Expo errors to appropriate reason messages. */ - FACTORS_REQUIREMENTS: { - SIGNED_CHALLENGE: { - id: MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE, - name: 'Signed Challenge', - parameter: 'signedChallenge', - length: undefined, - origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.BIOMETRICS, - }, - VALIDATE_CODE: { - id: MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE, - name: 'Email One-Time Password', - parameter: 'validateCode', - length: 6, - origin: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN.ADDITIONAL, - }, - }, - - /** - * Valid authentication factor combinations for different scenarios. - */ - FACTOR_COMBINATIONS: { - REGISTRATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.VALIDATE_CODE], - BIOMETRICS_AUTHENTICATION: [MULTIFACTOR_AUTHENTICATION_FACTORS.SIGNED_CHALLENGE], + EXPO_ERROR_MAPPINGS: { + [EXPO_ERRORS.SEARCH_STRING.CANCELED]: REASON.EXPO.CANCELED, + [EXPO_ERRORS.SEARCH_STRING.IN_PROGRESS]: REASON.EXPO.IN_PROGRESS, + [EXPO_ERRORS.SEARCH_STRING.NOT_IN_FOREGROUND]: REASON.EXPO.NOT_IN_FOREGROUND, + [EXPO_ERRORS.SEARCH_STRING.EXISTS]: REASON.EXPO.KEY_EXISTS, + [EXPO_ERRORS.SEARCH_STRING.NO_AUTHENTICATION]: REASON.EXPO.NO_METHOD_AVAILABLE, + [EXPO_ERRORS.SEARCH_STRING.OLD_ANDROID]: REASON.EXPO.NOT_SUPPORTED, }, - /** - * Factor origin classifications. - */ - FACTORS_ORIGIN: MULTIFACTOR_AUTHENTICATION_FACTOR_ORIGIN, - /** * Scenario name mappings. */ @@ -254,10 +195,17 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { REGISTRATION: 'registration', AUTHENTICATION: 'authentication', }, - FACTORS: MULTIFACTOR_AUTHENTICATION_FACTORS, + /** + * One of these parameters are always present in any MFA request. + * Validate code in the registration and signedChallenge in the authentication. + */ + BASE_PARAMETERS: { + SIGNED_CHALLENGE: 'signedChallenge', + VALIDATE_CODE: 'validateCode', + }, API_RESPONSE_MAP, REASON, } as const; -export {MultifactorAuthenticationCallbacks, MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS}; +export {MultifactorAuthenticationCallbacks}; export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts index 93b30a9953865..d463118c7e969 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/helpers.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/helpers.ts @@ -2,23 +2,8 @@ * Helper utilities for multifactor authentication biometrics operations. */ import type {Entries, ValueOf} from 'type-fest'; -import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; -import type { - MultifactorAuthenticationProcessScenarioParameters, - MultifactorAuthenticationScenario, - MultifactorAuthenticationScenarioConfig, - MultifactorAuthenticationScenarioParams, - MultifactorAuthenticationScenarioResponseWithSuccess, -} from '@components/MultifactorAuthentication/config/types'; -import type {MultifactorAuthenticationChallengeObject, SignedChallenge} from './ED25519/types'; -import type { - AllMultifactorAuthenticationFactors, - MultifactorAuthenticationFactor, - MultifactorAuthenticationPartialStatus, - MultifactorAuthenticationReason, - MultifactorAuthenticationResponseMap, -} from './types'; -import VALUES, {MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS} from './VALUES'; +import type {MultifactorAuthenticationReason, MultifactorAuthenticationResponseMap} from './types'; +import VALUES from './VALUES'; type ParseHTTPSource = ValueOf; @@ -30,7 +15,7 @@ const findMessageInSource = (source: ParseHTTPSource[keyof ParseHTTPSource], mes } const sourceEntries = Object.entries(source) as Entries; - const [, value] = sourceEntries.find(([, predefinedMessage]) => predefinedMessage === message) ?? []; + const [, value] = sourceEntries.find(([, predefinedMessage]) => message.endsWith(predefinedMessage)) ?? []; return value ?? VALUES.REASON.BACKEND.UNKNOWN_RESPONSE; }; @@ -69,136 +54,6 @@ function parseHttpRequest( }; } -/** - * Returns the appropriate error reason when a required authentication factor is missing. - */ -function factorMissingReason(factor: MultifactorAuthenticationFactor): MultifactorAuthenticationReason { - return MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.FACTOR_MISSING_REASONS[factor] ?? VALUES.REASON.GENERIC.FACTORS_ERROR; -} - -/** - * Returns the appropriate error reason when an authentication factor is invalid. - */ -function factorInvalidReason(factor: MultifactorAuthenticationFactor): MultifactorAuthenticationReason { - return MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.FACTOR_INVALID_REASONS[factor] ?? VALUES.REASON.GENERIC.FACTORS_ERROR; -} - -/** - * Creates an unsuccessful step result with the required factor for the next step. - */ -function createUnsuccessfulStep(requiredFactor: MultifactorAuthenticationFactor) { - return { - requiredFactorForNextStep: requiredFactor, - wasRecentStepSuccessful: false, - isRequestFulfilled: false, - }; -} - -/** - * Validates that all required authentication factors are present and valid. - * Checks factor existence and validates factor length if applicable. - */ -function areMultifactorAuthenticationFactorsSufficient( - factors: Partial, - factorsCombination: ValueOf, -): MultifactorAuthenticationPartialStatus { - const requiredFactors = factorsCombination.map((id) => VALUES.FACTORS_REQUIREMENTS[id]); - - for (const {id, parameter, name, length} of requiredFactors) { - const value = factors[parameter]; - - // Check if factor is missing - if (value === undefined) { - return { - value: `Missing required factor: ${name} (${parameter})`, - step: createUnsuccessfulStep(id), - reason: factorMissingReason(id), - }; - } - - // Check if factor length is valid (if length requirement exists) - if (typeof length === 'number' && (typeof value === 'string' || typeof value === 'number')) { - const valueLength = String(value).length; - if (valueLength !== length) { - return { - value: `Invalid length for factor: ${name} (${parameter}). Expected length ${length}, got length ${valueLength}`, - step: createUnsuccessfulStep(id), - reason: factorInvalidReason(id), - }; - } - } - } - - return { - value: true, - step: { - requiredFactorForNextStep: undefined, - wasRecentStepSuccessful: undefined, - isRequestFulfilled: false, - }, - reason: VALUES.REASON.GENERIC.FACTORS_VERIFIED, - }; -} - -/** - * Processes the authorization response and determines the next step in the authentication flow. - */ -const transformMultifactorAuthenticationActionResponse = ( - status: MultifactorAuthenticationPartialStatus, - params: MultifactorAuthenticationScenarioParams, - failedFactor?: MultifactorAuthenticationFactor, -) => { - const {successful} = status.value; - const {validateCode} = params; - - return { - ...status, - value: validateCode && successful ? validateCode : undefined, - step: { - requiredFactorForNextStep: failedFactor, - wasRecentStepSuccessful: successful, - isRequestFulfilled: !failedFactor, - }, - reason: status.reason, - }; -}; - -/** - * Processes a multifactor authentication scenario by validating factors and calling the scenario action. - */ -async function processMultifactorAuthenticationScenario( - scenario: T, - params: MultifactorAuthenticationProcessScenarioParameters, - factorsCombination: ValueOf, -): Promise> { - const factorsCheckResult = areMultifactorAuthenticationFactorsSufficient(params, factorsCombination); - - const currentScenario = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] as MultifactorAuthenticationScenarioConfig; - - if (factorsCheckResult.value !== true) { - return transformMultifactorAuthenticationActionResponse( - { - ...factorsCheckResult, - value: {httpCode: undefined, successful: false}, - }, - params, - factorsCheckResult.step.requiredFactorForNextStep, - ); - } - - // We can safely make this assertion because the factors check method guarantees that the necessary conditions are met - const {httpCode, reason} = await currentScenario.action(params); - const successful = String(httpCode).startsWith('2'); - - return transformMultifactorAuthenticationActionResponse( - { - value: {successful, httpCode}, - reason, - }, - params, - ); -} - /** * Decodes Expo error messages and maps them to authentication error reasons. */ @@ -207,7 +62,7 @@ function decodeExpoMessage(error: unknown): MultifactorAuthenticationReason { const parts = errorString.split(VALUES.EXPO_ERRORS.SEPARATOR); const searchString = parts.length > 1 ? parts.slice(1).join(';').trim() : errorString; - for (const [searchKey, errorValue] of Object.entries(MULTIFACTOR_AUTHENTICATION_ERROR_MAPPINGS.EXPO_ERROR_MAPPINGS)) { + for (const [searchKey, errorValue] of Object.entries(VALUES.EXPO_ERROR_MAPPINGS)) { if (searchString.includes(searchKey)) { return errorValue; } @@ -224,11 +79,4 @@ const decodeMultifactorAuthenticationExpoMessage = (message: unknown, fallback?: return decodedMessage === VALUES.REASON.EXPO.GENERIC && fallback ? fallback : decodedMessage; }; -/** - * Type guard to check if a challenge has been signed by verifying the presence of rawId property. - */ -function isChallengeSigned(challenge: MultifactorAuthenticationChallengeObject | SignedChallenge): challenge is SignedChallenge { - return 'rawId' in challenge; -} - -export {processMultifactorAuthenticationScenario as processScenario, decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, isChallengeSigned, parseHttpRequest}; +export {decodeMultifactorAuthenticationExpoMessage as decodeExpoMessage, parseHttpRequest}; diff --git a/src/libs/MultifactorAuthentication/Biometrics/types.ts b/src/libs/MultifactorAuthentication/Biometrics/types.ts index bea4b1298f727..8c190d2be1c4b 100644 --- a/src/libs/MultifactorAuthentication/Biometrics/types.ts +++ b/src/libs/MultifactorAuthentication/Biometrics/types.ts @@ -1,17 +1,30 @@ /** * Type definitions for multifactor authentication biometrics operations. */ -import type {EmptyObject, Simplify, ValueOf} from 'type-fest'; +import type {ValueOf} from 'type-fest'; +import type {AllMultifactorAuthenticationOutcomeType} from '@components/MultifactorAuthentication/config/types'; import type {SignedChallenge} from './ED25519/types'; import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; + /** - * Basic authentication requirement types for signed challenge and validation code. + * Authentication type name derived from secure store values. */ -type BasicMultifactorAuthenticationRequirementTypes = { - [VALUES.FACTORS.SIGNED_CHALLENGE]: SignedChallenge; - [VALUES.FACTORS.VALIDATE_CODE]: number; +type AuthTypeName = ValueOf['NAME']; + +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE']; + +type AuthTypeInfo = { + code: MultifactorAuthenticationMethodCode; + name: AuthTypeName; + marqetaValue: MarqetaAuthTypeName; +}; + +type OutcomePaths = { + successOutcome: AllMultifactorAuthenticationOutcomeType; + failureOutcome: AllMultifactorAuthenticationOutcomeType; }; /** @@ -21,22 +34,11 @@ type MultifactorAuthenticationReason = ValueOf<{ [K in keyof typeof VALUES.REASON]: ValueOf<(typeof VALUES.REASON)[K]>; }>; -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; - /** - * Conditional type for including or omitting the step field in partial status. + * Represents a status result of multifactor authentication keystore operation. + * Contains the operation result value, reason message and auth type code. */ -type MultifactorAuthenticationPartialStatusConditional = OmitStep extends false - ? { - step: MultifactorAuthenticationStep; - } - : EmptyObject; - -/** - * Represents a partial status result of multifactor authentication operations. - * Contains the operation result value, reason message, and optionally the authentication step state. - */ -type MultifactorAuthenticationPartialStatus = MultifactorAuthenticationPartialStatusConditional & { +type MultifactorAuthenticationKeyStoreStatus = { value: T; reason: MultifactorAuthenticationReason; @@ -45,51 +47,11 @@ type MultifactorAuthenticationPartialStatus = MultifactorAu }; /** - * Factors requirements configuration. - */ -type MultifactorAuthenticationFactorsRequirements = ValueOf; - -/** - * Individual authentication factor types. - */ -type MultifactorAuthenticationFactor = ValueOf; - -/** - * Main authentication factors excluding additional factors. - */ -type MultifactorAuthenticationFactors = { - [K in MultifactorAuthenticationFactorsRequirements as K extends { - origin: typeof VALUES.FACTORS_ORIGIN.ADDITIONAL; - } - ? never - : K['parameter']]: BasicMultifactorAuthenticationRequirementTypes[K['id']]; -}; - -/** - * Maps scenarios to their additional factors - */ -type MultifactorAuthorizationAdditionalFactors = { - [K in MultifactorAuthenticationFactorsRequirements as K extends { - origin: typeof VALUES.FACTORS_ORIGIN.ADDITIONAL; - } - ? K['parameter'] - : never]?: BasicMultifactorAuthenticationRequirementTypes[K['id']]; -}; - -/** - * Combined type representing all possible authentication factors (required and additional). + * Combined type representing all possible authentication base parameters. */ -type AllMultifactorAuthenticationFactors = Simplify; - -/** - * Represents the state of a step in the multifactor authentication flow. - */ -type MultifactorAuthenticationStep = { - wasRecentStepSuccessful: boolean | undefined; - - requiredFactorForNextStep: MultifactorAuthenticationFactor | undefined; - - isRequestFulfilled: boolean; +type AllMultifactorAuthenticationBaseParameters = { + signedChallenge: SignedChallenge; + validateCode?: string | undefined; }; /** @@ -105,33 +67,19 @@ type MultifactorAuthenticationKeyType = ValueOf; /** * Parameters for a multifactor authentication action with required authentication factor. */ -type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationFactors> = T & Pick; +type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationBaseParameters> = T & + Pick & {authenticationMethod: MarqetaAuthTypeName}; -/** - * Supported key types for multifactor authentication. - */ -type KeyInfoType = 'biometric' | 'public-key'; - -type ResponseDetails = T extends 'biometric' - ? { - biometric: { - publicKey: Base64URLString; - /** ED25519 algorithm identifier per COSE spec: -8 */ - algorithm: -8; - }; - } - : { - clientDataJSON: Base64URLString; - attestationObject: Base64URLString; - }; - -/** - * Information about a cryptographic key including its raw ID, type, and response details. - */ -type MultifactorAuthenticationKeyInfo = { +type MultifactorAuthenticationKeyInfo = { rawId: Base64URLString; - type: T; - response: ResponseDetails; + type: typeof VALUES.ED25519_TYPE; + response: { + clientDataJSON: Base64URLString; + biometric: { + publicKey: Base64URLString; + algorithm: -8; + }; + }; }; /** @@ -146,17 +94,18 @@ type MultifactorKeyStoreOptions = T type ChallengeType = ValueOf; export type { - MultifactorAuthenticationFactor, - MultifactorAuthenticationStep, MultifactorAuthenticationResponseMap, MultifactorAuthenticationKeyType, - AllMultifactorAuthenticationFactors, - MultifactorAuthenticationPartialStatus, + AllMultifactorAuthenticationBaseParameters, + MultifactorAuthenticationKeyStoreStatus, + MultifactorAuthenticationKeyInfo, MultifactorAuthenticationActionParams, MultifactorKeyStoreOptions, MultifactorAuthenticationReason, - MultifactorAuthenticationKeyInfo, MultifactorAuthenticationMethodCode, - ResponseDetails, ChallengeType, + MarqetaAuthTypeName, + OutcomePaths, + AuthTypeName, + AuthTypeInfo, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f99f6c0a856fb..3fa736f0dfa7d 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -89,6 +89,25 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.MISSING_PERSONAL_DETAILS]: { animationTypeForReplace: 'push', }, + + [SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE]: { + animationTypeForReplace: 'push', + }, + [SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST]: { + animationTypeForReplace: 'push', + }, + [SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME]: { + animationTypeForReplace: 'push', + }, + [SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT]: { + animationTypeForReplace: 'push', + }, + [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: { + animationTypeForReplace: 'push', + }, + [SCREENS.MULTIFACTOR_AUTHENTICATION.NOT_FOUND]: { + animationTypeForReplace: 'push', + }, }; /** diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 4b27936a29b1c..481d86a06144a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -4,6 +4,7 @@ import React, {useCallback, 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 {MultifactorAuthenticationContextProviders} from '@components/MultifactorAuthentication/Context'; import { animatedWideRHPWidth, expandedRHPProgress, @@ -172,258 +173,260 @@ 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 ce5436664bdda..1f10609b532fa 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'; @@ -3094,10 +3095,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/libs/actions/MultifactorAuthentication.ts b/src/libs/actions/MultifactorAuthentication/index.ts similarity index 52% rename from src/libs/actions/MultifactorAuthentication.ts rename to src/libs/actions/MultifactorAuthentication/index.ts index 075cee1130a29..0bd64c8082163 100644 --- a/src/libs/actions/MultifactorAuthentication.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -7,8 +7,9 @@ import type {MultifactorAuthenticationScenarioParameters} from '@components/Mult import {makeRequestWithSideEffects} from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; +import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/Biometrics/ED25519/types'; import {parseHttpRequest} from '@libs/MultifactorAuthentication/Biometrics/helpers'; -import type {ChallengeType} from '@libs/MultifactorAuthentication/Biometrics/types'; +import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -28,9 +29,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; * Please consult before using this pattern. */ -async function registerAuthenticationKey({keyInfo, validateCode}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { +async function registerAuthenticationKey({keyInfo, authenticationMethod}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { try { - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo, validateCode}, {}); + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo: JSON.stringify(keyInfo), authenticationMethod}); const {jsonCode, message} = response ?? {}; return parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY, message); @@ -40,18 +41,60 @@ async function registerAuthenticationKey({keyInfo, validateCode}: MultifactorAut } } -async function requestAuthenticationChallenge(challengeType: ChallengeType = 'authentication') { +type RegistrationChallengeResponse = { + httpCode: number; + reason: MultifactorAuthenticationReason; + challenge: RegistrationChallenge | undefined; + publicKeys: string[] | undefined; +}; + +type AuthenticationChallengeResponse = { + httpCode: number; + reason: MultifactorAuthenticationReason; + challenge: AuthenticationChallenge | undefined; + publicKeys: string[] | undefined; +}; + +async function requestRegistrationChallenge(validateCode: string): Promise { try { - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, {challengeType}, {}); + const optimisticData: Array> = [ + { + key: ONYXKEYS.ACCOUNT, + onyxMethod: Onyx.METHOD.MERGE, + value: { + isLoading: true, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, + }, + }, + ]; + const finallyData: Array> = [ + { + key: ONYXKEYS.ACCOUNT, + onyxMethod: Onyx.METHOD.MERGE, + value: { + isLoading: false, + loadingForm: undefined, + }, + }, + ]; + const response = await makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, + { + challengeType: 'registration', + validateCode, + }, + {optimisticData, finallyData}, + ); const {jsonCode, challenge, publicKeys, message} = response ?? {}; + const parsedResponse = parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE, message); return { - ...parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE, message), - challenge, + ...parsedResponse, + challenge: challenge as RegistrationChallenge | undefined, publicKeys, }; } catch (error) { - Log.hmmm('[MultifactorAuthentication] Failed to request an authentication challenge', {error}); + Log.hmmm('[MultifactorAuthentication] Failed to request a registration challenge', {error}); return { ...parseHttpRequest(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE, undefined), challenge: undefined, @@ -60,9 +103,40 @@ async function requestAuthenticationChallenge(challengeType: ChallengeType = 'au } } -async function troubleshootMultifactorAuthentication({signedChallenge}: MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']) { +async function requestAuthorizationChallenge(): Promise { try { - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION, {signedChallenge}, {}); + const response = await makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, + { + challengeType: 'authentication', + }, + {}, + ); + const {jsonCode, challenge, publicKeys, message} = response ?? {}; + const parsedResponse = parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE, message); + + return { + ...parsedResponse, + challenge: challenge as AuthenticationChallenge | undefined, + publicKeys, + }; + } catch (error) { + Log.hmmm('[MultifactorAuthentication] Failed to request an authorization challenge', {error}); + return { + ...parseHttpRequest(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REQUEST_AUTHENTICATION_CHALLENGE, undefined), + challenge: undefined, + publicKeys: undefined, + }; + } +} + +async function troubleshootMultifactorAuthentication({signedChallenge, authenticationMethod}: MultifactorAuthenticationScenarioParameters['BIOMETRICS-TEST']) { + try { + const response = await makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.TROUBLESHOOT_MULTIFACTOR_AUTHENTICATION, + {signedChallenge: JSON.stringify(signedChallenge), authenticationMethod}, + {}, + ); const {jsonCode, message} = response ?? {}; @@ -113,4 +187,17 @@ async function revokeMultifactorAuthenticationCredentials() { } } -export {registerAuthenticationKey, requestAuthenticationChallenge, troubleshootMultifactorAuthentication, revokeMultifactorAuthenticationCredentials}; +function markHasAcceptedSoftPrompt() { + Onyx.merge(ONYXKEYS.DEVICE_BIOMETRICS, { + hasAcceptedSoftPrompt: true, + }); +} + +export { + registerAuthenticationKey, + requestRegistrationChallenge, + requestAuthorizationChallenge, + troubleshootMultifactorAuthentication, + revokeMultifactorAuthenticationCredentials, + markHasAcceptedSoftPrompt, +}; diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts new file mode 100644 index 0000000000000..df0962ebb258a --- /dev/null +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -0,0 +1,141 @@ +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import type { + MultifactorAuthenticationProcessScenarioParameters, + MultifactorAuthenticationScenario, + MultifactorAuthenticationScenarioConfig, +} from '@components/MultifactorAuthentication/config/types'; +import type {MarqetaAuthTypeName, MultifactorAuthenticationKeyInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/Biometrics/types'; +import VALUES from '@libs/MultifactorAuthentication/Biometrics/VALUES'; +import CONST from '@src/CONST'; +import Base64URL from '@src/utils/Base64URL'; +import {registerAuthenticationKey} from './index'; + +type ProcessResult = { + success: boolean; + reason: MultifactorAuthenticationReason; +}; + +/** + * Determines if an HTTP response code indicates success. + * Checks if the status code is in the 2xx range. + * + * @param httpCode - The HTTP status code to check + * @returns True if the code is in the 2xx range, false otherwise + */ +function isHttpSuccess(httpCode: number | undefined): boolean { + return String(httpCode).startsWith('2'); +} + +type RegistrationParams = { + publicKey: string; + authenticationMethod: MarqetaAuthTypeName; + challenge: string; +}; + +/** + * Creates a MultifactorAuthenticationKeyInfo object from a public key and challenge. + * Constructs the required clientDataJSON with base64URL encoding and embeds the public key + * with ED25519 algorithm information for registration. + * + * @param params - Parameters object + * @param params.publicKey - The public key as a base64URL string + * @param params.challenge - The challenge string to be embedded in clientDataJSON + * @returns Key info object with encoded challenge and public key + */ +function createKeyInfoObject({publicKey, challenge}: {publicKey: string; challenge: string}): MultifactorAuthenticationKeyInfo { + const rawId: Base64URLString = publicKey; + + // Create clientDataJSON with the challenge + const clientDataJSON = JSON.stringify({challenge}); + const clientDataJSONBase64 = Base64URL.encode(clientDataJSON); + + return { + rawId, + type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + response: { + clientDataJSON: clientDataJSONBase64, + biometric: { + publicKey, + algorithm: -8 as const, + }, + }, + }; +} + +/** + * Processes a biometric registration request. + * Validates the challenge, constructs the key info object, and registers the authentication key + * with the backend API. Returns success status and reason code. + * + * @async + * @param params - Registration parameters including: + * - publicKey: The public key from biometric registration + * - authenticationMethod: The biometric method used (face, fingerprint, etc.) + * - challenge: The registration challenge from the backend + * - currentPublicKeyIDs: Existing public key IDs for this account + * @returns Object with success status and reason code + */ +async function processRegistration(params: RegistrationParams): Promise { + if (!params.challenge) { + return { + success: false, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING, + }; + } + + const keyInfo = createKeyInfoObject({ + publicKey: params.publicKey, + challenge: params.challenge, + }); + + const {httpCode, reason} = await registerAuthenticationKey({ + keyInfo, + authenticationMethod: params.authenticationMethod, + }); + + const success = isHttpSuccess(httpCode); + + return { + success, + reason, + }; +} + +/** + * Processes a multifactor authentication scenario action. + * Executes the scenario-specific action with the signed challenge + * and additional parameters. Returns success status and reason. + * + * @async + * @template T - The type of the multifactor authentication scenario + * @param scenario - The MFA scenario to process + * @param params - Scenario parameters including: + * - signedChallenge: The signed challenge response from biometric authentication + * - authenticationMethod: The biometric method used + * - Additional scenario-specific parameters (e.g., transactionID) + * @returns Object with success status and reason + */ +async function processScenario( + scenario: T, + params: MultifactorAuthenticationProcessScenarioParameters & {authenticationMethod: MarqetaAuthTypeName}, +): Promise { + const currentScenario = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario] as MultifactorAuthenticationScenarioConfig; + + if (!params.signedChallenge) { + return { + success: false, + reason: VALUES.REASON.GENERIC.SIGNATURE_MISSING, + }; + } + + const {httpCode, reason} = await currentScenario.action(params); + const success = isHttpSuccess(httpCode); + + return { + success, + reason, + }; +} + +export {processRegistration, processScenario}; +export type {ProcessResult, RegistrationParams}; diff --git a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx index 048703a98ab6e..023d34b1c1337 100644 --- a/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx +++ b/src/pages/MultifactorAuthentication/BiometricsTestPage.tsx @@ -1,22 +1,49 @@ import React, {useEffect} from 'react'; +import {InteractionManager} from 'react-native'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context'; import ScreenWrapper from '@components/ScreenWrapper'; -import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; - -const LOADING_DELAY_MS = 400; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; function MultifactorAuthenticationBiometricsTestPage() { + const {executeScenario} = useMultifactorAuthentication(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + 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); + if (isOffline) { + return; + } + + // The reason for using it, despite it being deprecated: https://github.com/Expensify/App/pull/79473/files#r2745847379 + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST)); - return () => clearTimeout(timeoutId); - }, []); + // This should only fire once - on mount, or if the user switches from offline to online. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOffline]); return ( - + {/* + The back button needs to be displayed when the user is offline so they can exit the offline page, + and not get stuck there. If they are online, they will simply be redirected to the next flow page. + */} + {isOffline && ( + + )} + + + ); } diff --git a/src/pages/MultifactorAuthentication/OutcomePage.tsx b/src/pages/MultifactorAuthentication/OutcomePage.tsx index 0d505af02dea4..c8509c41781af 100644 --- a/src/pages/MultifactorAuthentication/OutcomePage.tsx +++ b/src/pages/MultifactorAuthentication/OutcomePage.tsx @@ -4,6 +4,10 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {loadIllustration} from '@components/Icon/IllustrationLoader'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP} from '@components/MultifactorAuthentication/config'; +import {getOutcomePath} from '@components/MultifactorAuthentication/config/outcomePaths'; +import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context'; import ScreenWrapper from '@components/ScreenWrapper'; import {useMemoizedLazyAsset} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -11,49 +15,50 @@ 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 {MultifactorAuthenticationTranslationParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; 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; +type MultifactorAuthenticationLocalize = LocaleContextProps & { + translate: (path: TPath, params: MultifactorAuthenticationTranslationParams) => string; +}; + function MultifactorAuthenticationOutcomePage({route}: MultifactorAuthenticationOutcomePageProps) { - const {translate} = useLocalize(); + const {translate} = useLocalize() as MultifactorAuthenticationLocalize; const styles = useThemeStyles(); + const {state} = useMultifactorAuthenticationState(); const onGoBackPress = () => { - Navigation.dismissModal(); + Navigation.closeRHPFlow(); }; - const isSuccessOutcome = route.params.outcomeType === CONST.MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE.SUCCESS; - - let headerTitle = translate(mockedConfigFailure.headerTitle); - let title = translate(mockedConfigFailure.title); - let description = translate(mockedConfigFailure.description); + let outcomePath = route.params.outcomeType; - 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'}); + switch (state.error?.reason) { + case CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.NO_ELIGIBLE_METHODS: + outcomePath = getOutcomePath(state.scenario, 'no-eligible-methods'); + break; + case CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNSUPPORTED_DEVICE: + outcomePath = getOutcomePath(state.scenario, 'unsupported-device'); + break; + default: + break; } - const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(isSuccessOutcome ? 'OpenPadlock' : 'HumptyDumpty')); + const data = MULTIFACTOR_AUTHENTICATION_OUTCOME_MAP[outcomePath]; + + const {asset: icon} = useMemoizedLazyAsset(() => loadIllustration(data?.illustration ?? 'HumptyDumpty')); + + // Get text values from outcome config and translate them + const headerTitle = translate(data.headerTitle ?? 'multifactorAuthentication.biometricsTest.biometricsAuthentication'); + const title = translate(data.title ?? 'multifactorAuthentication.oops'); + + const description = translate(data.description ?? 'multifactorAuthentication.biometricsTest.yourAttemptWasUnsuccessful', {authType: state.authenticationMethod?.name, registered: false}); + + const CustomDescription = data?.customDescription; + const CustomSubtitle = CustomDescription ? : undefined; return ( @@ -66,11 +71,12 @@ function MultifactorAuthenticationOutcomePage({route}: MultifactorAuthentication ; +type MultifactorAuthenticationPromptPageProps = PlatformStackScreenProps; -function MultifactorAuthenticationPromptPage() { +function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationPromptPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {cancel} = useMultifactorAuthentication(); + const {state, dispatch} = useMultifactorAuthenticationState(); + const {isOffline} = useNetwork(); - const [isConfirmModalVisible, setConfirmModalVisibility] = useState(false); + const {animation, title, subtitle, shouldDisplayConfirmButton} = usePromptContent(route.params.promptType); + + const [isCancelModalVisible, setCancelModalVisibility] = 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)); + markHasAcceptedSoftPrompt(); + dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }; - const showConfirmModal = () => { - setConfirmModalVisibility(true); + const showCancelModal = () => { + if (isOffline) { + Navigation.closeRHPFlow(); + } else { + setCancelModalVisibility(true); + } }; - const hideConfirmModal = () => { - setConfirmModalVisibility(false); + const hideCancelModal = () => { + setCancelModalVisibility(false); }; const cancelFlow = () => { - if (!isConfirmModalVisible) { - return; + if (isCancelModalVisible) { + hideCancelModal(); } - - hideConfirmModal(); - // Temporary navigation, expected behavior: trigger the failure from the MultifactorAuthenticationContext - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(CONST.MULTIFACTOR_AUTHENTICATION_OUTCOME_TYPE.FAILURE)); + cancel(); }; const focusTrapConfirmModal = () => { - setConfirmModalVisibility(true); + setCancelModalVisibility(true); return false; }; @@ -66,26 +73,35 @@ function MultifactorAuthenticationPromptPage() { > -