Skip to content

Commit

Permalink
feat(api): verifyUserAttribute full support
Browse files Browse the repository at this point in the history
  • Loading branch information
jagregory committed Dec 10, 2021
1 parent b18af6a commit 320dd17
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 4 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions integration-tests/aws-sdk/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(),
Expand All @@ -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()
);
Expand Down Expand Up @@ -93,7 +98,10 @@ export const withCognitoSdk =
});
});

fn(() => cognitoSdk);
fn(
() => cognitoSdk,
() => dataStoreFactory
);

afterEach((done) => {
httpServer.close(() => {
Expand Down
91 changes: 91 additions & 0 deletions integration-tests/aws-sdk/verifyUserAttribute.test.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]" }],
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: "[email protected]" }],
})
.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: "[email protected]" },
{ Name: "email_verified", Value: "true" },
]);
});
})
);
1 change: 1 addition & 0 deletions src/__tests__/testDataBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const user = (partial?: Partial<User>): 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,
Expand Down
2 changes: 2 additions & 0 deletions src/targets/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,6 +52,7 @@ export const Targets = {
RespondToAuthChallenge,
RevokeToken,
SignUp,
VerifyUserAttribute,
} as const;

type TargetName = keyof typeof Targets;
Expand Down
153 changes: 153 additions & 0 deletions src/targets/verifyUserAttribute.test.ts
Original file line number Diff line number Diff line change
@@ -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<UserPoolService>;

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"
)
);
});
});
70 changes: 70 additions & 0 deletions src/targets/verifyUserAttribute.ts
Original file line number Diff line number Diff line change
@@ -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<Services, "clock" | "cognito">;

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 {};
};

0 comments on commit 320dd17

Please sign in to comment.