From af955a1a5c093c8c9b3b9dd7821bfbb2a51243d9 Mon Sep 17 00:00:00 2001 From: James Gregory Date: Mon, 29 Nov 2021 21:09:53 +1100 Subject: [PATCH] feat(lambda): preSignUp trigger support in signUp --- README.md | 12 +- src/__tests__/mockTriggers.ts | 1 + src/errors.ts | 15 ++ src/services/lambda.test.ts | 132 +++++++++++++- src/services/lambda.ts | 43 ++++- src/services/triggers/customMessage.ts | 89 +++++----- src/services/triggers/preSignUp.test.ts | 75 ++++++++ src/services/triggers/preSignUp.ts | 63 +++++++ src/services/triggers/triggers.ts | 14 +- src/services/userPoolService.ts | 7 +- src/targets/signUp.test.ts | 217 +++++++++++++++++++++++- src/targets/signUp.ts | 36 +++- 12 files changed, 631 insertions(+), 73 deletions(-) create mode 100644 src/services/triggers/preSignUp.test.ts create mode 100644 src/services/triggers/preSignUp.ts diff --git a/README.md b/README.md index a8387042..bc6cbe1b 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,9 @@ cognito-local how to connect to your local Lambda server: | PostConfirmation | ConfirmForgotPassword | ✅ | | PostConfirmation | ConfirmSignUp | ✅ | | PreAuthentication | \* | ❌ | -| PreSignUp | \* | ❌ | +| PreSignUp | PreSignUp_AdminCreateUser | ❌ | +| PreSignUp | PreSignUp_ExternalProvider | ❌ | +| PreSignUp | PreSignUp_SignUp | ✅ | | PreTokenGeneration | \* | ❌ | | UserMigration | Authentication | ✅ | | UserMigration | ForgotPassword | ❌ | @@ -280,9 +282,11 @@ You can edit that `.cognito/config.json` and add any of the following settings: | `LambdaClient.region` | `string` | `local` | | | `TokenConfig.IssuerDomain` | `string` | `http://localhost:9229` | Issuer domain override | | `TriggerFunctions` | `object` | `{}` | Trigger name to Function name mapping | -| `TriggerFunctions.CustomMessage` | `string` | | CustomMessage lambda name | -| `TriggerFunctions.PostConfirmation` | `string` | | PostConfirmation lambda name | -| `TriggerFunctions.UserMigration` | `string` | | UserMigration lambda name | +| `TriggerFunctions.CustomMessage` | `string` | | CustomMessage local lambda function name | +| `TriggerFunctions.PostAuthentication` | `string` | | PostAuthentication local lambda function name | +| `TriggerFunctions.PostConfirmation` | `string` | | PostConfirmation local lambda function name | +| `TriggerFunctions.PreSignUp` | `string` | | PostConfirmation local lambda function name | +| `TriggerFunctions.UserMigration` | `string` | | PreSignUp local lambda function name | | `UserPoolDefaults` | `object` | | Default behaviour to use for the User Pool | | `UserPoolDefaults.Id` | `string` | `local` | Default User Pool Id | | `UserPoolDefaults.MfaConfiguration` | `string` | | MFA type | diff --git a/src/__tests__/mockTriggers.ts b/src/__tests__/mockTriggers.ts index 11d386b5..d15a8be5 100644 --- a/src/__tests__/mockTriggers.ts +++ b/src/__tests__/mockTriggers.ts @@ -5,5 +5,6 @@ export const newMockTriggers = (): jest.Mocked => ({ enabled: jest.fn(), postAuthentication: jest.fn(), postConfirmation: jest.fn(), + preSignUp: jest.fn(), userMigration: jest.fn(), }); diff --git a/src/errors.ts b/src/errors.ts index ae0b7398..18980560 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -63,6 +63,21 @@ export class UnexpectedLambdaExceptionError extends CognitoError { } } +export class UserLambdaValidationError extends CognitoError { + public constructor(message?: string) { + super( + "UserLambdaValidationException", + message ?? "Lambda threw an exception" + ); + } +} + +export class InvalidLambdaResponseError extends CognitoError { + public constructor() { + super("InvalidLambdaResponseException", "Invalid Lambda response"); + } +} + export class InvalidParameterError extends CognitoError { public constructor(message = "Invalid parameter") { super("InvalidParameterException", message); diff --git a/src/services/lambda.test.ts b/src/services/lambda.test.ts index adb9191b..43377799 100644 --- a/src/services/lambda.test.ts +++ b/src/services/lambda.test.ts @@ -1,4 +1,8 @@ import { MockLogger } from "../__tests__/mockLogger"; +import { + InvalidLambdaResponseError, + UserLambdaValidationError, +} from "../errors"; import { LambdaService } from "./lambda"; import * as AWS from "aws-sdk"; import { version } from "aws-sdk/package.json"; @@ -50,11 +54,11 @@ describe("Lambda function invoker", () => { ).rejects.toEqual(new Error("UserMigration trigger not configured")); }); - describe("when lambda successful", () => { + describe("when lambda is successful", () => { it("returns string payload as json", async () => { const response = Promise.resolve({ StatusCode: 200, - Payload: '{ "response": "value" }', + Payload: '{ "response": { "ok": "value" } }', }); mockLambdaClient.invoke.mockReturnValue({ promise: () => response, @@ -78,7 +82,69 @@ describe("Lambda function invoker", () => { validationData: undefined, }); - expect(result).toEqual("value"); + expect(result).toEqual({ ok: "value" }); + }); + + it("throws if an invalid payload is returned", async () => { + const response = Promise.resolve({ + StatusCode: 200, + Payload: '{ "respo...', + }); + mockLambdaClient.invoke.mockReturnValue({ + promise: () => response, + } as any); + const lambda = new LambdaService( + { + UserMigration: "MyLambdaName", + }, + mockLambdaClient, + MockLogger + ); + + await expect( + lambda.invoke("UserMigration", { + clientId: "clientId", + clientMetadata: undefined, + password: "password", + triggerSource: "UserMigration_Authentication", + userAttributes: {}, + username: "username", + userPoolId: "userPoolId", + validationData: undefined, + }) + ).rejects.toBeInstanceOf(InvalidLambdaResponseError); + }); + + it("throws if the function returns an error", async () => { + const response = Promise.resolve({ + StatusCode: 500, + FunctionError: "Something bad happened", + }); + mockLambdaClient.invoke.mockReturnValue({ + promise: () => response, + } as any); + const lambda = new LambdaService( + { + UserMigration: "MyLambdaName", + }, + mockLambdaClient, + MockLogger + ); + + await expect( + lambda.invoke("UserMigration", { + clientId: "clientId", + clientMetadata: undefined, + password: "password", + triggerSource: "UserMigration_Authentication", + userAttributes: {}, + username: "username", + userPoolId: "userPoolId", + validationData: undefined, + }) + ).rejects.toEqual( + new UserLambdaValidationError("Something bad happened") + ); }); it("returns Buffer payload as json", async () => { @@ -112,6 +178,66 @@ describe("Lambda function invoker", () => { }); }); + describe.each([ + "PreSignUp_AdminCreateUser", + "PreSignUp_ExternalProvider", + "PreSignUp_SignUp", + ] as const)("%s", (source) => { + it("invokes the lambda", async () => { + const response = Promise.resolve({ + StatusCode: 200, + Payload: '{ "some": "json" }', + }); + mockLambdaClient.invoke.mockReturnValue({ + promise: () => response, + } as any); + const lambda = new LambdaService( + { + PreSignUp: "MyLambdaName", + }, + mockLambdaClient, + MockLogger + ); + + await lambda.invoke("PreSignUp", { + clientId: "clientId", + clientMetadata: { + client: "metadata", + }, + triggerSource: source, + username: "username", + userPoolId: "userPoolId", + userAttributes: {}, + validationData: { + validation: "data", + }, + }); + + expect(mockLambdaClient.invoke).toHaveBeenCalledWith({ + FunctionName: "MyLambdaName", + InvocationType: "RequestResponse", + Payload: expect.jsonMatching({ + version: 0, + callerContext: { awsSdkVersion: version, clientId: "clientId" }, + region: "local", + userPoolId: "userPoolId", + triggerSource: source, + request: { + clientMetadata: { + client: "metadata", + }, + userAttributes: {}, + validationData: { + validation: "data", + }, + }, + response: {}, + userName: "username", + }), + }); + }); + }); + describe("UserMigration_Authentication", () => { it("invokes the lambda", async () => { const response = Promise.resolve({ diff --git a/src/services/lambda.ts b/src/services/lambda.ts index 8e139d04..2d6ea8ac 100644 --- a/src/services/lambda.ts +++ b/src/services/lambda.ts @@ -1,7 +1,11 @@ import { CognitoUserPoolEvent } from "aws-lambda"; import type { Lambda as LambdaClient } from "aws-sdk"; import { InvocationResponse } from "aws-sdk/clients/lambda"; -import { UnexpectedLambdaExceptionError } from "../errors"; +import { + InvalidLambdaResponseError, + UnexpectedLambdaExceptionError, + UserLambdaValidationError, +} from "../errors"; import { version as awsSdkVersion } from "aws-sdk/package.json"; import { Logger } from "../log"; @@ -33,6 +37,15 @@ interface UserMigrationEvent extends EventCommonParameters { validationData: Record | undefined; } +interface PreSignUpEvent extends EventCommonParameters { + clientMetadata: Record | undefined; + triggerSource: + | "PreSignUp_AdminCreateUser" + | "PreSignUp_ExternalProvider" + | "PreSignUp_SignUp"; + validationData: Record | undefined; +} + interface PostAuthenticationEvent extends EventCommonParameters { clientMetadata: Record | undefined; triggerSource: "PostAuthentication_Authentication"; @@ -49,9 +62,10 @@ export type CognitoUserPoolResponse = CognitoUserPoolEvent["response"]; export interface FunctionConfig { CustomMessage?: string; - UserMigration?: string; PostAuthentication?: string; PostConfirmation?: string; + PreSignUp?: string; + UserMigration?: string; } export interface Lambda { @@ -64,6 +78,10 @@ export interface Lambda { lambda: "UserMigration", event: UserMigrationEvent ): Promise; + invoke( + lambda: "PreSignUp", + event: PreSignUpEvent + ): Promise; invoke( lambda: "PostAuthentication", event: PostAuthenticationEvent @@ -97,9 +115,10 @@ export class LambdaService implements Lambda { trigger: keyof FunctionConfig, event: | CustomMessageEvent - | UserMigrationEvent | PostAuthenticationEvent | PostConfirmationEvent + | PreSignUpEvent + | UserMigrationEvent ) { const functionName = this.config[trigger]; if (!functionName) { @@ -129,6 +148,13 @@ export class LambdaService implements Lambda { lambdaEvent.request.clientMetadata = event.clientMetadata; break; } + case "PreSignUp_AdminCreateUser": + case "PreSignUp_ExternalProvider": + case "PreSignUp_SignUp": { + lambdaEvent.request.clientMetadata = event.clientMetadata; + lambdaEvent.request.validationData = event.validationData; + break; + } case "UserMigration_Authentication": { lambdaEvent.request.clientMetadata = event.clientMetadata; lambdaEvent.request.password = event.password; @@ -172,12 +198,17 @@ export class LambdaService implements Lambda { `Lambda completed with StatusCode=${result.StatusCode} and FunctionError=${result.FunctionError}` ); if (result.StatusCode === 200) { - const parsedPayload = JSON.parse(result.Payload as string); + try { + const parsedPayload = JSON.parse(result.Payload as string); - return parsedPayload.response as CognitoUserPoolResponse; + return parsedPayload.response as CognitoUserPoolResponse; + } catch (err) { + this.logger.error(err); + throw new InvalidLambdaResponseError(); + } } else { this.logger.error(result.FunctionError); - throw new UnexpectedLambdaExceptionError(); + throw new UserLambdaValidationError(result.FunctionError); } } } diff --git a/src/services/triggers/customMessage.ts b/src/services/triggers/customMessage.ts index 4d6f1a7a..10ba4abb 100644 --- a/src/services/triggers/customMessage.ts +++ b/src/services/triggers/customMessage.ts @@ -48,50 +48,53 @@ export type CustomMessageTrigger = (params: { clientMetadata: Record | undefined; }) => Promise; -type CustomMessageServices = { +interface CustomMessageServices { lambda: Lambda; cognitoClient: CognitoService; -}; -export const CustomMessage = ( - { lambda, cognitoClient }: CustomMessageServices, - logger: Logger -): CustomMessageTrigger => async ({ - clientId, - clientMetadata, - code, - source, - userAttributes, - username, - userPoolId, -}): Promise => { - const userPool = await cognitoClient.getUserPoolForClientId(clientId); - if (!userPool) { - throw new ResourceNotFoundError(); - } +} + +export const CustomMessage = + ( + { lambda, cognitoClient }: CustomMessageServices, + logger: Logger + ): CustomMessageTrigger => + async ({ + clientId, + clientMetadata, + code, + source, + userAttributes, + username, + userPoolId, + }): Promise => { + const userPool = await cognitoClient.getUserPoolForClientId(clientId); + if (!userPool) { + throw new ResourceNotFoundError(); + } - try { - const response = await lambda.invoke("CustomMessage", { - clientId, - clientMetadata, - codeParameter: AWS_CODE_PARAMETER, - triggerSource: source, - userAttributes: attributesToRecord(userAttributes), - username, - usernameParameter: AWS_USERNAME_PARAMETER, - userPoolId, - }); + try { + const response = await lambda.invoke("CustomMessage", { + clientId, + clientMetadata, + codeParameter: AWS_CODE_PARAMETER, + triggerSource: source, + userAttributes: attributesToRecord(userAttributes), + username, + usernameParameter: AWS_USERNAME_PARAMETER, + userPoolId, + }); - return { - emailMessage: response.emailMessage - ?.replace(AWS_CODE_PARAMETER, code) - .replace(AWS_USERNAME_PARAMETER, username), - emailSubject: response.emailSubject, - smsMessage: response.smsMessage - ?.replace(AWS_CODE_PARAMETER, code) - .replace(AWS_USERNAME_PARAMETER, username), - }; - } catch (ex) { - logger.error(ex); - return null; - } -}; + return { + emailMessage: response.emailMessage + ?.replace(AWS_CODE_PARAMETER, code) + .replace(AWS_USERNAME_PARAMETER, username), + emailSubject: response.emailSubject, + smsMessage: response.smsMessage + ?.replace(AWS_CODE_PARAMETER, code) + .replace(AWS_USERNAME_PARAMETER, username), + }; + } catch (ex) { + logger.error(ex); + return null; + } + }; diff --git a/src/services/triggers/preSignUp.test.ts b/src/services/triggers/preSignUp.test.ts new file mode 100644 index 00000000..9f95149a --- /dev/null +++ b/src/services/triggers/preSignUp.test.ts @@ -0,0 +1,75 @@ +import { newMockLambda } from "../../__tests__/mockLambda"; +import { Lambda } from "../lambda"; +import { PreSignUp, PreSignUpTrigger } from "./preSignUp"; + +describe("PreSignUp trigger", () => { + let mockLambda: jest.Mocked; + let preSignUp: PreSignUpTrigger; + + beforeEach(() => { + mockLambda = newMockLambda(); + preSignUp = PreSignUp({ + lambda: mockLambda, + }); + }); + + describe.each([ + "PreSignUp_AdminCreateUser", + "PreSignUp_ExternalProvider", + "PreSignUp_SignUp", + ] as const)("%s", (source) => { + describe("when lambda invoke fails", () => { + it("throws", async () => { + mockLambda.invoke.mockRejectedValue( + new Error("Something bad happened") + ); + + await expect( + preSignUp({ + clientId: "clientId", + clientMetadata: undefined, + source, + userAttributes: [], + username: "username", + userPoolId: "userPoolId", + validationData: undefined, + }) + ).rejects.toEqual(new Error("Something bad happened")); + }); + }); + + describe("when lambda invoke succeeds", () => { + it("quietly completes", async () => { + mockLambda.invoke.mockResolvedValue({}); + + await preSignUp({ + clientMetadata: { + client: "metadata", + }, + userPoolId: "userPoolId", + clientId: "clientId", + username: "example@example.com", + userAttributes: [{ Name: "email", Value: "example@example.com" }], + source: source, + validationData: { + validation: "data", + }, + }); + + expect(mockLambda.invoke).toHaveBeenCalledWith("PreSignUp", { + clientId: "clientId", + clientMetadata: { + client: "metadata", + }, + triggerSource: source, + userAttributes: { email: "example@example.com" }, + userPoolId: "userPoolId", + username: "example@example.com", + validationData: { + validation: "data", + }, + }); + }); + }); + }); +}); diff --git a/src/services/triggers/preSignUp.ts b/src/services/triggers/preSignUp.ts new file mode 100644 index 00000000..cd0bc97d --- /dev/null +++ b/src/services/triggers/preSignUp.ts @@ -0,0 +1,63 @@ +import { AttributeListType } from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { Lambda } from "../lambda"; +import { attributesToRecord } from "../userPoolService"; + +interface PreSignUpTriggerResponse { + autoConfirmUser?: boolean; + autoVerifyPhone?: boolean; + autoVerifyEmail?: boolean; +} + +export type PreSignUpTrigger = (params: { + clientId: string; + source: + | "PreSignUp_AdminCreateUser" + | "PreSignUp_ExternalProvider" + | "PreSignUp_SignUp"; + userAttributes: AttributeListType; + username: string; + userPoolId: string; + + /** + * One or more name-value pairs containing the validation data in the request to register a user. The validation data + * is set and then passed from the client in the request to register a user. You can pass this data to your Lambda + * function by using the ClientMetadata parameter in the InitiateAuth and AdminInitiateAuth API actions. + * + * Source: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html#cognito-user-pools-lambda-trigger-syntax-pre-signup + */ + clientMetadata: Record | undefined; + + /** + * One or more name-value pairs containing the validation data in the request to register a user. The validation data + * is set and then passed from the client in the request to register a user. You can pass this data to your Lambda + * function by using the ClientMetadata parameter in the InitiateAuth and AdminInitiateAuth API actions. + * + * Source: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html#cognito-user-pools-lambda-trigger-syntax-pre-signup + */ + validationData: Record | undefined; +}) => Promise; + +type PreSignUpServices = { + lambda: Lambda; +}; + +export const PreSignUp = + ({ lambda }: PreSignUpServices): PreSignUpTrigger => + async ({ + clientId, + clientMetadata, + source, + userAttributes, + username, + userPoolId, + validationData, + }) => + lambda.invoke("PreSignUp", { + clientId, + clientMetadata, + triggerSource: source, + userAttributes: attributesToRecord(userAttributes), + username, + userPoolId, + validationData, + }); diff --git a/src/services/triggers/triggers.ts b/src/services/triggers/triggers.ts index 1893b151..8573c314 100644 --- a/src/services/triggers/triggers.ts +++ b/src/services/triggers/triggers.ts @@ -8,28 +8,32 @@ import { PostAuthenticationTrigger, } from "./postAuthentication"; import { PostConfirmation, PostConfirmationTrigger } from "./postConfirmation"; +import { PreSignUp, PreSignUpTrigger } from "./preSignUp"; import { UserMigration, UserMigrationTrigger } from "./userMigration"; type SupportedTriggers = | "CustomMessage" | "UserMigration" | "PostAuthentication" - | "PostConfirmation"; + | "PostConfirmation" + | "PreSignUp"; export interface Triggers { enabled(trigger: SupportedTriggers): boolean; customMessage: CustomMessageTrigger; - userMigration: UserMigrationTrigger; postAuthentication: PostAuthenticationTrigger; postConfirmation: PostConfirmationTrigger; + preSignUp: PreSignUpTrigger; + userMigration: UserMigrationTrigger; } -export class TriggersService { +export class TriggersService implements Triggers { private readonly lambda: Lambda; public readonly customMessage: CustomMessageTrigger; public readonly postAuthentication: PostAuthenticationTrigger; public readonly postConfirmation: PostConfirmationTrigger; + public readonly preSignUp: PreSignUpTrigger; public readonly userMigration: UserMigrationTrigger; public constructor( @@ -38,11 +42,13 @@ export class TriggersService { lambda: Lambda, logger: Logger ) { + this.lambda = lambda; + this.customMessage = CustomMessage({ lambda, cognitoClient }, logger); this.postAuthentication = PostAuthentication({ lambda }, logger); this.postConfirmation = PostConfirmation({ lambda, cognitoClient }, logger); + this.preSignUp = PreSignUp({ lambda }); this.userMigration = UserMigration({ clock, lambda, cognitoClient }); - this.lambda = lambda; } public enabled(trigger: SupportedTriggers): boolean { diff --git a/src/services/userPoolService.ts b/src/services/userPoolService.ts index 0626d203..e74bedf4 100644 --- a/src/services/userPoolService.ts +++ b/src/services/userPoolService.ts @@ -2,6 +2,7 @@ import { AttributeListType, MFAOptionListType, UserPoolType, + UserStatusType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; import { Logger } from "../log"; import { AppClient, newId } from "./appClient"; @@ -46,11 +47,7 @@ export interface User { UserCreateDate: Date; UserLastModifiedDate: Date; Enabled: boolean; - UserStatus: - | "CONFIRMED" - | "FORCE_CHANGE_PASSWORD" - | "RESET_REQUIRED" - | "UNCONFIRMED"; + UserStatus: UserStatusType; Attributes: AttributeListType; MFAOptions?: MFAOptionListType; diff --git a/src/targets/signUp.test.ts b/src/targets/signUp.test.ts index 703b9484..fd30f16d 100644 --- a/src/targets/signUp.test.ts +++ b/src/targets/signUp.test.ts @@ -2,11 +2,21 @@ import { ClockFake } from "../__tests__/clockFake"; import { newMockCognitoService } from "../__tests__/mockCognitoService"; import { newMockMessageDelivery } from "../__tests__/mockMessageDelivery"; import { newMockMessages } from "../__tests__/mockMessages"; +import { newMockTriggers } from "../__tests__/mockTriggers"; import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { UUID } from "../__tests__/patterns"; import * as TDB from "../__tests__/testDataBuilder"; -import { InvalidParameterError, UsernameExistsError } from "../errors"; -import { MessageDelivery, Messages, UserPoolService } from "../services"; +import { + InvalidParameterError, + UserLambdaValidationError, + UsernameExistsError, +} from "../errors"; +import { + MessageDelivery, + Messages, + Triggers, + UserPoolService, +} from "../services"; import { SignUp, SignUpTarget } from "./signUp"; describe("SignUp target", () => { @@ -15,6 +25,7 @@ describe("SignUp target", () => { let mockMessageDelivery: jest.Mocked; let mockMessages: jest.Mocked; let mockOtp: jest.MockedFunction<() => string>; + let mockTriggers: jest.Mocked; let now: Date; beforeEach(() => { @@ -27,12 +38,14 @@ describe("SignUp target", () => { emailSubject: "Mock message", }); mockOtp = jest.fn(); + mockTriggers = newMockTriggers(); signUp = SignUp({ cognito: newMockCognitoService(mockUserPoolService), clock: new ClockFake(now), messageDelivery: mockMessageDelivery, messages: mockMessages, otp: mockOtp, + triggers: mockTriggers, }); }); @@ -78,6 +91,206 @@ describe("SignUp target", () => { }); }); + describe("when PreSignUp trigger is enabled", () => { + beforeEach(() => { + mockTriggers.enabled.mockImplementation( + (trigger) => trigger === "PreSignUp" + ); + }); + + it("calls the trigger lambda", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({}); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockTriggers.preSignUp).toHaveBeenCalledWith({ + clientId: "clientId", + clientMetadata: { + client: "metadata", + }, + source: "PreSignUp_SignUp", + userAttributes: [ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example@example.com" }, + ], + userPoolId: "test", + username: "user-supplied", + validationData: undefined, + }); + }); + + it("throws if the trigger lambda fails", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockRejectedValue(new UserLambdaValidationError()); + + await expect( + signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + ValidationData: [{ Name: "another", Value: "attribute" }], + }) + ).rejects.toBeInstanceOf(UserLambdaValidationError); + }); + + it("confirms the user if the lambda returns autoConfirmUser=true", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({ + autoConfirmUser: true, + }); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + expect.objectContaining({ + UserStatus: "CONFIRMED", + }) + ); + }); + + it("verifies the user's email if the lambda returns autoVerifyEmail=true and the user has an email attribute", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({ + autoVerifyEmail: true, + }); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + expect.objectContaining({ + Attributes: [ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example@example.com" }, + { Name: "email_verified", Value: "true" }, + ], + }) + ); + }); + + it("does not verify the user's email if the lambda returns autoVerifyEmail=true but the user does not have an email attribute", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({ + autoVerifyEmail: true, + }); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + expect.objectContaining({ + Attributes: [{ Name: "sub", Value: expect.stringMatching(UUID) }], + }) + ); + }); + + it("verifies the user's phone_number if the lambda returns autoVerifyPhone=true and the user has an phone_number attribute", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({ + autoVerifyPhone: true, + }); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "phone_number", Value: "0400000000" }], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + expect.objectContaining({ + Attributes: [ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "phone_number", Value: "0400000000" }, + { Name: "phone_number_verified", Value: "true" }, + ], + }) + ); + }); + + it("does not verify the user's phone_number if the lambda returns autoVerifyPhone=true but the user does not have a phone_number attribute", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + mockTriggers.preSignUp.mockResolvedValue({ + autoVerifyPhone: true, + }); + + await signUp({ + ClientId: "clientId", + ClientMetadata: { + client: "metadata", + }, + Password: "pwd", + Username: "user-supplied", + UserAttributes: [], + ValidationData: [{ Name: "another", Value: "attribute" }], + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + expect.objectContaining({ + Attributes: [{ Name: "sub", Value: expect.stringMatching(UUID) }], + }) + ); + }); + }); + + describe("when PreSignUp trigger is disabled", () => { + it("does not call the trigger lambda", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await signUp({ + ClientId: "clientId", + Password: "pwd", + Username: "user-supplied", + UserAttributes: [{ Name: "email", Value: "example@example.com" }], + }); + + expect(mockTriggers.preSignUp).not.toHaveBeenCalled(); + }); + }); + describe("messages", () => { describe("UserPool.AutoVerifiedAttributes=default", () => { beforeEach(() => { diff --git a/src/targets/signUp.ts b/src/targets/signUp.ts index 2111c987..feab5e82 100644 --- a/src/targets/signUp.ts +++ b/src/targets/signUp.ts @@ -1,6 +1,7 @@ import { SignUpRequest, SignUpResponse, + UserStatusType, VerifiedAttributesListType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; import uuid from "uuid"; @@ -22,7 +23,7 @@ export type SignUpTarget = (req: SignUpRequest) => Promise; type SignUpServices = Pick< Services, - "clock" | "cognito" | "messages" | "messageDelivery" | "otp" + "clock" | "cognito" | "messages" | "messageDelivery" | "otp" | "triggers" >; const selectAppropriateDeliveryMethod = ( @@ -97,6 +98,7 @@ export const SignUp = messageDelivery, messages, otp, + triggers, }: SignUpServices): SignUpTarget => async (req) => { // TODO: This should behave differently depending on if PreventUserExistenceErrors @@ -111,6 +113,30 @@ export const SignUp = const attributes = attributesInclude("sub", req.UserAttributes) ? req.UserAttributes ?? [] : [{ Name: "sub", Value: uuid.v4() }, ...(req.UserAttributes ?? [])]; + let userStatus: UserStatusType = "UNCONFIRMED"; + + if (triggers.enabled("PreSignUp")) { + const { autoConfirmUser, autoVerifyEmail, autoVerifyPhone } = + await triggers.preSignUp({ + clientId: req.ClientId, + clientMetadata: req.ClientMetadata, + source: "PreSignUp_SignUp", + userAttributes: attributes, + username: req.Username, + userPoolId: userPool.config.Id, + validationData: undefined, + }); + + if (autoConfirmUser) { + userStatus = "CONFIRMED"; + } + if (attributesInclude("email", attributes) && autoVerifyEmail) { + attributes.push({ Name: "email_verified", Value: "true" }); + } + if (attributesInclude("phone_number", attributes) && autoVerifyPhone) { + attributes.push({ Name: "phone_number_verified", Value: "true" }); + } + } const now = clock.get(); const user: User = { @@ -119,14 +145,10 @@ export const SignUp = Password: req.Password, UserCreateDate: now, UserLastModifiedDate: now, - UserStatus: "UNCONFIRMED", Username: req.Username, + UserStatus: userStatus, }; - // TODO: call PreSignUp trigger - // TODO: do we also need a UserMigration call in here? - // TODO: call PostConfirmation if PreSignUp confirms auto confirms the user - const code = otp(); const deliveryDetails = await deliverWelcomeMessage( @@ -144,6 +166,8 @@ export const SignUp = ConfirmationCode: code, }); + // TODO: call PostConfirmation if PreSignUp confirms auto confirms the user + return { CodeDeliveryDetails: deliveryDetails ?? undefined, UserConfirmed: user.UserStatus === "CONFIRMED",