From 43a5c96f77fe5e64b2a0c2d74545eca11b9ff478 Mon Sep 17 00:00:00 2001 From: Ben Clarke <34235717+ben-clarke@users.noreply.github.com> Date: Wed, 16 Nov 2022 13:51:19 +0000 Subject: [PATCH 1/5] feat(auth): adding in admin respond to auth challenge target and handling adminInitiateAuth SMS_MFA --- README.md | 6 +- src/server/server.ts | 4 +- src/targets/addCustomAttributes.test.ts | 8 +- src/targets/adminInitiateAuth.test.ts | 224 ++++++++++- src/targets/adminInitiateAuth.ts | 73 +++- .../adminRespondToAuthChallenge.test.ts | 351 ++++++++++++++++++ src/targets/adminRespondToAuthChallenge.ts | 109 ++++++ src/targets/targets.ts | 3 +- 8 files changed, 763 insertions(+), 15 deletions(-) create mode 100644 src/targets/adminRespondToAuthChallenge.test.ts create mode 100644 src/targets/adminRespondToAuthChallenge.ts diff --git a/README.md b/README.md index 19ab63f9..d3d75b6d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Cognito Local -![CI](https://github.com/jagregory/cognito-local/workflows/CI/badge.svg) +![CI](https://github.com/LHV-UK/cognito-local/workflows/CI/badge.svg) -A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cognito/). +A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cognito/), forked from [cognito-local](https://github.com/jagregory/cognito-local) until we are able to procure and hookup localstack pro @@ -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/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..8eeb3e2b 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -1,14 +1,17 @@ import { + DeliveryMediumType, AdminInitiateAuthRequest, AdminInitiateAuthResponse, } from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { v4 } from "uuid"; import { InvalidParameterError, InvalidPasswordError, NotAuthorizedError, UnsupportedError, } 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"; @@ -19,9 +22,67 @@ export type AdminInitiateAuthTarget = Target< type AdminInitiateAuthServices = Pick< Services, - "cognito" | "triggers" | "tokenGenerator" + "cognito" | "messages" | "otp" | "triggers" | "tokenGenerator" >; +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 UnsupportedError("MFA challenge without SMS"); + } + + const deliveryDestination = attributeValue( + smsMfaOption.AttributeName, + user.Attributes + ); + if (!deliveryDestination) { + throw new UnsupportedError(`SMS_MFA without ${smsMfaOption.AttributeName}`); + } + + 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 +132,14 @@ const adminUserPasswordAuthFlow = async ( throw new InvalidPasswordError(); } + 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..7fd942ed --- /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("RespondToAuthChallenge 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, { + 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", + }); + + 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..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..bf9728be 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,6 +10,7 @@ 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 { AdminUpdateUserAttributes } from "./adminUpdateUserAttributes"; import { ChangePassword } from "./changePassword"; @@ -59,6 +59,7 @@ export const Targets = { AdminInitiateAuth, AdminListGroupsForUser, AdminRemoveUserFromGroup, + AdminRespondToAuthChallenge, AdminSetUserPassword, AdminUpdateUserAttributes, ChangePassword, From 7e86287ac81d1e56fadda77d862deec90db69b7d Mon Sep 17 00:00:00 2001 From: Ben Clarke <34235717+ben-clarke@users.noreply.github.com> Date: Wed, 16 Nov 2022 13:58:56 +0000 Subject: [PATCH 2/5] feat(auth): adding in admin respond to auth challenge target and handling adminInitiateAuth SMS_MFA --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d3d75b6d..b75ad552 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Cognito Local -![CI](https://github.com/LHV-UK/cognito-local/workflows/CI/badge.svg) +![CI](https://github.com/jagregory/cognito-local/workflows/CI/badge.svg) -A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cognito/), forked from [cognito-local](https://github.com/jagregory/cognito-local) until we are able to procure and hookup localstack pro +A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cognito/). From 37193538b794c37f98f85b23a8025197e97409f9 Mon Sep 17 00:00:00 2001 From: Ben Clarke <34235717+ben-clarke@users.noreply.github.com> Date: Thu, 17 Nov 2022 11:27:48 +0000 Subject: [PATCH 3/5] feat(auth): adding in proper SMS_MFA response when changing a password --- README.md | 2 +- src/targets/adminInitiateAuth.ts | 12 ++++- src/targets/adminRespondToAuthChallenge.ts | 28 ++++++++++- src/targets/adminSetUserSettings.test.ts | 55 ++++++++++++++++++++++ src/targets/adminSetUserSettings.ts | 31 ++++++++++++ src/targets/targets.ts | 2 + 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/targets/adminSetUserSettings.test.ts create mode 100644 src/targets/adminSetUserSettings.ts diff --git a/README.md b/README.md index b75ad552..2eaf410b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | AdminRespondToAuthChallenge | 🕒 (partial support) | | AdminSetUserMFAPreference | ❌ | | AdminSetUserPassword | ✅ | -| AdminSetUserSettings | ❌ | +| AdminSetUserSettings | ✅ | | AdminUpdateAuthEventFeedback | ❌ | | AdminUpdateDeviceStatus | ❌ | | AdminUpdateUserAttributes | ✅ | diff --git a/src/targets/adminInitiateAuth.ts b/src/targets/adminInitiateAuth.ts index 8eeb3e2b..ba162e83 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -20,12 +20,12 @@ export type AdminInitiateAuthTarget = Target< AdminInitiateAuthResponse >; -type AdminInitiateAuthServices = Pick< +export type AdminInitiateAuthServices = Pick< Services, "cognito" | "messages" | "otp" | "triggers" | "tokenGenerator" >; -const verifyMfaChallenge = async ( +export const verifyMfaChallenge = async ( ctx: Context, user: User, req: AdminInitiateAuthRequest, @@ -132,6 +132,14 @@ 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) || diff --git a/src/targets/adminRespondToAuthChallenge.ts b/src/targets/adminRespondToAuthChallenge.ts index d81b30bb..8df9d121 100644 --- a/src/targets/adminRespondToAuthChallenge.ts +++ b/src/targets/adminRespondToAuthChallenge.ts @@ -1,4 +1,5 @@ import { + AdminInitiateAuthRequest, AdminRespondToAuthChallengeRequest, AdminRespondToAuthChallengeResponse, } from "aws-sdk/clients/cognitoidentityserviceprovider"; @@ -9,6 +10,10 @@ import { UnsupportedError, } from "../errors"; import { Services } from "../services"; +import { + AdminInitiateAuthServices, + verifyMfaChallenge, +} from "./adminInitiateAuth"; import { Target } from "./Target"; export type AdminRespondToAuthChallengeTarget = Target< @@ -18,13 +23,15 @@ export type AdminRespondToAuthChallengeTarget = Target< type AdminRespondToAuthChallengeService = Pick< Services, - "clock" | "cognito" | "triggers" | "tokenGenerator" + "clock" | "cognito" | "messages" | "otp" | "triggers" | "tokenGenerator" >; export const AdminRespondToAuthChallenge = ({ clock, cognito, + messages, + otp, triggers, tokenGenerator, }: AdminRespondToAuthChallengeService): AdminRespondToAuthChallengeTarget => @@ -76,6 +83,25 @@ export const AdminRespondToAuthChallenge = 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}` 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/targets.ts b/src/targets/targets.ts index bf9728be..7d03ff6c 100644 --- a/src/targets/targets.ts +++ b/src/targets/targets.ts @@ -12,6 +12,7 @@ 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"; @@ -61,6 +62,7 @@ export const Targets = { AdminRemoveUserFromGroup, AdminRespondToAuthChallenge, AdminSetUserPassword, + AdminSetUserSettings, AdminUpdateUserAttributes, ChangePassword, ConfirmForgotPassword, From 02f571f1f33ae3621f55e83a384da87bcdcfe4f1 Mon Sep 17 00:00:00 2001 From: Ben Clarke <34235717+ben-clarke@users.noreply.github.com> Date: Mon, 21 Nov 2022 10:31:01 +0000 Subject: [PATCH 4/5] feat(mfa): adding in mfa method not found exception --- src/errors.ts | 9 +++++++++ src/targets/adminInitiateAuth.ts | 3 ++- src/targets/initiateAuth.ts | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index c3910dad..48750df8 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/targets/adminInitiateAuth.ts b/src/targets/adminInitiateAuth.ts index ba162e83..7c168466 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -7,6 +7,7 @@ import { v4 } from "uuid"; import { InvalidParameterError, InvalidPasswordError, + MFAMethodNotFoundException, NotAuthorizedError, UnsupportedError, } from "../errors"; @@ -48,7 +49,7 @@ export 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/initiateAuth.ts b/src/targets/initiateAuth.ts index 2817110d..c48b26f1 100644 --- a/src/targets/initiateAuth.ts +++ b/src/targets/initiateAuth.ts @@ -7,6 +7,7 @@ import { v4 } from "uuid"; import { InvalidParameterError, InvalidPasswordError, + MFAMethodNotFoundException, NotAuthorizedError, PasswordResetRequiredError, UnsupportedError, @@ -55,7 +56,7 @@ const verifyMfaChallenge = async ( user.Attributes ); if (!deliveryDestination) { - throw new UnsupportedError(`SMS_MFA without ${smsMfaOption.AttributeName}`); + throw new MFAMethodNotFoundException(); } const code = services.otp(); From 3d7a54048e362af599a2c4e65f3f5dc4b2b2e279 Mon Sep 17 00:00:00 2001 From: Ben Clarke <34235717+ben-clarke@users.noreply.github.com> Date: Tue, 22 Nov 2022 16:14:23 +0000 Subject: [PATCH 5/5] feat(mfa): adding in mfa method not found exception --- src/errors.ts | 2 +- src/targets/adminInitiateAuth.ts | 3 ++- .../adminRespondToAuthChallenge.test.ts | 14 ++++++++++- src/targets/adminRespondToAuthChallenge.ts | 24 +++++++++++++++++++ src/targets/initiateAuth.ts | 3 ++- src/targets/respondToAuthChallenge.test.ts | 5 ++++ src/targets/respondToAuthChallenge.ts | 23 ++++++++++++++++++ 7 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 48750df8..4d02c4da 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -37,7 +37,7 @@ export class MFAMethodNotFoundException extends CognitoError { public constructor() { super( "MFAMethodNotFoundException", - "No MFA mechanism registered in the account." + "No MFA mechanism registered in the account" ); } } diff --git a/src/targets/adminInitiateAuth.ts b/src/targets/adminInitiateAuth.ts index 7c168466..f58c1953 100644 --- a/src/targets/adminInitiateAuth.ts +++ b/src/targets/adminInitiateAuth.ts @@ -10,6 +10,7 @@ import { MFAMethodNotFoundException, NotAuthorizedError, UnsupportedError, + UserNotFoundError, } from "../errors"; import { Services, UserPoolService } from "../services"; import { attributeValue, MFAOption, User } from "../services/userPoolService"; @@ -41,7 +42,7 @@ export const verifyMfaChallenge = async ( x.DeliveryMedium === "SMS" ); if (!smsMfaOption) { - throw new UnsupportedError("MFA challenge without SMS"); + throw new MFAMethodNotFoundException(); } const deliveryDestination = attributeValue( diff --git a/src/targets/adminRespondToAuthChallenge.test.ts b/src/targets/adminRespondToAuthChallenge.test.ts index 7fd942ed..f902a32a 100644 --- a/src/targets/adminRespondToAuthChallenge.test.ts +++ b/src/targets/adminRespondToAuthChallenge.test.ts @@ -9,13 +9,14 @@ import { InvalidParameterError, NotAuthorizedError, } from "../errors"; -import { Triggers, UserPoolService } from "../services"; +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(); @@ -24,6 +25,8 @@ describe("RespondToAuthChallenge target", () => { 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(); @@ -31,6 +34,8 @@ describe("RespondToAuthChallenge target", () => { clock = new ClockFake(currentDate); mockTokenGenerator = newMockTokenGenerator(); mockTriggers = newMockTriggers(); + mockMessages = newMockMessages(); + mockOtp = jest.fn().mockReturnValue("123456"); mockUserPoolService = newMockUserPoolService({ Id: userPoolClient.UserPoolId, }); @@ -40,6 +45,8 @@ describe("RespondToAuthChallenge target", () => { adminRespondToAuthChallenge = AdminRespondToAuthChallenge({ clock, + messages: mockMessages, + otp: mockOtp, cognito: mockCognitoService, tokenGenerator: mockTokenGenerator, triggers: mockTriggers, @@ -110,6 +117,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/adminRespondToAuthChallenge.ts b/src/targets/adminRespondToAuthChallenge.ts index 8df9d121..22ddefab 100644 --- a/src/targets/adminRespondToAuthChallenge.ts +++ b/src/targets/adminRespondToAuthChallenge.ts @@ -2,14 +2,18 @@ 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, @@ -60,6 +64,26 @@ export const AdminRespondToAuthChallenge = } 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(); } diff --git a/src/targets/initiateAuth.ts b/src/targets/initiateAuth.ts index c48b26f1..9fb36b3a 100644 --- a/src/targets/initiateAuth.ts +++ b/src/targets/initiateAuth.ts @@ -11,6 +11,7 @@ import { NotAuthorizedError, PasswordResetRequiredError, UnsupportedError, + UserNotFoundError, } from "../errors"; import { Services, UserPoolService } from "../services"; import { AppClient } from "../services/appClient"; @@ -48,7 +49,7 @@ const verifyMfaChallenge = async ( x.DeliveryMedium === "SMS" ); if (!smsMfaOption) { - throw new UnsupportedError("MFA challenge without SMS"); + throw new MFAMethodNotFoundException(); } const deliveryDestination = attributeValue( 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(); }