diff --git a/README.md b/README.md index 69f2771d..e68d8112 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | AdminSetUserSettings | ❌ | | AdminUpdateAuthEventFeedback | ❌ | | AdminUpdateDeviceStatus | ❌ | -| AdminUpdateUserAttributes | 🕒 (partial support) | +| AdminUpdateUserAttributes | ✅ | | AdminUserGlobalSignOut | ❌ | | AssociateSoftwareToken | ❌ | | ChangePassword | 🕒 (partial support) | @@ -456,4 +456,4 @@ This will configure NodeJS to start the inspector on port `9230`. Open Chrome and navigate to `chrome://inspect`. Click the `Open dedicated DevTools for Node` link which will open a new DevTools window. You can then open the Sources tab and browse to a Cognito Local file, or press `Cmd+P` or `Ctrl+P` to open the file navigator and open `src/bin/start.ts` or a target you want to debug then place a breakpoint and run your -code that uses Cognito Local or a CLI command. \ No newline at end of file +code that uses Cognito Local or a CLI command. diff --git a/integration-tests/aws-sdk/adminConfirmSignUp.test.ts b/integration-tests/aws-sdk/adminConfirmSignUp.test.ts index 7a1f0c12..6d847f5d 100644 --- a/integration-tests/aws-sdk/adminConfirmSignUp.test.ts +++ b/integration-tests/aws-sdk/adminConfirmSignUp.test.ts @@ -3,7 +3,7 @@ import { withCognitoSdk } from "./setup"; describe( "CognitoIdentityServiceProvider.adminConfirmSignUp", withCognitoSdk((Cognito) => { - it("creates a user with only the required parameters", async () => { + it("confirms a user", async () => { const client = Cognito(); await client diff --git a/integration-tests/aws-sdk/adminUpdateUserAttributes.test.ts b/integration-tests/aws-sdk/adminUpdateUserAttributes.test.ts new file mode 100644 index 00000000..2064b0c1 --- /dev/null +++ b/integration-tests/aws-sdk/adminUpdateUserAttributes.test.ts @@ -0,0 +1,57 @@ +import { UUID } from "../../src/__tests__/patterns"; +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.adminUpdateUserAttributes", + withCognitoSdk((Cognito) => { + it("updates a user's attributes", async () => { + const client = Cognito(); + + await client + .adminCreateUser({ + UserAttributes: [ + { Name: "email", Value: "example@example.com" }, + { Name: "phone_number", Value: "0400000000" }, + ], + Username: "abc", + UserPoolId: "test", + }) + .promise(); + + let user = await client + .adminGetUser({ + UserPoolId: "test", + Username: "abc", + }) + .promise(); + + expect(user.UserAttributes).toEqual([ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example@example.com" }, + { Name: "phone_number", Value: "0400000000" }, + ]); + + await client + .adminUpdateUserAttributes({ + UserPoolId: "test", + Username: "abc", + UserAttributes: [{ Name: "email", Value: "example2@example.com" }], + }) + .promise(); + + user = await client + .adminGetUser({ + UserPoolId: "test", + Username: "abc", + }) + .promise(); + + expect(user.UserAttributes).toEqual([ + { Name: "sub", Value: expect.stringMatching(UUID) }, + { Name: "email", Value: "example2@example.com" }, + { Name: "phone_number", Value: "0400000000" }, + { Name: "email_verified", Value: "false" }, + ]); + }); + }) +); diff --git a/src/__tests__/mockUserPoolService.ts b/src/__tests__/mockUserPoolService.ts index 5cfd2e18..776650c3 100644 --- a/src/__tests__/mockUserPoolService.ts +++ b/src/__tests__/mockUserPoolService.ts @@ -1,10 +1,12 @@ import { UserPoolService } from "../services"; -import { UserPoolServiceFactory } from "../services/userPoolService"; +import { UserPool, UserPoolServiceFactory } from "../services/userPoolService"; -export const newMockUserPoolService = (): jest.Mocked => ({ - config: { +export const newMockUserPoolService = ( + config: UserPool = { Id: "test", - }, + } +): jest.Mocked => ({ + config, createAppClient: jest.fn(), deleteUser: jest.fn(), getUserByRefreshToken: jest.fn(), diff --git a/src/services/lambda.ts b/src/services/lambda.ts index 025b0cc1..9fbade63 100644 --- a/src/services/lambda.ts +++ b/src/services/lambda.ts @@ -41,7 +41,8 @@ interface EventCommonParameters { userPoolId: string; } -interface CustomMessageEvent extends EventCommonParameters { +interface CustomMessageEvent extends Omit { + clientId: string | null; clientMetadata: Record | undefined; codeParameter: string; triggerSource: diff --git a/src/services/messageDelivery/deliveryMethod.ts b/src/services/messageDelivery/deliveryMethod.ts new file mode 100644 index 00000000..2487c8fa --- /dev/null +++ b/src/services/messageDelivery/deliveryMethod.ts @@ -0,0 +1,32 @@ +import { VerifiedAttributesListType } from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { attributeValue, User } from "../userPoolService"; +import { DeliveryDetails } from "./messageDelivery"; + +export const selectAppropriateDeliveryMethod = ( + desiredDeliveryMediums: VerifiedAttributesListType, + user: User +): DeliveryDetails | null => { + if (desiredDeliveryMediums.includes("phone_number")) { + const phoneNumber = attributeValue("phone_number", user.Attributes); + if (phoneNumber) { + return { + AttributeName: "phone_number", + DeliveryMedium: "SMS", + Destination: phoneNumber, + }; + } + } + + if (desiredDeliveryMediums.includes("email")) { + const email = attributeValue("email", user.Attributes); + if (email) { + return { + AttributeName: "email", + DeliveryMedium: "EMAIL", + Destination: email, + }; + } + } + + return null; +}; diff --git a/src/services/messages.test.ts b/src/services/messages.test.ts index 235db92c..df57c76a 100644 --- a/src/services/messages.test.ts +++ b/src/services/messages.test.ts @@ -22,7 +22,15 @@ describe("messages service", () => { mockMessageDelivery = newMockMessageDelivery(); }); - describe("authentication", () => { + describe.each([ + "AdminCreateUser", + "Authentication", + "ForgotPassword", + "ResendCode", + "SignUp", + "UpdateUserAttribute", + "VerifyUserAttribute", + ] as const)("%s", (source) => { describe("CustomMessage lambda is configured", () => { describe("lambda returns a custom message", () => { it("delivers the customised message", async () => { @@ -41,7 +49,7 @@ describe("messages service", () => { ); await messages.deliver( TestContext, - "Authentication", + source, "clientId", "userPoolId", user, @@ -58,7 +66,7 @@ describe("messages service", () => { client: "metadata", }, code: "1234", - source: "CustomMessage_Authentication", + source: `CustomMessage_${source}`, userAttributes: user.Attributes, userPoolId: "userPoolId", username: user.Username, @@ -91,7 +99,7 @@ describe("messages service", () => { ); await messages.deliver( TestContext, - "Authentication", + source, "clientId", "userPoolId", user, @@ -108,7 +116,7 @@ describe("messages service", () => { client: "metadata", }, code: "1234", - source: "CustomMessage_Authentication", + source: `CustomMessage_${source}`, userAttributes: user.Attributes, userPoolId: "userPoolId", username: user.Username, @@ -133,7 +141,7 @@ describe("messages service", () => { const messages = new MessagesService(mockTriggers, mockMessageDelivery); await messages.deliver( TestContext, - "Authentication", + source, "clientId", "userPoolId", user, @@ -156,274 +164,4 @@ describe("messages service", () => { }); }); }); - - describe("forgotPassword", () => { - describe("CustomMessage lambda is configured", () => { - describe("lambda returns a custom message", () => { - it("delivers the customised message", async () => { - mockTriggers.enabled.mockImplementation((name) => { - return name === "CustomMessage"; - }); - mockTriggers.customMessage.mockResolvedValue({ - smsMessage: "sms", - emailSubject: "email subject", - emailMessage: "email", - }); - - const messages = new MessagesService( - mockTriggers, - mockMessageDelivery - ); - await messages.deliver( - TestContext, - "ForgotPassword", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).toHaveBeenCalledWith(TestContext, { - clientId: "clientId", - clientMetadata: { - client: "metadata", - }, - code: "1234", - source: "CustomMessage_ForgotPassword", - userAttributes: user.Attributes, - userPoolId: "userPoolId", - username: user.Username, - }); - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - emailMessage: "email", - emailSubject: "email subject", - smsMessage: "sms", - } - ); - }); - }); - - describe("lambda does not return a custom message", () => { - it("delivers just the code", async () => { - mockTriggers.enabled.mockImplementation((name) => { - return name === "CustomMessage"; - }); - mockTriggers.customMessage.mockResolvedValue(null); - - const messages = new MessagesService( - mockTriggers, - mockMessageDelivery - ); - await messages.deliver( - TestContext, - "ForgotPassword", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).toHaveBeenCalledWith(TestContext, { - clientId: "clientId", - clientMetadata: { - client: "metadata", - }, - code: "1234", - source: "CustomMessage_ForgotPassword", - userAttributes: user.Attributes, - userPoolId: "userPoolId", - username: user.Username, - }); - - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - } - ); - }); - }); - }); - - describe("CustomMessage lambda is not configured", () => { - it("delivers just the code", async () => { - mockTriggers.enabled.mockReturnValue(false); - - const messages = new MessagesService(mockTriggers, mockMessageDelivery); - const message = await messages.deliver( - TestContext, - "ForgotPassword", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).not.toHaveBeenCalled(); - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - } - ); - }); - }); - }); - - describe("signUp", () => { - describe("CustomMessage lambda is configured", () => { - describe("lambda returns a custom message", () => { - it("delivers the customised message", async () => { - mockTriggers.enabled.mockImplementation((name) => { - return name === "CustomMessage"; - }); - mockTriggers.customMessage.mockResolvedValue({ - smsMessage: "sms", - emailSubject: "email subject", - emailMessage: "email", - }); - - const messages = new MessagesService( - mockTriggers, - mockMessageDelivery - ); - await messages.deliver( - TestContext, - "SignUp", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).toHaveBeenCalledWith(TestContext, { - clientId: "clientId", - clientMetadata: { - client: "metadata", - }, - code: "1234", - source: "CustomMessage_SignUp", - userAttributes: user.Attributes, - userPoolId: "userPoolId", - username: user.Username, - }); - - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - emailMessage: "email", - emailSubject: "email subject", - smsMessage: "sms", - } - ); - }); - }); - - describe("lambda does not return a custom message", () => { - it("delivers just the code", async () => { - mockTriggers.enabled.mockImplementation((name) => { - return name === "CustomMessage"; - }); - mockTriggers.customMessage.mockResolvedValue(null); - - const messages = new MessagesService( - mockTriggers, - mockMessageDelivery - ); - await messages.deliver( - TestContext, - "SignUp", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).toHaveBeenCalledWith(TestContext, { - clientId: "clientId", - clientMetadata: { - client: "metadata", - }, - code: "1234", - source: "CustomMessage_SignUp", - userAttributes: user.Attributes, - userPoolId: "userPoolId", - username: user.Username, - }); - - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - } - ); - }); - }); - }); - - describe("CustomMessage lambda is not configured", () => { - it("delivers just the code", async () => { - mockTriggers.enabled.mockReturnValue(false); - - const messages = new MessagesService(mockTriggers, mockMessageDelivery); - await messages.deliver( - TestContext, - "SignUp", - "clientId", - "userPoolId", - user, - "1234", - { - client: "metadata", - }, - deliveryDetails - ); - - expect(mockTriggers.customMessage).not.toHaveBeenCalled(); - - expect(mockMessageDelivery.deliver).toHaveBeenCalledWith( - TestContext, - user, - deliveryDetails, - { - __code: "1234", - } - ); - }); - }); - }); }); diff --git a/src/services/triggers/customMessage.test.ts b/src/services/triggers/customMessage.test.ts index 08899419..0fe6f14b 100644 --- a/src/services/triggers/customMessage.test.ts +++ b/src/services/triggers/customMessage.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 { CustomMessage, CustomMessageTrigger } from "./customMessage"; describe("CustomMessage trigger", () => { let mockLambda: jest.Mocked; - let mockUserPoolService: jest.Mocked; let customMessage: CustomMessageTrigger; beforeEach(() => { mockLambda = newMockLambda(); - mockUserPoolService = newMockUserPoolService(); customMessage = CustomMessage({ lambda: mockLambda, - cognitoClient: newMockCognitoService(mockUserPoolService), }); }); diff --git a/src/services/triggers/customMessage.ts b/src/services/triggers/customMessage.ts index c2b1d5cd..c6ba0d62 100644 --- a/src/services/triggers/customMessage.ts +++ b/src/services/triggers/customMessage.ts @@ -1,8 +1,6 @@ import { AttributeListType } from "aws-sdk/clients/cognitoidentityserviceprovider"; -import { CognitoService } from "../index"; import { CustomMessageTriggerResponse, Lambda } from "../lambda"; import { attributesToRecord } from "../userPoolService"; -import { ResourceNotFoundError } from "../../errors"; import { Trigger } from "./trigger"; const AWS_USERNAME_PARAMETER = "{username}"; @@ -19,7 +17,7 @@ export type CustomMessageTrigger = Trigger< | "CustomMessage_VerifyUserAttribute" | "CustomMessage_Authentication"; userPoolId: string; - clientId: string; + clientId: string | null; username: string; code: string; userAttributes: AttributeListType; @@ -47,11 +45,10 @@ export type CustomMessageTrigger = Trigger< interface CustomMessageServices { lambda: Lambda; - cognitoClient: CognitoService; } export const CustomMessage = - ({ lambda, cognitoClient }: CustomMessageServices): CustomMessageTrigger => + ({ lambda }: CustomMessageServices): CustomMessageTrigger => async ( ctx, { @@ -64,11 +61,6 @@ export const CustomMessage = userPoolId, } ) => { - const userPool = await cognitoClient.getUserPoolForClientId(ctx, clientId); - if (!userPool) { - throw new ResourceNotFoundError(); - } - try { const response = await lambda.invoke(ctx, "CustomMessage", { clientId, diff --git a/src/services/triggers/triggers.ts b/src/services/triggers/triggers.ts index ca5d38bf..4872da25 100644 --- a/src/services/triggers/triggers.ts +++ b/src/services/triggers/triggers.ts @@ -49,7 +49,7 @@ export class TriggersService implements Triggers { ) { this.lambda = lambda; - this.customMessage = CustomMessage({ lambda, cognitoClient }); + this.customMessage = CustomMessage({ lambda }); this.postAuthentication = PostAuthentication({ lambda }); this.postConfirmation = PostConfirmation({ lambda }); this.preSignUp = PreSignUp({ lambda }); diff --git a/src/targets/adminCreateUser.test.ts b/src/targets/adminCreateUser.test.ts index 7579e4b6..7b80304b 100644 --- a/src/targets/adminCreateUser.test.ts +++ b/src/targets/adminCreateUser.test.ts @@ -1,6 +1,5 @@ import { ClockFake } from "../__tests__/clockFake"; import { newMockCognitoService } from "../__tests__/mockCognitoService"; -import { newMockMessageDelivery } from "../__tests__/mockMessageDelivery"; import { newMockMessages } from "../__tests__/mockMessages"; import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { UUID } from "../__tests__/patterns"; diff --git a/src/targets/adminUpdateUserAttributes.test.ts b/src/targets/adminUpdateUserAttributes.test.ts new file mode 100644 index 00000000..8a38e488 --- /dev/null +++ b/src/targets/adminUpdateUserAttributes.test.ts @@ -0,0 +1,307 @@ +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockMessages } from "../__tests__/mockMessages"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import { Messages, UserPoolService } from "../services"; +import { + attribute, + attributesAppend, + attributeValue, +} from "../services/userPoolService"; +import { + AdminUpdateUserAttributes, + AdminUpdateUserAttributesTarget, +} from "./adminUpdateUserAttributes"; +import * as TDB from "../__tests__/testDataBuilder"; + +describe("AdminUpdateUserAttributes target", () => { + let adminUpdateUserAttributes: AdminUpdateUserAttributesTarget; + let mockUserPoolService: jest.Mocked; + let clock: ClockFake; + let mockMessages: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + clock = new ClockFake(new Date()); + mockMessages = newMockMessages(); + adminUpdateUserAttributes = AdminUpdateUserAttributes({ + clock, + cognito: newMockCognitoService(mockUserPoolService), + messages: mockMessages, + otp: () => "1234", + }); + }); + + it("throws if the user doesn't exist", async () => { + await expect( + adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [{ Name: "custom:example", Value: "1" }], + Username: "abc", + }) + ).rejects.toEqual(new NotAuthorizedError()); + }); + + it("saves the updated attributes on the user", async () => { + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + mockUserPoolService.config.SchemaAttributes = [ + { + Name: "custom:example", + Mutable: true, + }, + ]; + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [attribute("custom:example", "1")], + Username: "abc", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute("custom:example", "1") + ), + UserLastModifiedDate: clock.get(), + }); + }); + + describe.each` + desc | attribute | expectedError + ${"an attribute not in the schema"} | ${"custom:missing"} | ${"user.custom:missing: Attribute does not exist in the schema."} + ${"an attribute which isn't mutable in the schema"} | ${"custom:immutable"} | ${"user.custom:immutable: Attribute cannot be updated. (changing an immutable attribute)"} + ${"email_verified without an email attribute"} | ${"email_verified"} | ${"Email is required to verify/un-verify an email"} + ${"phone_number_verified without an phone_number attribute"} | ${"phone_number_verified"} | ${"Phone Number is required to verify/un-verify a phone number"} + `("req.UserAttributes contains $desc", ({ attribute, expectedError }) => { + beforeEach(() => { + mockUserPoolService.config.SchemaAttributes = [ + { + Name: "email_verified", + Mutable: true, + }, + { + Name: "phone_number_verified", + Mutable: true, + }, + { + Name: "custom:immutable", + Mutable: false, + }, + ]; + }); + + it("throws an invalid parameter error", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(TDB.user()); + + await expect( + adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [{ Name: attribute, Value: "1" }], + Username: "abc", + }) + ).rejects.toEqual(new InvalidParameterError(expectedError)); + }); + }); + + describe.each(["email", "phone_number"])( + "%s is in req.UserAttributes without the relevant verified attribute", + (attr) => { + it(`sets the ${attr}_verified attribute to false`, async () => { + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [attribute(attr, "new value")], + Username: "abc", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute(attr, "new value"), + attribute(`${attr}_verified`, "false") + ), + UserLastModifiedDate: clock.get(), + }); + }); + } + ); + + describe("user pool has auto verified attributes enabled", () => { + beforeEach(() => { + mockUserPoolService.config.AutoVerifiedAttributes = ["email"]; + }); + + describe.each` + attributes + ${["email"]} + ${["phone_number"]} + ${["email", "phone_number"]} + `("when $attributes is unverified", ({ attributes }) => { + describe("the verification status was not affected by the update", () => { + it("does not deliver a OTP code to the user", async () => { + const user = TDB.user({ + Attributes: attributes.map((attr: string) => + attribute(`${attr}_verified`, "false") + ), + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + mockUserPoolService.config.SchemaAttributes = [ + { Name: "example", Mutable: true }, + ]; + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [attribute("example", "1")], + Username: "abc", + }); + + expect(mockMessages.deliver).not.toHaveBeenCalled(); + }); + }); + + describe("the verification status changed because of the update", () => { + it("throws if the user doesn't have a valid way to contact them", async () => { + const user = TDB.user({ + Attributes: [], + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await expect( + adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + Username: "abc", + }) + ).rejects.toEqual( + new InvalidParameterError( + "User has no attribute matching desired auto verified attributes" + ) + ); + }); + + it("delivers a OTP code to the user", async () => { + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + Username: "abc", + }); + + expect(mockMessages.deliver).toHaveBeenCalledWith( + TestContext, + "UpdateUserAttribute", + null, + "test", + user, + "1234", + { client: "metadata" }, + { + AttributeName: "email", + DeliveryMedium: "EMAIL", + Destination: attributeValue("email", user.Attributes), + } + ); + }); + }); + }); + }); + + describe("user pool does not have auto verified attributes", () => { + beforeEach(() => { + mockUserPoolService.config.AutoVerifiedAttributes = []; + }); + + describe.each` + attributes + ${["email"]} + ${["phone_number"]} + ${["email", "phone_number"]} + `("when $attributes is unverified", ({ attributes }) => { + describe("the verification status was not affected by the update", () => { + it("does not deliver a OTP code to the user", async () => { + const user = TDB.user({ + Attributes: attributes.map((attr: string) => + attribute(`${attr}_verified`, "false") + ), + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + mockUserPoolService.config.SchemaAttributes = [ + { Name: "example", Mutable: true }, + ]; + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: [attribute("example", "1")], + Username: "abc", + }); + + expect(mockMessages.deliver).not.toHaveBeenCalled(); + }); + }); + + describe("the verification status changed because of the update", () => { + it("does not deliver a OTP code to the user", async () => { + const user = TDB.user(); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await adminUpdateUserAttributes(TestContext, { + ClientMetadata: { + client: "metadata", + }, + UserPoolId: "test", + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + Username: "abc", + }); + + expect(mockMessages.deliver).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/targets/adminUpdateUserAttributes.ts b/src/targets/adminUpdateUserAttributes.ts index 38047e85..d93909a2 100644 --- a/src/targets/adminUpdateUserAttributes.ts +++ b/src/targets/adminUpdateUserAttributes.ts @@ -1,18 +1,134 @@ import { AdminUpdateUserAttributesRequest, AdminUpdateUserAttributesResponse, + AttributeListType, + SchemaAttributesListType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; -import { Services } from "../services"; -import { NotAuthorizedError } from "../errors"; -import { Target } from "./router"; +import { Messages, Services, UserPoolService } from "../services"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import { USER_POOL_AWS_DEFAULTS } from "../services/cognitoService"; +import { selectAppropriateDeliveryMethod } from "../services/messageDelivery/deliveryMethod"; +import { + attribute, + attributesAppend, + attributesInclude, + attributeValue, + User, +} from "../services/userPoolService"; +import { Context, Target } from "./router"; + +const validatePermittedAttributeChanges = ( + requestAttributes: AttributeListType, + schemaAttributes: SchemaAttributesListType +): AttributeListType => { + for (const attr of requestAttributes) { + const attrSchema = schemaAttributes.find((x) => x.Name === attr.Name); + if (!attrSchema) { + throw new InvalidParameterError( + `user.${attr.Name}: Attribute does not exist in the schema.` + ); + } + if (!attrSchema.Mutable) { + throw new InvalidParameterError( + `user.${attr.Name}: Attribute cannot be updated. (changing an immutable attribute)` + ); + } + } + + if ( + attributesInclude("email_verified", requestAttributes) && + !attributesInclude("email", requestAttributes) + ) { + throw new InvalidParameterError( + "Email is required to verify/un-verify an email" + ); + } + + if ( + attributesInclude("phone_number_verified", requestAttributes) && + !attributesInclude("phone_number", requestAttributes) + ) { + throw new InvalidParameterError( + "Phone Number is required to verify/un-verify a phone number" + ); + } + + return requestAttributes; +}; + +const defaultVerifiedAttributesIfModified = ( + attributes: AttributeListType +): AttributeListType => { + const attributesToSet = [...attributes]; + if ( + attributesInclude("email", attributes) && + !attributesInclude("email_verified", attributes) + ) { + attributesToSet.push(attribute("email_verified", "false")); + } + if ( + attributesInclude("phone_number", attributes) && + !attributesInclude("phone_number_verified", attributes) + ) { + attributesToSet.push(attribute("phone_number_verified", "false")); + } + return attributesToSet; +}; + +const hasUnverifiedContactAttributes = ( + userAttributesToSet: AttributeListType +): boolean => + attributeValue("email_verified", userAttributesToSet) === "false" || + attributeValue("phone_number_verified", userAttributesToSet) === "false"; + +const sendAttributeVerificationCode = async ( + ctx: Context, + userPool: UserPoolService, + user: User, + messages: Messages, + req: AdminUpdateUserAttributesRequest, + code: string +) => { + const deliveryDetails = selectAppropriateDeliveryMethod( + userPool.config.AutoVerifiedAttributes ?? [], + user + ); + if (!deliveryDetails) { + // TODO: I don't know what the real error message should be for this + throw new InvalidParameterError( + "User has no attribute matching desired auto verified attributes" + ); + } + + await messages.deliver( + ctx, + "UpdateUserAttribute", + null, + userPool.config.Id, + user, + code, + req.ClientMetadata, + deliveryDetails + ); +}; export type AdminUpdateUserAttributesTarget = Target< AdminUpdateUserAttributesRequest, AdminUpdateUserAttributesResponse >; +type AdminUpdateUserAttributesServices = Pick< + Services, + "clock" | "cognito" | "otp" | "messages" +>; + export const AdminUpdateUserAttributes = - ({ cognito }: Services): AdminUpdateUserAttributesTarget => + ({ + clock, + cognito, + otp, + messages, + }: AdminUpdateUserAttributesServices): AdminUpdateUserAttributesTarget => async (ctx, req) => { const userPool = await cognito.getUserPool(ctx, req.UserPoolId); const user = await userPool.getUserByUsername(ctx, req.Username); @@ -20,8 +136,50 @@ export const AdminUpdateUserAttributes = throw new NotAuthorizedError(); } - // TODO: Should save the attributes. - return { - UserAttributes: user.Attributes, + const userAttributesToSet = defaultVerifiedAttributesIfModified( + validatePermittedAttributeChanges( + req.UserAttributes, + // if the user pool doesn't have any SchemaAttributes it was probably created manually + // or before we started explicitly saving the defaults. Fallback on the AWS defaults in + // this case, otherwise checks against the schema for default attributes like email will + // fail. + userPool.config.SchemaAttributes ?? + USER_POOL_AWS_DEFAULTS.SchemaAttributes ?? + [] + ) + ); + + const updatedUser = { + ...user, + Attributes: attributesAppend(user.Attributes, ...userAttributesToSet), + UserLastModifiedDate: clock.get(), }; + + await userPool.saveUser(ctx, updatedUser); + + // deliberately only check the affected user attributes, not the combined attributes + // e.g. a user with email_verified=false that you don't touch the email attributes won't get notified + if ( + userPool.config.AutoVerifiedAttributes?.length && + hasUnverifiedContactAttributes(userAttributesToSet) + ) { + const code = otp(); + + await sendAttributeVerificationCode( + ctx, + userPool, + user, + messages, + req, + code + ); + + await userPool.saveUser(ctx, { + ...updatedUser, + // TODO: should we have separate ConfirmationCode for MFA vs attribute confirming? + ConfirmationCode: code, + }); + } + + return {}; }; diff --git a/src/targets/forgotPassword.test.ts b/src/targets/forgotPassword.test.ts index cb3a2ac1..7f16f770 100644 --- a/src/targets/forgotPassword.test.ts +++ b/src/targets/forgotPassword.test.ts @@ -1,6 +1,5 @@ import { ClockFake } from "../__tests__/clockFake"; import { newMockCognitoService } from "../__tests__/mockCognitoService"; -import { newMockMessageDelivery } from "../__tests__/mockMessageDelivery"; import { newMockMessages } from "../__tests__/mockMessages"; import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; import { TestContext } from "../__tests__/testContext"; diff --git a/src/targets/signUp.ts b/src/targets/signUp.ts index 41f78207..1f307694 100644 --- a/src/targets/signUp.ts +++ b/src/targets/signUp.ts @@ -2,11 +2,11 @@ import { SignUpRequest, SignUpResponse, UserStatusType, - VerifiedAttributesListType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; import * as uuid from "uuid"; import { InvalidParameterError, UsernameExistsError } from "../errors"; import { Messages, Services, UserPoolService } from "../services"; +import { selectAppropriateDeliveryMethod } from "../services/messageDelivery/deliveryMethod"; import { DeliveryDetails } from "../services/messageDelivery/messageDelivery"; import { attribute, @@ -24,35 +24,6 @@ type SignUpServices = Pick< "clock" | "cognito" | "messages" | "otp" | "triggers" >; -const selectAppropriateDeliveryMethod = ( - desiredDeliveryMediums: VerifiedAttributesListType, - user: User -): DeliveryDetails | null => { - if (desiredDeliveryMediums.includes("phone_number")) { - const phoneNumber = attributeValue("phone_number", user.Attributes); - if (phoneNumber) { - return { - AttributeName: "phone_number", - DeliveryMedium: "SMS", - Destination: phoneNumber, - }; - } - } - - if (desiredDeliveryMediums.includes("email")) { - const email = attributeValue("email", user.Attributes); - if (email) { - return { - AttributeName: "email", - DeliveryMedium: "EMAIL", - Destination: email, - }; - } - } - - return null; -}; - const deliverWelcomeMessage = async ( ctx: Context, code: string,