diff --git a/integration-tests/aws-sdk/respondToAuthChallenge.test.ts b/integration-tests/aws-sdk/respondToAuthChallenge.test.ts new file mode 100644 index 00000000..58181333 --- /dev/null +++ b/integration-tests/aws-sdk/respondToAuthChallenge.test.ts @@ -0,0 +1,64 @@ +import { UUID } from "../../src/__tests__/patterns"; +import { attributeValue } from "../../src/services/userPoolClient"; +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.respondToAuthChallenge", + withCognitoSdk((Cognito) => { + it("handles NEW_PASSWORD_REQUIRED challenge", async () => { + const client = Cognito(); + + const upc = await client + .createUserPoolClient({ + UserPoolId: "test", + ClientName: "test", + }) + .promise(); + + const createUserResponse = await client + .adminCreateUser({ + TemporaryPassword: "def", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + Username: "abc", + UserPoolId: "test", + }) + .promise(); + const userSub = attributeValue( + "sub", + createUserResponse.User?.Attributes + ); + + const initiateAuthResponse = await client + .initiateAuth({ + ClientId: upc.UserPoolClient?.ClientId!, + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "def", + }, + }) + .promise(); + + const response = await client + .respondToAuthChallenge({ + ChallengeName: "NEW_PASSWORD_REQUIRED", + ClientId: upc.UserPoolClient?.ClientId!, + Session: initiateAuthResponse.Session, + ChallengeResponses: { + USERNAME: "abc", + NEW_PASSWORD: "new_password", + }, + }) + .promise(); + + expect(response).toEqual({ + AuthenticationResult: { + AccessToken: expect.any(String), + IdToken: expect.any(String), + RefreshToken: expect.any(String), + }, + ChallengeParameters: {}, + }); + }); + }) +); diff --git a/src/targets/respondToAuthChallenge.test.ts b/src/targets/respondToAuthChallenge.test.ts index 8e20ca04..05927492 100644 --- a/src/targets/respondToAuthChallenge.test.ts +++ b/src/targets/respondToAuthChallenge.test.ts @@ -1,4 +1,3 @@ -import { advanceTo } from "jest-date-mock"; import jwt from "jsonwebtoken"; import { ClockFake } from "../__tests__/clockFake"; import { MockUserPoolClient } from "../__tests__/mockUserPoolClient"; @@ -14,25 +13,26 @@ import { RespondToAuthChallenge, RespondToAuthChallengeTarget, } from "./respondToAuthChallenge"; +import { User } from "../services/userPoolClient"; + +const currentDate = new Date(); describe("RespondToAuthChallenge target", () => { let respondToAuthChallenge: RespondToAuthChallengeTarget; let mockCognitoClient: jest.Mocked; - let now: Date; + let clock: ClockFake; beforeEach(() => { - now = new Date(2020, 1, 2, 3, 4, 5); - advanceTo(now); - mockCognitoClient = { getAppClient: jest.fn(), getUserPool: jest.fn().mockResolvedValue(MockUserPoolClient), getUserPoolForClientId: jest.fn().mockResolvedValue(MockUserPoolClient), }; + clock = new ClockFake(currentDate); respondToAuthChallenge = RespondToAuthChallenge({ cognitoClient: mockCognitoClient, - clock: new ClockFake(now), + clock, }); }); @@ -65,34 +65,227 @@ describe("RespondToAuthChallenge target", () => { ); }); - describe("when code matches", () => { - it("generates tokens", async () => { - MockUserPoolClient.getUserByUsername.mockResolvedValue({ - Attributes: [ - { Name: "sub", Value: "0000-0000" }, - { Name: "email", Value: "example@example.com" }, - ], + it("throws if ChallengeResponses.USERNAME is missing", async () => { + await expect( + respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "SMS_MFA", + ChallengeResponses: {}, + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter USERNAME") + ); + }); + + it("throws if Session is missing", async () => { + // we don't actually do anything with the session right now, but we still want to + // replicate Cognito's behaviour if you don't provide it + await expect( + respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "abc", + }, + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter Session") + ); + }); + + describe("ChallengeName=SMS_MFA", () => { + const user: User = { + Attributes: [ + { Name: "sub", Value: "0000-0000" }, + { Name: "email", Value: "example@example.com" }, + ], + UserStatus: "CONFIRMED", + Password: "hunter2", + Username: "0000-0000", + Enabled: true, + UserCreateDate: currentDate.getTime(), + UserLastModifiedDate: currentDate.getTime(), + MFACode: "1234", + }; + describe("when code matches", () => { + it("updates the user and removes the MFACode", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); + + const newDate = clock.advanceBy(1200); + + await respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "0000-0000", + SMS_MFA_CODE: "1234", + }, + Session: "Session", + }); + + expect(MockUserPoolClient.saveUser).toHaveBeenCalledWith({ + ...user, + MFACode: undefined, + UserLastModifiedDate: newDate.getTime(), + }); + }); + + it("generates tokens", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); + + const output = await respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "0000-0000", + SMS_MFA_CODE: "1234", + }, + Session: "Session", + }); + + expect(output).toBeDefined(); + + // access token + expect(output.AuthenticationResult?.AccessToken).toBeDefined(); + const decodedAccessToken = jwt.decode( + output.AuthenticationResult?.AccessToken ?? "" + ); + expect(decodedAccessToken).toMatchObject({ + client_id: "clientId", + iss: "http://localhost:9229/test", + sub: "0000-0000", + token_use: "access", + username: "0000-0000", + event_id: expect.stringMatching(UUID), + scope: "aws.cognito.signin.user.admin", // TODO: scopes + auth_time: Math.floor(clock.get().getTime() / 1000), + jti: expect.stringMatching(UUID), + }); + expect( + jwt.verify( + output.AuthenticationResult?.AccessToken ?? "", + PublicKey.pem, + { + algorithms: ["RS256"], + } + ) + ).toBeTruthy(); + + // id token + expect(output.AuthenticationResult?.IdToken).toBeDefined(); + const decodedIdToken = jwt.decode( + output.AuthenticationResult?.IdToken ?? "" + ); + expect(decodedIdToken).toMatchObject({ + aud: "clientId", + iss: "http://localhost:9229/test", + sub: "0000-0000", + token_use: "id", + "cognito:username": "0000-0000", + email_verified: true, + event_id: expect.stringMatching(UUID), + auth_time: Math.floor(clock.get().getTime() / 1000), + email: "example@example.com", + }); + expect( + jwt.verify( + output.AuthenticationResult?.IdToken ?? "", + PublicKey.pem, + { + algorithms: ["RS256"], + } + ) + ).toBeTruthy(); + }); + }); + + describe("when code is incorrect", () => { + it("throws an error", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); + + await expect( + respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "0000-0000", + SMS_MFA_CODE: "4321", + }, + Session: "Session", + }) + ).rejects.toBeInstanceOf(CodeMismatchError); + }); + }); + }); + + describe("ChallengeName=NEW_PASSWORD_REQUIRED", () => { + const user: User = { + Attributes: [ + { Name: "sub", Value: "0000-0000" }, + { Name: "email", Value: "example@example.com" }, + ], + UserStatus: "FORCE_CHANGE_PASSWORD", + Password: "hunter2", + Username: "0000-0000", + Enabled: true, + UserCreateDate: currentDate.getTime(), + UserLastModifiedDate: currentDate.getTime(), + }; + + it("throws if NEW_PASSWORD missing", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); + + await expect( + respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: "0000-0000", + }, + Session: "session", + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter NEW_PASSWORD") + ); + }); + + it("updates the user's password and status", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); + + const newDate = clock.advanceBy(1200); + + await respondToAuthChallenge({ + ClientId: "clientId", + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: "0000-0000", + NEW_PASSWORD: "foo", + }, + Session: "Session", + }); + + expect(MockUserPoolClient.saveUser).toHaveBeenCalledWith({ + ...user, + Password: "foo", + UserLastModifiedDate: newDate.getTime(), UserStatus: "CONFIRMED", - Password: "hunter2", - Username: "0000-0000", - Enabled: true, - UserCreateDate: new Date().getTime(), - UserLastModifiedDate: new Date().getTime(), - MFACode: "1234", }); + }); + + it("generates tokens", async () => { + MockUserPoolClient.getUserByUsername.mockResolvedValue(user); const output = await respondToAuthChallenge({ ClientId: "clientId", - ChallengeName: "SMS_MFA", + ChallengeName: "NEW_PASSWORD_REQUIRED", ChallengeResponses: { USERNAME: "0000-0000", - SMS_MFA_CODE: "1234", + NEW_PASSWORD: "foo", }, Session: "Session", }); expect(output).toBeDefined(); - expect(output.Session).toBe("Session"); // access token expect(output.AuthenticationResult?.AccessToken).toBeDefined(); @@ -107,7 +300,7 @@ describe("RespondToAuthChallenge target", () => { username: "0000-0000", event_id: expect.stringMatching(UUID), scope: "aws.cognito.signin.user.admin", // TODO: scopes - auth_time: Math.floor(now.getTime() / 1000), + auth_time: Math.floor(clock.get().getTime() / 1000), jti: expect.stringMatching(UUID), }); expect( @@ -133,7 +326,7 @@ describe("RespondToAuthChallenge target", () => { "cognito:username": "0000-0000", email_verified: true, event_id: expect.stringMatching(UUID), - auth_time: Math.floor(now.getTime() / 1000), + auth_time: Math.floor(clock.get().getTime() / 1000), email: "example@example.com", }); expect( @@ -143,34 +336,4 @@ describe("RespondToAuthChallenge target", () => { ).toBeTruthy(); }); }); - - describe("when code is incorrect", () => { - it("throws an error", async () => { - MockUserPoolClient.getUserByUsername.mockResolvedValue({ - Attributes: [ - { Name: "sub", Value: "0000-0000" }, - { Name: "email", Value: "example@example.com" }, - ], - UserStatus: "CONFIRMED", - Password: "hunter2", - Username: "0000-0000", - Enabled: true, - UserCreateDate: new Date().getTime(), - UserLastModifiedDate: new Date().getTime(), - MFACode: "1234", - }); - - await expect( - respondToAuthChallenge({ - ClientId: "clientId", - ChallengeName: "SMS_MFA", - ChallengeResponses: { - USERNAME: "0000-0000", - SMS_MFA_CODE: "4321", - }, - Session: "Session", - }) - ).rejects.toBeInstanceOf(CodeMismatchError); - }); - }); }); diff --git a/src/targets/respondToAuthChallenge.ts b/src/targets/respondToAuthChallenge.ts index 60c14542..c561963b 100644 --- a/src/targets/respondToAuthChallenge.ts +++ b/src/targets/respondToAuthChallenge.ts @@ -27,6 +27,12 @@ export const RespondToAuthChallenge = ({ "Missing required parameter challenge responses" ); } + if (!req.ChallengeResponses.USERNAME) { + throw new InvalidParameterError("Missing required parameter USERNAME"); + } + if (!req.Session) { + throw new InvalidParameterError("Missing required parameter Session"); + } const userPool = await cognitoClient.getUserPoolForClientId(req.ClientId); const user = await userPool.getUserByUsername( @@ -36,17 +42,37 @@ export const RespondToAuthChallenge = ({ throw new NotAuthorizedError(); } - if (user.MFACode !== req.ChallengeResponses.SMS_MFA_CODE) { - throw new CodeMismatchError(); - } + if (req.ChallengeName === "SMS_MFA") { + if (user.MFACode !== req.ChallengeResponses.SMS_MFA_CODE) { + throw new CodeMismatchError(); + } - await userPool.saveUser({ - ...user, - MFACode: undefined, - }); + await userPool.saveUser({ + ...user, + MFACode: undefined, + UserLastModifiedDate: clock.get().getTime(), + }); + } else if (req.ChallengeName === "NEW_PASSWORD_REQUIRED") { + if (!req.ChallengeResponses.NEW_PASSWORD) { + throw new InvalidParameterError( + "Missing required parameter NEW_PASSWORD" + ); + } + + // TODO: validate the password? + await userPool.saveUser({ + ...user, + Password: req.ChallengeResponses.NEW_PASSWORD, + UserLastModifiedDate: clock.get().getTime(), + UserStatus: "CONFIRMED", + }); + } else { + throw new UnsupportedError( + `respondToAuthChallenge with ChallengeName=${req.ChallengeName}` + ); + } return { - ChallengeName: req.ChallengeName, ChallengeParameters: {}, AuthenticationResult: await generateTokens( user, @@ -54,6 +80,5 @@ export const RespondToAuthChallenge = ({ userPool.config.Id, clock ), - Session: req.Session, }; };