Skip to content

Commit c1c7a5e

Browse files
committed
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
1 parent 4b2c458 commit c1c7a5e

File tree

7 files changed

+479
-63
lines changed

7 files changed

+479
-63
lines changed

packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { confirmSignIn } from '../../../src/providers/cognito/apis/confirmSignIn
66
import { RespondToAuthChallengeException } from '../../../src/providers/cognito/types/errors';
77
import { respondToAuthChallenge } from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider';
88
import { signInStore } from '../../../src/providers/cognito/utils/signInStore';
9+
import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings';
910

1011
import { getMockError } from './testUtils/data';
1112
import { setUpGetConfig } from './testUtils/setUpGetConfig';
@@ -25,8 +26,8 @@ describe('confirmSignIn API error path cases:', () => {
2526
const signInSession = '1234234232';
2627
const { username } = authAPITestParams.user1;
2728
// assert mocks
28-
const mockStoreGetState = signInStore.getState as jest.Mock;
29-
const mockRespondToAuthChallenge = respondToAuthChallenge as jest.Mock;
29+
const mockStoreGetState = jest.mocked(signInStore.getState);
30+
const mockRespondToAuthChallenge = jest.mocked(respondToAuthChallenge);
3031

3132
beforeAll(() => {
3233
setUpGetConfig(Amplify);
@@ -77,4 +78,23 @@ describe('confirmSignIn API error path cases:', () => {
7778
);
7879
}
7980
});
81+
it('should throw an error when sign-in step is MFA_SETUP and challengeResponse is not valid', async () => {
82+
expect.assertions(3);
83+
84+
mockStoreGetState.mockReturnValue({
85+
username,
86+
challengeName: 'MFA_SETUP',
87+
signInSession,
88+
});
89+
90+
try {
91+
await confirmSignIn({
92+
challengeResponse: 'SMS',
93+
});
94+
} catch (err: any) {
95+
expect(err).toBeInstanceOf(AuthError);
96+
expect(err.name).toBe(AuthErrorCodes.SignInException);
97+
expect(err.message).toContain('SMS');
98+
}
99+
});
80100
});

