From 90726a42feb7537424562d97c0983c60450d7abd Mon Sep 17 00:00:00 2001 From: James Gregory Date: Sat, 11 Dec 2021 09:44:06 +1100 Subject: [PATCH] feat(api): getUserAttributeVerificationCode full support --- README.md | 2 +- .../getUserAttributeVerificationCode.test.ts | 71 ++++++++++ .../getUserAttributeVerificationCode.test.ts | 134 ++++++++++++++++++ .../getUserAttributeVerificationCode.ts | 93 ++++++++++++ src/targets/router.ts | 2 + 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 integration-tests/aws-sdk/getUserAttributeVerificationCode.test.ts create mode 100644 src/targets/getUserAttributeVerificationCode.test.ts create mode 100644 src/targets/getUserAttributeVerificationCode.ts diff --git a/README.md b/README.md index 85ac5935..5cd6f68b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | GetSigningCertificate | ❌ | | GetUICustomization | ❌ | | GetUser | ✅ | -| GetUserAttributeVerificationCode | ❌ | +| GetUserAttributeVerificationCode | ✅ | | GetUserPoolMfaConfig | ❌ | | GlobalSignOut | ❌ | | InitiateAuth | 🕒 (partial support) | diff --git a/integration-tests/aws-sdk/getUserAttributeVerificationCode.test.ts b/integration-tests/aws-sdk/getUserAttributeVerificationCode.test.ts new file mode 100644 index 00000000..d396290a --- /dev/null +++ b/integration-tests/aws-sdk/getUserAttributeVerificationCode.test.ts @@ -0,0 +1,71 @@ +import { UUID } from "../../src/__tests__/patterns"; +import { TestContext } from "../../src/__tests__/testContext"; +import { withCognitoSdk } from "./setup"; +import { User } from "../../src/services/userPoolService"; + +describe( + "CognitoIdentityServiceProvider.getUserAttributeVerificationCode", + withCognitoSdk((Cognito, DataStoreFactory) => { + it("sends a verification code for a user's attribute", 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" }], + Username: "abc", + UserPoolId: userPoolId, + TemporaryPassword: "def", + DesiredDeliveryMediums: ["EMAIL"], + }) + .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(); + + await client + .getUserAttributeVerificationCode({ + AccessToken: initiateAuthResponse.AuthenticationResult + ?.AccessToken as string, + AttributeName: "email", + }) + .promise(); + + // get the user's code -- this is very nasty + const ds = await DataStoreFactory().create(TestContext, userPoolId, {}); + const storedUser = (await ds.get(TestContext, ["Users", "abc"])) as User; + + expect(storedUser.AttributeVerificationCode).toMatch(/^\d{4}$/); + }); + }) +); diff --git a/src/targets/getUserAttributeVerificationCode.test.ts b/src/targets/getUserAttributeVerificationCode.test.ts new file mode 100644 index 00000000..e3990e15 --- /dev/null +++ b/src/targets/getUserAttributeVerificationCode.test.ts @@ -0,0 +1,134 @@ +import jwt from "jsonwebtoken"; +import * as uuid from "uuid"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockMessages } from "../__tests__/mockMessages"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import { InvalidParameterError, UserNotFoundError } from "../errors"; +import PrivateKey from "../keys/cognitoLocal.private.json"; +import { Messages, UserPoolService } from "../services"; +import { attribute, attributeValue } from "../services/userPoolService"; +import { + GetUserAttributeVerificationCode, + GetUserAttributeVerificationCodeTarget, +} from "./getUserAttributeVerificationCode"; +import * as TDB from "../__tests__/testDataBuilder"; + +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("GetUserAttributeVerificationCode target", () => { + let getUserAttributeVerificationCode: GetUserAttributeVerificationCodeTarget; + let mockUserPoolService: jest.Mocked; + let mockMessages: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService({ + Id: "test", + AutoVerifiedAttributes: ["email"], + }); + mockMessages = newMockMessages(); + getUserAttributeVerificationCode = GetUserAttributeVerificationCode({ + cognito: newMockCognitoService(mockUserPoolService), + messages: mockMessages, + otp: () => "1234", + }); + }); + + it("throws if token isn't valid", async () => { + await expect( + getUserAttributeVerificationCode(TestContext, { + AccessToken: "blah", + AttributeName: "email", + }) + ).rejects.toBeInstanceOf(InvalidParameterError); + }); + + it("throws if user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + getUserAttributeVerificationCode(TestContext, { + AccessToken: validToken, + AttributeName: "email", + }) + ).rejects.toEqual(new UserNotFoundError()); + }); + + 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( + getUserAttributeVerificationCode(TestContext, { + ClientMetadata: { + client: "metadata", + }, + AccessToken: validToken, + AttributeName: "email", + }) + ).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({ + Attributes: [attribute("email", "example@example.com")], + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await getUserAttributeVerificationCode(TestContext, { + ClientMetadata: { + client: "metadata", + }, + AccessToken: validToken, + AttributeName: "email", + }); + + expect(mockMessages.deliver).toHaveBeenCalledWith( + TestContext, + "VerifyUserAttribute", + null, + "test", + user, + "1234", + { client: "metadata" }, + { + AttributeName: "email", + DeliveryMedium: "EMAIL", + Destination: attributeValue("email", user.Attributes), + } + ); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith( + TestContext, + expect.objectContaining({ + AttributeVerificationCode: "1234", + }) + ); + }); +}); diff --git a/src/targets/getUserAttributeVerificationCode.ts b/src/targets/getUserAttributeVerificationCode.ts new file mode 100644 index 00000000..2266f295 --- /dev/null +++ b/src/targets/getUserAttributeVerificationCode.ts @@ -0,0 +1,93 @@ +import { + GetUserAttributeVerificationCodeRequest, + GetUserAttributeVerificationCodeResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import jwt from "jsonwebtoken"; +import { Messages, Services, UserPoolService } from "../services"; +import { InvalidParameterError, UserNotFoundError } from "../errors"; +import { selectAppropriateDeliveryMethod } from "../services/messageDelivery/deliveryMethod"; +import { Token } from "../services/tokenGenerator"; +import { User } from "../services/userPoolService"; +import { Context, Target } from "./router"; + +const sendAttributeVerificationCode = async ( + ctx: Context, + userPool: UserPoolService, + user: User, + messages: Messages, + req: GetUserAttributeVerificationCodeRequest, + 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, + "VerifyUserAttribute", + null, + userPool.config.Id, + user, + code, + req.ClientMetadata, + deliveryDetails + ); +}; + +export type GetUserAttributeVerificationCodeTarget = Target< + GetUserAttributeVerificationCodeRequest, + GetUserAttributeVerificationCodeResponse +>; + +type GetUserAttributeVerificationCodeServices = Pick< + Services, + "cognito" | "otp" | "messages" +>; + +export const GetUserAttributeVerificationCode = + ({ + cognito, + otp, + messages, + }: GetUserAttributeVerificationCodeServices): GetUserAttributeVerificationCodeTarget => + 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 UserNotFoundError(); + } + + const code = otp(); + + await userPool.saveUser(ctx, { + ...user, + AttributeVerificationCode: code, + }); + + await sendAttributeVerificationCode( + ctx, + userPool, + user, + messages, + req, + code + ); + + return {}; + }; diff --git a/src/targets/router.ts b/src/targets/router.ts index 72c5cb93..b89f9d38 100644 --- a/src/targets/router.ts +++ b/src/targets/router.ts @@ -11,6 +11,7 @@ import { DeleteUser } from "./deleteUser"; import { DescribeUserPoolClient } from "./describeUserPoolClient"; import { ForgotPassword } from "./forgotPassword"; import { ChangePassword } from "./changePassword"; +import { GetUserAttributeVerificationCode } from "./getUserAttributeVerificationCode"; import { InitiateAuth } from "./initiateAuth"; import { ListGroups } from "./listGroups"; import { ListUserPools } from "./listUserPools"; @@ -45,6 +46,7 @@ export const Targets = { DescribeUserPoolClient, ForgotPassword, GetUser, + GetUserAttributeVerificationCode, InitiateAuth, ListGroups, ListUsers,