forked from Expensify/App
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmfaMachine.ts
More file actions
358 lines (345 loc) · 19.8 KB
/
Copy pathmfaMachine.ts
File metadata and controls
358 lines (345 loc) · 19.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import {deviceVerificationType} from '@components/MultifactorAuthentication/biometrics/operations';
import {navigate as mfaNavigate, resetMfaNavigation} from '@components/MultifactorAuthentication/mfaNavigation';
import {createUnhandledExceptionMFAError, getMFAFailureError} from '@libs/MultifactorAuthentication/shared/MFAResult';
import Navigation from '@libs/Navigation/Navigation';
import {markHasAcceptedSoftPrompt} from '@userActions/MultifactorAuthentication';
import {requestValidateCodeAction} from '@userActions/User';
import CONST from '@src/CONST';
import SCREENS from '@src/SCREENS';
import {CONST as COMMON_CONST} from 'expensify-common';
import {assign, setup} from 'xstate';
import type {MfaMachineEvent} from './machineEvents';
import type {MfaContext} from './types';
import createActors from './mfaActors';
const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE;
// Absolute targets for the screen branches. The device check runs under `preparing`, so reaching a
// sibling branch needs an id target rather than a relative one.
const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const;
const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const;
const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const;
const MAGIC_CODE_TARGET = `#${MFA_STATE.MAGIC_CODE}` as const;
const CREATING_CREDENTIAL_TARGET = `#${MFA_STATE.CREATING_CREDENTIAL}` as const;
// One literal shared by both soft-prompt exits (approval and the persisted-acceptance skip), so they can't drift apart.
const SOFT_PROMPT_ACCEPTED_ACTIONS = ['approveSoftPrompt', 'persistSoftPromptAcceptance'] as const;
// Which prompt variant the screen renders is a device property, resolved once per platform.
const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType];
const DEFAULT_CONTEXT: MfaContext = {
accountID: undefined,
error: undefined,
scenarioName: undefined,
scenario: undefined,
payload: undefined,
validateCode: undefined,
registrationChallenge: undefined,
softPromptApproved: false,
isCancelConfirmVisible: false,
};
/**
* MFA state machine. The top level models the modal lifecycle (`closed` -> `open` -> `closing`); the
* child states of `open` map 1:1 to the screen the user currently sees.
*
* No state is `final`: one long-lived actor serves every MFA flow (a top-level final state would
* stop it).
*/
const MFAMachine = setup({
// `{} as T` inside setup({types}) is XState v5's documented typing idiom (the values are erased
// at runtime and only carry types); there is no assertion-free way to express it.
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
types: {
context: {} as MfaContext,
events: {} as MfaMachineEvent,
},
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
actors: createActors(),
guards: {
hasError: ({context}) => context.error !== undefined,
hasRegistrationChallenge: ({context}) => context.registrationChallenge !== undefined,
},
actions: {
// Seeds the flow's context from the INIT event. A named action's event is typed as the full
// machine-event union, so the guard narrows it to INIT to read the scenario fields; INIT is the
// only transition wired here, so that early return is unreachable (it just satisfies the type checker).
initFlow: assign(({event}) => {
if (event.type !== 'INIT') {
return {};
}
return {
...DEFAULT_CONTEXT,
accountID: event.accountID,
scenarioName: event.scenarioName,
scenario: event.scenario,
payload: event.payload,
};
}),
// Deferring the outcome push until the modal-open transition settles lets the screen slide in
// with a measured width and avoids the Android animation race.
navigateToSuccessOutcome: () => {
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS));
},
navigateToFailureOutcome: () => {
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE));
},
navigateToPrompt: () => {
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType: PROMPT_TYPE}));
},
navigateToMagicCode: () => {
Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE));
},
// Emails the user a magic code. Runs only on the decision transition into the magic-code
// screen and on an explicit resend request, never on (re)entry, so the invalid-code retry
// loop cannot resend the email.
requestValidateCode: () => requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY}),
// Stores the submitted code. Same narrowing pattern as initFlow: only VALIDATE_CODE_ENTERED
// is wired here, so the early return just satisfies the type checker.
submitValidateCode: assign(({event}) => {
if (event.type !== 'VALIDATE_CODE_ENTERED') {
return {};
}
return {validateCode: event.validateCode};
}),
approveSoftPrompt: assign({softPromptApproved: true}),
persistSoftPromptAcceptance: ({context}) => {
if (context.accountID === undefined) {
throw new Error('MFA account must be initialized before persisting soft-prompt acceptance');
}
markHasAcceptedSoftPrompt(context.accountID);
},
// Runs on CLOSE_MODAL: drops the cancel-confirmation modal so it cannot linger over the
// closing navigator (CLOSE_MODAL can fire without the flow completing, e.g. an offline cancel).
hideCancelConfirmModal: assign({isCancelConfirmVisible: false}),
resetContext: assign(() => ({...DEFAULT_CONTEXT})),
// Clears the module-level navigation buffer (pendingNavigation/hasInitialLaidOut). Owned by
// the machine so a navigator that unmounts mid-close cannot leave a stale buffered screen
// behind for the next flow.
clearModalOpenNavigationState: () => resetMfaNavigation(),
},
delays: {
// How long `closing` waits for MODAL_CLOSED before re-entering `closed` on its own; longer
// than any close animation can take.
closeFallback: CONST.MAX_TRANSITION_START_WAIT_MS + CONST.MAX_TRANSITION_DURATION_MS + CONST.ANIMATED_TRANSITION,
},
}).createMachine({
id: 'mfa',
initial: MFA_STATE.CLOSED,
context: DEFAULT_CONTEXT,
states: {
[MFA_STATE.CLOSED]: {
// The wipe runs on every (re)entry so no flow data (validate code, challenges, scenario
// response) outlives the modal.
entry: ['resetContext', 'clearModalOpenNavigationState'],
on: {
// Accepted only here: an INIT sent while the modal is open or still closing is
// dropped rather than started on dirty state.
INIT: {target: MFA_STATE.OPEN, actions: 'initFlow'},
},
},
[MFA_STATE.OPEN]: {
initial: MFA_STATE.PREPARING,
on: {
CLOSE_MODAL: {target: MFA_STATE.CLOSING, actions: 'hideCancelConfirmModal'},
},
states: {
// This is the transparent initial screen, and its child states run the pre-screen
// work the user waits through.
[MFA_STATE.PREPARING]: {
initial: MFA_STATE.VALIDATING_DEVICE,
states: {
[MFA_STATE.VALIDATING_DEVICE]: {
invoke: {
id: 'validateDevice',
src: 'validateDevice',
input: ({context}) => {
if (!context.scenario) {
throw new Error('MFA scenario must be initialized before device validation');
}
return {allowedAuthenticationMethods: context.scenario.allowedAuthenticationMethods};
},
// An error stored earlier in the flow wins even over a successful device check.
onDone: [
{guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
{guard: ({context}) => context.error !== undefined, target: OUTCOME_TARGET},
{target: MFA_STATE.DECIDING_REGISTRATION},
],
// Expected refusals travel as failed results through onDone, so a
// rejection means the platform check itself threw unexpectedly.
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Device check', event.error)}),
},
},
},
[MFA_STATE.DECIDING_REGISTRATION]: {
invoke: {
id: 'checkLocalCredentials',
src: 'checkLocalCredentials',
input: ({context}) => {
if (context.accountID === undefined) {
throw new Error('MFA account must be initialized before the registration decision');
}
return {accountID: context.accountID};
},
// A returning user's credentials are already registered, so only a fresh registration asks for a code.
onDone: [
{guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET},
{target: MAGIC_CODE_TARGET, actions: 'requestValidateCode'},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Local credentials check', event.error)}),
},
},
},
[MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE]: {
id: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE,
invoke: {
id: 'readHasAcceptedSoftPrompt',
src: 'readHasAcceptedSoftPrompt',
input: ({context}) => {
if (context.accountID === undefined) {
throw new Error('MFA account must be initialized before reading soft-prompt acceptance');
}
return {accountID: context.accountID};
},
// Not accepted yet -> show the prompt. Accepted with a challenge pending -> create the
// credential. Accepted, nothing pending -> a returning user, straight to the outcome.
onDone: [
{guard: ({event}) => !event.output, target: PROMPT_TARGET},
{guard: 'hasRegistrationChallenge', target: CREATING_CREDENTIAL_TARGET},
{target: OUTCOME_TARGET},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Soft-prompt acceptance read', event.error)}),
},
},
},
},
},
[MFA_STATE.MAGIC_CODE]: {
id: MFA_STATE.MAGIC_CODE,
entry: 'navigateToMagicCode',
initial: MFA_STATE.AWAITING_VALIDATE_CODE,
states: {
// Waits for the emailed code. A resend is accepted only here, so one fired
// while the challenge request is in flight is dropped instead of emailing a
// code the pending submission ignores.
[MFA_STATE.AWAITING_VALIDATE_CODE]: {
initial: MFA_STATE.AWAITING_INPUT,
on: {
VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'},
RESEND_VALIDATE_CODE: {target: `.${MFA_STATE.AWAITING_INPUT}`, actions: 'requestValidateCode'},
},
states: {
[MFA_STATE.AWAITING_INPUT]: {},
// The backend rejected the submitted code. The screen shows the
// inline error exactly while this state is active, so every way out
// (typing, a resend, a new submission) drops the error by
// construction and nothing stale can outlive the screen.
[MFA_STATE.INVALID_CODE]: {
on: {
VALIDATE_CODE_CHANGED: MFA_STATE.AWAITING_INPUT,
},
},
},
},
[MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: {
invoke: {
id: 'requestRegistrationChallenge',
src: 'requestRegistrationChallenge',
input: ({context}) => {
if (context.validateCode === undefined) {
throw new Error('MFA validate code must be stored before requesting a registration challenge');
}
return {validateCode: context.validateCode};
},
onDone: [
{
guard: ({event}) => event.output.success,
target: SOFT_PROMPT_CHECK_TARGET,
actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}),
},
{
guard: ({event}) =>
!event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE,
target: `${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.INVALID_CODE}`,
},
{target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}),
},
},
},
},
},
// This branch shows the soft prompt when the current account has not accepted it on this device.
[MFA_STATE.PROMPT]: {
id: MFA_STATE.PROMPT,
entry: ['navigateToPrompt'],
initial: MFA_STATE.AWAITING_SOFT_PROMPT,
on: {
SOFT_PROMPT_APPROVED: [
{guard: 'hasRegistrationChallenge', target: MFA_STATE.CREATING_CREDENTIAL, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
{target: MFA_STATE.OUTCOME, actions: SOFT_PROMPT_ACCEPTED_ACTIONS},
],
},
states: {
[MFA_STATE.AWAITING_SOFT_PROMPT]: {},
},
},
// Turns a pending registration challenge into a real credential: platform ceremony, then
// backend registration. Reached from both soft-prompt exits when a challenge is pending.
// No `entry` action on purpose — whatever screen is already up (prompt, or nothing) just
// stays visible during the ceremony, same as legacy.
[MFA_STATE.CREATING_CREDENTIAL]: {
id: MFA_STATE.CREATING_CREDENTIAL,
invoke: {
id: 'createCredential',
src: 'createCredential',
input: ({context}) => {
if (context.accountID === undefined || context.registrationChallenge === undefined) {
throw new Error('MFA account and registration challenge must be stored before creating a credential');
}
return {accountID: context.accountID, registrationChallenge: context.registrationChallenge};
},
onDone: [
{guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
{target: OUTCOME_TARGET},
],
onError: {
target: OUTCOME_TARGET,
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Credential registration', event.error)}),
},
},
},
[MFA_STATE.OUTCOME]: {
id: MFA_STATE.OUTCOME,
initial: MFA_STATE.RESOLVING_OUTCOME,
states: {
[MFA_STATE.RESOLVING_OUTCOME]: {
always: [{guard: 'hasError', target: MFA_STATE.FAILURE}, {target: MFA_STATE.SUCCESS}],
},
[MFA_STATE.SUCCESS]: {
entry: ['navigateToSuccessOutcome'],
},
[MFA_STATE.FAILURE]: {entry: ['navigateToFailureOutcome']},
},
},
},
},
// Modal teardown. The context still holds the flow data here on purpose: the outcome screen
// stays visible while it slides out. The navigator sends MODAL_CLOSED once the close
// animation finishes; if it unmounts before that, the event never comes and the
// `closeFallback` timer re-enters `closed` instead.
[MFA_STATE.CLOSING]: {
on: {
MODAL_CLOSED: MFA_STATE.CLOSED,
},
after: {
closeFallback: {target: MFA_STATE.CLOSED},
},
},
},
});
export default MFAMachine;