diff --git a/README.md b/README.md index 19ab63f9..b75ad552 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | AdminListUserAuthEvents | ❌ | | AdminRemoveUserFromGroup | ✅ | | AdminResetUserPassword | ❌ | -| AdminRespondToAuthChallenge | ❌ | +| AdminRespondToAuthChallenge | 🕒 (partial support) | | AdminSetUserMFAPreference | ❌ | | AdminSetUserPassword | ✅ | | AdminSetUserSettings | ❌ | diff --git a/integration-tests/server.test.ts b/integration-tests/server.test.ts index 98d3561e..83de3487 100644 --- a/integration-tests/server.test.ts +++ b/integration-tests/server.test.ts @@ -126,4 +126,20 @@ describe("HTTP server", () => { }); }); }); + + describe("OpenId Configuration Endpoint", () => { + it("responds with open id configuration", async () => { + const server = createServer(jest.fn(), MockLogger as any); + + const response = await supertest(server.application).get( + "/any-user-pool/.well-known/openid-configuration" + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + id_token_signing_alg_values_supported: ["RS256"], + jwks_uri: `http://localhost:9229/any-user-pool/.well-known/jwks.json`, + issuer: `http://localhost:9229/any-user-pool`, + }); + }); + }); }); diff --git a/src/server/server.ts b/src/server/server.ts index e6e8afab..a662bd69 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -55,6 +55,14 @@ export const createServer = ( }); }); + app.get("/:userPoolId/.well-known/openid-configuration", (req, res) => { + res.status(200).json({ + id_token_signing_alg_values_supported: ["RS256"], + jwks_uri: `http://localhost:9229/${req.params.userPoolId}/.well-known/jwks.json`, + issuer: `http://localhost:9229/${req.params.userPoolId}`, + }); + }); + app.get("/health", (req, res) => { res.status(200).json({ ok: true }); }); diff --git a/src/targets/adminInitiateAuth.ts b/src/targets/adminInitiateAuth.ts index d8db177a..d0456fcd 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -8,6 +8,8 @@ import { NotAuthorizedError, UnsupportedError, } from "../errors"; +import { v4 } from "uuid"; +import { attributesToRecord, User } from "../services/userPoolService"; import { Services } from "../services"; import { Target } from "./Target"; import { Context } from "../services/context"; @@ -22,6 +24,16 @@ type AdminInitiateAuthServices = Pick< "cognito" | "triggers" | "tokenGenerator" >; +const newPasswordChallenge = (user: User): AdminInitiateAuthResponse => ({ + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeParameters: { + USER_ID_FOR_SRP: user.Username, + requiredAttributes: JSON.stringify([]), + userAttributes: JSON.stringify(attributesToRecord(user.Attributes)), + }, + Session: v4(), +}); + const adminUserPasswordAuthFlow = async ( ctx: Context, services: AdminInitiateAuthServices, @@ -70,6 +82,9 @@ const adminUserPasswordAuthFlow = async ( if (user.Password !== req.AuthParameters.PASSWORD) { throw new InvalidPasswordError(); } + if (user.UserStatus === "FORCE_CHANGE_PASSWORD") { + return newPasswordChallenge(user); + } const userGroups = await userPool.listUserGroupMembership(ctx, user); diff --git a/src/targets/adminRespondToAuthChallenge.test.ts b/src/targets/adminRespondToAuthChallenge.test.ts new file mode 100644 index 00000000..4da0b24d --- /dev/null +++ b/src/targets/adminRespondToAuthChallenge.test.ts @@ -0,0 +1,351 @@ +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockTokenGenerator } from "../__tests__/mockTokenGenerator"; +import { newMockTriggers } from "../__tests__/mockTriggers"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import { + CodeMismatchError, + InvalidParameterError, + NotAuthorizedError, +} from "../errors"; +import { Triggers, UserPoolService } from "../services"; +import { TokenGenerator } from "../services/tokenGenerator"; +import { + AdminRespondToAuthChallenge, + AdminRespondToAuthChallengeTarget, +} from "./adminRespondToAuthChallenge"; +import * as TDB from "../__tests__/testDataBuilder"; + +const currentDate = new Date(); + +describe("AdminRespondToAuthChallenge target", () => { + let adminRespondToAuthChallenge: AdminRespondToAuthChallengeTarget; + let mockTokenGenerator: jest.Mocked; + let mockTriggers: jest.Mocked; + let mockUserPoolService: jest.Mocked; + let clock: ClockFake; + const userPoolClient = TDB.appClient(); + + beforeEach(() => { + clock = new ClockFake(currentDate); + mockTokenGenerator = newMockTokenGenerator(); + mockTriggers = newMockTriggers(); + mockUserPoolService = newMockUserPoolService({ + Id: userPoolClient.UserPoolId, + }); + + const mockCognitoService = newMockCognitoService(mockUserPoolService); + mockCognitoService.getAppClient.mockResolvedValue(userPoolClient); + + adminRespondToAuthChallenge = AdminRespondToAuthChallenge({ + clock, + cognito: mockCognitoService, + tokenGenerator: mockTokenGenerator, + triggers: mockTriggers, + }); + }); + + it("throws if user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + adminRespondToAuthChallenge(TestContext, { + ClientId: "clientId", + UserPoolId: "test", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "username", + SMS_MFA_CODE: "123456", + }, + Session: "Session", + }) + ).rejects.toBeInstanceOf(NotAuthorizedError); + }); + + it("throws if ChallengeResponses missing", async () => { + await expect( + adminRespondToAuthChallenge(TestContext, { + ClientId: "clientId", + UserPoolId: "test", + ChallengeName: "SMS_MFA", + }) + ).rejects.toEqual( + new InvalidParameterError( + "Missing required parameter challenge responses" + ) + ); + }); + + it("throws if ChallengeResponses.USERNAME is missing", async () => { + await expect( + adminRespondToAuthChallenge(TestContext, { + ClientId: "clientId", + UserPoolId: "test", + 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( + adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: "test", + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "abc", + }, + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter Session") + ); + }); + + describe("ChallengeName=SMS_MFA", () => { + const user = TDB.user({ + MFACode: "123456", + }); + + beforeEach(() => { + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + describe("when code matches", () => { + it("updates the user and removes the MFACode", async () => { + const newDate = clock.advanceBy(1200); + + await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: user.Username, + SMS_MFA_CODE: "123456", + }, + UserPoolId: "test", + Session: "Session", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + MFACode: undefined, + UserLastModifiedDate: newDate, + }); + }); + + it("generates tokens", async () => { + mockTokenGenerator.generate.mockResolvedValue({ + AccessToken: "access", + IdToken: "id", + RefreshToken: "refresh", + }); + mockUserPoolService.listUserGroupMembership.mockResolvedValue([]); + + const output = await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: user.Username, + SMS_MFA_CODE: "123456", + }, + Session: "Session", + ClientMetadata: { + client: "metadata", + }, + }); + + expect(output).toBeDefined(); + + expect(output.AuthenticationResult?.AccessToken).toEqual("access"); + expect(output.AuthenticationResult?.IdToken).toEqual("id"); + expect(output.AuthenticationResult?.RefreshToken).toEqual("refresh"); + + expect(mockTokenGenerator.generate).toHaveBeenCalledWith( + TestContext, + user, + [], + userPoolClient, + { + client: "metadata", + }, + "Authentication" + ); + }); + + describe("when Post Authentication trigger is enabled", () => { + it("does invokes the trigger", async () => { + mockTriggers.enabled.mockImplementation( + (trigger) => trigger === "PostAuthentication" + ); + + await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "SMS_MFA", + ClientMetadata: { + client: "metadata", + }, + ChallengeResponses: { + USERNAME: user.Username, + SMS_MFA_CODE: "123456", + }, + Session: "Session", + }); + + expect(mockTriggers.postAuthentication).toHaveBeenCalledWith( + TestContext, + { + clientId: userPoolClient.ClientId, + clientMetadata: { + client: "metadata", + }, + source: "PostAuthentication_Authentication", + userAttributes: user.Attributes, + username: user.Username, + userPoolId: userPoolClient.UserPoolId, + } + ); + }); + }); + }); + + describe("when code is incorrect", () => { + it("throws an error", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await expect( + adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: user.Username, + SMS_MFA_CODE: "4321", + }, + Session: "Session", + }) + ).rejects.toBeInstanceOf(CodeMismatchError); + }); + }); + }); + + describe("ChallengeName=NEW_PASSWORD_REQUIRED", () => { + const user = TDB.user(); + + beforeEach(() => { + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + it("throws if NEW_PASSWORD missing", async () => { + await expect( + adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: user.Username, + }, + Session: "session", + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter NEW_PASSWORD") + ); + }); + + it("updates the user's password and status", async () => { + const newDate = clock.advanceBy(1200); + + await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: user.Username, + NEW_PASSWORD: "foo", + }, + Session: "Session", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Password: "foo", + UserLastModifiedDate: newDate, + UserStatus: "CONFIRMED", + }); + }); + + it("generates tokens", async () => { + mockTokenGenerator.generate.mockResolvedValue({ + AccessToken: "access", + IdToken: "id", + RefreshToken: "refresh", + }); + mockUserPoolService.listUserGroupMembership.mockResolvedValue([]); + + const output = await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: user.Username, + NEW_PASSWORD: "foo", + }, + Session: "Session", + ClientMetadata: { + client: "metadata", + }, + }); + + expect(output).toBeDefined(); + + expect(output.AuthenticationResult?.AccessToken).toEqual("access"); + expect(output.AuthenticationResult?.IdToken).toEqual("id"); + expect(output.AuthenticationResult?.RefreshToken).toEqual("refresh"); + + expect(mockTokenGenerator.generate).toHaveBeenCalledWith( + TestContext, + user, + [], + userPoolClient, + { client: "metadata" }, + "Authentication" + ); + }); + + describe("when Post Authentication trigger is enabled", () => { + it("does invokes the trigger", async () => { + mockTriggers.enabled.mockImplementation( + (trigger) => trigger === "PostAuthentication" + ); + + await adminRespondToAuthChallenge(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeResponses: { + USERNAME: user.Username, + NEW_PASSWORD: "foo", + }, + Session: "Session", + }); + + expect(mockTriggers.postAuthentication).toHaveBeenCalledWith( + TestContext, + { + clientId: userPoolClient.ClientId, + source: "PostAuthentication_Authentication", + userAttributes: user.Attributes, + username: user.Username, + userPoolId: userPoolClient.UserPoolId, + } + ); + }); + }); + }); +}); diff --git a/src/targets/adminRespondToAuthChallenge.ts b/src/targets/adminRespondToAuthChallenge.ts new file mode 100644 index 00000000..d81b30bb --- /dev/null +++ b/src/targets/adminRespondToAuthChallenge.ts @@ -0,0 +1,109 @@ +import { + AdminRespondToAuthChallengeRequest, + AdminRespondToAuthChallengeResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { + CodeMismatchError, + InvalidParameterError, + NotAuthorizedError, + UnsupportedError, +} from "../errors"; +import { Services } from "../services"; +import { Target } from "./Target"; + +export type AdminRespondToAuthChallengeTarget = Target< + AdminRespondToAuthChallengeRequest, + AdminRespondToAuthChallengeResponse +>; + +type AdminRespondToAuthChallengeService = Pick< + Services, + "clock" | "cognito" | "triggers" | "tokenGenerator" +>; + +export const AdminRespondToAuthChallenge = + ({ + clock, + cognito, + triggers, + tokenGenerator, + }: AdminRespondToAuthChallengeService): AdminRespondToAuthChallengeTarget => + async (ctx, req) => { + if (!req.ChallengeResponses) { + throw new InvalidParameterError( + "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 cognito.getUserPoolForClientId(ctx, req.ClientId); + const userPoolClient = await cognito.getAppClient(ctx, req.ClientId); + + const user = await userPool.getUserByUsername( + ctx, + req.ChallengeResponses.USERNAME + ); + if (!user || !userPoolClient) { + throw new NotAuthorizedError(); + } + + if (req.ChallengeName === "SMS_MFA") { + if (user.MFACode !== req.ChallengeResponses.SMS_MFA_CODE) { + throw new CodeMismatchError(); + } + + await userPool.saveUser(ctx, { + ...user, + MFACode: undefined, + UserLastModifiedDate: clock.get(), + }); + } 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(ctx, { + ...user, + Password: req.ChallengeResponses.NEW_PASSWORD, + UserLastModifiedDate: clock.get(), + UserStatus: "CONFIRMED", + }); + } else { + throw new UnsupportedError( + `respondToAuthChallenge with ChallengeName=${req.ChallengeName}` + ); + } + + if (triggers.enabled("PostAuthentication")) { + await triggers.postAuthentication(ctx, { + clientId: req.ClientId, + clientMetadata: req.ClientMetadata, + source: "PostAuthentication_Authentication", + userAttributes: user.Attributes, + username: user.Username, + userPoolId: userPool.options.Id, + }); + } + + const userGroups = await userPool.listUserGroupMembership(ctx, user); + + return { + ChallengeParameters: {}, + AuthenticationResult: await tokenGenerator.generate( + ctx, + user, + userGroups, + userPoolClient, + req.ClientMetadata, + "Authentication" + ), + }; + }; diff --git a/src/targets/targets.ts b/src/targets/targets.ts index 3bd432b8..7ffc7190 100644 --- a/src/targets/targets.ts +++ b/src/targets/targets.ts @@ -1,3 +1,4 @@ +import { AdminRespondToAuthChallenge } from "./adminRespondToAuthChallenge"; import { AddCustomAttributes } from "./addCustomAttributes"; import { AdminAddUserToGroup } from "./adminAddUserToGroup"; import { AdminConfirmSignUp } from "./adminConfirmSignUp"; @@ -59,6 +60,7 @@ export const Targets = { AdminInitiateAuth, AdminListGroupsForUser, AdminRemoveUserFromGroup, + AdminRespondToAuthChallenge, AdminSetUserPassword, AdminUpdateUserAttributes, ChangePassword,