Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions src/components/MultifactorAuthentication/Context/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenari
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 type {ChallengeType, MultifactorAuthenticationCallbackInput, MultifactorAuthenticationReason, OutcomePaths} from '@libs/MultifactorAuthentication/Biometrics/types';
import Navigation from '@navigation/Navigation';
import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {processRegistration, processScenario} from '@userActions/MultifactorAuthentication/processing';
Expand Down Expand Up @@ -74,6 +74,47 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
const platform = getPlatform();
const isWeb = useMemo(() => platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.MOBILE_WEB, [platform]);

/**
* Handles the completion of a multifactor authentication scenario.
* Invokes the scenario's callback function and navigates to the appropriate outcome screen.
* This function is called after the MFA flow completes (either successfully or with failure).
* It provides the scenario callback with relevant information (HTTP codes, error messages, response body)
* and then either:
* 1. Allows the callback to handle navigation (if it returns SKIP_OUTCOME_SCREEN)
* 2. Navigates to the success/failure outcome screen
*
* @param isSuccessful - Whether the authentication scenario completed successfully
*/
const handleCallback = useCallback(
async (isSuccessful: boolean) => {
const {error, scenario, scenarioResponse, outcomePaths} = state;
const paths = outcomePaths ?? getOutcomePaths(scenario);

if (!scenario) {
return;
}

const scenarioConfig = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[scenario];
const callbackInput: MultifactorAuthenticationCallbackInput = {
httpCode: scenarioResponse?.httpCode,
message: scenarioResponse?.reason ?? error?.reason,
body: scenarioResponse?.body,
};

const callbackResponse = await scenarioConfig.callback?.(isSuccessful, callbackInput);

// If the callback returns SKIP_OUTCOME_SCREEN, the callback handles navigation itself
if (callbackResponse === CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN) {
dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
return;
}

Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(isSuccessful ? paths.successOutcome : paths.failureOutcome), {forceReplace: true});
dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
},
[dispatch, state],
);

/**
* Internal process function that runs after each step.
* Uses if statements to determine and execute the next step in the flow.
Expand All @@ -88,7 +129,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
registrationChallenge,
authorizationChallenge,
payload,
outcomePaths,
isRegistrationComplete,
isAuthorizationComplete,
isFlowComplete,
Expand All @@ -106,8 +146,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
return;
}

const paths = outcomePaths ?? getOutcomePaths(scenario);

// 1. Check if there's an error - stop processing
if (error) {
if (error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.REGISTRATION_REQUIRED) {
Expand All @@ -116,8 +154,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
return;
}

Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(paths.failureOutcome), {forceReplace: true});
dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
handleCallback(false);
return;
}

Expand Down Expand Up @@ -293,17 +330,18 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
return;
}

// Store the scenario response for callback invocation at outcome navigation
dispatch({type: 'SET_SCENARIO_RESPONSE', payload: scenarioAPIResponse});
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]);
// 5. All steps completed - invoke callback to determine whether to show the outcome screen
handleCallback(true);
}, [biometrics, dispatch, handleCallback, isOffline, state, isWeb]);

/**
* Drives the MFA state machine forward whenever relevant state changes occur.
Expand Down
13 changes: 12 additions & 1 deletion src/components/MultifactorAuthentication/Context/State.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, {createContext, useContext, useMemo, useReducer} from 'react';
import type {ReactNode} from 'react';
import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types';
import type {
MultifactorAuthenticationScenario,
MultifactorAuthenticationScenarioAdditionalParams,
MultifactorAuthenticationScenarioResponse,
} 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';
Expand Down Expand Up @@ -49,6 +53,9 @@ type MultifactorAuthenticationState = {

/** Authentication method used (e.g., 'BIOMETRIC_FACE', 'BIOMETRIC_FINGERPRINT') */
authenticationMethod: AuthTypeInfo | undefined;

/** Response from the scenario API call, stored for callback invocation at outcome navigation */
scenarioResponse: MultifactorAuthenticationScenarioResponse | undefined;
};

