Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e060c98
feat(mfa): migrate the magic code and registration decision into the …
dariusz-biela Jul 21, 2026
3ca407f
fix(mfa): clear the inline error when a rejected code is resubmitted
dariusz-biela Jul 24, 2026
414d974
refactor(mfa): drop the unreachable carried-code guard from the regis…
dariusz-biela Jul 24, 2026
10a49a8
feat(mfa): exchange the magic code for a registration challenge
dariusz-biela Jul 24, 2026
3016ec0
refactor(mfa): decide registration from the credentials snapshot capt…
dariusz-biela Jul 27, 2026
b0973f4
Revert "refactor(mfa): decide registration from the credentials snaps…
dariusz-biela Jul 27, 2026
1a09786
feat(mfa): route the magic-code resend through the state machine
dariusz-biela Jul 27, 2026
fd24563
fix(mfa): disable the resend button while the challenge request is in…
dariusz-biela Jul 27, 2026
d1b00be
fix(mfa): send the registration reason code with the magic code request
dariusz-biela Jul 27, 2026
cda053e
test(mfa): follow main's security-code translation rename
dariusz-biela Jul 27, 2026
9e20ab4
fix(mfa): type the one-shot Onyx read with OnyxValue
dariusz-biela Jul 27, 2026
e8b1182
test(mfa): align compiler handling for countdown mock
dariusz-biela Jul 27, 2026
fdee165
fix(mfa): derive resend availability from machine
dariusz-biela Jul 28, 2026
80c66a7
fix(mfa): show submit spinner for accounts with 2FA
dariusz-biela Jul 28, 2026
e0c7247
Remove unused MFA test helper
dariusz-biela Jul 28, 2026
d32b67e
Reuse MFA actor done event helper
dariusz-biela Jul 28, 2026
76d32be
refactor(mfa): nest magic-code request states
dariusz-biela Jul 28, 2026
c63162e
refactor(mfa): model the inline invalid-code error as a state
dariusz-biela Jul 28, 2026
8b43487
fix(mfa): avoid stale loading state blocking resend
dariusz-biela Jul 29, 2026
d88f31d
test(mfa): cover registration challenge loading data
dariusz-biela Jul 29, 2026
20792aa
test(mfa): move validate-code loading test
dariusz-biela Jul 29, 2026
2bade7d
fix(mfa): cancel local credential reads
dariusz-biela Jul 29, 2026
73cbb2f
docs(mfa): clarify credential check ownership
dariusz-biela Jul 29, 2026
a7bea77
Improve MFA actor event type safety
dariusz-biela Jul 29, 2026
33e37cf
refactor(mfa): derive framework event types from invoked actors
dariusz-biela Jul 29, 2026
884788a
refactor(mfa): rename idle input state
dariusz-biela Jul 29, 2026
c881945
refactor(mfa): derive invalid-code error from state
dariusz-biela Jul 29, 2026
cfc1273
fix(mfa): wait for account data before deciding on registration
dariusz-biela Aug 3, 2026
7cf44c6
refactor(mfa): derive validate code submitting state from machine
dariusz-biela Aug 3, 2026
c8ac892
fix(mfa): clear validate code after challenge request
dariusz-biela Aug 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ type MultifactorAuthenticationInternalApi = {
/** Approve the soft prompt. The machine persists the acceptance and moves the flow to the outcome. */
approveSoftPrompt: () => void;

/** Submit the magic code the user entered. The machine stores it and moves the flow forward. */
submitValidateCode: (validateCode: string) => void;

/** Request a fresh magic-code email. The machine sends it only while the magic-code screen waits for a code. */
resendValidateCode: () => void;

/** Notify the machine that the user edited the entered code; the machine then drops the inline invalid-code error. */
notifyValidateCodeChanged: () => void;

/** Centralized back-press / backdrop entry. */
requestCancel: () => void;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
const closeModal = () => send({type: 'CLOSE_MODAL'});
const notifyModalClosed = () => send({type: 'MODAL_CLOSED'});
const approveSoftPrompt = () => send({type: 'SOFT_PROMPT_APPROVED'});
const submitValidateCode = (validateCode: string) => send({type: 'VALIDATE_CODE_ENTERED', validateCode});
const resendValidateCode = () => send({type: 'RESEND_VALIDATE_CODE'});
const notifyValidateCodeChanged = () => send({type: 'VALIDATE_CODE_CHANGED'});

// There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly.
const requestCancel = () => send({type: 'CLOSE_MODAL'});
Expand All @@ -102,6 +105,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
closeModal,
notifyModalClosed,
approveSoftPrompt,
submitValidateCode,
resendValidateCode,
notifyValidateCodeChanged,
requestCancel,
hideCancelConfirm,
confirmCancel,
Expand Down
15 changes: 1 addition & 14 deletions src/components/MultifactorAuthentication/Context/state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types';

import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';

/**
Expand All @@ -10,15 +9,6 @@ import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';
* via `snapshotToState`.
*/
type MultifactorAuthenticationState = {
/** Continuable error - displayed on current screen without stopping the flow */
continuableError: MFAError | 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;

Expand All @@ -39,9 +29,6 @@ type MultifactorAuthenticationState = {
};

const DEFAULT_STATE: MultifactorAuthenticationState = {
continuableError: undefined,
validateCode: undefined,
registrationChallenge: undefined,
authorizationChallenge: undefined,
isRegistrationComplete: false,
isAuthorizationComplete: false,
Expand Down
16 changes: 0 additions & 16 deletions src/components/MultifactorAuthentication/Context/stateReducer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import CONST from '@src/CONST';

import type {Action, MultifactorAuthenticationState} from './types';

import {DEFAULT_STATE} from './state';
Expand All @@ -9,20 +7,6 @@ import {DEFAULT_STATE} from './state';
*/
function stateReducer(state: MultifactorAuthenticationState, action: Action): MultifactorAuthenticationState {
switch (action.type) {
case 'SET_ERROR': {
// Only a continuable error (an invalid validate code) belongs to the reducer; a fatal error
// stops the flow and is owned by the machine, so anything else just clears the continuable one.
if (action.payload?.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE) {
return {...state, continuableError: action.payload};
}
return {...state, 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_REGISTRATION_COMPLETE':
Expand Down
7 changes: 1 addition & 6 deletions src/components/MultifactorAuthentication/Context/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types';

import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes';
import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types';

import type {MultifactorAuthenticationState} from './state';

type Action =
| {type: 'SET_ERROR'; payload: MFAError | 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_REGISTRATION_COMPLETE'; payload: boolean}
| {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function MultifactorAuthenticationValidateCodeResendButton({
) : (
<PressableWithFeedback
style={styles.mt5}
testID={CONST.MULTIFACTOR_AUTHENTICATION.TEST_ID.VALIDATE_CODE_RESEND_BUTTON}
onPress={onResendValidationCode}
disabled={shouldDisableResendCode}
hoverDimmingValue={1}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';

import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers';
import waitForAccountDataReady from '@libs/MultifactorAuthentication/shared/waitForAccountDataReady';
import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import Base64URL from '@src/utils/Base64URL';

import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics';
import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics';
import {mfaCredentialIDsSelector} from '@selectors/Account';

/**
* Platform-resolved biometric operations for the device check. These functions read no Onyx and no
* React state, so the MFA machine actors and other non-React callers can import them directly.
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
* read no React state, so the machine actors and other non-React callers can import them directly.
*/

/** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */
Expand All @@ -19,4 +28,36 @@ async function doesDeviceSupportAuthenticationMethod(): Promise<boolean> {
return sensorResult.isDeviceSecure;
}

export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
/** Resolves to the account's HSM-backed credential ID, or undefined when no key exists or the keystore read fails. */
async function getLocalCredentialID(accountID: number): Promise<string | undefined> {
try {
const {keys} = await getAllKeys(getKeyAlias(accountID));
const entry = keys.at(0);
if (!entry) {
return undefined;
}
return Base64URL.base64ToBase64url(entry.publicKey);
} catch (error) {
addMFABreadcrumb('Failed to get local credential ID', decodeLibraryError(error), 'error');
return undefined;
}
}

/**
* Resolves to whether the account has a local HSM key the server also knows, meaning it can skip registration.
*
* This is the canonical non-React implementation. The legacy `useNativeBiometricsHSM` hook
* intentionally performs the same comparison using its reactive Onyx values. Keep both
* implementations aligned until the hook is removed.
*/
async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise<boolean> {
const localCredentialID = await getLocalCredentialID(accountID);
if (!localCredentialID) {
return false;
}
await waitForAccountDataReady(signal);
const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal);
return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID);
Comment on lines +53 to +60

@jakubstec jakubstec Jul 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB (native and web): we don't distinguish credentials that were not loaded yet from those non-existing (both undefined) - is there a chance, perhaps is there any chance that this actor runs before MFA data arrives from OpenApp? I think it's a small possibility for that, but if so, it would trigger registration flow unnecessarily

what's more, we could have false positive too (server credentials are gone but local credentials still exist), but only if multifactorAuthenticationPublicKeyIDs is not hydrated yet. it's worth to take under consideration in recovery slice

}

export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn';
import waitForAccountDataReady from '@libs/MultifactorAuthentication/shared/waitForAccountDataReady';
import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue';

import {getPasskeyOnyxKey} from '@userActions/Passkey';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import {mfaCredentialIDsSelector} from '@selectors/Account';

/**
* Platform-resolved biometric operations for the device check. These functions read no Onyx and no
* React state, so the MFA machine actors and other non-React callers can import them directly.
* Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions
* read no React state, so the machine actors and other non-React callers can import them directly.
*/

/** The authentication method this platform verifies with. Web verifies with passkeys. */
Expand All @@ -18,4 +25,22 @@ async function doesDeviceSupportAuthenticationMethod(): Promise<boolean> {
return isWebAuthnSupported();
}

export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
/**
* Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration.
*
* This is the canonical non-React implementation. The legacy `usePasskeys` hook intentionally
* performs the same comparison using its reactive Onyx values. Keep both implementations aligned
* until the hook is removed.
*/
async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise<boolean> {
const localPasskeyCredentials = await readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal);
if (!localPasskeyCredentials?.length) {
return false;
}
await waitForAccountDataReady(signal);
const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal);
const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []);
return localPasskeyCredentials.some((credential) => serverKnownCredentialIDs.has(credential.id));
}

export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod};
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ function useNativeBiometricsHSM(): UseBiometricsReturn {
}
};

/**
* Legacy compatibility path. The MFA machine uses the platform-resolved biometrics operation,
* while this hook keeps using reactive Onyx values for existing React consumers. Keep this
* comparison aligned with `operations/index.native.ts` until the hook is removed.
*/
const areLocalCredentialsKnownToServer = async () => {
const key = await getLocalCredentialID();
return !!key && serverKnownCredentialIDs.includes(key);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ function usePasskeys(): UseBiometricsReturn {

const hasLocalCredentials = async () => (localPasskeyCredentials?.length ?? 0) > 0;

/**
* Legacy compatibility path. The MFA machine uses the platform-resolved biometrics operation,
* while this hook keeps using reactive Onyx values for existing React consumers. Keep this
* comparison aligned with `operations/index.ts` until the hook is removed.
*/
const areLocalCredentialsKnownToServer = async () => {
const serverSet = new Set(serverKnownCredentialIDs);
return (localPasskeyCredentials ?? []).some((c) => serverSet.has(c.id));
Expand Down
27 changes: 27 additions & 0 deletions src/components/MultifactorAuthentication/machine/machineEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {DoneActorEvent, ErrorActorEvent, OutputFrom} from 'xstate';

import type createActors from './mfaActors';
import type {MfaEvent} from './types';

type MfaActors = ReturnType<typeof createActors>;
type MfaActorId = keyof MfaActors;
type MfaActorOutput<Id extends MfaActorId> = OutputFrom<MfaActors[Id]>;

/** The event XState raises when an invoked actor resolves, carrying that actor's own output type. */
type MfaActorDoneEvent<Id extends MfaActorId = MfaActorId> = Id extends MfaActorId ? DoneActorEvent<MfaActorOutput<Id>, Id> : never;

/** The event XState raises when an invoked actor rejects. */
type MfaActorErrorEvent<Id extends MfaActorId = MfaActorId> = Id extends MfaActorId ? ErrorActorEvent<unknown, Id> : never;

/** The events XState raises on its own without a payload, which are the initial event and the delayed-transition timers. */
type MfaInternalEvent = {type: 'xstate.init'} | {type: `xstate.after${string}`};

/**
* Everything the machine receives. XState leaves its own events out of a declared event union, which
* is enough for the machine itself because `invoke` types its `onDone` and `onError` transitions from
* the actor. The graph traversal has to drive those events explicitly, so declaring them here keeps
* its fixtures assignable without an assertion.
*/
type MfaMachineEvent = MfaEvent | MfaActorDoneEvent | MfaActorErrorEvent | MfaInternalEvent;

export type {MfaActorDoneEvent, MfaActorErrorEvent, MfaActorId, MfaActorOutput, MfaInternalEvent, MfaMachineEvent};
50 changes: 29 additions & 21 deletions src/components/MultifactorAuthentication/machine/mfaActors.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility';
import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations';

import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers';
import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult';
import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult';
import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue';

import {getDeviceBiometricsOnyxKey} from '@userActions/MultifactorAuthentication';
import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';

import Onyx from 'react-native-onyx';
import {fromPromise} from 'xstate';

import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from './types';
import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types';

/**
* A refused device resolves as a failed MFAResult, so the machine's onError transition for this
Expand All @@ -19,30 +22,35 @@ const validateDevice = fromPromise<MFAResult, ValidateDeviceInput>(({input}) =>
* Reads the account's device-local soft-prompt flag once. The temporary Onyx connection is
* disconnected after the first value or when XState stops the actor.
*/
const readHasAcceptedSoftPrompt = fromPromise<boolean, ReadHasAcceptedSoftPromptInput>(
({input, signal}) =>
new Promise<boolean>((resolve) => {
let connection: ReturnType<typeof Onyx.connectWithoutView>;
const disconnect = () => Onyx.disconnect(connection);

signal.addEventListener('abort', disconnect, {once: true});
connection = Onyx.connectWithoutView({
key: getDeviceBiometricsOnyxKey(input.accountID),
callback: (deviceBiometrics) => {
signal.removeEventListener('abort', disconnect);
disconnect();
resolve(deviceBiometrics?.hasAcceptedSoftPrompt ?? false);
},
});
}),
);
const readHasAcceptedSoftPrompt = fromPromise<boolean, ReadHasAcceptedSoftPromptInput>(async ({input, signal}) => {
const deviceBiometrics = await readOnyxValueOnce(getDeviceBiometricsOnyxKey(input.accountID), signal);
return deviceBiometrics?.hasAcceptedSoftPrompt ?? false;
});

/**
* Resolves to whether the account's local credentials are known to the server. A returning user
* (true) skips the registration path entirely.
*/
const checkLocalCredentials = fromPromise<boolean, CheckLocalCredentialsInput>(({input, signal}) => areLocalCredentialsKnownToServer(input.accountID, signal));

/**
* Exchanges the submitted magic code for a validated registration challenge. The action normalizes
* backend failures into a reason; the actor exposes them as failed MFA results for machine routing.
*/
const requestRegistrationChallengeActor = fromPromise<RequestRegistrationChallengeOutput, RequestRegistrationChallengeInput>(async ({input}) => {
const {challenge, httpStatusCode, reason, message} = await requestRegistrationChallenge(input.validateCode);
if (!isHttpSuccess(httpStatusCode) || !challenge) {
return {success: false, error: createMFAErrorFromApiResponse(httpStatusCode, reason, message)};
}
return {success: true, challenge};
});

Comment on lines +40 to 47

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this actor doesn't accept an AbortSignal like other functions in this file, so it can resolve after the modal closes or the actor stops. Its finallyData unconditionally clears isLoading on ONYXKEYS.ACCOUNT, dismissing the modal and starting a new flow before the stale request finishes can let it clear the new flow's loading state causing race condition. I think it should skip the onyx write when aborted

/**
* Builds the side-effect actors that the machine states invoke. The machine is always created with
* these working implementations, so no caller needs to provide stubs or overrides.
*/
function createActors() {
return {validateDevice, readHasAcceptedSoftPrompt};
return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials, requestRegistrationChallenge: requestRegistrationChallengeActor};
}

export default createActors;
Loading