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 24 commits into
base: feat-email-mfa/main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -4,6 +4,10 @@ import {
} from '../../types';
import { UseAuthenticator } from '../types';

const allowedMfaTypes = [
'EMAIL',
'TOTP',
] as AuthenticatorMachineContext['allowedMfaTypes'];
const authStatus = 'unauthenticated';
const challengeName = 'CUSTOM_CHALLENGE';
const codeDeliveryDetails =
Expand Down Expand Up @@ -32,6 +36,7 @@ const user = { username: 'username', userId: 'userId' };
const validationErrors = {};

export const mockMachineContext: AuthenticatorMachineContext = {
allowedMfaTypes,
authStatus,
challengeName,
codeDeliveryDetails,
Expand All @@ -53,7 +58,6 @@ export const mockMachineContext: AuthenticatorMachineContext = {
toFederatedSignIn,
toForgotPassword,
totpSecretCode,

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,44 @@ 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', () => {
const getMfaTypeLabelByValueTestCases: [AuthMFAType, string][] = [
['EMAIL', 'Email Message (EMAIL)'],
['SMS', 'Text Message (SMS)'],
['TOTP', 'Authenticator App (TOTP)'],
];

it.each(getMfaTypeLabelByValueTestCases)(
'returns the correct text when value is %s',
(input, output) => {
expect(authenticatorTextUtil.getMfaTypeLabelByValue(input)).toEqual(
output
);
}
);
});

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 @@ -16,6 +17,9 @@ import {
SignInState,
} from '../../../machines/authenticator/types';
import { getPrimaryAlias } from '../formFields/utils';
import { defaultTexts } from '../../../i18n/dictionaries';

const { getMfaTypeLabelByValue } = authenticatorTextUtil;

export const DEFAULT_COUNTRY_CODE = '+1';

Expand Down Expand Up @@ -166,6 +170,29 @@ const getForceNewPasswordFormFields = (state: AuthMachineState): FormFields => {
return formField;
};

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

return {
mfa_type: {
label: defaultTexts.SELECT_MFA_TYPE_LABEL,
placeholder: defaultTexts.SELECT_MFA_TYPE_PLACEHOLDER,
type: 'radio',
isRequired: true,
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
37 changes: 37 additions & 0 deletions packages/ui/src/helpers/authenticator/textUtil.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { SocialProvider } from '../../types';
import {
AuthMFAType,
ChallengeName,
V5CodeDeliveryDetails,
} from '../../machines/authenticator/types';
import { translate, DefaultTexts } from '../../i18n';
import { AuthenticatorRoute } from './facade';
import { defaultTexts } from '../../i18n/dictionaries';

/**
* ConfirmSignIn
*/
const getChallengeText = (challengeName?: ChallengeName): string => {
switch (challengeName) {
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,32 @@ const getSignInWithFederationText = (
);
};

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

return translate(DefaultTexts.MFA_SELECTION);
};

const getMfaTypeLabelByValue = (mfaType: AuthMFAType): string => {
switch (mfaType) {
case 'EMAIL':
return translate(defaultTexts.EMAIL_OTP);
case 'SMS':
return translate(defaultTexts.SMS_MFA);
case 'TOTP':
return translate(defaultTexts.SOFTWARE_TOKEN_MFA);
default:
return translate(mfaType);
}
};

export const authenticatorTextUtil = {
/** Shared */
getBackToSignInText: () => translate(DefaultTexts.BACK_SIGN_IN),
Expand Down Expand Up @@ -125,6 +155,9 @@ export const authenticatorTextUtil = {
/** ForgotPassword */
getResetYourPasswordText: () => translate(DefaultTexts.RESET_PASSWORD),

/** SetupEmail */
getSetupEmailText: () => translate(DefaultTexts.SETUP_EMAIL),

/** SetupTotp */
getSetupTotpText: () => translate(DefaultTexts.SETUP_TOTP),
// TODO: add defaultText for below
Expand All @@ -138,6 +171,10 @@ export const authenticatorTextUtil = {
/** FederatedSignIn */
getSignInWithFederationText,

/** SelectMfaType */
getSelectMfaTypeByChallengeName,
getMfaTypeLabelByValue,

/** VerifyUser */
getSkipText: () => translate(DefaultTexts.SKIP),
getVerifyText: () => translate(DefaultTexts.VERIFY),
Expand Down
Loading
Loading