Skip to content

Commit

Permalink
feat(lambda): post confirmation lambda trigger
Browse files Browse the repository at this point in the history
  • Loading branch information
jagregory committed Apr 13, 2020
1 parent e011cc0 commit f30573b
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 12 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The goal for this project is to be _Good Enough_ for local development use, and
- [x] Confirm Forgot Password
- [x] User Migration lambda trigger (Authentication)
- [ ] User Migration lambda trigger (Forgot Password)
- [x] Post Confirmation lambda trigger (ConfirmSignUp & ConfirmForgotPassword)

## Installation

Expand Down
30 changes: 24 additions & 6 deletions src/services/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,33 @@ interface UserMigrationEvent {
triggerSource: "UserMigration_Authentication";
}

interface PostConfirmationEvent {
userPoolId: string;
clientId: string;
username: string;
userAttributes: Record<string, string>;
triggerSource:
| "PostConfirmation_ConfirmSignUp"
| "PostConfirmation_ConfirmForgotPassword";
}

export type CognitoUserPoolResponse = CognitoUserPoolEvent["response"];

export interface Lambda {
invoke(
lambda: "UserMigration",
event: UserMigrationEvent
): Promise<CognitoUserPoolResponse>;
invoke(
lambda: "PostConfirmation",
event: PostConfirmationEvent
): Promise<CognitoUserPoolResponse>;
enabled(lambda: "UserMigration"): boolean;
}

export interface FunctionConfig {
UserMigration?: string;
PostConfirmation?: string;
}

