Skip to content
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

feat(authenticator): state machine updates for email mfa #6317

Open
wants to merge 14 commits into
base: feat-email-mfa/main
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const skipAttributeVerification = jest.fn();
const toFederatedSignIn = jest.fn();
const totpSecretCode = undefined;
const unverifiedUserAttributes = {};
const allowedMfaTypes = undefined;

export const mockMachineContext: NextAuthenticatorServiceFacade = {
challengeName,
Expand All @@ -31,6 +32,7 @@ export const mockMachineContext: NextAuthenticatorServiceFacade = {
totpSecretCode,
unverifiedUserAttributes,
username: 'Charles',
allowedMfaTypes,
};

export const mockUseMachineOutput: UseMachine = mockMachineContext;
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const mockServiceFacade: NextAuthenticatorServiceFacade = {
totpSecretCode: undefined,
unverifiedUserAttributes: { email: 'test#example.com' },
username: undefined,
allowedMfaTypes: undefined,
};

const getNextServiceFacadeSpy = jest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

exports[`useMachine returns the expected values 1`] = `
{
"allowedMfaTypes": undefined,
"challengeName": undefined,
"codeDeliveryDetails": undefined,
"errorMessage": undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const updateBlur = jest.fn();
const updateForm = jest.fn();
const user = { username: 'username', userId: 'userId' };
const validationErrors = {};
const allowedMfaTypes = [
'EMAIL',
'TOTP',
] as AuthenticatorMachineContext['allowedMfaTypes'];

export const mockMachineContext: AuthenticatorMachineContext = {
authStatus,
Expand All @@ -53,7 +57,7 @@ export const mockMachineContext: AuthenticatorMachineContext = {
toFederatedSignIn,
toForgotPassword,
totpSecretCode,

allowedMfaTypes,
unverifiedUserAttributes,
username: 'george',
validationErrors,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
exports[`useAuthenticator returns the expected values 1`] = `
{
"QRFields": null,
"allowedMfaTypes": [
"EMAIL",
"TOTP",
],
"authStatus": "authenticated",
"challengeName": "SELECT_MFA_TYPE",
"codeDeliveryDetails": {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const mockServiceFacade: AuthenticatorServiceFacade = {
toSignIn: jest.fn(),
toSignUp: jest.fn(),
skipVerification: jest.fn(),
allowedMfaTypes: ['EMAIL', 'TOTP'],
};

const getServiceFacadeSpy = jest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { V6AuthDeliveryMedium } from '../../../machines/authenticator/types';
import {
AuthMFAType,
V6AuthDeliveryMedium,
} from '../../../machines/authenticator/types';

import { authenticatorTextUtil } from '../textUtil';

describe('authenticatorTextUtil', () => {
describe('getChallengeText', () => {
it('returns the correct text for the "EMAIL_OTP" challenge', () => {
expect(authenticatorTextUtil.getChallengeText('EMAIL_OTP')).toEqual(
'Confirm Email Code'
);
});

it('returns the correct text for the "SMS_MFA" challenge', () => {
expect(authenticatorTextUtil.getChallengeText('SMS_MFA')).toEqual(
'Confirm SMS Code'
Expand Down Expand Up @@ -111,12 +120,38 @@ describe('authenticatorTextUtil', () => {
});
});

describe('getSelectMfaTypeByChallengeName', () => {
it('returns the correct text when challengeName is MFA_SETUP', () => {
expect(
authenticatorTextUtil.getSelectMfaTypeByChallengeName('MFA_SETUP')
).toEqual('Multi-Factor Authentication Setup');
});
it('returns the correct text when challengeName is SELECT_MFA_TYPE', () => {
expect(
authenticatorTextUtil.getSelectMfaTypeByChallengeName('SELECT_MFA_TYPE')
).toEqual('Multi-Factor Authentication');
});
});

describe('getMfaTypeLabelByValue', () => {
it.each(['EMAIL', 'SMS', 'TOTP'] as AuthMFAType[])(
'returns the correct text when value is %s',
(value) => {
expect(authenticatorTextUtil.getMfaTypeLabelByValue(value)).toEqual(
value
);
}
);
});

describe('authenticator shared text', () => {
it('return a text for all the utils', () => {
Object.entries(authenticatorTextUtil).map(([name, fn]) => {
let result;
if (name === 'getChallengeText') {
result = fn.call(authenticatorTextUtil, 'SMS_MFA');
} else if (name === 'getMfaTypeLabelByValue') {
result = fn.call(authenticatorTextUtil, 'EMAIL');
} else {
result = fn.call(authenticatorTextUtil);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/ui/src/helpers/authenticator/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {

import {
AuthActorContext,
AuthMFAType,
AuthEvent,
AuthEventData,
AuthEventTypes,
Expand Down Expand Up @@ -45,7 +46,9 @@ export type AuthenticatorRoute =
| 'signIn'
| 'signUp'
| 'transition'
| 'verifyUser';
| 'verifyUser'
| 'setupEmail'
| 'selectMfaType';

type AuthenticatorValidationErrors = ValidationError;
export type AuthStatus = 'configuring' | 'authenticated' | 'unauthenticated';
Expand All @@ -64,6 +67,7 @@ interface AuthenticatorServiceContextFacade {
user: AuthUser;
username: string;
validationErrors: AuthenticatorValidationErrors;
allowedMfaTypes: AuthMFAType[] | undefined;
}

type SendEventAlias =
Expand Down Expand Up @@ -99,6 +103,7 @@ interface NextAuthenticatorServiceContextFacade {
totpSecretCode: string | undefined;
username: string | undefined;
unverifiedUserAttributes: UnverifiedUserAttributes | undefined;
allowedMfaTypes: AuthMFAType[] | undefined;
}

interface NextAuthenticatorSendEventAliases
Expand Down Expand Up @@ -179,6 +184,7 @@ export const getServiceContextFacade = (
totpSecretCode = null,
unverifiedUserAttributes,
username,
allowedMfaTypes,
} = actorContext;

const { socialProviders = [] } = state.context?.config ?? {};
Expand Down Expand Up @@ -224,6 +230,7 @@ export const getServiceContextFacade = (
user,
username,
validationErrors,
allowedMfaTypes,

// @v6-migration-note
// While most of the properties
Expand All @@ -248,6 +255,7 @@ export const getNextServiceContextFacade = (
totpSecretCode,
unverifiedUserAttributes,
username,
allowedMfaTypes,
} = actorContext;

const { socialProviders: federatedProviders, loginMechanisms } =
Expand All @@ -272,6 +280,7 @@ export const getNextServiceContextFacade = (
totpSecretCode,
unverifiedUserAttributes,
username,
allowedMfaTypes,
};
};

Expand Down
31 changes: 30 additions & 1 deletion packages/ui/src/helpers/authenticator/formFields/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* This file contains helpers that generate default formFields for each screen
*/
import { getActorState } from '../actor';
import { authenticatorTextUtil } from '../textUtil';
import { getActorContext, getActorState } from '../actor';
import { defaultFormFieldOptions } from '../constants';
import { isAuthFieldWithDefaults } from '../form';
import {
Expand All @@ -17,6 +18,8 @@ import {
} from '../../../machines/authenticator/types';
import { getPrimaryAlias } from '../formFields/utils';

const { getMfaTypeLabelByValue } = authenticatorTextUtil;

export const DEFAULT_COUNTRY_CODE = '+1';

/** Helper function that gets the default formField for given field name */
Expand Down Expand Up @@ -166,6 +169,30 @@ const getForceNewPasswordFormFields = (state: AuthMachineState): FormFields => {
return formField;
};

const getSelectMfaTypeFormFields = (state: AuthMachineState): FormFields => {
const { allowedMfaTypes = [] } = getActorContext(state) || {};

return {
mfa_type: {
label: 'Select MFA Type',
placeholder: 'Please select desired MFA type',
type: 'radio',
isRequired: true,
// TODO - i18n
radioOptions: allowedMfaTypes.map((value) => ({
label: getMfaTypeLabelByValue(value),
value,
})),
},
};
};

const getSetupEmailFormFields = (_: AuthMachineState): FormFields => ({
email: {
...getDefaultFormField('email'),
},
});

/** Collect all the defaultFormFields getters */
export const defaultFormFieldsGetters: Record<
FormFieldComponents,
Expand All @@ -180,4 +207,6 @@ export const defaultFormFieldsGetters: Record<
confirmResetPassword: getConfirmResetPasswordFormFields,
confirmVerifyUser: getConfirmationCodeFormFields,
setupTotp: getConfirmationCodeFormFields,
setupEmail: getSetupEmailFormFields,
selectMfaType: getSelectMfaTypeFormFields,
};
4 changes: 4 additions & 0 deletions packages/ui/src/helpers/authenticator/getRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export const getRoute = (
return 'verifyUser';
case actorState?.matches('confirmVerifyUserAttribute'):
return 'confirmVerifyUser';
case actorState?.matches('setupEmail'):
return 'setupEmail';
case actorState?.matches('selectMfaType'):
return 'selectMfaType';
case state.matches('getCurrentUser'):
case actorState?.matches('fetchUserAttributes'):
/**
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/helpers/authenticator/textUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SocialProvider } from '../../types';
import {
AuthMFAType,
ChallengeName,
V5CodeDeliveryDetails,
} from '../../machines/authenticator/types';
Expand All @@ -11,6 +12,9 @@ import { AuthenticatorRoute } from './facade';
*/
const getChallengeText = (challengeName?: ChallengeName): string => {
switch (challengeName) {
// TODO - i18n
case 'EMAIL_OTP':
return translate(DefaultTexts.CONFIRM_EMAIL);
case 'SMS_MFA':
return translate(DefaultTexts.CONFIRM_SMS);
case 'SOFTWARE_TOKEN_MFA':
Expand Down Expand Up @@ -79,6 +83,24 @@ const getSignInWithFederationText = (
);
};

/**
* SelectMfaType
*/
// TODO - i18n
const getSelectMfaTypeByChallengeName = (
challengeName: ChallengeName
): string => {
if (challengeName === 'MFA_SETUP') {
return translate(DefaultTexts.MFA_SETUP_SELECTION);
}

return translate(DefaultTexts.MFA_SELECTION);
};
// TODO - i18n
const getMfaTypeLabelByValue = (value: AuthMFAType): string => {
return value;
};

export const authenticatorTextUtil = {
/** Shared */
getBackToSignInText: () => translate(DefaultTexts.BACK_SIGN_IN),
Expand Down Expand Up @@ -138,6 +160,10 @@ export const authenticatorTextUtil = {
/** FederatedSignIn */
getSignInWithFederationText,

/** SelectMfaType */
getSelectMfaTypeByChallengeName,
getMfaTypeLabelByValue,

/** VerifyUser */
getSkipText: () => translate(DefaultTexts.SKIP),
getVerifyText: () => translate(DefaultTexts.VERIFY),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const defaultTexts = {
CONFIRM_RESET_PASSWORD_HEADING: 'Reset your Password',
CONFIRM_SIGNUP_HEADING: 'Confirm Sign Up',
CONFIRM_SMS: 'Confirm SMS Code',
CONFIRM_EMAIL: 'Confirm Email Code',
// If challenge name is not returned
CONFIRM_MFA_DEFAULT: 'Confirm MFA Code',
CONFIRM_TOTP: 'Confirm TOTP Code',
Expand Down Expand Up @@ -47,6 +48,8 @@ export const defaultTexts = {
LOADING: 'Loading',
LOGIN_NAME: 'Username',
MIDDLE_NAME: 'Middle Name',
MFA_SETUP_SELECTION: 'Multi-Factor Authentication Setup',
MFA_SELECTION: 'Multi-Factor Authentication',
NAME: 'Name',
NICKNAME: 'Nickname',
NEW_PASSWORD: 'New password',
Expand Down
29 changes: 24 additions & 5 deletions packages/ui/src/machines/authenticator/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ const setTotpSecretCode = assign({
},
});

const setAllowedMfaTypes = assign({
allowedMfaTypes: (_, { data }: AuthEvent) => {
return data.nextStep?.allowedMFATypes;
},
});

const setSignInStep = assign({ step: 'SIGN_IN' });

const setShouldVerifyUserAttributeStep = assign({
Expand All @@ -59,11 +65,23 @@ const setConfirmAttributeCompleteStep = assign({
const setChallengeName = assign({
challengeName: (_, { data }: AuthEvent): ChallengeName | undefined => {
const { signInStep } = (data as SignInOutput).nextStep;
return signInStep === 'CONFIRM_SIGN_IN_WITH_SMS_CODE'
? 'SMS_MFA'
: signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE'
? 'SOFTWARE_TOKEN_MFA'
: undefined;

switch (signInStep) {
case 'CONFIRM_SIGN_IN_WITH_SMS_CODE':
return 'SMS_MFA';
case 'CONFIRM_SIGN_IN_WITH_TOTP_CODE':
return 'SOFTWARE_TOKEN_MFA';
case 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE':
return 'EMAIL_OTP';
case 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION':
case 'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP':
case 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP':
return 'MFA_SETUP';
case 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION':
return 'SELECT_MFA_TYPE';
default:
return undefined;
}
},
});

Expand Down Expand Up @@ -243,6 +261,7 @@ const ACTIONS: MachineOptions<AuthActorContext, AuthEvent>['actions'] = {
setUsernameForgotPassword,
setUsernameSignIn,
setUsernameSignUp,
setAllowedMfaTypes,
};

export default ACTIONS;
Loading
Loading