packages/auth/__tests__/providers/cognito/confirmSignInHappyCases.test.ts

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const authConfig = {
2929

3030
// getCurrentUser is mocked so Hub is able to dispatch a mocked AuthUser
3131
// before returning an `AuthSignInResult`
32-
const mockedGetCurrentUser = getCurrentUser as jest.Mock;
32+
const mockedGetCurrentUser = jest.mocked(getCurrentUser);
3333

3434
describe('confirmSignIn API happy path cases', () => {
3535
let handleChallengeNameSpy: jest.SpyInstance;
@@ -706,3 +706,245 @@ describe('Cognito ASF', () => {
706706
);
707707
});
708708
});
709+
710+
describe('confirmSignIn MFA_SETUP challenge happy path cases', () => {
711+
const { username, password } = authAPITestParams.user1;
712+
713+
test('confirmSignIn with multiple MFA_SETUP options using SOFTWARE_TOKEN_MFA', async () => {
714+
Amplify.configure({
715+
Auth: authConfig,
716+
});
717+
jest
718+
.spyOn(signInHelpers, 'handleUserSRPAuthFlow')
719+
.mockImplementationOnce(
720+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
721+
authAPITestParams.RespondToAuthChallengeMultipleMfaSetupOutput,
722+
);
723+
724+
const result = await signIn({ username, password });
725+
726+
expect(result.isSignedIn).toBe(false);
727+
expect(result.nextStep.signInStep).toBe(
728+
'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION',
729+
);
730+
731+
jest.spyOn(clients, 'associateSoftwareToken').mockResolvedValueOnce({
732+
SecretCode: 'secret-code',
733+
Session: '12341234',
734+
$metadata: {},
735+
});
736+
737+
const selectMfaToSetupConfirmSignInResult = await confirmSignIn({
738+
challengeResponse: 'TOTP',
739+
});
740+
741+
expect(selectMfaToSetupConfirmSignInResult.isSignedIn).toBe(false);
742+
expect(selectMfaToSetupConfirmSignInResult.nextStep.signInStep).toBe(
743+
'CONTINUE_SIGN_IN_WITH_TOTP_SETUP',
744+
);
745+
746+
const verifySoftwareTokenSpy = jest
747+
.spyOn(clients, 'verifySoftwareToken')
748+
.mockResolvedValueOnce({
749+
Session: '12341234',
750+
Status: 'SUCCESS',
751+
$metadata: {},
752+
});
753+
754+
jest
755+
.spyOn(clients, 'respondToAuthChallenge')
756+
.mockImplementationOnce(
757+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
758+
authAPITestParams.RespondToAuthChallengeCommandOutput,
759+
);
760+
761+
const totpCode = '123456';
762+
const confirmSignInResult = await confirmSignIn({
763+
challengeResponse: totpCode,
764+
});
765+
766+
expect(verifySoftwareTokenSpy).toHaveBeenCalledWith(
767+
expect.objectContaining({
768+
region: 'us-west-2',
769+
}),
770+
expect.objectContaining({
771+
UserCode: totpCode,
772+
Session: '12341234',
773+
}),
774+
);
775+
expect(confirmSignInResult.isSignedIn).toBe(true);
776+
expect(confirmSignInResult.nextStep.signInStep).toBe('DONE');
777+
});
778+
779+
test('confirmSignIn with multiple MFA_SETUP options using EMAIL_OTP', async () => {
780+
Amplify.configure({
781+
Auth: authConfig,
782+
});
783+
784+
jest
785+
.spyOn(signInHelpers, 'handleUserSRPAuthFlow')
786+
.mockImplementationOnce(
787+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
788+
authAPITestParams.RespondToAuthChallengeMultipleMfaSetupOutput,
789+
);
790+
791+
const result = await signIn({ username, password });
792+
793+
expect(result.isSignedIn).toBe(false);
794+
expect(result.nextStep.signInStep).toBe(
795+
'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION',
796+
);
797+
798+
const selectMfaToSetupConfirmSignInResult = await confirmSignIn({
799+
challengeResponse: 'EMAIL',
800+
});
801+
802+
expect(selectMfaToSetupConfirmSignInResult.isSignedIn).toBe(false);
803+
expect(selectMfaToSetupConfirmSignInResult.nextStep.signInStep).toBe(
804+
'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP',
805+
);
806+
807+
jest.spyOn(signInHelpers, 'handleChallengeName').mockImplementationOnce(
808+
async (): Promise<RespondToAuthChallengeCommandOutput> => ({
809+
ChallengeName: 'EMAIL_OTP',
810+
Session: '1234234232',
811+
$metadata: {},
812+
ChallengeParameters: {
813+
CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL',
814+
CODE_DELIVERY_DESTINATION: 'j***@a***',
815+
},
816+
}),
817+
);
818+
819+
const setupEmailConfirmSignInResult = await confirmSignIn({
820+
challengeResponse: 'j***@a***',
821+
});
822+
823+
expect(setupEmailConfirmSignInResult.nextStep.signInStep).toBe(
824+
'CONFIRM_SIGN_IN_WITH_EMAIL_CODE',
825+
);
826+
827+
jest
828+
.spyOn(clients, 'respondToAuthChallenge')
829+
.mockImplementationOnce(
830+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
831+
authAPITestParams.RespondToAuthChallengeCommandOutput,
832+
);
833+
834+
const confirmSignInResult = await confirmSignIn({
835+
challengeResponse: '123456',
836+
});
837+
838+
expect(confirmSignInResult.isSignedIn).toBe(true);
839+
expect(confirmSignInResult.nextStep.signInStep).toBe('DONE');
840+
});
841+
842+
test('confirmSignIn with single MFA_SETUP option using EMAIL_OTP', async () => {
843+
Amplify.configure({
844+
Auth: authConfig,
845+
});
846+
847+
jest
848+
.spyOn(signInHelpers, 'handleUserSRPAuthFlow')
849+
.mockImplementationOnce(
850+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
851+
authAPITestParams.RespondToAuthChallengeEmailMfaSetupOutput,
852+
);
853+
854+
const result = await signIn({ username, password });
855+
856+
expect(result.isSignedIn).toBe(false);
857+
expect(result.nextStep.signInStep).toBe(
858+
'CONTINUE_SIGN_IN_WITH_EMAIL_SETUP',
859+
);
860+
861+
jest.spyOn(signInHelpers, 'handleChallengeName').mockImplementationOnce(
862+
async (): Promise<RespondToAuthChallengeCommandOutput> => ({
863+
ChallengeName: 'EMAIL_OTP',
864+
Session: '1234234232',
865+
$metadata: {},
866+
ChallengeParameters: {
867+
CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL',
868+
CODE_DELIVERY_DESTINATION: 'j***@a***',
869+
},
870+
}),
871+
);
872+
873+
const setupEmailConfirmSignInResult = await confirmSignIn({
874+
challengeResponse: 'j***@a***',
875+
});
876+
877+
expect(setupEmailConfirmSignInResult.nextStep.signInStep).toBe(
878+
'CONFIRM_SIGN_IN_WITH_EMAIL_CODE',
879+
);
880+
881+
jest
882+
.spyOn(signInHelpers, 'handleChallengeName')
883+
.mockImplementationOnce(
884+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
885+
authAPITestParams.RespondToAuthChallengeCommandOutput,
886+
);
887+
888+
const confirmSignInResult = await confirmSignIn({
889+
challengeResponse: '123456',
890+
});
891+
892+
expect(confirmSignInResult.isSignedIn).toBe(true);
893+
expect(confirmSignInResult.nextStep.signInStep).toBe('DONE');
894+
});
895+
896+
test('confirmSignIn with single MFA_SETUP option using SOFTWARE_TOKEN_MFA', async () => {
897+
Amplify.configure({
898+
Auth: authConfig,
899+
});
900+
jest
901+
.spyOn(signInHelpers, 'handleUserSRPAuthFlow')
902+
.mockImplementationOnce(
903+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
904+
authAPITestParams.RespondToAuthChallengeTotpMfaSetupOutput,
905+
);
906+
907+
jest.spyOn(clients, 'associateSoftwareToken').mockResolvedValueOnce({
908+
SecretCode: 'secret-code',
909+
Session: '12341234',
910+
$metadata: {},
911+
});
912+
913+
const result = await signIn({ username, password });
914+
915+
expect(result.isSignedIn).toBe(false);
916+
expect(result.nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_TOTP_SETUP');
917+
918+
const verifySoftwareTokenSpy = jest
919+
.spyOn(clients, 'verifySoftwareToken')
920+
.mockResolvedValueOnce({
921+
Session: '12341234',
922+
Status: 'SUCCESS',
923+
$metadata: {},
924+
});
925+
926+
jest
927+
.spyOn(clients, 'respondToAuthChallenge')
928+
.mockImplementationOnce(
929+
async (): Promise<RespondToAuthChallengeCommandOutput> =>
930+
authAPITestParams.RespondToAuthChallengeCommandOutput,
931+
);
932+
933+
const totpCode = '123456';
934+
const confirmSignInResult = await confirmSignIn({
935+
challengeResponse: totpCode,
936+
});
937+
938+
expect(verifySoftwareTokenSpy).toHaveBeenCalledWith(
939+
expect.objectContaining({
940+
region: 'us-west-2',
941+
}),
942+
expect.objectContaining({
943+
UserCode: totpCode,
944+
Session: '12341234',
945+
}),
946+
);
947+
expect(confirmSignInResult.isSignedIn).toBe(true);
948+
expect(confirmSignInResult.nextStep.signInStep).toBe('DONE');
949+
});
950+
});

packages/auth/__tests__/providers/cognito/signInErrorCases.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { getCurrentUser, signIn } from '../../../src/providers/cognito';
99
import { initiateAuth } from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider';
1010
import { InitiateAuthException } from '../../../src/providers/cognito/types/errors';
1111
import { USER_ALREADY_AUTHENTICATED_EXCEPTION } from '../../../src/errors/constants';
12+
import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings';
13+
import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers';
1214

1315
import { authAPITestParams } from './testUtils/authApiTestParams';
1416
import { getMockError } from './testUtils/data';
@@ -97,4 +99,28 @@ describe('signIn API error path cases:', () => {
9799
expect(error.name).toBe(InitiateAuthException.InvalidParameterException);
98100
}
99101
});
102+
it('should throw an error when sign in step is MFA_SETUP and there are no valid setup options', async () => {
103+
expect.assertions(3);
104+
105+
jest
106+
.spyOn(signInHelpers, 'handleUserSRPAuthFlow')
107+
.mockImplementationOnce(async () => ({
108+
ChallengeName: 'MFA_SETUP',
109+
ChallengeParameters: {
110+
MFAS_CAN_SETUP: '["SMS_MFA"]',
111+
},
112+
$metadata: {},
113+
}));
114+
115+
try {
116+
await signIn({
117+
username: authAPITestParams.user1.username,
118+
password: authAPITestParams.user1.password,
119+
});
120+
} catch (error: any) {
121+
expect(error).toBeInstanceOf(AuthError);
122+
expect(error.name).toBe(AuthErrorCodes.SignInException);
123+
expect(error.message).toContain('SMS');
124+
}
125+
});
100126
});

