From 42db720ac46d8c1bbd307cd21924b934e7b1b270 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Thu, 31 Oct 2024 11:33:07 -0700 Subject: [PATCH] feat(auth): add support for Email MFA (#13945) * [Email MFA] Updating fetchMFAPreference and updateMFAPreference (#13720) * add EMAIL MFA option in fetchMFAPreference * add EMAIL MFA option in updateMFAPreference * update fetchMFAPreference tests * update updateMFAPreference tests * update bundle size * remove redundant assertions * [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> * feat(auth): [EMAIL MFA] Sign In / Confirm Sign In With MFA_SETUP (#13760) * Sign In / Confirm Sign In With MFA_SETUP * Sign In State Management Tests * Confirm Sign In Happy Path Tests * Fix State Management Tests * Apply Feedback * loose email matching * Remove Unnecessary Export * Update SignInHelpers For Getting Allowed MFA Setup Types * Add Error Case Unit Tests * feat(auth): [EMAIL MFA] enable integ tests with backend configuration swapping (#13794) * chore: enable mfa integ tests * chore: add mfa-setup test def * chore: temporarily enable push integ tests * chore: disable push integ tests * chore: address test strategy feedback * chore: use trimmed challenge response * chore: improved naming * chore: update bundle size tests * chore: remove trimmed challenge response --------- Co-authored-by: israx <70438514+israx@users.noreply.github.com> --- .github/integ-config/integ-all.yml | 45 ++ .github/workflows/callable-e2e-test.yml | 11 +- .github/workflows/callable-e2e-tests.yml | 1 + .../cognito/confirmSignInErrorCases.test.ts | 24 +- .../cognito/confirmSignInHappyCases.test.ts | 446 +++++++++++++++++- .../cognito/fetchMFAPreference.test.ts | 91 +++- .../cognito/signInErrorCases.test.ts | 51 +- .../cognito/testUtils/authApiTestParams.ts | 25 +- .../cognito/updateMFAPreference.test.ts | 64 ++- packages/auth/src/common/AuthErrorStrings.ts | 6 +- .../cognitoIdentityProvider/types/sdk.ts | 24 +- .../providers/cognito/apis/confirmSignIn.ts | 4 +- .../cognito/apis/updateMFAPreference.ts | 3 +- .../src/providers/cognito/types/inputs.ts | 1 + .../providers/cognito/utils/signInHelpers.ts | 412 +++++++++------- packages/auth/src/types/models.ts | 49 +- packages/aws-amplify/package.json | 2 +- 17 files changed, 1021 insertions(+), 238 deletions(-) diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index c7316c37c1f..94a2d85a157 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -870,3 +870,48 @@ tests: spec: ssr-context-isolation yarn_script: ci:ssr-context-isolation browser: [chrome] + - test_name: integ_next_mfa_req_email + desc: 'mfa required with email sign in attribute' + framework: next + category: auth + sample_name: [mfa] + spec: mfa-req-email + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: mfa-req-email + - test_name: integ_next_mfa_req_phone + desc: 'mfa required with phone sign in attribute' + framework: next + category: auth + sample_name: [mfa] + spec: mfa-req-phone + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: mfa-req-phone + - test_name: integ_next_mfa_opt_email + desc: 'mfa optional with email sign in attribute' + framework: next + category: auth + sample_name: [mfa] + spec: mfa-opt-email + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: mfa-opt-email + - test_name: integ_next_mfa_opt_phone + desc: 'mfa optional with phone sign in attribute' + framework: next + category: auth + sample_name: [mfa] + spec: mfa-opt-phone + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: mfa-opt-phone + - test_name: integ_next_mfa_setup + desc: 'mfa setup sign in flow' + framework: next + category: auth + sample_name: [mfa] + spec: mfa-setup + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: mfa-setup diff --git a/.github/workflows/callable-e2e-test.yml b/.github/workflows/callable-e2e-test.yml index 18697cf5dc5..7df6b042969 100644 --- a/.github/workflows/callable-e2e-test.yml +++ b/.github/workflows/callable-e2e-test.yml @@ -37,6 +37,9 @@ on: yarn_script: required: false type: string + env: + required: false + type: string env: AMPLIFY_DIR: /home/runner/work/amplify-js/amplify-js/amplify-js @@ -84,6 +87,7 @@ jobs: E2E_RETRY_COUNT: ${{ inputs.retry_count }} E2E_TEST_NAME: ${{ inputs.test_name }} E2E_YARN_SCRIPT: ${{ inputs.yarn_script }} + E2E_ENV: ${{ inputs.env }} run: | if [ -z "$E2E_YARN_SCRIPT" ]; then ../amplify-js/scripts/retry-yarn-script.sh -s \ @@ -95,7 +99,8 @@ jobs: $E2E_BROWSER \ dev \ $E2E_BACKEND \ - $E2E_AMPLIFY_JS_DIR" \ + $E2E_AMPLIFY_JS_DIR \ + --env $(echo $E2E_ENV | jq -r 'tostring')" \ $E2E_YARN_SCRIPT \ -n $E2E_RETRY_COUNT else @@ -115,6 +120,7 @@ jobs: E2E_RETRY_COUNT: ${{ inputs.retry_count }} E2E_TEST_NAME: ${{ inputs.test_name }} E2E_YARN_SCRIPT: ${{ inputs.yarn_script }} + E2E_ENV: ${{ inputs.env }} run: | if [ -z "$E2E_YARN_SCRIPT" ]; then ../amplify-js/scripts/retry-yarn-script.sh -s \ @@ -126,7 +132,8 @@ jobs: $E2E_BROWSER \ prod \ $E2E_BACKEND \ - $E2E_AMPLIFY_JS_DIR" \ + $E2E_AMPLIFY_JS_DIR \ + --env $(echo $E2E_ENV | jq -r 'tostring')" \ $E2E_YARN_SCRIPT \ -n $E2E_RETRY_COUNT else diff --git a/.github/workflows/callable-e2e-tests.yml b/.github/workflows/callable-e2e-tests.yml index 4ae74a69c88..2b7604b1215 100644 --- a/.github/workflows/callable-e2e-tests.yml +++ b/.github/workflows/callable-e2e-tests.yml @@ -44,6 +44,7 @@ jobs: timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 35 }} retry_count: ${{ matrix.integ-config.retry_count || 3 }} yarn_script: ${{ matrix.integ-config.yarn_script || '' }} + env: ${{ matrix.integ-config.env && toJSON(matrix.integ-config.env) || '{}' }} # e2e-test-runner-headless: # name: E2E test runnner_headless diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts index a2d561799d9..39e4fdd8c81 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts @@ -5,6 +5,7 @@ import { AuthValidationErrorCode } from '../../../src/errors/types/validation'; import { confirmSignIn } from '../../../src/providers/cognito/apis/confirmSignIn'; import { RespondToAuthChallengeException } from '../../../src/providers/cognito/types/errors'; import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; import { createRespondToAuthChallengeClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { getMockError } from './testUtils/data'; @@ -26,7 +27,7 @@ describe('confirmSignIn API error path cases:', () => { const signInSession = '1234234232'; const { username } = authAPITestParams.user1; // assert mocks - const mockStoreGetState = signInStore.getState as jest.Mock; + const mockStoreGetState = jest.mocked(signInStore.getState); const mockRespondToAuthChallenge = jest.fn(); const mockCreateRespondToAuthChallengeClient = jest.mocked( createRespondToAuthChallengeClient, @@ -62,7 +63,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' }); @@ -88,4 +89,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 ddfcc9c2d8e..8ebd38b52e1 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts @@ -14,8 +14,10 @@ import { tokenOrchestrator, } from '../../../src/providers/cognito/tokenProvider'; import { + createAssociateSoftwareTokenClient, createInitiateAuthClient, createRespondToAuthChallengeClient, + createVerifySoftwareTokenClient, } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; @@ -35,7 +37,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; @@ -135,6 +137,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, @@ -175,7 +226,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, }); @@ -188,7 +239,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"]', }, }), ); @@ -217,7 +268,7 @@ describe('confirmSignIn API happy path cases', () => { isSignedIn: false, nextStep: { signInStep: 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION', - allowedMFATypes: ['SMS', 'TOTP'], + allowedMFATypes: ['SMS', 'TOTP', 'EMAIL'], }, }); @@ -239,6 +290,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, @@ -533,3 +699,275 @@ 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', + ); + + const mockAssociateSoftwareToken = jest.fn(); + + jest + .mocked(createAssociateSoftwareTokenClient) + .mockReturnValue(mockAssociateSoftwareToken); + + mockAssociateSoftwareToken.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 mockVerifySoftwareToken = jest.fn(); + + jest + .mocked(createVerifySoftwareTokenClient) + .mockReturnValue(mockVerifySoftwareToken); + + mockVerifySoftwareToken.mockResolvedValue({ + Session: '12341234', + Status: 'SUCCESS', + $metadata: {}, + }); + + const mockRespondToAuthChallenge = jest.fn(); + + jest + .mocked(createRespondToAuthChallengeClient) + .mockReturnValue(mockRespondToAuthChallenge); + + mockRespondToAuthChallenge.mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const totpCode = '123456'; + const confirmSignInResult = await confirmSignIn({ + challengeResponse: totpCode, + }); + + expect(mockVerifySoftwareToken).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', + ); + + const mockRespondToAuthChallenge = jest.fn(); + + jest + .mocked(createRespondToAuthChallengeClient) + .mockReturnValue(mockRespondToAuthChallenge); + + mockRespondToAuthChallenge.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, + ); + + const mockAssociateSoftwareToken = jest.fn(); + jest + .mocked(createAssociateSoftwareTokenClient) + .mockReturnValue(mockAssociateSoftwareToken); + + mockAssociateSoftwareToken.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 mockVerifySoftwareToken = jest.fn(); + jest + .mocked(createVerifySoftwareTokenClient) + .mockReturnValue(mockVerifySoftwareToken); + + mockVerifySoftwareToken.mockResolvedValueOnce({ + Session: '12341234', + Status: 'SUCCESS', + $metadata: {}, + }); + + const mockRespondToAuthChallenge = jest.fn(); + + jest + .mocked(createRespondToAuthChallengeClient) + .mockReturnValue(mockRespondToAuthChallenge); + + mockRespondToAuthChallenge.mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const totpCode = '123456'; + const confirmSignInResult = await confirmSignIn({ + challengeResponse: totpCode, + }); + + expect(mockVerifySoftwareToken).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/fetchMFAPreference.test.ts b/packages/auth/__tests__/providers/cognito/fetchMFAPreference.test.ts index c4d8a7a9efa..18dba7d80f0 100644 --- a/packages/auth/__tests__/providers/cognito/fetchMFAPreference.test.ts +++ b/packages/auth/__tests__/providers/cognito/fetchMFAPreference.test.ts @@ -24,7 +24,7 @@ jest.mock('../../../src/providers/cognito/factories'); describe('fetchMFAPreference', () => { // assert mocks - const mockFetchAuthSession = fetchAuthSession as jest.Mock; + const mockFetchAuthSession = jest.mocked(fetchAuthSession); const mockGetUser = jest.fn(); const mockCreateGetUserClient = jest.mocked(createGetUserClient); const mockCreateCognitoUserPoolEndpointResolver = jest.mocked( @@ -36,37 +36,77 @@ describe('fetchMFAPreference', () => { mockFetchAuthSession.mockResolvedValue({ tokens: { accessToken: decodeJWT(mockAccessToken) }, }); + mockCreateGetUserClient.mockReturnValue(mockGetUser); }); - beforeEach(() => { - mockGetUser.mockResolvedValue({ + afterEach(() => { + mockGetUser.mockReset(); + mockFetchAuthSession.mockClear(); + }); + + it('should return correct MFA preferences when SMS is preferred', async () => { + mockGetUser.mockResolvedValueOnce({ UserAttributes: [], Username: 'XXXXXXXX', PreferredMfaSetting: 'SMS_MFA', - UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA'], + UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'], $metadata: {}, }); - mockCreateGetUserClient.mockReturnValueOnce(mockGetUser); + const resp = await fetchMFAPreference(); + expect(resp).toEqual({ + preferred: 'SMS', + enabled: ['SMS', 'TOTP', 'EMAIL'], + }); }); - afterEach(() => { - mockGetUser.mockReset(); - mockFetchAuthSession.mockClear(); + it('should return correct MFA preferences when EMAIL is preferred', async () => { + mockGetUser.mockResolvedValueOnce({ + UserAttributes: [], + Username: 'XXXXXXXX', + PreferredMfaSetting: 'EMAIL_OTP', + UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'], + $metadata: {}, + }); + const resp = await fetchMFAPreference(); + expect(resp).toEqual({ + preferred: 'EMAIL', + enabled: ['SMS', 'TOTP', 'EMAIL'], + }); }); - - it('should return the preferred MFA setting', async () => { + it('should return correct MFA preferences when TOTP is preferred', async () => { + mockGetUser.mockResolvedValueOnce({ + UserAttributes: [], + Username: 'XXXXXXXX', + PreferredMfaSetting: 'SOFTWARE_TOKEN_MFA', + UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'], + $metadata: {}, + }); const resp = await fetchMFAPreference(); - expect(resp).toEqual({ preferred: 'SMS', enabled: ['SMS', 'TOTP'] }); - expect(mockGetUser).toHaveBeenCalledTimes(1); - expect(mockGetUser).toHaveBeenCalledWith( - { - region: 'us-west-2', - userAgentValue: expect.any(String), - }, - { - AccessToken: mockAccessToken, - }, - ); + expect(resp).toEqual({ + preferred: 'TOTP', + enabled: ['SMS', 'TOTP', 'EMAIL'], + }); + }); + it('should return the correct MFA preferences when there is no preferred option', async () => { + mockGetUser.mockResolvedValueOnce({ + UserAttributes: [], + Username: 'XXXXXXXX', + UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'], + $metadata: {}, + }); + const resp = await fetchMFAPreference(); + expect(resp).toEqual({ + enabled: ['SMS', 'TOTP', 'EMAIL'], + }); + }); + it('should return the correct MFA preferences when there is no available options', async () => { + mockGetUser.mockResolvedValueOnce({ + UserAttributes: [], + Username: 'XXXXXXXX', + $metadata: {}, + }); + const resp = await fetchMFAPreference(); + expect(resp).toEqual({}); }); it('invokes mockCreateCognitoUserPoolEndpointResolver with expected endpointOverride', async () => { @@ -81,6 +121,15 @@ describe('fetchMFAPreference', () => { }, }, }); + + mockGetUser.mockResolvedValueOnce({ + UserAttributes: [], + Username: 'XXXXXXXX', + PreferredMfaSetting: 'SMS_MFA', + UserMFASettingList: ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'], + $metadata: {}, + }); + await fetchMFAPreference(); expect(mockCreateCognitoUserPoolEndpointResolver).toHaveBeenCalledWith({ diff --git a/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts index f66241497f3..94b4029418b 100644 --- a/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts @@ -9,6 +9,7 @@ import { getCurrentUser, signIn } from '../../../src/providers/cognito'; import { InitiateAuthException } from '../../../src/providers/cognito/types/errors'; import { USER_ALREADY_AUTHENTICATED_EXCEPTION } from '../../../src/errors/constants'; import { createInitiateAuthClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; import { authAPITestParams } from './testUtils/authApiTestParams'; import { getMockError } from './testUtils/data'; @@ -26,11 +27,13 @@ jest.mock('../../../src/providers/cognito/apis/getCurrentUser'); jest.mock( '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', ); +jest.mock('../../../src/providers/cognito/tokenProvider'); describe('signIn API error path cases:', () => { // assert mocks const mockCreateInitiateAuthClient = jest.mocked(createInitiateAuthClient); const mockInitiateAuth = jest.fn(); + const mockedGetCurrentUser = getCurrentUser as jest.Mock; beforeAll(() => { @@ -43,7 +46,7 @@ describe('signIn API error path cases:', () => { afterEach(() => { mockedGetCurrentUser.mockReset(); - mockInitiateAuth.mockClear(); + mockInitiateAuth.mockReset(); }); it('should throw an error when a user is already signed-in', async () => { @@ -88,18 +91,44 @@ describe('signIn API error path cases:', () => { }); it('should throw an error when service returns an error response', async () => { - expect.assertions(2); mockInitiateAuth.mockImplementation(() => { throw getMockError(InitiateAuthException.InvalidParameterException); }); - try { - await signIn({ - username: authAPITestParams.user1.username, - password: authAPITestParams.user1.password, - }); - } catch (error: any) { - expect(error).toBeInstanceOf(AuthError); - expect(error.name).toBe(InitiateAuthException.InvalidParameterException); - } + + const signInResultPromise = signIn({ + username: authAPITestParams.user1.username, + password: authAPITestParams.user1.password, + }); + + expect(signInResultPromise).rejects.toThrow( + new AuthError({ + name: InitiateAuthException.InvalidParameterException, + message: 'Error message', + }), + ); + }); + it('should throw an error when sign in step is MFA_SETUP and there are no valid setup options', async () => { + mockInitiateAuth.mockImplementation(() => ({ + ChallengeName: 'MFA_SETUP', + ChallengeParameters: { + MFAS_CAN_SETUP: '["SMS_MFA"]', + }, + $metadata: {}, + })); + + const signInResultPromise = signIn({ + username: authAPITestParams.user1.username, + password: authAPITestParams.user1.password, + options: { + authFlowType: 'USER_PASSWORD_AUTH', + }, + }); + + expect(signInResultPromise).rejects.toThrow( + new AuthError({ + name: AuthErrorCodes.SignInException, + message: 'Cannot initiate MFA setup from available types: 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/__tests__/providers/cognito/updateMFAPreference.test.ts b/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts index a9d4d6c9e65..0d597b5ec9b 100644 --- a/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts +++ b/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts @@ -11,12 +11,15 @@ import { import { AuthError } from '../../../src/errors/AuthError'; import { SetUserMFAPreferenceException } from '../../../src/providers/cognito/types/errors'; import { getMFASettings } from '../../../src/providers/cognito/apis/updateMFAPreference'; +import { MFAPreference } from '../../../src/providers/cognito/types'; import { createSetUserMFAPreferenceClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { createCognitoUserPoolEndpointResolver } from '../../../src/providers/cognito/factories'; import { getMockError, mockAccessToken } from './testUtils/data'; import { setUpGetConfig } from './testUtils/setUpGetConfig'; +type MfaPreferenceValue = MFAPreference | undefined; + jest.mock('@aws-amplify/core', () => ({ ...(jest.createMockFromModule('@aws-amplify/core') as object), Amplify: { getConfig: jest.fn(() => ({})) }, @@ -30,25 +33,39 @@ jest.mock( ); jest.mock('../../../src/providers/cognito/factories'); -const mfaChoices: UpdateMFAPreferenceInput[] = [ - { sms: 'DISABLED', totp: 'DISABLED' }, - { sms: 'DISABLED', totp: 'ENABLED' }, - { sms: 'DISABLED', totp: 'PREFERRED' }, - { sms: 'DISABLED', totp: 'NOT_PREFERRED' }, - { sms: 'ENABLED', totp: 'DISABLED' }, - { sms: 'ENABLED', totp: 'ENABLED' }, - { sms: 'ENABLED', totp: 'PREFERRED' }, - { sms: 'ENABLED', totp: 'NOT_PREFERRED' }, - { sms: 'PREFERRED', totp: 'DISABLED' }, - { sms: 'PREFERRED', totp: 'ENABLED' }, - { sms: 'PREFERRED', totp: 'PREFERRED' }, - { sms: 'PREFERRED', totp: 'NOT_PREFERRED' }, - { sms: 'NOT_PREFERRED', totp: 'DISABLED' }, - { sms: 'NOT_PREFERRED', totp: 'ENABLED' }, - { sms: 'NOT_PREFERRED', totp: 'PREFERRED' }, - { sms: 'NOT_PREFERRED', totp: 'NOT_PREFERRED' }, - { sms: undefined, totp: undefined }, -]; +// generates all preference permutations +const generateUpdateMFAPreferenceOptions = () => { + const mfaPreferenceTypes: MfaPreferenceValue[] = [ + 'PREFERRED', + 'NOT_PREFERRED', + 'ENABLED', + 'DISABLED', + undefined, + ]; + const mfaKeys: (keyof UpdateMFAPreferenceInput)[] = ['email', 'sms', 'totp']; + + const generatePermutations = ( + keys: string[], + values: T[], + ): Record[] => { + if (!keys.length) return [{}]; + + const [curr, ...rest] = keys; + const permutations: Record[] = []; + + for (const value of values) { + for (const perm of generatePermutations(rest, values)) { + permutations.push({ ...perm, [curr]: value }); + } + } + + return permutations; + }; + + return generatePermutations(mfaKeys, mfaPreferenceTypes); +}; + +const mfaChoices = generateUpdateMFAPreferenceOptions(); describe('updateMFAPreference', () => { // assert mocks @@ -82,10 +99,10 @@ describe('updateMFAPreference', () => { }); it.each(mfaChoices)( - 'should update with sms $sms and totp $totp', - async mfaChoise => { - const { totp, sms } = mfaChoise; - await updateMFAPreference(mfaChoise); + 'should update with email $email, sms $sms, and totp $totp', + async mfaChoice => { + const { totp, sms, email } = mfaChoice; + await updateMFAPreference(mfaChoice); expect(mockSetUserMFAPreference).toHaveBeenCalledWith( { region: 'us-west-2', @@ -95,6 +112,7 @@ describe('updateMFAPreference', () => { AccessToken: mockAccessToken, SMSMfaSettings: getMFASettings(sms), SoftwareTokenMfaSettings: getMFASettings(totp), + EmailMfaSettings: getMFASettings(email), }, ); }, 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/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts index c08589ad448..f7a1d4a483a 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.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', @@ -1430,6 +1432,10 @@ export interface SetUserMFAPreferenceRequest { *

The time-based one-time password software token MFA settings.

*/ SoftwareTokenMfaSettings?: SoftwareTokenMfaSettingsType; + /** + *

The email message multi-factor authentication (MFA) settings.

+ */ + EmailMfaSettings?: EmailMfaSettingsType; /** *

The access token for the user.

*/ @@ -1538,6 +1544,22 @@ export interface SoftwareTokenMfaSettingsType { */ PreferredMfa?: boolean; } +/** + *

The type used for enabling email MFA at the user level. If an MFA type is activated for a user, the user will be prompted for MFA during all sign-in attempts, unless device tracking + * is turned on and the device has been trusted. If you want MFA to be applied selectively based on the assessed risk level of sign-in attempts, deactivate MFA for users and turn on Adaptive + * Authentication for the user pool.

+ */ +export interface EmailMfaSettingsType { + /** + *

Specifies whether email MFA is activated. If an MFA type is activated for a user, the user will be prompted for MFA during all sign-in attempts, unless device tracking is turned + * on and the device has been trusted.

+ */ + Enabled?: boolean; + /** + *

Specifies whether email MFA is the preferred MFA method.

+ */ + PreferredMfa?: boolean; +} export type UpdateDeviceStatusCommandInput = UpdateDeviceStatusRequest; export interface UpdateDeviceStatusCommandOutput extends UpdateDeviceStatusResponse, diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index b8e8475aa42..2b577f1a1a9 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/apis/updateMFAPreference.ts b/packages/auth/src/providers/cognito/apis/updateMFAPreference.ts index 5f87522af5a..200c9e59f0e 100644 --- a/packages/auth/src/providers/cognito/apis/updateMFAPreference.ts +++ b/packages/auth/src/providers/cognito/apis/updateMFAPreference.ts @@ -27,7 +27,7 @@ import { createCognitoUserPoolEndpointResolver } from '../factories'; export async function updateMFAPreference( input: UpdateMFAPreferenceInput, ): Promise { - const { sms, totp } = input; + const { sms, totp, email } = input; const authConfig = Amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); const { userPoolEndpoint, userPoolId } = authConfig; @@ -47,6 +47,7 @@ export async function updateMFAPreference( AccessToken: tokens.accessToken.toString(), SMSMfaSettings: getMFASettings(sms), SoftwareTokenMfaSettings: getMFASettings(totp), + EmailMfaSettings: getMFASettings(email), }, ); } diff --git a/packages/auth/src/providers/cognito/types/inputs.ts b/packages/auth/src/providers/cognito/types/inputs.ts index fa7223f71da..13952bf53e9 100644 --- a/packages/auth/src/providers/cognito/types/inputs.ts +++ b/packages/auth/src/providers/cognito/types/inputs.ts @@ -118,6 +118,7 @@ export type SignUpInput = AuthSignUpInput>; export interface UpdateMFAPreferenceInput { sms?: MFAPreference; totp?: MFAPreference; + email?: MFAPreference; } /** diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index ef4c1422bf3..d3bce2aa6f2 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -154,49 +154,116 @@ export async function handleMFASetupChallenge({ config, }: HandleAuthChallengeRequest): Promise { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; - const challengeResponses = { + + if (challengeResponse === 'EMAIL') { + return { + ChallengeName: 'MFA_SETUP', + Session: session, + ChallengeParameters: { + MFAS_CAN_SETUP: '["EMAIL_OTP"]', + }, + $metadata: {}, + }; + } + + if (challengeResponse === 'TOTP') { + return { + ChallengeName: 'MFA_SETUP', + Session: session, + ChallengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA"]', + }, + $metadata: {}, + }; + } + + const challengeResponses: Record = { USERNAME: username, }; - const verifySoftwareToken = createVerifySoftwareTokenClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - const { Session } = await verifySoftwareToken( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - { - UserCode: challengeResponse, + + const isTOTPCode = /^\d+$/.test(challengeResponse); + + if (isTOTPCode) { + const verifySoftwareToken = createVerifySoftwareTokenClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { Session } = await verifySoftwareToken( + { + region: getRegionFromUserPoolId(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, + }; + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + jsonReq, + ); + } + + const isEmail = challengeResponse.includes('@'); + + if (isEmail) { + challengeResponses.EMAIL = challengeResponse; + + const jsonReq: RespondToAuthChallengeCommandInput = { + ChallengeName: 'MFA_SETUP', + ChallengeResponses: challengeResponses, Session: session, - FriendlyDeviceName: deviceName, - }, - ); + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + }; - signInStore.dispatch({ - type: 'SET_SIGN_IN_SESSION', - value: Session, - }); + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); - const jsonReq: RespondToAuthChallengeCommandInput = { - ChallengeName: 'MFA_SETUP', - ChallengeResponses: challengeResponses, - Session, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - }; + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + jsonReq, + ); + } - const respondToAuthChallenge = createRespondToAuthChallengeClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), + 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.', }); - - return respondToAuthChallenge( - { region: getRegionFromUserPoolId(userPoolId) }, - jsonReq, - ); } export async function handleSelectMFATypeChallenge({ @@ -208,7 +275,9 @@ export async function handleSelectMFATypeChallenge({ }: HandleAuthChallengeRequest): Promise { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; assertValidationError( - challengeResponse === 'TOTP' || challengeResponse === 'SMS', + challengeResponse === 'TOTP' || + challengeResponse === 'SMS' || + challengeResponse === 'EMAIL', AuthValidationErrorCode.IncorrectMFAMethod, ); @@ -247,88 +316,6 @@ export async function handleSelectMFATypeChallenge({ ); } -export async function handleSMSMFAChallenge({ - challengeResponse, - clientMetadata, - session, - username, - config, -}: HandleAuthChallengeRequest): Promise { - const { userPoolId, userPoolClientId, userPoolEndpoint } = 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, - }; - - const respondToAuthChallenge = createRespondToAuthChallengeClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - - return respondToAuthChallenge( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - jsonReq, - ); -} -export async function handleSoftwareTokenMFAChallenge({ - challengeResponse, - clientMetadata, - session, - username, - config, -}: HandleAuthChallengeRequest): Promise { - const { userPoolId, userPoolClientId, userPoolEndpoint } = 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, - }; - - const respondToAuthChallenge = createRespondToAuthChallengeClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - - return respondToAuthChallenge( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - jsonReq, - ); -} export async function handleCompleteNewPasswordChallenge({ challengeResponse, clientMetadata, @@ -842,37 +829,65 @@ 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 associateSoftwareToken = createAssociateSoftwareTokenClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: authConfig.userPoolEndpoint, + }), + }); + const { Session, SecretCode: secretCode } = + await associateSoftwareToken( + { region: getRegionFromUserPoolId(authConfig.userPoolId) }, + { + Session: signInSession, + }, + ); + + signInStore.dispatch({ + type: 'SET_SIGN_IN_SESSION', + value: Session, }); - const associateSoftwareToken = createAssociateSoftwareTokenClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: authConfig.userPoolEndpoint, - }), - }); - const { Session, SecretCode: secretCode } = await associateSoftwareToken( - { region: getRegionFromUserPoolId(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 { @@ -913,6 +928,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': @@ -1000,14 +1027,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, @@ -1050,8 +1069,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, @@ -1062,7 +1084,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.`, }); } @@ -1070,6 +1092,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; } @@ -1077,6 +1100,7 @@ export function mapMfaType(mfa: string): CognitoMFAType { export function getMFAType(type?: string): AuthMFAType | undefined { if (type === 'SMS_MFA') return 'SMS'; if (type === 'SOFTWARE_TOKEN_MFA') return 'TOTP'; + if (type === 'EMAIL_OTP') return 'EMAIL'; // TODO: log warning for unknown MFA type } @@ -1091,15 +1115,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() { @@ -1230,3 +1249,64 @@ 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, userPoolEndpoint } = 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, + }; + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + jsonReq, + ); +} diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index 9bcc006141d..e08b7bce5f9 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -44,7 +44,7 @@ export interface AuthTOTPSetupDetails { getSetupUri(appName: string, accountName?: string): URL; } -export type AuthMFAType = 'SMS' | 'TOTP'; +export type AuthMFAType = 'SMS' | 'TOTP' | 'EMAIL'; export type AuthAllowedMFATypes = AuthMFAType[]; @@ -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. @@ -146,6 +175,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,7 +225,10 @@ export type AuthNextSignInStep< | ConfirmSignInWithNewPasswordRequired | ConfirmSignInWithSMSCode | ConfirmSignInWithTOTPCode + | ConfirmSignInWithEmailCode | ContinueSignInWithTOTPSetup + | ContinueSignInWithEmailSetup + | ContinueSignInWithMFASetupSelection | ConfirmSignUpStep | ResetPasswordStep | DoneSignInStep; diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index d39dd6e0fad..cc14fb0b487 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -389,7 +389,7 @@ "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "11.98 kB" + "limit": "12.00 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)",