diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts index 355f89c2ac8..3516c146e6a 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts @@ -6,6 +6,7 @@ import { confirmSignIn } from '../../../src/providers/cognito/apis/confirmSignIn import { RespondToAuthChallengeException } from '../../../src/providers/cognito/types/errors'; import { respondToAuthChallenge } from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider'; import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; import { getMockError } from './testUtils/data'; import { setUpGetConfig } from './testUtils/setUpGetConfig'; @@ -25,8 +26,8 @@ describe('confirmSignIn API error path cases:', () => { const signInSession = '1234234232'; const { username } = authAPITestParams.user1; // assert mocks - const mockStoreGetState = signInStore.getState as jest.Mock; - const mockRespondToAuthChallenge = respondToAuthChallenge as jest.Mock; + const mockStoreGetState = jest.mocked(signInStore.getState); + const mockRespondToAuthChallenge = jest.mocked(respondToAuthChallenge); beforeAll(() => { setUpGetConfig(Amplify); @@ -77,4 +78,23 @@ describe('confirmSignIn API error path cases:', () => { ); } }); + it('should throw an error when sign-in step is MFA_SETUP and challengeResponse is not valid', async () => { + expect.assertions(3); + + mockStoreGetState.mockReturnValue({ + username, + challengeName: 'MFA_SETUP', + signInSession, + }); + + try { + await confirmSignIn({ + challengeResponse: 'SMS', + }); + } catch (err: any) { + expect(err).toBeInstanceOf(AuthError); + expect(err.name).toBe(AuthErrorCodes.SignInException); + expect(err.message).toContain('SMS'); + } + }); }); diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts index 4e094c3bd0f..11bd3a85b08 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts @@ -29,7 +29,7 @@ const authConfig = { // getCurrentUser is mocked so Hub is able to dispatch a mocked AuthUser // before returning an `AuthSignInResult` -const mockedGetCurrentUser = getCurrentUser as jest.Mock; +const mockedGetCurrentUser = jest.mocked(getCurrentUser); describe('confirmSignIn API happy path cases', () => { let handleChallengeNameSpy: jest.SpyInstance; @@ -706,3 +706,245 @@ describe('Cognito ASF', () => { ); }); }); + +describe('confirmSignIn MFA_SETUP challenge happy path cases', () => { + const { username, password } = authAPITestParams.user1; + + test('confirmSignIn with multiple MFA_SETUP options using SOFTWARE_TOKEN_MFA', async () => { + Amplify.configure({ + Auth: authConfig, + }); + jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeMultipleMfaSetupOutput, + ); + + const result = await signIn({ username, password }); + + expect(result.isSignedIn).toBe(false); + expect(result.nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION', + ); + + jest.spyOn(clients, 'associateSoftwareToken').mockResolvedValueOnce({ + SecretCode: 'secret-code', + Session: '12341234', + $metadata: {}, + }); + + const selectMfaToSetupConfirmSignInResult = await confirmSignIn({ + challengeResponse: 'TOTP', + }); + + expect(selectMfaToSetupConfirmSignInResult.isSignedIn).toBe(false); + expect(selectMfaToSetupConfirmSignInResult.nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP', + ); + + const verifySoftwareTokenSpy = jest + .spyOn(clients, 'verifySoftwareToken') + .mockResolvedValueOnce({ + Session: '12341234', + Status: 'SUCCESS', + $metadata: {}, + }); + + jest + .spyOn(clients, 'respondToAuthChallenge') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const totpCode = '123456'; + const confirmSignInResult = await confirmSignIn({ + challengeResponse: totpCode, + }); + + expect(verifySoftwareTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'us-west-2', + }), + expect.objectContaining({ + UserCode: totpCode, + Session: '12341234', + }), + ); + expect(confirmSignInResult.isSignedIn).toBe(true); + expect(confirmSignInResult.nextStep.signInStep).toBe('DONE'); + }); + + test('confirmSignIn with multiple MFA_SETUP options using EMAIL_OTP', async () => { + Amplify.configure({ + Auth: authConfig, + }); + + jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeMultipleMfaSetupOutput, + ); + + const result = await signIn({ username, password }); + + expect(result.isSignedIn).toBe(false); + expect(result.nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION', + ); + + const selectMfaToSetupConfirmSignInResult = await confirmSignIn({ + challengeResponse: 'EMAIL', + }); + + expect(selectMfaToSetupConfirmSignInResult.isSignedIn).toBe(false); + expect(selectMfaToSetupConfirmSignInResult.nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP', + ); + + jest.spyOn(signInHelpers, 'handleChallengeName').mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'EMAIL_OTP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@a***', + }, + }), + ); + + const setupEmailConfirmSignInResult = await confirmSignIn({ + challengeResponse: 'j***@a***', + }); + + expect(setupEmailConfirmSignInResult.nextStep.signInStep).toBe( + 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + ); + + jest + .spyOn(clients, 'respondToAuthChallenge') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const confirmSignInResult = await confirmSignIn({ + challengeResponse: '123456', + }); + + expect(confirmSignInResult.isSignedIn).toBe(true); + expect(confirmSignInResult.nextStep.signInStep).toBe('DONE'); + }); + + test('confirmSignIn with single MFA_SETUP option using EMAIL_OTP', async () => { + Amplify.configure({ + Auth: authConfig, + }); + + jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeEmailMfaSetupOutput, + ); + + const result = await signIn({ username, password }); + + expect(result.isSignedIn).toBe(false); + expect(result.nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP', + ); + + jest.spyOn(signInHelpers, 'handleChallengeName').mockImplementationOnce( + async (): Promise => ({ + ChallengeName: 'EMAIL_OTP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'j***@a***', + }, + }), + ); + + const setupEmailConfirmSignInResult = await confirmSignIn({ + challengeResponse: 'j***@a***', + }); + + expect(setupEmailConfirmSignInResult.nextStep.signInStep).toBe( + 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + ); + + jest + .spyOn(signInHelpers, 'handleChallengeName') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const confirmSignInResult = await confirmSignIn({ + challengeResponse: '123456', + }); + + expect(confirmSignInResult.isSignedIn).toBe(true); + expect(confirmSignInResult.nextStep.signInStep).toBe('DONE'); + }); + + test('confirmSignIn with single MFA_SETUP option using SOFTWARE_TOKEN_MFA', async () => { + Amplify.configure({ + Auth: authConfig, + }); + jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeTotpMfaSetupOutput, + ); + + jest.spyOn(clients, 'associateSoftwareToken').mockResolvedValueOnce({ + SecretCode: 'secret-code', + Session: '12341234', + $metadata: {}, + }); + + const result = await signIn({ username, password }); + + expect(result.isSignedIn).toBe(false); + expect(result.nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_TOTP_SETUP'); + + const verifySoftwareTokenSpy = jest + .spyOn(clients, 'verifySoftwareToken') + .mockResolvedValueOnce({ + Session: '12341234', + Status: 'SUCCESS', + $metadata: {}, + }); + + jest + .spyOn(clients, 'respondToAuthChallenge') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const totpCode = '123456'; + const confirmSignInResult = await confirmSignIn({ + challengeResponse: totpCode, + }); + + expect(verifySoftwareTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'us-west-2', + }), + expect.objectContaining({ + UserCode: totpCode, + Session: '12341234', + }), + ); + expect(confirmSignInResult.isSignedIn).toBe(true); + expect(confirmSignInResult.nextStep.signInStep).toBe('DONE'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts index b4e8453b17d..fee490b8bfa 100644 --- a/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts @@ -9,6 +9,8 @@ import { getCurrentUser, signIn } from '../../../src/providers/cognito'; import { initiateAuth } from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider'; import { InitiateAuthException } from '../../../src/providers/cognito/types/errors'; import { USER_ALREADY_AUTHENTICATED_EXCEPTION } from '../../../src/errors/constants'; +import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; +import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers'; import { authAPITestParams } from './testUtils/authApiTestParams'; import { getMockError } from './testUtils/data'; @@ -97,4 +99,28 @@ describe('signIn API error path cases:', () => { expect(error.name).toBe(InitiateAuthException.InvalidParameterException); } }); + it('should throw an error when sign in step is MFA_SETUP and there are no valid setup options', async () => { + expect.assertions(3); + + jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce(async () => ({ + ChallengeName: 'MFA_SETUP', + ChallengeParameters: { + MFAS_CAN_SETUP: '["SMS_MFA"]', + }, + $metadata: {}, + })); + + try { + await signIn({ + username: authAPITestParams.user1.username, + password: authAPITestParams.user1.password, + }); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + expect(error.message).toContain('SMS'); + } + }); }); diff --git a/packages/auth/__tests__/providers/cognito/testUtils/authApiTestParams.ts b/packages/auth/__tests__/providers/cognito/testUtils/authApiTestParams.ts index 9d5cde07f27..1719bb8d9a4 100644 --- a/packages/auth/__tests__/providers/cognito/testUtils/authApiTestParams.ts +++ b/packages/auth/__tests__/providers/cognito/testUtils/authApiTestParams.ts @@ -112,6 +112,30 @@ export const authAPITestParams = { Session: 'aaabbbcccddd', $metadata: {}, }, + RespondToAuthChallengeMultipleMfaSetupOutput: { + ChallengeName: 'MFA_SETUP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + MFAS_CAN_SETUP: '["SMS_MFA","SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', + }, + }, + RespondToAuthChallengeEmailMfaSetupOutput: { + ChallengeName: 'MFA_SETUP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + MFAS_CAN_SETUP: '["SMS_MFA", "EMAIL_OTP"]', + }, + }, + RespondToAuthChallengeTotpMfaSetupOutput: { + ChallengeName: 'MFA_SETUP', + Session: '1234234232', + $metadata: {}, + ChallengeParameters: { + MFAS_CAN_SETUP: '["SMS_MFA", "SOFTWARE_TOKEN_MFA"]', + }, + }, CustomChallengeResponse: { ChallengeName: 'CUSTOM_CHALLENGE', AuthenticationResult: undefined, @@ -199,7 +223,6 @@ export const authAPITestParams = { }, GuestIdentityId: { id: 'guest-identity-id', type: 'guest' }, PrimaryIdentityId: { id: 'primary-identity-id', type: 'primary' }, - signInResultWithCustomAuth: () => { return { isSignedIn: false, diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index 6aad224af30..badbdf7850e 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts @@ -71,8 +71,8 @@ export async function confirmSignIn( throw new AuthError({ name: AuthErrorCodes.SignInException, message: ` - An error occurred during the sign in process. - + An error occurred during the sign in process. + This most likely occurred due to: 1. signIn was not called before confirmSignIn. 2. signIn threw an exception. diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index 99a615644b5..5b6ab56b210 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -148,36 +148,86 @@ export async function handleMFASetupChallenge({ config, }: HandleAuthChallengeRequest): Promise { const { userPoolId, userPoolClientId } = config; - const challengeResponses = { - USERNAME: username, - }; - const { Session } = await verifySoftwareToken( - { - region: getRegion(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - { - UserCode: challengeResponse, + if (challengeResponse === 'EMAIL') { + return { + ChallengeName: 'MFA_SETUP', Session: session, - FriendlyDeviceName: deviceName, - }, - ); + ChallengeParameters: { + MFAS_CAN_SETUP: '["EMAIL_OTP"]', + }, + $metadata: {}, + }; + } - signInStore.dispatch({ - type: 'SET_SIGN_IN_SESSION', - value: Session, - }); + if (challengeResponse === 'TOTP') { + return { + ChallengeName: 'MFA_SETUP', + Session: session, + ChallengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA"]', + }, + $metadata: {}, + }; + } - const jsonReq: RespondToAuthChallengeCommandInput = { - ChallengeName: 'MFA_SETUP', - ChallengeResponses: challengeResponses, - Session, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, + const challengeResponses: Record = { + USERNAME: username, }; - return respondToAuthChallenge({ region: getRegion(userPoolId) }, jsonReq); + const isTOTPCode = /^\d+$/.test(challengeResponse.trim()); + + if (isTOTPCode) { + const { Session } = await verifySoftwareToken( + { + region: getRegion(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + UserCode: challengeResponse, + Session: session, + FriendlyDeviceName: deviceName, + }, + ); + + signInStore.dispatch({ + type: 'SET_SIGN_IN_SESSION', + value: Session, + }); + + const jsonReq: RespondToAuthChallengeCommandInput = { + ChallengeName: 'MFA_SETUP', + ChallengeResponses: challengeResponses, + Session, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + }; + + return respondToAuthChallenge({ region: getRegion(userPoolId) }, jsonReq); + } + + const isEmail = /^\S+@\S+\.\S+$/.test(challengeResponse.trim()); + + if (isEmail) { + challengeResponses.EMAIL = challengeResponse; + + const jsonReq: RespondToAuthChallengeCommandInput = { + ChallengeName: 'MFA_SETUP', + ChallengeResponses: challengeResponses, + Session: session, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + }; + + return respondToAuthChallenge({ region: getRegion(userPoolId) }, jsonReq); + } + + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: `Cannot proceed with MFA setup using challengeResponse: ${challengeResponse}`, + recoverySuggestion: + 'Try passing "EMAIL", "TOTP", a valid email, or OTP code as the challengeResponse.', + }); } export async function handleSelectMFATypeChallenge({ @@ -691,31 +741,60 @@ export async function getSignInResult(params: { case 'MFA_SETUP': { const { signInSession, username } = signInStore.getState(); - if (!isMFATypeEnabled(challengeParameters, 'TOTP')) - throw new AuthError({ - name: AuthErrorCodes.SignInException, - message: `Cannot initiate MFA setup from available types: ${getMFATypes( - parseMFATypes(challengeParameters.MFAS_CAN_SETUP), - )}`, + const mfaSetupTypes = + getMFATypes(parseMFATypes(challengeParameters.MFAS_CAN_SETUP)) || []; + + const allowedMfaSetupTypes = getAllowedMfaSetupTypes(mfaSetupTypes); + + const isTotpMfaSetupAvailable = allowedMfaSetupTypes.includes('TOTP'); + const isEmailMfaSetupAvailable = allowedMfaSetupTypes.includes('EMAIL'); + + if (isTotpMfaSetupAvailable && isEmailMfaSetupAvailable) { + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION', + allowedMFATypes: allowedMfaSetupTypes, + }, + }; + } + + if (isEmailMfaSetupAvailable) { + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP', + }, + }; + } + + if (isTotpMfaSetupAvailable) { + const { Session, SecretCode: secretCode } = + await associateSoftwareToken( + { region: getRegion(authConfig.userPoolId) }, + { + Session: signInSession, + }, + ); + + signInStore.dispatch({ + type: 'SET_SIGN_IN_SESSION', + value: Session, }); - const { Session, SecretCode: secretCode } = await associateSoftwareToken( - { region: getRegion(authConfig.userPoolId) }, - { - Session: signInSession, - }, - ); - signInStore.dispatch({ - type: 'SET_SIGN_IN_SESSION', - value: Session, - }); - return { - isSignedIn: false, - nextStep: { - signInStep: 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP', - totpSetupDetails: getTOTPSetupDetails(secretCode!, username), - }, - }; + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP', + totpSetupDetails: getTOTPSetupDetails(secretCode!, username), + }, + }; + } + + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: `Cannot initiate MFA setup from available types: ${mfaSetupTypes}`, + }); } case 'NEW_PASSWORD_REQUIRED': return { @@ -912,7 +991,7 @@ export async function handleChallengeName( // TODO: remove this error message for production apps throw new AuthError({ name: AuthErrorCodes.SignInException, - message: `An error occurred during the sign in process. + message: `An error occurred during the sign in process. ${challengeName} challengeName returned by the underlying service was not addressed.`, }); } @@ -943,15 +1022,10 @@ export function parseMFATypes(mfa?: string): CognitoMFAType[] { return JSON.parse(mfa) as CognitoMFAType[]; } -export function isMFATypeEnabled( - challengeParams: ChallengeParameters, - mfaType: AuthMFAType, -): boolean { - const { MFAS_CAN_SETUP } = challengeParams; - const mfaTypes = getMFATypes(parseMFATypes(MFAS_CAN_SETUP)); - if (!mfaTypes) return false; - - return mfaTypes.includes(mfaType); +export function getAllowedMfaSetupTypes(availableMfaSetupTypes: AuthMFAType[]) { + return availableMfaSetupTypes.filter( + authMfaType => authMfaType === 'EMAIL' || authMfaType === 'TOTP', + ); } export async function assertUserNotAuthenticated() { diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index 0e671266524..e08b7bce5f9 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -63,6 +63,20 @@ export interface ContinueSignInWithTOTPSetup { signInStep: 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP'; totpSetupDetails: AuthTOTPSetupDetails; } +export interface ContinueSignInWithEmailSetup { + /** + * Auth step requires user to set up EMAIL as multifactor authentication by associating an email address + * and entering the OTP. + * + * @example + * ```typescript + * // Code retrieved from email + * const emailAddress = 'example@example.com'; + * await confirmSignIn({challengeResponse: emailAddress }); + * ``` + */ + signInStep: 'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP'; +} export interface ConfirmSignInWithTOTPCode { /** * Auth step requires user to use TOTP as multifactor authentication by retriving an OTP code from authenticator app. @@ -92,6 +106,21 @@ export interface ContinueSignInWithMFASelection { allowedMFATypes?: AuthAllowedMFATypes; } +export interface ContinueSignInWithMFASetupSelection { + /** + * Auth step requires user to select an mfa option (SMS | TOTP) to setup before continuing the sign-in flow. + * + * @example + * ```typescript + * await confirmSignIn({challengeResponse:'TOTP'}); + * // OR + * await confirmSignIn({challengeResponse:'EMAIL'}); + * ``` + */ + signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION'; + allowedMFATypes?: AuthAllowedMFATypes; +} + export interface ConfirmSignInWithCustomChallenge { /** * Auth step requires user to respond to a custom challenge. @@ -198,6 +227,8 @@ export type AuthNextSignInStep< | ConfirmSignInWithTOTPCode | ConfirmSignInWithEmailCode | ContinueSignInWithTOTPSetup + | ContinueSignInWithEmailSetup + | ContinueSignInWithMFASetupSelection | ConfirmSignUpStep | ResetPasswordStep | DoneSignInStep;