From df6cba9ee286643e042b327cc53c074300e0309f Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 27 Aug 2024 11:31:52 -0400 Subject: [PATCH] [Email MFA] Add support for EMAIL_OTP during sign in flows (#13745) * Confirm Sign In With Email OTP * Confirm Sign In Tests With Email OTP * Update packages/auth/src/types/models.ts Co-authored-by: israx <70438514+israx@users.noreply.github.com> * Fix Errant Pascal Casing --------- Co-authored-by: israx <70438514+israx@users.noreply.github.com> --- .../cognito/confirmSignInErrorCases.test.ts | 2 +- .../cognito/confirmSignInHappyCases.test.ts | 170 +++++++++++++++++- packages/auth/src/common/AuthErrorStrings.ts | 6 +- .../clients/CognitoIdentityProvider/types.ts | 4 +- .../providers/cognito/utils/signInHelpers.ts | 155 ++++++++-------- packages/auth/src/types/models.ts | 16 ++ 6 files changed, 266 insertions(+), 87 deletions(-) diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts index 0f20b1703f3..355f89c2ac8 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts @@ -51,7 +51,7 @@ describe('confirmSignIn API error path cases:', () => { } }); - it('should throw an error when sign-in step is CONTINUE_SIGN_IN_WITH_MFA_SELECTION and challengeResponse is not "SMS" or "TOTP"', async () => { + it('should throw an error when sign-in step is CONTINUE_SIGN_IN_WITH_MFA_SELECTION and challengeResponse is not "SMS", "TOTP", or "EMAIL"', async () => { expect.assertions(2); try { await confirmSignIn({ challengeResponse: 'NO_SMS' }); diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts index ddeb3c368fd..4e094c3bd0f 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts @@ -122,6 +122,55 @@ describe('confirmSignIn API happy path cases', () => { mockedGetCurrentUser.mockClear(); }); + test(`confirmSignIn with EMAIL_OTP ChallengeName`, async () => { + Amplify.configure({ + Auth: authConfig, + }); + + const handleUserSRPAuthflowSpy = jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'EMAIL_OTP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@a***', + }, + }), + ); + + const signInResult = await signIn({ username, password }); + + expect(signInResult).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + codeDeliveryDetails: { + deliveryMedium: 'EMAIL', + destination: 'j***@a***', + }, + }, + }); + + const confirmSignInResult = await confirmSignIn({ + challengeResponse: '123456', + }); + + expect(confirmSignInResult).toEqual({ + isSignedIn: true, + nextStep: { + signInStep: 'DONE', + }, + }); + + expect(handleChallengeNameSpy).toHaveBeenCalledTimes(1); + expect(handleUserSRPAuthflowSpy).toHaveBeenCalledTimes(1); + + handleUserSRPAuthflowSpy.mockClear(); + }); + test(`confirmSignIn tests MFA_SETUP challengeName`, async () => { Amplify.configure({ Auth: authConfig, @@ -162,7 +211,7 @@ describe('confirmSignIn API happy path cases', () => { handleUserSRPAuthflowSpy.mockClear(); }); - test(`confirmSignIn tests SELECT_MFA_TYPE challengeName `, async () => { + test(`confirmSignIn with SELECT_MFA_TYPE challengeName and SMS response`, async () => { Amplify.configure({ Auth: authConfig, }); @@ -175,7 +224,7 @@ describe('confirmSignIn API happy path cases', () => { Session: '1234234232', $metadata: {}, ChallengeParameters: { - MFAS_CAN_CHOOSE: '["SMS_MFA","SOFTWARE_TOKEN_MFA"]', + MFAS_CAN_CHOOSE: '["SMS_MFA","SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', }, }), ); @@ -204,7 +253,7 @@ describe('confirmSignIn API happy path cases', () => { isSignedIn: false, nextStep: { signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', - allowedMFATypes: ['SMS', 'TOTP'], + allowedMFATypes: ['SMS', 'TOTP', 'EMAIL'], }, }); @@ -226,6 +275,121 @@ describe('confirmSignIn API happy path cases', () => { handleUserSRPAuthflowSpy.mockClear(); }); + test(`confirmSignIn with SELECT_MFA_TYPE challengeName and TOTP response`, async () => { + Amplify.configure({ + Auth: authConfig, + }); + + const handleUserSRPAuthflowSpy = jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'SELECT_MFA_TYPE', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + MFAS_CAN_CHOOSE: '["SMS_MFA","SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', + }, + }), + ); + + handleChallengeNameSpy.mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'SOFTWARE_TOKEN_MFA', + $metadata: {}, + Session: '123456789', + ChallengeParameters: {}, + }), + ); + + const signInResult = await signIn({ username, password }); + + expect(signInResult).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', + allowedMFATypes: ['SMS', 'TOTP', 'EMAIL'], + }, + }); + + const confirmSignInResult = await confirmSignIn({ + challengeResponse: 'TOTP', + }); + + expect(confirmSignInResult).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_TOTP_CODE', + }, + }); + + expect(handleChallengeNameSpy).toHaveBeenCalledTimes(1); + expect(handleUserSRPAuthflowSpy).toHaveBeenCalledTimes(1); + + handleUserSRPAuthflowSpy.mockClear(); + }); + + test(`confirmSignIn with SELECT_MFA_TYPE challengeName and EMAIL response`, async () => { + Amplify.configure({ + Auth: authConfig, + }); + + const handleUserSRPAuthflowSpy = jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'SELECT_MFA_TYPE', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + MFAS_CAN_CHOOSE: '["SMS_MFA","SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', + }, + }), + ); + + handleChallengeNameSpy.mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'EMAIL_OTP', + $metadata: {}, + Session: '1234234232', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@a***', + }, + }), + ); + + const signInResult = await signIn({ username, password }); + + expect(signInResult).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', + allowedMFATypes: ['SMS', 'TOTP', 'EMAIL'], + }, + }); + + const confirmSignInResult = await confirmSignIn({ + challengeResponse: 'EMAIL', + }); + + expect(confirmSignInResult).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + codeDeliveryDetails: { + deliveryMedium: 'EMAIL', + destination: 'j***@a***', + }, + }, + }); + + expect(handleChallengeNameSpy).toHaveBeenCalledTimes(1); + expect(handleUserSRPAuthflowSpy).toHaveBeenCalledTimes(1); + + handleUserSRPAuthflowSpy.mockClear(); + }); + test('handleChallengeName should be called with clientMetadata and usersub', async () => { Amplify.configure({ Auth: authConfig, diff --git a/packages/auth/src/common/AuthErrorStrings.ts b/packages/auth/src/common/AuthErrorStrings.ts index c05e4d7bf4c..ad4b8c261ef 100644 --- a/packages/auth/src/common/AuthErrorStrings.ts +++ b/packages/auth/src/common/AuthErrorStrings.ts @@ -47,8 +47,10 @@ export const validationErrorMap: AmplifyErrorMap = { recoverySuggestion: 'Do not include a password in your signIn call.', }, [AuthValidationErrorCode.IncorrectMFAMethod]: { - message: 'Incorrect MFA method was chosen. It should be either SMS or TOTP', - recoverySuggestion: 'Try to pass TOTP or SMS as the challengeResponse', + message: + 'Incorrect MFA method was chosen. It should be either SMS, TOTP, or EMAIL', + recoverySuggestion: + 'Try to pass SMS, TOTP, or EMAIL as the challengeResponse', }, [AuthValidationErrorCode.EmptyVerifyTOTPSetupCode]: { message: 'code is required to verifyTotpSetup', diff --git a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts index 571ecf2276c..f7a1d4a483a 100644 --- a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts +++ b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts @@ -8,6 +8,7 @@ import { MetadataBearer as __MetadataBearer } from '@aws-sdk/types'; export type ChallengeName = | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' + | 'EMAIL_OTP' | 'SELECT_MFA_TYPE' | 'MFA_SETUP' | 'PASSWORD_VERIFIER' @@ -28,7 +29,7 @@ export type ChallengeParameters = { MFAS_CAN_SETUP?: string; } & Record; -export type CognitoMFAType = 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA'; +export type CognitoMFAType = 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'EMAIL_OTP'; export interface CognitoMFASettings { Enabled?: boolean; @@ -55,6 +56,7 @@ declare enum ChallengeNameType { SELECT_MFA_TYPE = 'SELECT_MFA_TYPE', SMS_MFA = 'SMS_MFA', SOFTWARE_TOKEN_MFA = 'SOFTWARE_TOKEN_MFA', + EMAIL_OTP = 'EMAIL_OTP', } declare enum DeliveryMediumType { EMAIL = 'EMAIL', diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index 8432fee7b30..99a615644b5 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -189,7 +189,9 @@ export async function handleSelectMFATypeChallenge({ }: HandleAuthChallengeRequest): Promise { const { userPoolId, userPoolClientId } = config; assertValidationError( - challengeResponse === 'TOTP' || challengeResponse === 'SMS', + challengeResponse === 'TOTP' || + challengeResponse === 'SMS' || + challengeResponse === 'EMAIL', AuthValidationErrorCode.IncorrectMFAMethod, ); @@ -222,76 +224,6 @@ export async function handleSelectMFATypeChallenge({ ); } -export async function handleSMSMFAChallenge({ - challengeResponse, - clientMetadata, - session, - username, - config, -}: HandleAuthChallengeRequest): Promise { - const { userPoolId, userPoolClientId } = config; - const challengeResponses = { - USERNAME: username, - SMS_MFA_CODE: challengeResponse, - }; - const UserContextData = getUserContextData({ - username, - userPoolId, - userPoolClientId, - }); - const jsonReq: RespondToAuthChallengeCommandInput = { - ChallengeName: 'SMS_MFA', - ChallengeResponses: challengeResponses, - Session: session, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - UserContextData, - }; - - return respondToAuthChallenge( - { - region: getRegion(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - jsonReq, - ); -} -export async function handleSoftwareTokenMFAChallenge({ - challengeResponse, - clientMetadata, - session, - username, - config, -}: HandleAuthChallengeRequest): Promise { - const { userPoolId, userPoolClientId } = config; - const challengeResponses = { - USERNAME: username, - SOFTWARE_TOKEN_MFA_CODE: challengeResponse, - }; - - const UserContextData = getUserContextData({ - username, - userPoolId, - userPoolClientId, - }); - - const jsonReq: RespondToAuthChallengeCommandInput = { - ChallengeName: 'SOFTWARE_TOKEN_MFA', - ChallengeResponses: challengeResponses, - Session: session, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - UserContextData, - }; - - return respondToAuthChallenge( - { - region: getRegion(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - jsonReq, - ); -} export async function handleCompleteNewPasswordChallenge({ challengeResponse, clientMetadata, @@ -824,6 +756,18 @@ export async function getSignInResult(params: { signInStep: 'CONFIRM_SIGN_IN_WITH_TOTP_CODE', }, }; + case 'EMAIL_OTP': + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + codeDeliveryDetails: { + deliveryMedium: + challengeParameters.CODE_DELIVERY_DELIVERY_MEDIUM as AuthDeliveryMedium, + destination: challengeParameters.CODE_DELIVERY_DESTINATION, + }, + }, + }; case 'ADMIN_NO_SRP_AUTH': break; case 'DEVICE_PASSWORD_VERIFIER': @@ -911,14 +855,6 @@ export async function handleChallengeName( const deviceName = options?.friendlyDeviceName; switch (challengeName) { - case 'SMS_MFA': - return handleSMSMFAChallenge({ - challengeResponse, - clientMetadata, - session, - username, - config, - }); case 'SELECT_MFA_TYPE': return handleSelectMFATypeChallenge({ challengeResponse, @@ -961,8 +897,11 @@ export async function handleChallengeName( username, tokenOrchestrator, ); + case 'SMS_MFA': case 'SOFTWARE_TOKEN_MFA': - return handleSoftwareTokenMFAChallenge({ + case 'EMAIL_OTP': + return handleMFAChallenge({ + challengeName, challengeResponse, clientMetadata, session, @@ -981,6 +920,7 @@ export async function handleChallengeName( export function mapMfaType(mfa: string): CognitoMFAType { let mfaType: CognitoMFAType = 'SMS_MFA'; if (mfa === 'TOTP') mfaType = 'SOFTWARE_TOKEN_MFA'; + if (mfa === 'EMAIL') mfaType = 'EMAIL_OTP'; return mfaType; } @@ -1131,3 +1071,58 @@ export function getActiveSignInUsername(username: string): string { return state.username ?? username; } + +export async function handleMFAChallenge({ + challengeName, + challengeResponse, + clientMetadata, + session, + username, + config, +}: HandleAuthChallengeRequest & { + challengeName: Extract< + ChallengeName, + 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' + >; +}) { + const { userPoolId, userPoolClientId } = config; + + const challengeResponses: Record = { + USERNAME: username, + }; + + if (challengeName === 'EMAIL_OTP') { + challengeResponses.EMAIL_OTP_CODE = challengeResponse; + } + + if (challengeName === 'SMS_MFA') { + challengeResponses.SMS_MFA_CODE = challengeResponse; + } + + if (challengeName === 'SOFTWARE_TOKEN_MFA') { + challengeResponses.SOFTWARE_TOKEN_MFA_CODE = challengeResponse; + } + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const jsonReq: RespondToAuthChallengeCommandInput = { + ChallengeName: challengeName, + ChallengeResponses: challengeResponses, + Session: session, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData: userContextData, + }; + + return respondToAuthChallenge( + { + region: getRegion(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + jsonReq, + ); +} diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index 64b80fdd874..0e671266524 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -146,6 +146,21 @@ export interface ConfirmSignInWithSMSCode { codeDeliveryDetails?: AuthCodeDeliveryDetails; } +export interface ConfirmSignInWithEmailCode { + /** + * Auth step requires user to use EMAIL as multifactor authentication by retrieving a code sent to inbox. + * + * @example + * ```typescript + * // Code retrieved from email + * const emailCode = '112233' + * await confirmSignIn({challengeResponse: emailCode}) + * ``` + */ + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE'; + codeDeliveryDetails?: AuthCodeDeliveryDetails; +} + export interface ConfirmSignUpStep { /** * Auth step requires to confirm user's sign-up. @@ -181,6 +196,7 @@ export type AuthNextSignInStep< | ConfirmSignInWithNewPasswordRequired | ConfirmSignInWithSMSCode | ConfirmSignInWithTOTPCode + | ConfirmSignInWithEmailCode | ContinueSignInWithTOTPSetup | ConfirmSignUpStep | ResetPasswordStep