Skip to content

Commit ba89683

Browse files
authored
Merge pull request Expensify#81747 from Expensify/chuckdries/ecuk/3ds/reregister
[No QA][ECUK] MFA re-register flow when server returns "registration required"
2 parents 9a74427 + dc7d20f commit ba89683

6 files changed

Lines changed: 56 additions & 7 deletions

File tree

src/components/MultifactorAuthentication/Context/Main.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {requestValidateCodeAction} from '@libs/actions/User';
1010
import getPlatform from '@libs/getPlatform';
1111
import type {ChallengeType, MultifactorAuthenticationReason, OutcomePaths} from '@libs/MultifactorAuthentication/Biometrics/types';
1212
import Navigation from '@navigation/Navigation';
13-
import {requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
13+
import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
1414
import {processRegistration, processScenario} from '@userActions/MultifactorAuthentication/processing';
1515
import CONST from '@src/CONST';
1616
import ONYXKEYS from '@src/ONYXKEYS';
@@ -24,9 +24,8 @@ let deviceBiometricsState: OnyxEntry<DeviceBiometrics>;
2424

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

112111
// 1. Check if there's an error - stop processing
113112
if (error) {
113+
if (error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.BACKEND.REGISTRATION_REQUIRED) {
114+
clearLocalMFAPublicKeyList();
115+
dispatch({type: 'REREGISTER'});
116+
return;
117+
}
118+
114119
Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_OUTCOME.getRoute(paths.failureOutcome), {forceReplace: true});
115120
dispatch({type: 'SET_FLOW_COMPLETE', payload: true});
116121
return;

src/components/MultifactorAuthentication/Context/State.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ type Action =
9393
| {type: 'SET_FLOW_COMPLETE'; payload: boolean}
9494
| {type: 'SET_AUTHENTICATION_METHOD'; payload: AuthTypeInfo | undefined}
9595
| {type: 'INIT'; payload: InitPayload}
96+
| {type: 'REREGISTER'}
9697
| {type: 'RESET'};
9798

9899
/**
@@ -153,6 +154,13 @@ function stateReducer(state: MultifactorAuthenticationState, action: Action): Mu
153154
};
154155
case 'RESET':
155156
return DEFAULT_STATE;
157+
case 'REREGISTER':
158+
return {
159+
...DEFAULT_STATE,
160+
scenario: state.scenario,
161+
payload: state.payload,
162+
outcomePaths: state.outcomePaths,
163+
};
156164
default:
157165
return state;
158166
}

src/components/MultifactorAuthentication/Context/usePromptContent.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {useEffect, useRef} from 'react';
12
import type {OnyxEntry} from 'react-native-onyx';
23
import type DotLottieAnimation from '@components/LottieAnimations/types';
34
import {MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from '@components/MultifactorAuthentication/config';
@@ -40,10 +41,29 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom
4041
const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS, {canBeMissing: true});
4142
const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false;
4243

44+
// This one's a real doozy. There's an edge case with the MFA flows where the user's keys were revoked
45+
// server-side, but the client missed the Onyx update to clear them locally. When the client launches the MFA
46+
// flow, it thinks it is already registered, so it goes directly to authentication. When it requests an
47+
// authentication challenge from the server, the server throws "400 Registration required", so we need to
48+
// restart the whole flow. The registration flow clears a relevant state, which causes the prompt page to
49+
// change from the authentication version to the registration version briefly before we navigate away from the
50+
// page. Since there is no legitimate case for the prompt page to transition from authentication =>
51+
// registration, only the other way around, this ref prevents that from happening. Functionally, it acts as a
52+
// latch for isReturningUser, so that once it becomes true, it'll never become false until this screen
53+
// unmounts.
54+
const wasPreviouslyRegisteredRef = useRef(false);
55+
4356
const contentData = MULTIFACTOR_AUTHENTICATION_PROMPT_UI[promptType];
4457

4558
// Returning user: server has credentials, but user hasn't approved soft prompt yet
46-
const isReturningUser = hasEverAcceptedSoftPrompt && serverHasCredentials && !state.softPromptApproved;
59+
const isReturningUser = wasPreviouslyRegisteredRef.current || (hasEverAcceptedSoftPrompt && serverHasCredentials && !state.softPromptApproved);
60+
61+
useEffect(() => {
62+
if (!isReturningUser) {
63+
return;
64+
}
65+
wasPreviouslyRegisteredRef.current = isReturningUser;
66+
}, [isReturningUser]);
4767

4868
let title: TranslationPaths = contentData.title;
4969
let subtitle: TranslationPaths | undefined = contentData.subtitle;
@@ -64,7 +84,8 @@ function usePromptContent(promptType: MultifactorAuthenticationPromptType): Prom
6484
// Display confirm button only for new users during their first biometric registration.
6585
// Hide it for: users who already approved the soft prompt, users who finished registration,
6686
// or returning users with existing server credentials. The button prompts users to enable biometrics.
67-
const shouldDisplayConfirmButton = !hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials);
87+
const shouldDisplayConfirmButton =
88+
!hasEverAcceptedSoftPrompt || (!state.softPromptApproved && !state.isRegistrationComplete && !serverHasCredentials && !wasPreviouslyRegisteredRef.current);
6889

6990
return {
7091
animation: contentData.animation,

src/libs/MultifactorAuthentication/Biometrics/VALUES.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = {
205205
},
206206
API_RESPONSE_MAP,
207207
REASON,
208+
/**
209+
* Specifically meaningful values for `multifactorAuthenticationPublicKeyIDs` in the `account` Onyx key.
210+
* Casting `[] as string[]` is necessary to allow us to actually store the value in Onyx. Otherwise the
211+
* `as const` would mean `[]` becomes `readonly []` (readonly empty array), which is more precise,
212+
* but isn't allowed to be assigned to a `string[]` field.
213+
*/
214+
PUBLIC_KEYS_PREVIOUSLY_BUT_NOT_CURRENTLY_REGISTERED: [] as string[],
215+
PUBLIC_KEYS_AUTHENTICATION_NEVER_REGISTERED: undefined,
208216
} as const;
209217