packages/auth/__tests__/providers/cognito/testUtils/authApiTestParams.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,30 @@ export const authAPITestParams = {
112112
Session: 'aaabbbcccddd',
113113
$metadata: {},
114114
},
115+
RespondToAuthChallengeMultipleMfaSetupOutput: {
116+
ChallengeName: 'MFA_SETUP',
117+
Session: '1234234232',
118+
$metadata: {},
119+
ChallengeParameters: {
120+
MFAS_CAN_SETUP: '["SMS_MFA","SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]',
121+
},
122+
},
123+
RespondToAuthChallengeEmailMfaSetupOutput: {
124+
ChallengeName: 'MFA_SETUP',
125+
Session: '1234234232',
126+
$metadata: {},
127+
ChallengeParameters: {
128+
MFAS_CAN_SETUP: '["SMS_MFA", "EMAIL_OTP"]',
129+
},
130+
},
131+
RespondToAuthChallengeTotpMfaSetupOutput: {
132+
ChallengeName: 'MFA_SETUP',
133+
Session: '1234234232',
134+
$metadata: {},
135+
ChallengeParameters: {
136+
MFAS_CAN_SETUP: '["SMS_MFA", "SOFTWARE_TOKEN_MFA"]',
137+
},
138+
},
115139
CustomChallengeResponse: {
116140
ChallengeName: 'CUSTOM_CHALLENGE',
117141
AuthenticationResult: undefined,
@@ -199,7 +223,6 @@ export const authAPITestParams = {
199223
},
200224
GuestIdentityId: { id: 'guest-identity-id', type: 'guest' },
201225
PrimaryIdentityId: { id: 'primary-identity-id', type: 'primary' },
202-
203226
signInResultWithCustomAuth: () => {
204227
return {
205228
isSignedIn: false,

packages/auth/src/providers/cognito/apis/confirmSignIn.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ export async function confirmSignIn(
7171
throw new AuthError({
7272
name: AuthErrorCodes.SignInException,
7373
message: `
74-
An error occurred during the sign in process.
75-
74+
An error occurred during the sign in process.
75+
7676
This most likely occurred due to:
7777
1. signIn was not called before confirmSignIn.
7878
2. signIn threw an exception.

0 commit comments

Comments
 (0)