From 308c9c25de3292fa2632af69679af34f6c68586f Mon Sep 17 00:00:00 2001 From: James Gregory Date: Sat, 11 Dec 2021 13:51:57 +1100 Subject: [PATCH] feat(api): updateUserAttributes full support --- README.md | 2 +- .../aws-sdk/updateUserAttributes.test.ts | 93 +++++ src/services/userPoolService.ts | 66 ++++ src/targets/adminUpdateUserAttributes.ts | 74 +--- src/targets/router.ts | 6 +- src/targets/updateUserAttributes.test.ts | 342 ++++++++++++++++++ src/targets/updateUserAttributes.ts | 137 +++++++ 7 files changed, 647 insertions(+), 73 deletions(-) create mode 100644 integration-tests/aws-sdk/updateUserAttributes.test.ts create mode 100644 src/targets/updateUserAttributes.test.ts create mode 100644 src/targets/updateUserAttributes.ts diff --git a/README.md b/README.md index 3c2a12d3..e8881a90 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | UpdateGroup | ❌ | | UpdateIdentityProvider | ❌ | | UpdateResourceServer | ❌ | -| UpdateUserAttributes | ❌ | +| UpdateUserAttributes | ✅ | | UpdateUserPool | ❌ | | UpdateUserPoolClient | ❌ | | UpdateUserPoolDomain | ❌ | diff --git a/integration-tests/aws-sdk/updateUserAttributes.test.ts b/integration-tests/aws-sdk/updateUserAttributes.test.ts new file mode 100644 index 00000000..54181483 --- /dev/null +++ b/integration-tests/aws-sdk/updateUserAttributes.test.ts @@ -0,0 +1,93 @@ +import Pino from "pino"; +import { UUID } from "../../src/__tests__/patterns"; +import { withCognitoSdk } from "./setup"; + +describe( + "CognitoIdentityServiceProvider.updateUserAttributes", + withCognitoSdk((Cognito) => { + it("updates a user's attributes", async () => { + const client = Cognito(); + + const pool = await client + .createUserPool({ + PoolName: "test", + AutoVerifiedAttributes: ["email"], + }) + .promise(); + const userPoolId = pool.UserPool?.Id as string; + + const upc = await client + .createUserPoolClient({ + UserPoolId: userPoolId, + ClientName: "test", + }) + .promise(); + + await client + .adminCreateUser({ + UserAttributes: [ + { Name: "email", Value: "example@example.com" }, + { Name: "phone_number", Value: "0400000000" }, + ], + Username: "abc", + UserPoolId: userPoolId, + TemporaryPassword: "def", + }) + .promise(); + + await client + .adminConfirmSignUp({ + UserPoolId: userPoolId, + Username: "abc", + }) + .promise(); + + // login as the user + const initiateAuthResponse = await client + .initiateAuth({ + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: "abc", + PASSWORD: "def", + }, + ClientId: upc.UserPoolClient?.ClientId as string, + }) + .promise(); + + let user = await client + .adminGetUser({ + UserPoolId: userPoolId, + 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 + .updateUserAttributes({ + AccessToken: initiateAuthResponse.AuthenticationResult + ?.AccessToken as string, + UserAttributes: [{ Name: "email", Value: "example2@example.com" }], + }) + .promise(); + + user = await client + .adminGetUser({ + UserPoolId: userPoolId, + 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/services/userPoolService.ts b/src/services/userPoolService.ts index 11859416..1b08f557 100644 --- a/src/services/userPoolService.ts +++ b/src/services/userPoolService.ts @@ -2,9 +2,11 @@ import { AttributeListType, AttributeType, MFAOptionListType, + SchemaAttributesListType, UserPoolType, UserStatusType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; +import { InvalidParameterError } from "../errors"; import { AppClient, newId } from "./appClient"; import { Clock } from "./clock"; import { Context } from "./context"; @@ -351,3 +353,67 @@ export class UserPoolServiceFactoryImpl implements UserPoolServiceFactory { ); } } + +export 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; +}; + +export 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; +}; + +export const hasUnverifiedContactAttributes = ( + userAttributesToSet: AttributeListType +): boolean => + attributeValue("email_verified", userAttributesToSet) === "false" || + attributeValue("phone_number_verified", userAttributesToSet) === "false"; diff --git a/src/targets/adminUpdateUserAttributes.ts b/src/targets/adminUpdateUserAttributes.ts index a6471603..4770babd 100644 --- a/src/targets/adminUpdateUserAttributes.ts +++ b/src/targets/adminUpdateUserAttributes.ts @@ -1,86 +1,20 @@ import { AdminUpdateUserAttributesRequest, AdminUpdateUserAttributesResponse, - AttributeListType, - SchemaAttributesListType, } from "aws-sdk/clients/cognitoidentityserviceprovider"; -import { Messages, Services, UserPoolService } from "../services"; import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import { Messages, Services, UserPoolService } from "../services"; import { USER_POOL_AWS_DEFAULTS } from "../services/cognitoService"; import { selectAppropriateDeliveryMethod } from "../services/messageDelivery/deliveryMethod"; import { - attribute, attributesAppend, - attributesInclude, - attributeValue, + defaultVerifiedAttributesIfModified, + hasUnverifiedContactAttributes, User, + validatePermittedAttributeChanges, } 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, diff --git a/src/targets/router.ts b/src/targets/router.ts index b89f9d38..a150bc27 100644 --- a/src/targets/router.ts +++ b/src/targets/router.ts @@ -26,14 +26,15 @@ import { AdminConfirmSignUp } from "./adminConfirmSignUp"; import { AdminUpdateUserAttributes } from "./adminUpdateUserAttributes"; import { AdminInitiateAuth } from "./adminInitiateAuth"; import { RevokeToken } from "./revokeToken"; +import { UpdateUserAttributes } from "./updateUserAttributes"; import { VerifyUserAttribute } from "./verifyUserAttribute"; export const Targets = { AdminConfirmSignUp, AdminCreateUser, AdminDeleteUser, - AdminInitiateAuth, AdminGetUser, + AdminInitiateAuth, AdminSetUserPassword, AdminUpdateUserAttributes, ChangePassword, @@ -49,11 +50,12 @@ export const Targets = { GetUserAttributeVerificationCode, InitiateAuth, ListGroups, - ListUsers, ListUserPools, + ListUsers, RespondToAuthChallenge, RevokeToken, SignUp, + UpdateUserAttributes, VerifyUserAttribute, } as const; diff --git a/src/targets/updateUserAttributes.test.ts b/src/targets/updateUserAttributes.test.ts new file mode 100644 index 00000000..7c2278df --- /dev/null +++ b/src/targets/updateUserAttributes.test.ts @@ -0,0 +1,342 @@ +import jwt from "jsonwebtoken"; +import * as uuid from "uuid"; +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 PrivateKey from "../keys/cognitoLocal.private.json"; +import { Messages, UserPoolService } from "../services"; +import { + attribute, + attributesAppend, + attributeValue, +} from "../services/userPoolService"; +import { + UpdateUserAttributes, + UpdateUserAttributesTarget, +} from "./updateUserAttributes"; +import * as TDB from "../__tests__/testDataBuilder"; + +const clock = new ClockFake(new Date()); + +const validToken = jwt.sign( + { + sub: "0000-0000", + event_id: "0", + token_use: "access", + scope: "aws.cognito.signin.user.admin", + auth_time: new Date(), + jti: uuid.v4(), + client_id: "test", + username: "0000-0000", + }, + PrivateKey.pem, + { + algorithm: "RS256", + issuer: `http://localhost:9229/test`, + expiresIn: "24h", + keyid: "CognitoLocal", + } +); + +describe("UpdateUserAttributes target", () => { + let updateUserAttributes: UpdateUserAttributesTarget; + let mockUserPoolService: jest.Mocked; + let mockMessages: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + mockMessages = newMockMessages(); + updateUserAttributes = UpdateUserAttributes({ + clock, + cognito: newMockCognitoService(mockUserPoolService), + messages: mockMessages, + otp: () => "1234", + }); + }); + + it("throws if the token is invalid", async () => { + await expect( + updateUserAttributes(TestContext, { + AccessToken: "invalid token", + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [{ Name: "custom:example", Value: "1" }], + }) + ).rejects.toBeInstanceOf(InvalidParameterError); + }); + + it("throws if the user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [{ Name: "custom:example", Value: "1" }], + }) + ).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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [attribute("custom:example", "1")], + }); + + 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( + updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [{ Name: attribute, Value: "1" }], + }) + ).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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [attribute(attr, "new value")], + }); + + 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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [attribute("example", "1")], + }); + + 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( + updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + }) + ).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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + }); + + expect(mockMessages.deliver).toHaveBeenCalledWith( + TestContext, + "UpdateUserAttribute", + null, + "test", + user, + "1234", + { client: "metadata" }, + { + AttributeName: "email", + DeliveryMedium: "EMAIL", + Destination: attributeValue("email", user.Attributes), + } + ); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + TestContext, + expect.objectContaining({ + AttributeVerificationCode: "1234", + }) + ); + }); + }); + }); + }); + + 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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: [attribute("example", "1")], + }); + + 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 updateUserAttributes(TestContext, { + AccessToken: validToken, + ClientMetadata: { + client: "metadata", + }, + UserAttributes: attributes.map((attr: string) => + attribute(attr, "new value") + ), + }); + + expect(mockMessages.deliver).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/targets/updateUserAttributes.ts b/src/targets/updateUserAttributes.ts new file mode 100644 index 00000000..098e7796 --- /dev/null +++ b/src/targets/updateUserAttributes.ts @@ -0,0 +1,137 @@ +import { + UpdateUserAttributesRequest, + UpdateUserAttributesResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import jwt from "jsonwebtoken"; +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 { Token } from "../services/tokenGenerator"; +import { + attributesAppend, + defaultVerifiedAttributesIfModified, + hasUnverifiedContactAttributes, + User, + validatePermittedAttributeChanges, +} from "../services/userPoolService"; +import { Context, Target } from "./router"; + +const sendAttributeVerificationCode = async ( + ctx: Context, + userPool: UserPoolService, + user: User, + messages: Messages, + req: UpdateUserAttributesRequest, + 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 + ); + + return deliveryDetails; +}; + +export type UpdateUserAttributesTarget = Target< + UpdateUserAttributesRequest, + UpdateUserAttributesResponse +>; + +type UpdateUserAttributesServices = Pick< + Services, + "clock" | "cognito" | "otp" | "messages" +>; + +export const UpdateUserAttributes = + ({ + clock, + cognito, + otp, + messages, + }: UpdateUserAttributesServices): UpdateUserAttributesTarget => + async (ctx, req) => { + const decodedToken = jwt.decode(req.AccessToken) as Token | null; + if (!decodedToken) { + ctx.logger.info("Unable to decode token"); + throw new InvalidParameterError(); + } + + const userPool = await cognito.getUserPoolForClientId( + ctx, + decodedToken.client_id + ); + const user = await userPool.getUserByUsername(ctx, decodedToken.sub); + if (!user) { + throw new NotAuthorizedError(); + } + + 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 userPool.saveUser(ctx, { + ...updatedUser, + AttributeVerificationCode: code, + }); + + const deliveryDetails = await sendAttributeVerificationCode( + ctx, + userPool, + user, + messages, + req, + code + ); + + return { + CodeDeliveryDetailsList: [deliveryDetails], + }; + } + + return { + CodeDeliveryDetailsList: [], + }; + };