type MultifactorAuthenticationStateContextValue = {
Expand All @@ -70,6 +77,7 @@ const DEFAULT_STATE: MultifactorAuthenticationState = {
isAuthorizationComplete: false,
isFlowComplete: false,
authenticationMethod: undefined,
scenarioResponse: undefined,
};

type InitPayload = {
Expand All @@ -92,6 +100,7 @@ type Action =
| {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean}
| {type: 'SET_FLOW_COMPLETE'; payload: boolean}
| {type: 'SET_AUTHENTICATION_METHOD'; payload: AuthTypeInfo | undefined}
| {type: 'SET_SCENARIO_RESPONSE'; payload: MultifactorAuthenticationScenarioResponse | undefined}
| {type: 'INIT'; payload: InitPayload}
| {type: 'REREGISTER'}
| {type: 'RESET'};
Expand Down Expand Up @@ -145,6 +154,8 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu
return {...state, isFlowComplete: action.payload};
case 'SET_AUTHENTICATION_METHOD':
return {...state, authenticationMethod: action.payload};
case 'SET_SCENARIO_RESPONSE':
return {...state, scenarioResponse: action.payload};
case 'INIT':
return {
...DEFAULT_STATE,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import type {MultifactorAuthenticationDefaultUIConfig, MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types';
import NoEligibleMethodsDescription from '@components/MultifactorAuthentication/NoEligibleMethodsDescription';
import UnsupportedDeviceDescription from '@components/MultifactorAuthentication/UnsupportedDeviceDescription';
import type {MultifactorAuthenticationCallbackInput, MultifactorAuthenticationCallbackResponse} from '@libs/MultifactorAuthentication/Biometrics/types';
// Spacing utilities are needed for icon padding configuration in outcomes defaults
// eslint-disable-next-line no-restricted-imports
import spacing from '@styles/utils/spacing';
import variables from '@styles/variables';
import CONST from '@src/CONST';

/**
* Default callback that returns SHOW_OUTCOME_SCREEN.
* Scenarios can override this with their own callback to handle custom navigation logic.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const defaultCallback = (isSuccessful: boolean, callbackInput: MultifactorAuthenticationCallbackInput): Promise<MultifactorAuthenticationCallbackResponse> =>
Promise.resolve(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN);

/**
* Default UI configuration for all multifactor authentication scenarios with modals and outcomes.
Expand Down Expand Up @@ -67,6 +77,7 @@ const DEFAULT_CONFIG = {
cancelButtonText: 'common.cancel',
},
},
callback: defaultCallback,
} as const satisfies MultifactorAuthenticationDefaultUIConfig;

/**
Expand Down
16 changes: 14 additions & 2 deletions src/components/MultifactorAuthentication/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
MultifactorAuthenticationActionParams,
MultifactorAuthenticationKeyInfo,
MultifactorAuthenticationReason,
MultifactorAuthenticationScenarioCallback,
} from '@libs/MultifactorAuthentication/Biometrics/types';
import type CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
Expand Down Expand Up @@ -138,8 +139,11 @@ type MultifactorAuthenticationOutcomeSuffixes<T extends MultifactorAuthenticatio
* Response from a multifactor authentication scenario action.
*/
type MultifactorAuthenticationScenarioResponse = {
httpCode: number;
httpCode: number | undefined;
reason: MultifactorAuthenticationReason;

/** Optional response body containing scenario-specific data (e.g., {pin: number} for PIN reveal) */
body?: Record<string, unknown>;
};

/**
Expand Down Expand Up @@ -168,6 +172,14 @@ type MultifactorAuthenticationScenarioConfig<T extends Record<string, unknown> =
* so the absence of payload will be tolerated at the run-time.
*/
pure?: true;

/**
* Callback function that is invoked after the API call completes (success or failure).
* The callback receives the success status and input containing HTTP code, message, and response body.
* Returns a MultifactorAuthenticationCallbackResponse value that determines the post-callback behavior
* (e.g., whether to show the outcome screen or let the callback handle navigation).
*/
callback?: MultifactorAuthenticationScenarioCallback;
} & MultifactorAuthenticationUI;

/**
Expand All @@ -181,7 +193,7 @@ type MultifactorAuthenticationScenarioCustomConfig<T extends Record<string, unkn
/**
* Default UI configuration shared across scenarios.
*/
type MultifactorAuthenticationDefaultUIConfig = Pick<MultifactorAuthenticationScenarioConfig<never>, 'MODALS' | 'OUTCOMES'>;
type MultifactorAuthenticationDefaultUIConfig = Pick<MultifactorAuthenticationScenarioConfig<never>, 'MODALS' | 'OUTCOMES' | 'callback'>;

/**
* Record mapping all scenarios to their configurations.
Expand Down
14 changes: 14 additions & 0 deletions src/libs/MultifactorAuthentication/Biometrics/VALUES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = {
REGISTRATION: 'registration',
AUTHENTICATION: 'authentication',
},

/**
* One of these parameters are always present in any MFA request.
* Validate code in the registration and signedChallenge in the authentication.
Expand All @@ -205,6 +206,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = {
},
API_RESPONSE_MAP,
REASON,

/**
* Specifically meaningful values for `multifactorAuthenticationPublicKeyIDs` in the `account` Onyx key.
* Casting `[] as string[]` is necessary to allow us to actually store the value in Onyx. Otherwise the
Expand All @@ -213,6 +215,18 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = {
*/
PUBLIC_KEYS_PREVIOUSLY_BUT_NOT_CURRENTLY_REGISTERED: [] as string[],
PUBLIC_KEYS_AUTHENTICATION_NEVER_REGISTERED: undefined,

/**
* Callback response values that determine what the MultifactorAuthenticationContext should do
* after a scenario callback is executed.
*/
CALLBACK_RESPONSE: {
/** Skip the outcome screen - the callback handles navigation itself */
SKIP_OUTCOME_SCREEN: 'SKIP_OUTCOME_SCREEN',

/** Show the outcome screen - continue with normal flow */
SHOW_OUTCOME_SCREEN: 'SHOW_OUTCOME_SCREEN',
},
} as const;

export {MultifactorAuthenticationCallbacks};
Expand Down
30 changes: 30 additions & 0 deletions src/libs/MultifactorAuthentication/Biometrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ type MultifactorKeyStoreOptions<T extends MultifactorAuthenticationKeyType> = T

type ChallengeType = ValueOf<typeof VALUES.CHALLENGE_TYPE>;

/**
* Response type that determines what the MultifactorAuthenticationContext should do
* after a scenario callback is executed.
*/
type MultifactorAuthenticationCallbackResponse = ValueOf<typeof VALUES.CALLBACK_RESPONSE>;

/**
* Input provided to the scenario callback containing information about the final API call.
*/
type MultifactorAuthenticationCallbackInput = {
/** The HTTP status code of the API response, if applicable */
httpCode: number | undefined;

/** The HTTP status message or a pre-defined reason if the error occurred on the front-end */
message?: string;

/** Object containing the data that is relevant to the Scenario (e.g., {pin: number} for PIN scenarios) */
body?: Record<string, unknown>;
};

/**
* Callback function type for multifactor authentication scenarios.
* Called after the API call completes (success or failure).
* Returns a response that determines whether to show the outcome screen.
*/
type MultifactorAuthenticationScenarioCallback = (isSuccessful: boolean, callbackInput: MultifactorAuthenticationCallbackInput) => Promise<MultifactorAuthenticationCallbackResponse>;

export type {
MultifactorAuthenticationResponseMap,
MultifactorAuthenticationKeyType,
Expand All @@ -108,4 +135,7 @@ export type {
OutcomePaths,
AuthTypeName,
AuthTypeInfo,
MultifactorAuthenticationCallbackResponse,
MultifactorAuthenticationCallbackInput,
MultifactorAuthenticationScenarioCallback,
};
11 changes: 10 additions & 1 deletion src/libs/actions/MultifactorAuthentication/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {registerAuthenticationKey} from './index';
type ProcessResult = {
success: boolean;
reason: MultifactorAuthenticationReason;
httpCode: number | undefined;

/** Optional response body containing scenario-specific data (e.g., {pin: number} for PIN reveal) */
body?: Record<string, unknown>;
};

/**
Expand Down Expand Up @@ -79,6 +83,7 @@ async function processRegistration(params: RegistrationParams): Promise<ProcessR
if (!params.challenge) {
return {
success: false,
httpCode: undefined,
reason: VALUES.REASON.CHALLENGE.CHALLENGE_MISSING,
};
}
Expand All @@ -98,6 +103,7 @@ async function processRegistration(params: RegistrationParams): Promise<ProcessR
return {
success,
reason,
httpCode,
};
}

Expand All @@ -124,16 +130,19 @@ async function processScenario<T extends MultifactorAuthenticationScenario>(
if (!params.signedChallenge) {
return {
success: false,
httpCode: undefined,
reason: VALUES.REASON.GENERIC.SIGNATURE_MISSING,
};
}

const {httpCode, reason} = await currentScenario.action(params);
const {httpCode, reason, body} = await currentScenario.action(params);
const success = isHttpSuccess(httpCode);

return {
success,
reason,
httpCode,
body,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,38 @@ describe('MultifactorAuthentication Scenarios Config', () => {
expect(biometricsTestConfig.OUTCOMES.failure.illustration).toBe('HumptyDumpty');
expect(biometricsTestConfig.OUTCOMES.outOfTime.illustration).toBe('RunOutOfTime');
});

/**
* Verifies that every scenario config includes a callback function.
*/
it('should have a callback function for every scenario config', () => {
const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord;

for (const scenarioConfig of Object.values(config)) {
expect(scenarioConfig).toHaveProperty('callback');
expect(typeof scenarioConfig.callback).toBe('function');
}
});

/**
* Verifies that the default callback behavior returns SHOW_OUTCOME_SCREEN.
* When a callback returns SHOW_OUTCOME_SCREEN, the handleCallback function
* will navigate to the appropriate success or failure outcome screen.
* This tests the default behavior for scenarios that don't override the callback.
*/
it('should have default callback that returns SHOW_OUTCOME_SCREEN', async () => {
const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord;
const biometricsTestConfig = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST];

// Invoke the callback with successful authentication and valid response data
const callbackResult = await biometricsTestConfig.callback?.(true, {
httpCode: 200,
message: CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.AUTHORIZATION_SUCCESSFUL,
body: {},
});

// Verify that the callback returns SHOW_OUTCOME_SCREEN, indicating
// the MFA flow should navigate to the outcome screen
expect(callbackResult).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN);
});
});
Loading