diff --git a/README.md b/README.md index 7ea0cc14..69f2771d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | -------------------------------- | -------------------- | | AddCustomAttributes | ❌ | | AdminAddUserToGroup | ❌ | -| AdminConfirmSignUp | 🕒 (partial support) | +| AdminConfirmSignUp | ✅ | | AdminCreateUser | 🕒 (partial support) | | AdminDeleteUser | ✅ | | AdminDeleteUserAttributes | ❌ | @@ -295,7 +295,7 @@ Before starting Cognito Local, create a config file if one doesn't already exist You can edit that `.cognito/config.json` and add any of the following settings: | Setting | Type | Default | Description | -|--------------------------------------------| ---------- | ----------------------- |-------------------------------------------------------------| +| ------------------------------------------ | ---------- | ----------------------- | ----------------------------------------------------------- | | `LambdaClient` | `object` | | Any setting you would pass to the AWS.Lambda Node.js client | | `LambdaClient.credentials.accessKeyId` | `string` | `local` | | | `LambdaClient.credentials.secretAccessKey` | `string` | `local` | | diff --git a/integration-tests/aws-sdk/adminConfirmSignUp.test.ts b/integration-tests/aws-sdk/adminConfirmSignUp.test.ts new file mode 100644 index 00000000..7a1f0c12 --- /dev/null +++ b/integration-tests/aws-sdk/adminConfirmSignUp.test.ts @@ -0,0 +1,43 @@ +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.adminConfirmSignUp", + withCognitoSdk((Cognito) => { + it("creates a user with only the required parameters", async () => { + const client = Cognito(); + + await client + .adminCreateUser({ + UserAttributes: [{ Name: "phone_number", Value: "0400000000" }], + Username: "abc", + UserPoolId: "test", + }) + .promise(); + + let user = await client + .adminGetUser({ + UserPoolId: "test", + Username: "abc", + }) + .promise(); + + expect(user.UserStatus).toEqual("FORCE_CHANGE_PASSWORD"); + + await client + .adminConfirmSignUp({ + UserPoolId: "test", + Username: "abc", + }) + .promise(); + + user = await client + .adminGetUser({ + UserPoolId: "test", + Username: "abc", + }) + .promise(); + + expect(user.UserStatus).toEqual("CONFIRMED"); + }); + }) +); diff --git a/integration-tests/aws-sdk/initiateAuth.test.ts b/integration-tests/aws-sdk/initiateAuth.test.ts index 09a3dec6..c8bc445f 100644 --- a/integration-tests/aws-sdk/initiateAuth.test.ts +++ b/integration-tests/aws-sdk/initiateAuth.test.ts @@ -143,7 +143,7 @@ describe( aud: upc.UserPoolClient?.ClientId, auth_time: expect.any(Number), email: "example@example.com", - email_verified: true, + email_verified: false, event_id: expect.stringMatching(UUID), exp: expect.any(Number), iat: expect.any(Number), @@ -245,7 +245,7 @@ describe( aud: upc.UserPoolClient?.ClientId, auth_time: expect.any(Number), email: "example@example.com", - email_verified: true, + email_verified: false, event_id: expect.stringMatching(UUID), exp: expect.any(Number), iat: expect.any(Number), diff --git a/src/services/lambda.ts b/src/services/lambda.ts index 3ff6f8a3..025b0cc1 100644 --- a/src/services/lambda.ts +++ b/src/services/lambda.ts @@ -113,11 +113,13 @@ interface PostAuthenticationEvent extends EventCommonParameters { triggerSource: "PostAuthentication_Authentication"; } -interface PostConfirmationEvent extends EventCommonParameters { +interface PostConfirmationEvent + extends Omit { triggerSource: | "PostConfirmation_ConfirmSignUp" | "PostConfirmation_ConfirmForgotPassword"; clientMetadata: Record | undefined; + clientId: string | null; } export interface FunctionConfig { @@ -257,7 +259,9 @@ export class LambdaService implements Lambda { const version = "0"; // TODO: how do we know what this is? const callerContext = { awsSdkVersion, - clientId: event.clientId, + + // client id can be null, even though the types don't allow it + clientId: event.clientId as string, }; const region = "local"; // TODO: pull from above, diff --git a/src/services/triggers/postConfirmation.test.ts b/src/services/triggers/postConfirmation.test.ts index 201aa7ff..cb4d8cd7 100644 --- a/src/services/triggers/postConfirmation.test.ts +++ b/src/services/triggers/postConfirmation.test.ts @@ -1,22 +1,16 @@ -import { newMockCognitoService } from "../../__tests__/mockCognitoService"; import { newMockLambda } from "../../__tests__/mockLambda"; -import { newMockUserPoolService } from "../../__tests__/mockUserPoolService"; import { TestContext } from "../../__tests__/testContext"; import { Lambda } from "../lambda"; -import { UserPoolService } from "../userPoolService"; import { PostConfirmation, PostConfirmationTrigger } from "./postConfirmation"; describe("PostConfirmation trigger", () => { let mockLambda: jest.Mocked; - let mockUserPoolService: jest.Mocked; let postConfirmation: PostConfirmationTrigger; beforeEach(() => { mockLambda = newMockLambda(); - mockUserPoolService = newMockUserPoolService(); postConfirmation = PostConfirmation({ lambda: mockLambda, - cognitoClient: newMockCognitoService(mockUserPoolService), }); }); diff --git a/src/services/triggers/postConfirmation.ts b/src/services/triggers/postConfirmation.ts index 162372d4..8ff862bf 100644 --- a/src/services/triggers/postConfirmation.ts +++ b/src/services/triggers/postConfirmation.ts @@ -1,8 +1,6 @@ import { AttributeListType } from "aws-sdk/clients/cognitoidentityserviceprovider"; -import { CognitoService } from "../cognitoService"; import { Lambda } from "../lambda"; import { attributesToRecord } from "../userPoolService"; -import { ResourceNotFoundError } from "../../errors"; import { Trigger } from "./trigger"; export type PostConfirmationTrigger = Trigger< @@ -10,7 +8,7 @@ export type PostConfirmationTrigger = Trigger< source: | "PostConfirmation_ConfirmSignUp" | "PostConfirmation_ConfirmForgotPassword"; - clientId: string; + clientId: string | null; /** * One or more key-value pairs that you can provide as custom input to the Lambda function that you specify for the @@ -29,23 +27,14 @@ export type PostConfirmationTrigger = Trigger< interface PostConfirmationServices { lambda: Lambda; - cognitoClient: CognitoService; } export const PostConfirmation = - ({ - lambda, - cognitoClient, - }: PostConfirmationServices): PostConfirmationTrigger => + ({ lambda }: PostConfirmationServices): PostConfirmationTrigger => async ( ctx, { clientId, clientMetadata, source, userAttributes, username, userPoolId } ) => { - const userPool = await cognitoClient.getUserPoolForClientId(ctx, clientId); - if (!userPool) { - throw new ResourceNotFoundError(); - } - try { await lambda.invoke(ctx, "PostConfirmation", { clientId, diff --git a/src/services/triggers/triggers.ts b/src/services/triggers/triggers.ts index b4d82d83..ca5d38bf 100644 --- a/src/services/triggers/triggers.ts +++ b/src/services/triggers/triggers.ts @@ -51,7 +51,7 @@ export class TriggersService implements Triggers { this.customMessage = CustomMessage({ lambda, cognitoClient }); this.postAuthentication = PostAuthentication({ lambda }); - this.postConfirmation = PostConfirmation({ lambda, cognitoClient }); + this.postConfirmation = PostConfirmation({ lambda }); this.preSignUp = PreSignUp({ lambda }); this.preTokenGeneration = PreTokenGeneration({ lambda }); this.userMigration = UserMigration({ clock, lambda, cognitoClient }); diff --git a/src/services/userPoolService.ts b/src/services/userPoolService.ts index 9308758a..3463e0cc 100644 --- a/src/services/userPoolService.ts +++ b/src/services/userPoolService.ts @@ -1,5 +1,6 @@ import { AttributeListType, + AttributeType, MFAOptionListType, UserPoolType, UserStatusType, @@ -15,6 +16,10 @@ export interface MFAOption { AttributeName: "phone_number"; } +export const attribute = ( + name: string, + value: string | undefined +): AttributeType => ({ Name: name, Value: value }); export const attributesIncludeMatch = ( attributeName: string, attributeValue: string, @@ -42,6 +47,22 @@ export const attributesFromRecord = ( attributes: Record ): AttributeListType => Object.entries(attributes).map(([Name, Value]) => ({ Name, Value })); +export const attributesAppend = ( + attributes: AttributeListType | undefined, + ...toAppend: AttributeListType +): AttributeListType => { + const attributeSet = attributesToRecord(attributes); + + for (const attr of toAppend) { + if (attr.Value) { + attributeSet[attr.Name] = attr.Value; + } else { + delete attributeSet[attr.Name]; + } + } + + return attributesFromRecord(attributeSet); +}; export const customAttributes = ( attributes: AttributeListType | undefined diff --git a/src/targets/adminConfirmSignUp.test.ts b/src/targets/adminConfirmSignUp.test.ts new file mode 100644 index 00000000..7db2297f --- /dev/null +++ b/src/targets/adminConfirmSignUp.test.ts @@ -0,0 +1,121 @@ +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockTriggers } from "../__tests__/mockTriggers"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import * as TDB from "../__tests__/testDataBuilder"; +import { NotAuthorizedError } from "../errors"; +import { Triggers, UserPoolService } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; +import { + AdminConfirmSignUp, + AdminConfirmSignUpTarget, +} from "./adminConfirmSignUp"; + +const currentDate = new Date(); + +const clock = new ClockFake(currentDate); + +describe("AdminConfirmSignUp target", () => { + let adminConfirmSignUp: AdminConfirmSignUpTarget; + let mockUserPoolService: jest.Mocked; + let mockTriggers: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + mockTriggers = newMockTriggers(); + adminConfirmSignUp = AdminConfirmSignUp({ + clock, + cognito: newMockCognitoService(mockUserPoolService), + triggers: mockTriggers, + }); + }); + + it("throws if the user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + adminConfirmSignUp(TestContext, { + ClientMetadata: { + client: "metadata", + }, + Username: "invalid user", + UserPoolId: "test", + }) + ).rejects.toEqual(new NotAuthorizedError()); + }); + + it("updates the user's status", async () => { + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminConfirmSignUp(TestContext, { + ClientMetadata: { + client: "metadata", + }, + Username: user.Username, + UserPoolId: "test", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + UserLastModifiedDate: currentDate, + UserStatus: "CONFIRMED", + }); + }); + + describe("when PostConfirmation trigger is enabled", () => { + it("invokes the trigger", async () => { + mockTriggers.enabled.mockImplementation( + (trigger) => trigger === "PostConfirmation" + ); + + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminConfirmSignUp(TestContext, { + ClientMetadata: { + client: "metadata", + }, + Username: user.Username, + UserPoolId: "test", + }); + + expect(mockTriggers.postConfirmation).toHaveBeenCalledWith(TestContext, { + clientId: null, + clientMetadata: { + client: "metadata", + }, + source: "PostConfirmation_ConfirmSignUp", + userAttributes: attributesAppend( + user.Attributes, + attribute("cognito:user_status", "CONFIRMED") + ), + userPoolId: "test", + username: user.Username, + }); + }); + }); + + describe("when PostConfirmation trigger is not enabled", () => { + it("invokes the trigger", async () => { + mockTriggers.enabled.mockReturnValue(false); + + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminConfirmSignUp(TestContext, { + ClientMetadata: { + client: "metadata", + }, + Username: user.Username, + UserPoolId: "test", + }); + + expect(mockTriggers.postConfirmation).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/targets/adminConfirmSignUp.ts b/src/targets/adminConfirmSignUp.ts index 71bbfcce..be8750f6 100644 --- a/src/targets/adminConfirmSignUp.ts +++ b/src/targets/adminConfirmSignUp.ts @@ -4,6 +4,7 @@ import { } from "aws-sdk/clients/cognitoidentityserviceprovider"; import { Services } from "../services"; import { NotAuthorizedError } from "../errors"; +import { attribute, attributesAppend } from "../services/userPoolService"; import { Target } from "./router"; export type AdminConfirmSignUpTarget = Target< @@ -11,8 +12,17 @@ export type AdminConfirmSignUpTarget = Target< AdminConfirmSignUpResponse >; +type AdminConfirmSignUpServices = Pick< + Services, + "clock" | "cognito" | "triggers" +>; + export const AdminConfirmSignUp = - ({ cognito }: Services): AdminConfirmSignUpTarget => + ({ + clock, + cognito, + triggers, + }: AdminConfirmSignUpServices): AdminConfirmSignUpTarget => async (ctx, req) => { const userPool = await cognito.getUserPool(ctx, req.UserPoolId); const user = await userPool.getUserByUsername(ctx, req.Username); @@ -20,20 +30,30 @@ export const AdminConfirmSignUp = throw new NotAuthorizedError(); } - // TODO: call PostConfirmation lambda - - await userPool.saveUser(ctx, { + const updatedUser = { ...user, + UserLastModifiedDate: clock.get(), UserStatus: "CONFIRMED", - // TODO: Remove existing email_verified attribute? - Attributes: [ - ...(user.Attributes || []), - { - Name: "email_verified", - Value: "true", - }, - ], - }); + }; + + await userPool.saveUser(ctx, updatedUser); + + if (triggers.enabled("PostConfirmation")) { + await triggers.postConfirmation(ctx, { + source: "PostConfirmation_ConfirmSignUp", + clientId: null, + clientMetadata: req.ClientMetadata, + username: updatedUser.Username, + userPoolId: req.UserPoolId, + + // not sure whether this is a one off for PostConfirmation, or whether we should be adding cognito:user_status + // into every place we send attributes to lambdas + userAttributes: attributesAppend( + updatedUser.Attributes, + attribute("cognito:user_status", updatedUser.UserStatus) + ), + }); + } return {}; }; diff --git a/src/targets/confirmForgotPassword.test.ts b/src/targets/confirmForgotPassword.test.ts index 131723fc..6000212a 100644 --- a/src/targets/confirmForgotPassword.test.ts +++ b/src/targets/confirmForgotPassword.test.ts @@ -5,6 +5,7 @@ import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { TestContext } from "../__tests__/testContext"; import { CodeMismatchError, UserNotFoundError } from "../errors"; import { Triggers, UserPoolService } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; import { ConfirmForgotPassword, ConfirmForgotPasswordTarget, @@ -120,7 +121,10 @@ describe("ConfirmForgotPassword target", () => { client: "metadata", }, source: "PostConfirmation_ConfirmForgotPassword", - userAttributes: user.Attributes, + userAttributes: attributesAppend( + user.Attributes, + attribute("cognito:user_status", "CONFIRMED") + ), userPoolId: "test", username: user.Username, } diff --git a/src/targets/confirmForgotPassword.ts b/src/targets/confirmForgotPassword.ts index d68a968e..3f018de9 100644 --- a/src/targets/confirmForgotPassword.ts +++ b/src/targets/confirmForgotPassword.ts @@ -4,6 +4,7 @@ import { } from "aws-sdk/clients/cognitoidentityserviceprovider"; import { CodeMismatchError, UserNotFoundError } from "../errors"; import { Services } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; import { Target } from "./router"; export type ConfirmForgotPasswordTarget = Target< @@ -33,22 +34,30 @@ export const ConfirmForgotPassword = throw new CodeMismatchError(); } - await userPool.saveUser(ctx, { + const updatedUser = { ...user, UserLastModifiedDate: clock.get(), UserStatus: "CONFIRMED", ConfirmationCode: undefined, Password: req.Password, - }); + }; + + await userPool.saveUser(ctx, updatedUser); if (triggers.enabled("PostConfirmation")) { await triggers.postConfirmation(ctx, { clientId: req.ClientId, clientMetadata: req.ClientMetadata, source: "PostConfirmation_ConfirmForgotPassword", - userAttributes: user.Attributes, - username: user.Username, + username: updatedUser.Username, userPoolId: userPool.config.Id, + + // not sure whether this is a one off for PostConfirmation, or whether we should be adding cognito:user_status + // into every place we send attributes to lambdas + userAttributes: attributesAppend( + updatedUser.Attributes, + attribute("cognito:user_status", updatedUser.UserStatus) + ), }); } diff --git a/src/targets/confirmSignUp.test.ts b/src/targets/confirmSignUp.test.ts index 2aee16bf..602ffe10 100644 --- a/src/targets/confirmSignUp.test.ts +++ b/src/targets/confirmSignUp.test.ts @@ -6,6 +6,7 @@ import { TestContext } from "../__tests__/testContext"; import * as TDB from "../__tests__/testDataBuilder"; import { CodeMismatchError, NotAuthorizedError } from "../errors"; import { Triggers, UserPoolService } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; import { ConfirmSignUp, ConfirmSignUpTarget } from "./confirmSignUp"; const originalDate = new Date(); @@ -113,7 +114,10 @@ describe("ConfirmSignUp target", () => { client: "metadata", }, source: "PostConfirmation_ConfirmSignUp", - userAttributes: user.Attributes, + userAttributes: attributesAppend( + user.Attributes, + attribute("cognito:user_status", "CONFIRMED") + ), userPoolId: "test", username: user.Username, } diff --git a/src/targets/confirmSignUp.ts b/src/targets/confirmSignUp.ts index b6bc3598..f13de2d6 100644 --- a/src/targets/confirmSignUp.ts +++ b/src/targets/confirmSignUp.ts @@ -4,6 +4,7 @@ import { } from "aws-sdk/clients/cognitoidentityserviceprovider"; import { CodeMismatchError, NotAuthorizedError } from "../errors"; import { Services } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; import { Target } from "./router"; export type ConfirmSignUpTarget = Target< @@ -28,21 +29,29 @@ export const ConfirmSignUp = throw new CodeMismatchError(); } - await userPool.saveUser(ctx, { + const updatedUser = { ...user, UserStatus: "CONFIRMED", ConfirmationCode: undefined, UserLastModifiedDate: clock.get(), - }); + }; + + await userPool.saveUser(ctx, updatedUser); if (triggers.enabled("PostConfirmation")) { await triggers.postConfirmation(ctx, { clientId: req.ClientId, clientMetadata: req.ClientMetadata, source: "PostConfirmation_ConfirmSignUp", - userAttributes: user.Attributes, - username: user.Username, + username: updatedUser.Username, userPoolId: userPool.config.Id, + + // not sure whether this is a one off for PostConfirmation, or whether we should be adding cognito:user_status + // into every place we send attributes to lambdas + userAttributes: attributesAppend( + updatedUser.Attributes, + attribute("cognito:user_status", updatedUser.UserStatus) + ), }); } diff --git a/src/targets/signUp.test.ts b/src/targets/signUp.test.ts index 7f37d9f7..8fd8633e 100644 --- a/src/targets/signUp.test.ts +++ b/src/targets/signUp.test.ts @@ -214,6 +214,7 @@ describe("SignUp target", () => { userAttributes: [ { Name: "sub", Value: expect.stringMatching(UUID) }, { Name: "email", Value: "example@example.com" }, + { Name: "cognito:user_status", Value: "CONFIRMED" }, ], userPoolId: "test", username: "user-supplied", diff --git a/src/targets/signUp.ts b/src/targets/signUp.ts index f580e47a..b5bc81ee 100644 --- a/src/targets/signUp.ts +++ b/src/targets/signUp.ts @@ -14,6 +14,8 @@ import { } from "../services"; import { DeliveryDetails } from "../services/messageDelivery/messageDelivery"; import { + attribute, + attributesAppend, attributesInclude, attributeValue, User, @@ -105,7 +107,7 @@ export const SignUp = }: SignUpServices): SignUpTarget => async (ctx, req) => { // TODO: This should behave differently depending on if PreventUserExistenceErrors - // is enabled on the user pool. This will be the default after Feb 2020. + // is enabled on the updatedUser pool. This will be the default after Feb 2020. // See: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-managing-errors.html const userPool = await cognito.getUserPoolForClientId(ctx, req.ClientId); const existingUser = await userPool.getUserByUsername(ctx, req.Username); @@ -142,15 +144,16 @@ export const SignUp = } const now = clock.get(); - const user: User = { + + const updatedUser: User = { Attributes: attributes, Enabled: true, Password: req.Password, + RefreshTokens: [], UserCreateDate: now, UserLastModifiedDate: now, Username: req.Username, UserStatus: userStatus, - RefreshTokens: [], }; const code = otp(); @@ -159,7 +162,7 @@ export const SignUp = ctx, code, req.ClientId, - user, + updatedUser, userPool, messages, messageDelivery, @@ -167,27 +170,33 @@ export const SignUp = ); await userPool.saveUser(ctx, { - ...user, + ...updatedUser, ConfirmationCode: code, }); if ( - user.UserStatus === "CONFIRMED" && + updatedUser.UserStatus === "CONFIRMED" && triggers.enabled("PostConfirmation") ) { await triggers.postConfirmation(ctx, { clientId: req.ClientId, clientMetadata: req.ClientMetadata, source: "PostConfirmation_ConfirmSignUp", - userAttributes: user.Attributes, - username: user.Username, + username: updatedUser.Username, userPoolId: userPool.config.Id, + + // not sure whether this is a one off for PostConfirmation, or whether we should be adding cognito:user_status + // into every place we send attributes to lambdas + userAttributes: attributesAppend( + updatedUser.Attributes, + attribute("cognito:user_status", updatedUser.UserStatus) + ), }); } return { CodeDeliveryDetails: deliveryDetails ?? undefined, - UserConfirmed: user.UserStatus === "CONFIRMED", - UserSub: attributeValue("sub", attributes) as string, + UserConfirmed: updatedUser.UserStatus === "CONFIRMED", + UserSub: attributeValue("sub", updatedUser.Attributes) as string, }; };