210218
export {MultifactorAuthenticationCallbacks};

src/libs/actions/MultifactorAuthentication/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,18 @@ function markHasAcceptedSoftPrompt() {
193193
});
194194
}
195195

196+
function clearLocalMFAPublicKeyList() {
197+
Onyx.merge(ONYXKEYS.ACCOUNT, {
198+
multifactorAuthenticationPublicKeyIDs: CONST.MULTIFACTOR_AUTHENTICATION.PUBLIC_KEYS_PREVIOUSLY_BUT_NOT_CURRENTLY_REGISTERED,
199+
});
200+
}
201+
196202
export {
197203
registerAuthenticationKey,
198204
requestRegistrationChallenge,
199205
requestAuthorizationChallenge,
200206
troubleshootMultifactorAuthentication,
201207
revokeMultifactorAuthenticationCredentials,
202208
markHasAcceptedSoftPrompt,
209+
clearLocalMFAPublicKeyList,
203210
};

src/pages/settings/Security/SecuritySettingsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function SecuritySettingsPage() {
9191
const hasDelegates = delegates.length > 0;
9292
const hasDelegators = delegators.length > 0;
9393

94-
const hasEverRegisteredForMultifactorAuthentication = account?.multifactorAuthenticationPublicKeyIDs !== undefined;
94+
const hasEverRegisteredForMultifactorAuthentication = account?.multifactorAuthenticationPublicKeyIDs !== CONST.MULTIFACTOR_AUTHENTICATION.PUBLIC_KEYS_AUTHENTICATION_NEVER_REGISTERED;
9595

9696
const setMenuPosition = useCallback(() => {
9797
if (!delegateButtonRef.current) {

0 commit comments

Comments
 (0)