diff --git a/README.md b/README.md index e68d8112..85ac5935 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog | UpdateUserPoolClient | ❌ | | UpdateUserPoolDomain | ❌ | | VerifySoftwareToken | ❌ | -| VerifyUserAttribute | ❌ | +| VerifyUserAttribute | ✅ | > ¹ does not support pagination or query filters, all results and attributes will be returned in the first request. diff --git a/integration-tests/aws-sdk/setup.ts b/integration-tests/aws-sdk/setup.ts index 18f563ce..0cd0fd43 100644 --- a/integration-tests/aws-sdk/setup.ts +++ b/integration-tests/aws-sdk/setup.ts @@ -15,6 +15,7 @@ import { } from "../../src/services"; import { CognitoServiceFactoryImpl } from "../../src/services/cognitoService"; import { NoOpCache } from "../../src/services/dataStore/cache"; +import { DataStoreFactory } from "../../src/services/dataStore/factory"; import { StormDBDataStoreFactory } from "../../src/services/dataStore/stormDb"; import { otp } from "../../src/services/otp"; import { JwtTokenGenerator } from "../../src/services/tokenGenerator"; @@ -26,7 +27,10 @@ const rmdir = promisify(fs.rmdir); export const withCognitoSdk = ( - fn: (cognito: () => AWS.CognitoIdentityServiceProvider) => void, + fn: ( + cognito: () => AWS.CognitoIdentityServiceProvider, + dataStoreFactory: () => DataStoreFactory + ) => void, { logger = MockLogger as any, clock = new DateClock(), @@ -36,12 +40,13 @@ export const withCognitoSdk = let dataDirectory: string; let httpServer: http.Server; let cognitoSdk: AWS.CognitoIdentityServiceProvider; + let dataStoreFactory: DataStoreFactory; beforeEach(async () => { dataDirectory = await mkdtemp("/tmp/cognito-local:"); const ctx = { logger }; - const dataStoreFactory = new StormDBDataStoreFactory( + dataStoreFactory = new StormDBDataStoreFactory( dataDirectory, new NoOpCache() ); @@ -93,7 +98,10 @@ export const withCognitoSdk = }); }); - fn(() => cognitoSdk); + fn( + () => cognitoSdk, + () => dataStoreFactory + ); afterEach((done) => { httpServer.close(() => { diff --git a/integration-tests/aws-sdk/verifyUserAttribute.test.ts b/integration-tests/aws-sdk/verifyUserAttribute.test.ts new file mode 100644 index 00000000..e207925d --- /dev/null +++ b/integration-tests/aws-sdk/verifyUserAttribute.test.ts @@ -0,0 +1,91 @@ +import { UUID } from "../../src/__tests__/patterns"; +import { TestContext } from "../../src/__tests__/testContext"; +import { withCognitoSdk } from "./setup"; +import { User } from "../../src/services/userPoolService"; + +describe( + "CognitoIdentityServiceProvider.verifyUserAttribute", + withCognitoSdk((Cognito, DataStoreFactory) => { + it("verifies 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(); + + await client + .adminUpdateUserAttributes({ + UserPoolId: userPoolId, + Username: "abc", + UserAttributes: [{ Name: "email", Value: "example2@example.com" }], + }) + .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; + + // 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 + .verifyUserAttribute({ + AttributeName: "email", + AccessToken: initiateAuthResponse.AuthenticationResult + ?.AccessToken as string, + Code: storedUser.AttributeVerificationCode as string, + }) + .promise(); + + const 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: "email_verified", Value: "true" }, + ]); + }); + }) +); diff --git a/src/__tests__/testDataBuilder.ts b/src/__tests__/testDataBuilder.ts index 59cdbceb..4d503f02 100644 --- a/src/__tests__/testDataBuilder.ts +++ b/src/__tests__/testDataBuilder.ts @@ -18,6 +18,7 @@ export const user = (partial?: Partial): User => ({ { Name: "sub", Value: v4() }, { Name: "email", Value: `${id("example")}@example.com` }, ], + AttributeVerificationCode: partial?.AttributeVerificationCode ?? undefined, ConfirmationCode: partial?.ConfirmationCode ?? undefined, Enabled: partial?.Enabled ?? true, MFACode: partial?.MFACode ?? undefined, diff --git a/src/targets/router.ts b/src/targets/router.ts index efe02990..72c5cb93 100644 --- a/src/targets/router.ts +++ b/src/targets/router.ts @@ -25,6 +25,7 @@ import { AdminConfirmSignUp } from "./adminConfirmSignUp"; import { AdminUpdateUserAttributes } from "./adminUpdateUserAttributes"; import { AdminInitiateAuth } from "./adminInitiateAuth"; import { RevokeToken } from "./revokeToken"; +import { VerifyUserAttribute } from "./verifyUserAttribute"; export const Targets = { AdminConfirmSignUp, @@ -51,6 +52,7 @@ export const Targets = { RespondToAuthChallenge, RevokeToken, SignUp, + VerifyUserAttribute, } as const; type TargetName = keyof typeof Targets; diff --git a/src/targets/verifyUserAttribute.test.ts b/src/targets/verifyUserAttribute.test.ts new file mode 100644 index 00000000..e6ad36d6 --- /dev/null +++ b/src/targets/verifyUserAttribute.test.ts @@ -0,0 +1,153 @@ +import jwt from "jsonwebtoken"; +import * as uuid from "uuid"; +import { ClockFake } from "../__tests__/clockFake"; +import { newMockCognitoService } from "../__tests__/mockCognitoService"; +import { newMockUserPoolService } from "../__tests__/mockUserPoolService"; +import { TestContext } from "../__tests__/testContext"; +import * as TDB from "../__tests__/testDataBuilder"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import PrivateKey from "../keys/cognitoLocal.private.json"; +import { UserPoolService } from "../services"; +import { attribute, attributesAppend } from "../services/userPoolService"; +import { + VerifyUserAttribute, + VerifyUserAttributeTarget, +} from "./verifyUserAttribute"; + +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("VerifyUserAttribute target", () => { + let verifyUserAttribute: VerifyUserAttributeTarget; + let mockUserPoolService: jest.Mocked; + + beforeEach(() => { + mockUserPoolService = newMockUserPoolService(); + verifyUserAttribute = VerifyUserAttribute({ + clock, + cognito: newMockCognitoService(mockUserPoolService), + }); + }); + + it("verifies the user's email", async () => { + const user = TDB.user({ + AttributeVerificationCode: "1234", + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await verifyUserAttribute(TestContext, { + AccessToken: validToken, + AttributeName: "email", + Code: "1234", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute("email_verified", "true") + ), + UserLastModifiedDate: clock.get(), + }); + }); + + it("verifies the user's phone_number", async () => { + const user = TDB.user({ + AttributeVerificationCode: "1234", + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await verifyUserAttribute(TestContext, { + AccessToken: validToken, + AttributeName: "phone_number", + Code: "1234", + }); + + expect(mockUserPoolService.saveUser).toHaveBeenCalledWith(TestContext, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute("phone_number_verified", "true") + ), + UserLastModifiedDate: clock.get(), + }); + }); + + it("does nothing for other attributes", async () => { + const user = TDB.user({ + AttributeVerificationCode: "1234", + }); + + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await verifyUserAttribute(TestContext, { + AccessToken: validToken, + AttributeName: "something else", + Code: "1234", + }); + + expect(mockUserPoolService.saveUser).not.toHaveBeenCalled(); + }); + + it("throws if token isn't valid", async () => { + await expect( + verifyUserAttribute(TestContext, { + AccessToken: "blah", + AttributeName: "email", + Code: "1234", + }) + ).rejects.toBeInstanceOf(InvalidParameterError); + }); + + it("throws if user doesn't exist", async () => { + mockUserPoolService.getUserByUsername.mockResolvedValue(null); + + await expect( + verifyUserAttribute(TestContext, { + AccessToken: validToken, + AttributeName: "email", + Code: "1234", + }) + ).rejects.toEqual(new NotAuthorizedError()); + }); + + it("throws if code doesn't match the user's AttributeVerificationCode", async () => { + const user = TDB.user({ + AttributeVerificationCode: "5555", + }); + mockUserPoolService.getUserByUsername.mockResolvedValue(user); + + await expect( + verifyUserAttribute(TestContext, { + AccessToken: validToken, + AttributeName: "email", + Code: "1234", + }) + ).rejects.toEqual( + new InvalidParameterError( + "Unable to verify attribute: email no value set to verify" + ) + ); + }); +}); diff --git a/src/targets/verifyUserAttribute.ts b/src/targets/verifyUserAttribute.ts new file mode 100644 index 00000000..e8bb6d60 --- /dev/null +++ b/src/targets/verifyUserAttribute.ts @@ -0,0 +1,70 @@ +import { + VerifyUserAttributeRequest, + VerifyUserAttributeResponse, +} from "aws-sdk/clients/cognitoidentityserviceprovider"; +import jwt from "jsonwebtoken"; +import { InvalidParameterError, NotAuthorizedError } from "../errors"; +import { Services } from "../services"; +import { Token } from "../services/tokenGenerator"; +import { attribute, attributesAppend } from "../services/userPoolService"; +import { Target } from "./router"; + +export type VerifyUserAttributeTarget = Target< + VerifyUserAttributeRequest, + VerifyUserAttributeResponse +>; + +type VerifyUserAttributeServices = Pick; + +export const VerifyUserAttribute = + ({ + clock, + cognito, + }: VerifyUserAttributeServices): VerifyUserAttributeTarget => + 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(); + } + + if (req.Code !== user.AttributeVerificationCode) { + // this might not be the right error + throw new InvalidParameterError( + `Unable to verify attribute: ${req.AttributeName} no value set to verify` + ); + } + + if (req.AttributeName === "email") { + await userPool.saveUser(ctx, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute("email_verified", "true") + ), + UserLastModifiedDate: clock.get(), + }); + } else if (req.AttributeName === "phone_number") { + await userPool.saveUser(ctx, { + ...user, + Attributes: attributesAppend( + user.Attributes, + attribute("phone_number_verified", "true") + ), + UserLastModifiedDate: clock.get(), + }); + } else { + // not sure what to do here + } + + return {}; + };