-
Notifications
You must be signed in to change notification settings - Fork 1
[No QA] feat(mfa): add the magic code step and registration decision to the state machine #355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e060c98
3ca407f
414d974
10a49a8
3016ec0
b0973f4
1a09786
fd24563
d1b00be
cda053e
9e20ab4
e8b1182
fdee165
80c66a7
e0c7247
d32b67e
76d32be
c63162e
8b43487
d88f31d
20792aa
2bade7d
73cbb2f
a7bea77
33e37cf
884788a
c881945
cfc1273
7cf44c6
c8ac892
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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}; |
| 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 | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this actor doesn't accept an |
||
| /** | ||
| * 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; | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 fromOpenApp? I think it's a small possibility for that, but if so, it would trigger registration flow unnecessarilywhat's more, we could have false positive too (server credentials are gone but local credentials still exist), but only if
multifactorAuthenticationPublicKeyIDsis not hydrated yet. it's worth to take under consideration in recovery slice