export type CreateLambda = (
Expand All @@ -33,10 +48,13 @@ export type CreateLambda = (

export const createLambda: CreateLambda = (config, lambdaClient) => ({
enabled: (lambda) => !!config[lambda],
async invoke(lambda, event) {
const lambdaName = config[lambda];
if (!lambdaName) {
throw new Error(`${lambda} trigger not configured`);
async invoke(
trigger: keyof FunctionConfig,
event: UserMigrationEvent | PostConfirmationEvent
) {
const functionName = config[trigger];
if (!functionName) {
throw new Error(`${trigger} trigger not configured`);
}

const lambdaEvent: CognitoUserPoolEvent = {
Expand All @@ -61,14 +79,14 @@ export const createLambda: CreateLambda = (config, lambdaClient) => ({
}

console.log(
`Invoking "${lambdaName}" with event`,
`Invoking "${functionName}" with event`,
JSON.stringify(lambdaEvent, undefined, 2)
);
let result: InvocationResponse;
try {
result = await lambdaClient
.invoke({
FunctionName: lambdaName,
FunctionName: functionName,
InvocationType: "RequestResponse",
Payload: JSON.stringify(lambdaEvent),
})
Expand Down
72 changes: 72 additions & 0 deletions src/services/triggers/postConfirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NotAuthorizedError } from "../../errors";
import { Lambda } from "../lambda";
import { UserPool } from "../userPool";
import { PostConfirmation, PostConfirmationTrigger } from "./postConfirmation";

const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;

describe("PostConfirmation trigger", () => {
let mockLambda: jest.Mocked<Lambda>;
let mockUserPool: jest.Mocked<UserPool>;
let postConfirmation: PostConfirmationTrigger;

beforeEach(() => {
mockLambda = {
enabled: jest.fn(),
invoke: jest.fn(),
};
mockUserPool = {
getUserByUsername: jest.fn(),
getUserPoolIdForClientId: jest.fn(),
saveUser: jest.fn(),
};

postConfirmation = PostConfirmation({
lambda: mockLambda,
userPool: mockUserPool,
});
});

describe.each([
"PostConfirmation_ConfirmSignUp",
"PostConfirmation_ConfirmForgotPassword",
])("%s", (source) => {
describe("when lambda invoke fails", () => {
it("quietly completes", async () => {
mockLambda.invoke.mockRejectedValue(
new Error("Something bad happened")
);

await postConfirmation({
userPoolId: "userPoolId",
clientId: "clientId",
username: "username",
userAttributes: [],
source: source as any,
});
});
});

describe("when lambda invoke succeeds", () => {
it("quietly completes", async () => {
mockLambda.invoke.mockResolvedValue({});

await postConfirmation({
userPoolId: "userPoolId",
clientId: "clientId",
username: "[email protected]",
userAttributes: [{ Name: "email", Value: "[email protected]" }],
source: source as any,
});

expect(mockLambda.invoke).toHaveBeenCalledWith("PostConfirmation", {
clientId: "clientId",
triggerSource: source,
userAttributes: { email: "[email protected]" },
userPoolId: "userPoolId",
username: "[email protected]",
});
});
});
});
});
38 changes: 38 additions & 0 deletions src/services/triggers/postConfirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { UserPool } from "../index";
import { Lambda } from "../lambda";
import { attributesToRecord } from "../userPool";

export type PostConfirmationTrigger = (params: {
source:
| "PostConfirmation_ConfirmSignUp"
| "PostConfirmation_ConfirmForgotPassword";
userPoolId: string;
clientId: string;
username: string;
userAttributes: readonly { Name: string; Value: string }[];
}) => Promise<void>;

export const PostConfirmation = ({
lambda,
}: {
lambda: Lambda;
userPool: UserPool;
}): PostConfirmationTrigger => async ({
source,
userPoolId,
clientId,
username,
userAttributes,
}): Promise<void> => {
try {
await lambda.invoke("PostConfirmation", {
userPoolId,
clientId,
username,
triggerSource: source,
userAttributes: attributesToRecord(userAttributes),
});
} catch (ex) {
console.error(ex);
}
};
5 changes: 4 additions & 1 deletion src/services/triggers/triggers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Services, UserPool } from "../index";
import { Lambda } from "../lambda";
import { PostConfirmation, PostConfirmationTrigger } from "./postConfirmation";
import { UserMigration, UserMigrationTrigger } from "./userMigration";

export interface Triggers {
enabled(trigger: "UserMigration"): boolean;
enabled(trigger: "UserMigration" | "PostConfirmation"): boolean;
userMigration: UserMigrationTrigger;
postConfirmation: PostConfirmationTrigger;
}

export const createTriggers = (services: {
Expand All @@ -13,4 +15,5 @@ export const createTriggers = (services: {
}): Triggers => ({
enabled: (trigger: "UserMigration") => services.lambda.enabled(trigger),
userMigration: UserMigration(services),
postConfirmation: PostConfirmation(services),
});
88 changes: 87 additions & 1 deletion src/targets/confirmForgotPassword.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { advanceBy, advanceTo } from "jest-date-mock";
import { CodeMismatchError, UserNotFoundError } from "../errors";
import {
CodeMismatchError,
ResourceNotFoundError,
UserNotFoundError,
} from "../errors";
import { UserPool } from "../services";
import { Triggers } from "../services/triggers";
import {
Expand All @@ -26,6 +30,7 @@ describe("ConfirmForgotPassword target", () => {
mockCodeDelivery = jest.fn();
mockTriggers = {
enabled: jest.fn(),
postConfirmation: jest.fn(),
userMigration: jest.fn(),
};

Expand All @@ -36,7 +41,21 @@ describe("ConfirmForgotPassword target", () => {
});
});

it("throws if can't find user pool by client id", async () => {
mockDataStore.getUserPoolIdForClientId.mockResolvedValue(null);

await expect(
confirmForgotPassword({
ClientId: "clientId",
Username: "janice",
ConfirmationCode: "1234",
Password: "newPassword",
})
).rejects.toBeInstanceOf(ResourceNotFoundError);
});

it("throws if user doesn't exist", async () => {
mockDataStore.getUserPoolIdForClientId.mockResolvedValue("userPoolId");
mockDataStore.getUserByUsername.mockResolvedValue(null);

await expect(
Expand All @@ -50,6 +69,7 @@ describe("ConfirmForgotPassword target", () => {
});

it("throws if confirmation code doesn't match stored value", async () => {
mockDataStore.getUserPoolIdForClientId.mockResolvedValue("userPoolId");
mockDataStore.getUserByUsername.mockResolvedValue({
Attributes: [{ Name: "email", Value: "[email protected]" }],
ConfirmationCode: "4567",
Expand All @@ -73,6 +93,7 @@ describe("ConfirmForgotPassword target", () => {

describe("when code matches", () => {
it("updates the user's password", async () => {
mockDataStore.getUserPoolIdForClientId.mockResolvedValue("userPoolId");
mockDataStore.getUserByUsername.mockResolvedValue({
Attributes: [{ Name: "email", Value: "[email protected]" }],
ConfirmationCode: "4567",
Expand Down Expand Up @@ -106,5 +127,70 @@ describe("ConfirmForgotPassword target", () => {
Username: "0000-0000",
});
});

describe("when PostConfirmation trigger configured", () => {
it("invokes the trigger", async () => {
mockTriggers.enabled.mockReturnValue(true);

mockDataStore.getUserPoolIdForClientId.mockResolvedValue("userPoolId");
mockDataStore.getUserByUsername.mockResolvedValue({
Attributes: [{ Name: "email", Value: "[email protected]" }],
ConfirmationCode: "4567",
Enabled: true,
Password: "pwd",
UserCreateDate: now.getTime(),
UserLastModifiedDate: now.getTime(),
UserStatus: "UNCONFIRMED",
Username: "0000-0000",
});

await confirmForgotPassword({
ClientId: "clientId",
Username: "janice",
ConfirmationCode: "4567",
Password: "newPassword",
});

expect(mockTriggers.postConfirmation).toHaveBeenCalledWith({
clientId: "clientId",
source: "PostConfirmation_ConfirmForgotPassword",
userAttributes: [
{
Name: "email",
Value: "[email protected]",
},
],
userPoolId: "userPoolId",
username: "0000-0000",
});
});
});

describe("when PostConfirmation trigger not configured", () => {
it("doesn't invoke the trigger", async () => {
mockTriggers.enabled.mockReturnValue(false);

mockDataStore.getUserPoolIdForClientId.mockResolvedValue("userPoolId");
mockDataStore.getUserByUsername.mockResolvedValue({
Attributes: [{ Name: "email", Value: "[email protected]" }],
ConfirmationCode: "4567",
Enabled: true,
Password: "pwd",
UserCreateDate: now.getTime(),
UserLastModifiedDate: now.getTime(),
UserStatus: "UNCONFIRMED",
Username: "0000-0000",
});

await confirmForgotPassword({
ClientId: "clientId",
Username: "janice",
ConfirmationCode: "4567",
Password: "newPassword",
});

expect(mockTriggers.postConfirmation).not.toHaveBeenCalled();
});
});
});
});
22 changes: 21 additions & 1 deletion src/targets/confirmForgotPassword.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { CodeMismatchError, UserNotFoundError } from "../errors";
import {
CodeMismatchError,
ResourceNotFoundError,
UserNotFoundError,
} from "../errors";
import { Services } from "../services";

interface Input {
Expand All @@ -12,7 +16,13 @@ export type ConfirmForgotPasswordTarget = (body: Input) => Promise<{}>;

export const ConfirmForgotPassword = ({
userPool,
triggers,
}: Services): ConfirmForgotPasswordTarget => async (body) => {
const userPoolId = await userPool.getUserPoolIdForClientId(body.ClientId);
if (!userPoolId) {
throw new ResourceNotFoundError();
}

const user = await userPool.getUserByUsername(body.Username);

if (!user) {
Expand All @@ -31,5 +41,15 @@ export const ConfirmForgotPassword = ({
Password: body.Password,
});

if (triggers.enabled("PostConfirmation")) {
await triggers.postConfirmation({
source: "PostConfirmation_ConfirmForgotPassword",
username: user.Username,
clientId: body.ClientId,
userPoolId,
userAttributes: user.Attributes,
});
}

return {};
};
Loading

0 comments on commit f30573b

Please sign in to comment.