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
13 changes: 9 additions & 4 deletions src/components/MultifactorAuthentication/Context/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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 {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {processRegistration, processScenario} from '@userActions/MultifactorAuthentication/processing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -24,9 +24,8 @@ let deviceBiometricsState: OnyxEntry<DeviceBiometrics>;

// Use Onyx.connectWithoutView instead of useOnyx hook to access the device biometrics state.
// This is a non-reactive read that allows us to check the current value (hasAcceptedSoftPrompt)
// from within the process() callback without triggering component re-renders or complicating
// the effect's dependency list. Since we only need the latest value at specific points in the
// MFA flow (not reactivity to changes), this is more efficient than using the useOnyx hook.
// from within the process() callback without triggering calling it too many times during the
// fresh registration flow
Onyx.connectWithoutView({
key: ONYXKEYS.DEVICE_BIOMETRICS,
callback: (data) => {
Expand Down Expand Up @@ -111,6 +110,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent

// 1. Check if there's an error - stop processing
if (error) {
if (error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.REGISTRATION_REQUIRED) {
clearLocalMFAPublicKeyList();
dispatch({type: 'REREGISTER'});
return;
}

Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(paths.failureOutcome), {forceReplace: true});
dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
return;
Expand Down
8 changes: 8 additions & 0 deletions src/components/MultifactorAuthentication/Context/State.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type Action =
| {type: 'SET_FLOW_COMPLETE'; payload: boolean}
| {type: 'SET_AUTHENTICATION_METHOD'; payload: AuthTypeInfo | undefined}
| {type: 'INIT'; payload: InitPayload}
| {type: 'REREGISTER'}
| {type: 'RESET'};

/**
Expand Down Expand Up @@ -153,6 +154,13 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu
};
case 'RESET':
return DEFAULT_STATE;
case 'REREGISTER':
return {
...DEFAULT_STATE,
scenario: state.scenario,
payload: state.payload,
outcomePaths: state.outcomePaths,
};
default:
return state;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useEffect, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import type DotLottieAnimation from '@components/LottieAnimations/types';
import {MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from '@components/MultifactorAuthentication/config';
Expand Down Expand Up @@ -40,10 +41,29 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom
const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS, {canBeMissing: true});
const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false;

// This one's a real doozy. There's an edge case with the MFA flows where the user's keys were revoked
// server-side, but the client missed the Onyx update to clear them locally. When the client launches the MFA
// flow, it thinks it is already registered, so it goes directly to authentication. When it requests an
// authentication challenge from the server, the server throws "400 Registration required", so we need to
// restart the whole flow. The registration flow clears a relevant state, which causes the prompt page to
// change from the authentication version to the registration version briefly before we navigate away from the
// page. Since there is no legitimate case for the prompt page to transition from authentication =>
// registration, only the other way around, this ref prevents that from happening. Functionally, it acts as a
// latch for isReturningUser, so that once it becomes true, it'll never become false until this screen
// unmounts.
const wasPreviouslyRegisteredRef = useRef(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;
const isReturningUser = wasPreviouslyRegisteredRef.current || (hasEverAcceptedSoftPrompt && serverHasCredentials && !state.softPromptApproved);

useEffect(() => {
if (!isReturningUser) {
return;
}
wasPreviouslyRegisteredRef.current = isReturningUser;
}, [isReturningUser]);

let title: TranslationPaths = contentData.title;
let subtitle: TranslationPaths | undefined = contentData.subtitle;
Expand All @@ -64,7 +84,8 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom
// 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);
const shouldDisplayConfirmButton =
!hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials && !wasPreviouslyRegisteredRef.current);

return {
animation: contentData.animation,
Expand Down
8 changes: 8 additions & 0 deletions src/libs/MultifactorAuthentication/Biometrics/VALUES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ 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
* `as const` would mean `[]` becomes `readonly []` (readonly empty array), which is more precise,
* but isn't allowed to be assigned to a `string[]` field.
*/
PUBLIC_KEYS_PREVIOUSLY_BUT_NOT_CURRENTLY_REGISTERED: [] as string[],
PUBLIC_KEYS_AUTHENTICATION_NEVER_REGISTERED: undefined,
} as const;

export {MultifactorAuthenticationCallbacks};
Expand Down
7 changes: 7 additions & 0 deletions src/libs/actions/MultifactorAuthentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,18 @@ function markHasAcceptedSoftPrompt() {
});
}

function clearLocalMFAPublicKeyList() {
Onyx.merge(ONYXKEYS.ACCOUNT, {
multifactorAuthenticationPublicKeyIDs: CONST.MULTIFACTOR_AUTHENTICATION.PUBLIC_KEYS_PREVIOUSLY_BUT_NOT_CURRENTLY_REGISTERED,
});
}

export {
registerAuthenticationKey,
requestRegistrationChallenge,
requestAuthorizationChallenge,
troubleshootMultifactorAuthentication,
revokeMultifactorAuthenticationCredentials,
markHasAcceptedSoftPrompt,
clearLocalMFAPublicKeyList,
};
2 changes: 1 addition & 1 deletion src/pages/settings/Security/SecuritySettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function SecuritySettingsPage() {
const hasDelegates = delegates.length > 0;
const hasDelegators = delegators.length > 0;

const hasEverRegisteredForMultifactorAuthentication = account?.multifactorAuthenticationPublicKeyIDs !== undefined;
const hasEverRegisteredForMultifactorAuthentication = account?.multifactorAuthenticationPublicKeyIDs !== CONST.MULTIFACTOR_AUTHENTICATION.PUBLIC_KEYS_AUTHENTICATION_NEVER_REGISTERED;

const setMenuPosition = useCallback(() => {
if (!delegateButtonRef.current) {
Expand Down
Loading