Skip to content

Commit 76d32be

Browse files
committed
refactor(mfa): nest magic-code request states
1 parent d32b67e commit 76d32be

4 files changed

Lines changed: 67 additions & 65 deletions

File tree

src/components/MultifactorAuthentication/machine/mfaMachine.ts

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE;
2424
const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const;
2525
const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const;
2626
const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const;
27-
const MAGIC_CODE_TARGET = `#${MFA_STATE.REQUESTING_VALIDATE_CODE}` as const;
28-
const REGISTRATION_CHALLENGE_TARGET = `#${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}` as const;
27+
const MAGIC_CODE_TARGET = `#${MFA_STATE.MAGIC_CODE}` as const;
2928

3029
// Which prompt variant the screen renders is a device property, resolved once per platform.
3130
const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType];
@@ -190,7 +189,7 @@ const MFAMachine = setup({
190189
// A returning user's credentials are already registered, so only a fresh registration asks for a code.
191190
onDone: [
192191
{guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET},
193-
{target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']},
192+
{target: MAGIC_CODE_TARGET, actions: 'requestValidateCode'},
194193
],
195194
onError: {
196195
target: OUTCOME_TARGET,
@@ -218,49 +217,51 @@ const MFAMachine = setup({
218217
},
219218
},
220219
},
221-
// This branch shows the magic-code screen while a fresh registration waits for the
222-
// emailed code. Submitting stores the code and starts the backend challenge request.
223-
// A resend is accepted only here, so one fired while the challenge request is in
224-
// flight is dropped instead of emailing a code the pending submission ignores.
225-
[MFA_STATE.REQUESTING_VALIDATE_CODE]: {
226-
id: MFA_STATE.REQUESTING_VALIDATE_CODE,
227-
on: {
228-
VALIDATE_CODE_ENTERED: {target: REGISTRATION_CHALLENGE_TARGET, actions: ['clearContinuableError', 'submitValidateCode']},
229-
RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']},
230-
CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'},
231-
},
232-
},
233-
// The magic-code screen stays mounted while the backend exchanges the code for a
234-
// registration challenge. Only a real challenge advances the flow; an invalid code
235-
// returns to the same screen with an inline error.
236-
[MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: {
237-
id: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE,
238-
invoke: {
239-
id: 'requestRegistrationChallenge',
240-
src: 'requestRegistrationChallenge',
241-
input: ({context}) => {
242-
if (context.validateCode === undefined) {
243-
throw new Error('MFA validate code must be stored before requesting a registration challenge');
244-
}
245-
return {validateCode: context.validateCode};
246-
},
247-
onDone: [
248-
{
249-
guard: ({event}) => event.output.success,
250-
target: SOFT_PROMPT_CHECK_TARGET,
251-
actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}),
220+
[MFA_STATE.MAGIC_CODE]: {
221+
id: MFA_STATE.MAGIC_CODE,
222+
entry: 'navigateToMagicCode',
223+
initial: MFA_STATE.AWAITING_VALIDATE_CODE,
224+
states: {
225+
// Waits for the emailed code. A resend is accepted only here, so one fired
226+
// while the challenge request is in flight is dropped instead of emailing a
227+
// code the pending submission ignores.
228+
[MFA_STATE.AWAITING_VALIDATE_CODE]: {
229+
on: {
230+
VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'},
231+
RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']},
232+
CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'},
252233
},
253-
{
254-
guard: ({event}) =>
255-
!event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE,
256-
target: MAGIC_CODE_TARGET,
257-
actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}),
234+
},
235+
[MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: {
236+
entry: 'clearContinuableError',
237+
invoke: {
238+
id: 'requestRegistrationChallenge',
239+
src: 'requestRegistrationChallenge',
240+
input: ({context}) => {
241+
if (context.validateCode === undefined) {
242+
throw new Error('MFA validate code must be stored before requesting a registration challenge');
243+
}
244+
return {validateCode: context.validateCode};
245+
},
246+
onDone: [
247+
{
248+
guard: ({event}) => event.output.success,
249+
target: SOFT_PROMPT_CHECK_TARGET,
250+
actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}),
251+
},
252+
{
253+
guard: ({event}) =>
254+
!event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE,
255+
target: MFA_STATE.AWAITING_VALIDATE_CODE,
256+
actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}),
257+
},
258+
{target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
259+
],
260+
onError: {
261+
target: OUTCOME_TARGET,
262+
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}),
263+
},
258264
},
259-
{target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})},
260-
],
261-
onError: {
262-
target: OUTCOME_TARGET,
263-
actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}),
264265
},
265266
},
266267
},

src/libs/MultifactorAuthentication/shared/VALUES.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@ const MFA_STATE = {
223223
VALIDATING_DEVICE: 'validatingDevice',
224224
DECIDING_REGISTRATION: 'decidingRegistration',
225225
CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance',
226-
REQUESTING_VALIDATE_CODE: 'requestingValidateCode',
226+
MAGIC_CODE: 'magicCode',
227+
AWAITING_VALIDATE_CODE: 'awaitingValidateCode',
227228
REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge',
228229
PROMPT: 'prompt',
229230
AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt',

tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ const testConfig = {
217217
expect(state.context.accountID).toBeDefined();
218218
expect(state.context.error).toBeUndefined();
219219
},
220-
[`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_VALIDATE_CODE}`]: (state: SnapshotFrom<typeof mfaMachine>) => {
220+
[`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}`]: (state: SnapshotFrom<typeof mfaMachine>) => {
221221
expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1);
222222
expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0);
223223
expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE);
@@ -234,7 +234,7 @@ const testConfig = {
234234
expect(screen.queryByText(inlineError)).not.toBeOnTheScreen();
235235
}
236236
},
237-
[`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom<typeof mfaMachine>) => {
237+
[`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom<typeof mfaMachine>) => {
238238
expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1);
239239
expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0);
240240
expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE);

tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('MFA magic code and registration decision', () => {
8484
actor.start();
8585
sendCheckLocalCredentialsDone(actor, false);
8686

87-
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true);
87+
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true);
8888
expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1);
8989
expect(requestValidateCodeActionMock).toHaveBeenCalledWith({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY});
9090

@@ -104,52 +104,52 @@ describe('MFA magic code and registration decision', () => {
104104
});
105105

106106
it('sends a fresh magic-code email and stays on the screen when the user requests a resend', () => {
107-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
107+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
108108

109109
actor.start();
110110
actor.send({type: 'RESEND_VALIDATE_CODE'});
111111

112-
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true);
112+
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true);
113113
expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1);
114114

115115
actor.stop();
116116
});
117117

118118
it('clears the inline error when the user requests a resend after a rejected code', () => {
119-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR});
119+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR});
120120

121121
actor.start();
122122
actor.send({type: 'RESEND_VALIDATE_CODE'});
123123

124124
const result = actor.getSnapshot();
125-
expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true);
125+
expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true);
126126
expect(result.context.continuableError).toBeUndefined();
127127
expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1);
128128

129129
actor.stop();
130130
});
131131

132132
it('drops a resend request while the registration challenge request is in flight', () => {
133-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
133+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
134134

135135
actor.start();
136136
actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE});
137137
actor.send({type: 'RESEND_VALIDATE_CODE'});
138138

139-
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true);
139+
expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(true);
140140
expect(requestValidateCodeActionMock).not.toHaveBeenCalled();
141141

142142
actor.stop();
143143
});
144144

145145
it('stores the submitted code and waits for a registration challenge before continuing', () => {
146-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
146+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
147147

148148
actor.start();
149149
actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE});
150150

151151
const result = actor.getSnapshot();
152-
expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true);
152+
expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(true);
153153
expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE);
154154
expect(result.context.registrationChallenge).toBeUndefined();
155155
expect(requestRegistrationChallengeMock).toHaveBeenCalledWith(MFA_TEST_VALIDATE_CODE);
@@ -158,31 +158,31 @@ describe('MFA magic code and registration decision', () => {
158158
});
159159

160160
it('stores a valid registration challenge before continuing the flow', async () => {
161-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
161+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
162162
requestRegistrationChallengeMock.mockResolvedValue(VALID_REGISTRATION_CHALLENGE_RESPONSE);
163163

164164
actor.start();
165165
actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE});
166166
await waitForBatchedUpdates();
167167

168168
const result = actor.getSnapshot();
169-
expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(false);
169+
expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(false);
170170
expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE);
171171
expect(result.context.error).toBeUndefined();
172172

173173
actor.stop();
174174
});
175175

176176
it('stays on the magic-code screen with an inline error and no new email when the code is invalid', async () => {
177-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
177+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
178178
requestRegistrationChallengeMock.mockResolvedValue(INVALID_CODE_RESPONSE);
179179

180180
actor.start();
181181
actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE});
182182
await waitForBatchedUpdates();
183183

184184
const result = actor.getSnapshot();
185-
expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true);
185+
expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true);
186186
expect(result.context.continuableError?.reason).toBe(REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE);
187187
expect(result.context.registrationChallenge).toBeUndefined();
188188
expect(result.context.error).toBeUndefined();
@@ -192,7 +192,7 @@ describe('MFA magic code and registration decision', () => {
192192
});
193193

194194
it('clears the inline error when the rejected code is submitted again without editing', async () => {
195-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
195+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
196196
requestRegistrationChallengeMock.mockResolvedValueOnce(INVALID_CODE_RESPONSE).mockResolvedValueOnce(VALID_REGISTRATION_CHALLENGE_RESPONSE);
197197

198198
actor.start();
@@ -210,7 +210,7 @@ describe('MFA magic code and registration decision', () => {
210210
});
211211

212212
it('ends the flow with the failure outcome when the challenge request fails fatally', async () => {
213-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
213+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
214214
requestRegistrationChallengeMock.mockResolvedValue(FATAL_REGISTRATION_CHALLENGE_RESPONSE);
215215

216216
actor.start();
@@ -227,7 +227,7 @@ describe('MFA magic code and registration decision', () => {
227227
});
228228

229229
it('does not continue when a successful response has no valid registration challenge', async () => {
230-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE});
230+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}});
231231
requestRegistrationChallengeMock.mockResolvedValue(MISSING_REGISTRATION_CHALLENGE_RESPONSE);
232232

233233
actor.start();
@@ -243,13 +243,13 @@ describe('MFA magic code and registration decision', () => {
243243
});
244244

245245
it('clears the inline error when the user starts typing again', () => {
246-
const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR});
246+
const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR});
247247

248248
actor.start();
249249
actor.send({type: 'CLEAR_CONTINUABLE_ERROR'});
250250

251251
const result = actor.getSnapshot();
252-
expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true);
252+
expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true);
253253
expect(result.context.continuableError).toBeUndefined();
254254

255255
actor.stop();

0 commit comments

Comments
 (0)