diff --git a/README.md b/README.md index 19ab63f9..2eaf410b 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | AdminListUserAuthEvents | ❌ | | AdminRemoveUserFromGroup | ✅ | | AdminResetUserPassword | ❌ | -| AdminRespondToAuthChallenge | ❌ | +| AdminRespondToAuthChallenge | 🕒 (partial support) | | AdminSetUserMFAPreference | ❌ | | AdminSetUserPassword | ✅ | -| AdminSetUserSettings | ❌ | +| AdminSetUserSettings | ✅ | | AdminUpdateAuthEventFeedback | ❌ | | AdminUpdateDeviceStatus | ❌ | | AdminUpdateUserAttributes | ✅ | diff --git a/src/errors.ts b/src/errors.ts index c3910dad..4d02c4da 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -33,6 +33,15 @@ export class CodeMismatchError extends CognitoError { } } +export class MFAMethodNotFoundException extends CognitoError { + public constructor() { + super( + "MFAMethodNotFoundException", + "No MFA mechanism registered in the account" + ); + } +} + export class InvalidPasswordError extends CognitoError { public constructor() { super("InvalidPasswordException", "Invalid password"); diff --git a/src/server/server.ts b/src/server/server.ts index e6e8afab..227f94a5 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -107,14 +107,14 @@ export const createServer = ( req.log.error(`Cognito Local unsupported feature: ${ex.message}`); res.status(500).json({ - code: "CognitoLocal#Unsupported", + __type: "CognitoLocal#Unsupported", message: `Cognito Local unsupported feature: ${ex.message}`, }); return; } else if (ex instanceof CognitoError) { req.log.warn(ex, `Error handling target: ${target}`); res.status(400).json({ - code: ex.code, + __type: ex.code, message: ex.message, }); return; diff --git a/src/targets/addCustomAttributes.test.ts b/src/targets/addCustomAttributes.test.ts index 8543e2eb..09293443 100644 --- a/src/targets/addCustomAttributes.test.ts +++ b/src/targets/addCustomAttributes.test.ts @@ -3,12 +3,8 @@ import { newMockCognitoService } from "../__tests__/mockCognitoService"; import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { TestContext } from "../__tests__/testContext"; import * as TDB from "../__tests__/testDataBuilder"; -import { - GroupNotFoundError, - InvalidParameterError, - UserNotFoundError, -} from "../errors"; -import { CognitoService, UserPoolService } from "../services"; +import { InvalidParameterError } from "../errors"; +import { CognitoService } from "../services"; import { AddCustomAttributes, AddCustomAttributesTarget, diff --git a/src/targets/adminInitiateAuth.test.ts b/src/targets/adminInitiateAuth.test.ts index 6164d103..d84d6212 100644 --- a/src/targets/adminInitiateAuth.test.ts +++ b/src/targets/adminInitiateAuth.test.ts @@ -1,11 +1,19 @@ import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockMessages } from "../__tests__/mockMessages"; import { newMockTokenGenerator } from "../__tests__/mockTokenGenerator"; import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { newMockTriggers } from "../__tests__/mockTriggers"; import { TestContext } from "../__tests__/testContext"; import * as TDB from "../__tests__/testDataBuilder"; -import { CognitoService, Triggers, UserPoolService } from "../services"; +import { NotAuthorizedError } from "../errors"; +import { + CognitoService, + Messages, + Triggers, + UserPoolService, +} from "../services"; import { TokenGenerator } from "../services/tokenGenerator"; +import { User } from "../services/userPoolService"; import { AdminInitiateAuth, AdminInitiateAuthTarget, @@ -16,6 +24,8 @@ describe("AdminInitiateAuth target", () => { let mockCognitoService: jest.Mocked; let mockTokenGenerator: jest.Mocked; + let mockMessages: jest.Mocked; + let mockOtp: jest.MockedFunction<() => string>; let mockTriggers: jest.Mocked; let mockUserPoolService: jest.Mocked; const userPoolClient = TDB.appClient(); @@ -24,6 +34,8 @@ describe("AdminInitiateAuth target", () => { mockUserPoolService = newMockUserPoolService({ Id: userPoolClient.UserPoolId, }); + mockMessages = newMockMessages(); + mockOtp = jest.fn().mockReturnValue("123456"); mockCognitoService = newMockCognitoService(mockUserPoolService); mockCognitoService.getAppClient.mockResolvedValue(userPoolClient); mockTriggers = newMockTriggers(); @@ -31,6 +43,8 @@ describe("AdminInitiateAuth target", () => { adminInitiateAuth = AdminInitiateAuth({ triggers: mockTriggers, cognito: mockCognitoService, + messages: mockMessages, + otp: mockOtp, tokenGenerator: mockTokenGenerator, }); }); @@ -131,4 +145,212 @@ describe("AdminInitiateAuth target", () => { "RefreshTokens" ); }); + + describe("when password matches", () => { + describe("when MFA is ON", () => { + beforeEach(() => { + mockUserPoolService.options.MfaConfiguration = "ON"; + }); + + describe("when user has SMS_MFA configured", () => { + let user: User; + + beforeEach(() => { + user = TDB.user({ + Attributes: [ + { + Name: "phone_number", + Value: "0411000111", + }, + ], + MFAOptions: [ + { + DeliveryMedium: "SMS", + AttributeName: "phone_number", + }, + ], + }); + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + it("sends MFA code to user", async () => { + const output = await adminInitiateAuth(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: user.Username, + PASSWORD: user.Password, + }, + }); + + expect(output).toBeDefined(); + + expect(mockMessages.deliver).toHaveBeenCalledWith( + TestContext, + "Authentication", + userPoolClient.ClientId, + userPoolClient.UserPoolId, + user, + "123456", + undefined, + { + AttributeName: "phone_number", + DeliveryMedium: "SMS", + Destination: "0411000111", + } + ); + + // also saves the code on the user for comparison later + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + TestContext, + { + ...user, + MFACode: "123456", + } + ); + }); + }); + + describe("when user doesn't have MFA configured", () => { + const user = TDB.user({ MFAOptions: undefined }); + + beforeEach(() => { + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + it("throws an exception", async () => { + await expect( + adminInitiateAuth(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: user.Username, + PASSWORD: user.Password, + }, + }) + ).rejects.toBeInstanceOf(NotAuthorizedError); + }); + }); + }); + + describe("when MFA is OPTIONAL", () => { + beforeEach(() => { + mockUserPoolService.options.MfaConfiguration = "OPTIONAL"; + }); + + describe("when user has SMS_MFA configured", () => { + let user: User; + + beforeEach(() => { + user = TDB.user({ + Attributes: [ + { + Name: "phone_number", + Value: "0411000111", + }, + ], + MFAOptions: [ + { + DeliveryMedium: "SMS", + AttributeName: "phone_number", + }, + ], + }); + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + it("sends MFA code to user", async () => { + const output = await adminInitiateAuth(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + ClientMetadata: { + client: "metadata", + }, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: user.Username, + PASSWORD: user.Password, + }, + }); + + expect(output).toBeDefined(); + + expect(mockMessages.deliver).toHaveBeenCalledWith( + TestContext, + "Authentication", + userPoolClient.ClientId, + userPoolClient.UserPoolId, + user, + "123456", + { + client: "metadata", + }, + { + AttributeName: "phone_number", + DeliveryMedium: "SMS", + Destination: "0411000111", + } + ); + + // also saves the code on the user for comparison later + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + TestContext, + { + ...user, + MFACode: "123456", + } + ); + }); + }); + + describe("when user doesn't have MFA configured", () => { + const user = TDB.user({ + MFAOptions: undefined, + }); + + beforeEach(() => { + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + }); + + it("generates tokens", async () => { + mockTokenGenerator.generate.mockResolvedValue({ + AccessToken: "access", + IdToken: "id", + RefreshToken: "refresh", + }); + mockUserPoolService.listUserGroupMembership.mockResolvedValue([]); + + const output = await adminInitiateAuth(TestContext, { + ClientId: userPoolClient.ClientId, + UserPoolId: userPoolClient.UserPoolId, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: user.Username, + PASSWORD: user.Password, + }, + 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" + ); + }); + }); + }); + }); }); diff --git a/src/targets/adminInitiateAuth.ts b/src/targets/adminInitiateAuth.ts index d8db177a..f58c1953 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -1,14 +1,19 @@ import { + DeliveryMediumType, AdminInitiateAuthRequest, AdminInitiateAuthResponse, } from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { v4 } from "uuid"; import { InvalidParameterError, InvalidPasswordError, + MFAMethodNotFoundException, NotAuthorizedError, UnsupportedError, + UserNotFoundError, } from "../errors"; -import { Services } from "../services"; +import { Services, UserPoolService } from "../services"; +import { attributeValue, MFAOption, User } from "../services/userPoolService"; import { Target } from "./Target"; import { Context } from "../services/context"; @@ -17,11 +22,69 @@ export type AdminInitiateAuthTarget = Target< AdminInitiateAuthResponse >; -type AdminInitiateAuthServices = Pick< +export type AdminInitiateAuthServices = Pick< Services, - "cognito" | "triggers" | "tokenGenerator" + "cognito" | "messages" | "otp" | "triggers" | "tokenGenerator" >; +export const verifyMfaChallenge = async ( + ctx: Context, + user: User, + req: AdminInitiateAuthRequest, + userPool: UserPoolService, + services: AdminInitiateAuthServices +): Promise => { + if (!user.MFAOptions?.length) { + throw new NotAuthorizedError(); + } + const smsMfaOption = user.MFAOptions?.find( + (x): x is MFAOption & { DeliveryMedium: DeliveryMediumType } => + x.DeliveryMedium === "SMS" + ); + if (!smsMfaOption) { + throw new MFAMethodNotFoundException(); + } + + const deliveryDestination = attributeValue( + smsMfaOption.AttributeName, + user.Attributes + ); + if (!deliveryDestination) { + throw new MFAMethodNotFoundException(); + } + + const code = services.otp(); + await services.messages.deliver( + ctx, + "Authentication", + req.ClientId, + userPool.options.Id, + user, + code, + req.ClientMetadata, + { + DeliveryMedium: smsMfaOption.DeliveryMedium, + AttributeName: smsMfaOption.AttributeName, + Destination: deliveryDestination, + } + ); + + await userPool.saveUser(ctx, { + ...user, + MFACode: code, + }); + + return { + ChallengeName: "SMS_MFA", + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: "SMS", + CODE_DELIVERY_DESTINATION: deliveryDestination, + USER_ID_FOR_SRP: user.Username, + }, + Session: v4(), + }; +}; + const adminUserPasswordAuthFlow = async ( ctx: Context, services: AdminInitiateAuthServices, @@ -71,6 +134,22 @@ const adminUserPasswordAuthFlow = async ( throw new InvalidPasswordError(); } + if (user.UserStatus === "FORCE_CHANGE_PASSWORD") { + return { + ChallengeName: "NEW_PASSWORD_REQUIRED", + ChallengeParameters: { USER_ID_FOR_SRP: user.Username }, + Session: v4(), + }; + } + + if ( + (userPool.options.MfaConfiguration === "OPTIONAL" && + (user.MFAOptions ?? []).length > 0) || + userPool.options.MfaConfiguration === "ON" + ) { + return verifyMfaChallenge(ctx, user, req, userPool, services); + } + const userGroups = await userPool.listUserGroupMembership(ctx, user); const tokens = await services.tokenGenerator.generate( diff --git a/src/targets/adminRespondToAuthChallenge.test.ts b/src/targets/adminRespondToAuthChallenge.test.ts new file mode 100644 index 00000000..f902a32a --- /dev/null +++ b/src/targets/adminRespondToAuthChallenge.test.ts @@ -0,0 +1,363 @@ +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 { Messages, Triggers, UserPoolService } from "../services"; +import { TokenGenerator } from "../services/tokenGenerator"; +import { + AdminRespondToAuthChallenge, + AdminRespondToAuthChallengeTarget, +} from "./adminRespondToAuthChallenge"; +import * as TDB from "../__tests__/testDataBuilder"; +import { newMockMessages } from "../__tests__/mockMessages"; + +const currentDate = new Date(); + +describe("RespondToAuthChallenge target", () => { + let adminRespondToAuthChallenge: AdminRespondToAuthChallengeTarget; + let mockTokenGenerator: jest.Mocked; + let mockTriggers: jest.Mocked; + let mockUserPoolService: jest.Mocked; + let mockMessages: jest.Mocked; + let mockOtp: jest.MockedFunction<() => string>; + let clock: ClockFake; + const userPoolClient = TDB.appClient(); + + beforeEach(() => { + clock = new ClockFake(currentDate); + mockTokenGenerator = newMockTokenGenerator(); + mockTriggers = newMockTriggers(); + mockMessages = newMockMessages(); + mockOtp = jest.fn().mockReturnValue("123456"); + mockUserPoolService = newMockUserPoolService({ + Id: userPoolClient.UserPoolId, + }); + + const mockCognitoService = newMockCognitoService(mockUserPoolService); + mockCognitoService.getAppClient.mockResolvedValue(userPoolClient); + + adminRespondToAuthChallenge = AdminRespondToAuthChallenge({ + clock, + messages: mockMessages, + otp: mockOtp, + cognito: mockCognitoService, + tokenGenerator: mockTokenGenerator, + triggers: mockTriggers, + }); + }); + + it("throws if user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + adminRespondToAuthChallenge(TestContext, { + UserPoolId: "poolId", + ClientId: "clientId", + 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, { + UserPoolId: "poolId", + ClientId: "clientId", + ChallengeName: "SMS_MFA", + }) + ).rejects.toEqual( + new InvalidParameterError( + "Missing required parameter challenge responses" + ) + ); + }); + + it("throws if ChallengeResponses.USERNAME is missing", async () => { + await expect( + adminRespondToAuthChallenge(TestContext, { + UserPoolId: "poolId", + 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( + adminRespondToAuthChallenge(TestContext, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: "abc", + }, + }) + ).rejects.toEqual( + new InvalidParameterError("Missing required parameter Session") + ); + }); + + describe("ChallengeName=SMS_MFA", () => { + const user = TDB.user({ + MFACode: "123456", + MFAOptions: [{ DeliveryMedium: "SMS", AttributeName: "phone_number" }], + Attributes: [ + { Name: "phone_number", Value: "+447900000001" }, + { Name: "phone_number_verified", Value: "true" }, + ], + }); + + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + ChallengeName: "SMS_MFA", + ChallengeResponses: { + USERNAME: user.Username, + SMS_MFA_CODE: "123456", + }, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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, { + UserPoolId: "poolId", + ClientId: userPoolClient.ClientId, + 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..22ddefab --- /dev/null +++ b/src/targets/adminRespondToAuthChallenge.ts @@ -0,0 +1,159 @@ +import { + AdminInitiateAuthRequest, + AdminRespondToAuthChallengeRequest, + AdminRespondToAuthChallengeResponse, + DeliveryMediumType, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { + CodeMismatchError, + InvalidParameterError, + MFAMethodNotFoundException, + NotAuthorizedError, + UnsupportedError, + UserNotFoundError, +} from "../errors"; +import { Services } from "../services"; +import { attributeValue, MFAOption } from "../services/userPoolService"; +import { + AdminInitiateAuthServices, + verifyMfaChallenge, +} from "./adminInitiateAuth"; +import { Target } from "./Target"; + +export type AdminRespondToAuthChallengeTarget = Target< + AdminRespondToAuthChallengeRequest, + AdminRespondToAuthChallengeResponse +>; + +type AdminRespondToAuthChallengeService = Pick< + Services, + "clock" | "cognito" | "messages" | "otp" | "triggers" | "tokenGenerator" +>; + +export const AdminRespondToAuthChallenge = + ({ + clock, + cognito, + messages, + otp, + 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.MFAOptions?.length) { + throw new NotAuthorizedError(); + } + const smsMfaOption = user.MFAOptions?.find( + (x): x is MFAOption & { DeliveryMedium: DeliveryMediumType } => + x.DeliveryMedium === "SMS" + ); + console.log(smsMfaOption); + if (!smsMfaOption) { + throw new MFAMethodNotFoundException(); + } + + const deliveryDestinationVerified = attributeValue( + `${smsMfaOption.AttributeName}_verified`, + user.Attributes + ); + if (!deliveryDestinationVerified) { + throw new UserNotFoundError(); + } + + 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", + }); + + if ( + (userPool.options.MfaConfiguration === "OPTIONAL" && + (user.MFAOptions ?? []).length > 0) || + userPool.options.MfaConfiguration === "ON" + ) { + const services: AdminInitiateAuthServices = { + cognito, + messages, + otp, + triggers, + tokenGenerator, + }; + const mfaReq: AdminInitiateAuthRequest = { + ...req, + AuthFlow: "ADMIN_USER_PASSWORD_AUTH", + }; + return verifyMfaChallenge(ctx, user, mfaReq, userPool, services); + } + } 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/adminSetUserSettings.test.ts b/src/targets/adminSetUserSettings.test.ts new file mode 100644 index 00000000..415a01c7 --- /dev/null +++ b/src/targets/adminSetUserSettings.test.ts @@ -0,0 +1,55 @@ +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import * as TDB from "../__tests__/testDataBuilder"; +import { UserPoolService } from "../services"; +import { + AdminSetUserSettings, + AdminSetUserSettingsTarget, +} from "./adminSetUserSettings"; + +describe("AdminSetUserSettings target", () => { + let adminSetUserSettings: AdminSetUserSettingsTarget; + let mockUserPoolService: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + adminSetUserSettings = AdminSetUserSettings({ + cognito: newMockCognitoService(mockUserPoolService), + }); + }); + + it("sets the mfa options", async () => { + const existingUser = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(existingUser); + + await adminSetUserSettings(TestContext, { + Username: existingUser.Username, + UserPoolId: "test", + MFAOptions: [{ DeliveryMedium: "SMS", AttributeName: "phone_number" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...existingUser, + MFAOptions: [{ DeliveryMedium: "SMS", AttributeName: "phone_number" }], + }); + }); + + it("sets the mfa options when nothing provided", async () => { + const existingUser = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(existingUser); + + await adminSetUserSettings(TestContext, { + Username: existingUser.Username, + UserPoolId: "test", + MFAOptions: [], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...existingUser, + MFAOptions: [], + }); + }); +}); diff --git a/src/targets/adminSetUserSettings.ts b/src/targets/adminSetUserSettings.ts new file mode 100644 index 00000000..fbe4d90e --- /dev/null +++ b/src/targets/adminSetUserSettings.ts @@ -0,0 +1,31 @@ +import { + AdminSetUserSettingsRequest, + AdminSetUserSettingsResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { UserNotFoundError } from "../errors"; +import { Services } from "../services"; +import { Target } from "./Target"; + +export type AdminSetUserSettingsTarget = Target< + AdminSetUserSettingsRequest, + AdminSetUserSettingsResponse +>; + +type AdminSetUserSettingsServices = Pick; + +export const AdminSetUserSettings = + ({ cognito }: AdminSetUserSettingsServices): AdminSetUserSettingsTarget => + async (ctx, req) => { + const userPool = await cognito.getUserPool(ctx, req.UserPoolId); + const user = await userPool.getUserByUsername(ctx, req.Username); + if (!user) { + throw new UserNotFoundError("User does not exist"); + } + + await userPool.saveUser(ctx, { + ...user, + MFAOptions: req.MFAOptions, + }); + + return {}; + }; diff --git a/src/targets/initiateAuth.ts b/src/targets/initiateAuth.ts index 2817110d..9fb36b3a 100644 --- a/src/targets/initiateAuth.ts +++ b/src/targets/initiateAuth.ts @@ -7,9 +7,11 @@ import { v4 } from "uuid"; import { InvalidParameterError, InvalidPasswordError, + MFAMethodNotFoundException, NotAuthorizedError, PasswordResetRequiredError, UnsupportedError, + UserNotFoundError, } from "../errors"; import { Services, UserPoolService } from "../services"; import { AppClient } from "../services/appClient"; @@ -47,7 +49,7 @@ const verifyMfaChallenge = async ( x.DeliveryMedium === "SMS" ); if (!smsMfaOption) { - throw new UnsupportedError("MFA challenge without SMS"); + throw new MFAMethodNotFoundException(); } const deliveryDestination = attributeValue( @@ -55,7 +57,7 @@ const verifyMfaChallenge = async ( user.Attributes ); if (!deliveryDestination) { - throw new UnsupportedError(`SMS_MFA without ${smsMfaOption.AttributeName}`); + throw new MFAMethodNotFoundException(); } const code = services.otp(); diff --git a/src/targets/respondToAuthChallenge.test.ts b/src/targets/respondToAuthChallenge.test.ts index 274b22b0..28df9431 100644 --- a/src/targets/respondToAuthChallenge.test.ts +++ b/src/targets/respondToAuthChallenge.test.ts @@ -106,6 +106,11 @@ describe("RespondToAuthChallenge target", () => { describe("ChallengeName=SMS_MFA", () => { const user = TDB.user({ MFACode: "123456", + MFAOptions: [{ DeliveryMedium: "SMS", AttributeName: "phone_number" }], + Attributes: [ + { Name: "phone_number", Value: "+447900000001" }, + { Name: "phone_number_verified", Value: "true" }, + ], }); beforeEach(() => { diff --git a/src/targets/respondToAuthChallenge.ts b/src/targets/respondToAuthChallenge.ts index 485da3ea..68151f71 100644 --- a/src/targets/respondToAuthChallenge.ts +++ b/src/targets/respondToAuthChallenge.ts @@ -1,14 +1,18 @@ import { + DeliveryMediumType, RespondToAuthChallengeRequest, RespondToAuthChallengeResponse, } from "aws-sdk/clients/cognitoidentityserviceprovider"; import { CodeMismatchError, InvalidParameterError, + MFAMethodNotFoundException, NotAuthorizedError, UnsupportedError, + UserNotFoundError, } from "../errors"; import { Services } from "../services"; +import { attributeValue, MFAOption } from "../services/userPoolService"; import { Target } from "./Target"; export type RespondToAuthChallengeTarget = Target< @@ -53,6 +57,25 @@ export const RespondToAuthChallenge = } if (req.ChallengeName === "SMS_MFA") { + if (!user.MFAOptions?.length) { + throw new NotAuthorizedError(); + } + const smsMfaOption = user.MFAOptions?.find( + (x): x is MFAOption & { DeliveryMedium: DeliveryMediumType } => + x.DeliveryMedium === "SMS" + ); + if (!smsMfaOption) { + throw new MFAMethodNotFoundException(); + } + + const deliveryDestinationVerified = attributeValue( + `${smsMfaOption.AttributeName}_verified`, + user.Attributes + ); + if (!deliveryDestinationVerified) { + throw new UserNotFoundError(); + } + if (user.MFACode !== req.ChallengeResponses.SMS_MFA_CODE) { throw new CodeMismatchError(); } diff --git a/src/targets/targets.ts b/src/targets/targets.ts index 3bd432b8..7d03ff6c 100644 --- a/src/targets/targets.ts +++ b/src/targets/targets.ts @@ -3,7 +3,6 @@ import { AdminAddUserToGroup } from "./adminAddUserToGroup"; import { AdminConfirmSignUp } from "./adminConfirmSignUp"; import { AdminCreateUser } from "./adminCreateUser"; import { AdminDeleteUser } from "./adminDeleteUser"; - import { AdminDeleteUserAttributes } from "./adminDeleteUserAttributes"; import { AdminDisableUser } from "./adminDisableUser"; import { AdminEnableUser } from "./adminEnableUser"; @@ -11,7 +10,9 @@ import { AdminGetUser } from "./adminGetUser"; import { AdminInitiateAuth } from "./adminInitiateAuth"; import { AdminListGroupsForUser } from "./adminListGroupsForUser"; import { AdminRemoveUserFromGroup } from "./adminRemoveUserFromGroup"; +import { AdminRespondToAuthChallenge } from "./adminRespondToAuthChallenge"; import { AdminSetUserPassword } from "./adminSetUserPassword"; +import { AdminSetUserSettings } from "./adminSetUserSettings"; import { AdminUpdateUserAttributes } from "./adminUpdateUserAttributes"; import { ChangePassword } from "./changePassword"; import { ConfirmForgotPassword } from "./confirmForgotPassword"; @@ -59,7 +60,9 @@ export const Targets = { AdminInitiateAuth, AdminListGroupsForUser, AdminRemoveUserFromGroup, + AdminRespondToAuthChallenge, AdminSetUserPassword, + AdminSetUserSettings, AdminUpdateUserAttributes, ChangePassword, ConfirmForgotPassword,