Skip to content

Commit

Permalink
feat(api): sms_mfa support for initiateAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
jagregory committed May 3, 2020
1 parent 507ca9a commit f16afe6
Show file tree
Hide file tree
Showing 24 changed files with 549 additions and 234 deletions.
27 changes: 27 additions & 0 deletions integration-tests/aws-sdk/createUserPoolClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { withCognitoSdk } from "./setup";

describe(
"CognitoIdentityServiceProvider.createUserPoolClient",
withCognitoSdk((Cognito) => {
it("can create a new app client", async () => {
const result = await Cognito()
.createUserPoolClient({
ClientName: "test",
UserPoolId: "test",
})
.promise();

expect(result).toEqual({
UserPoolClient: {
AllowedOAuthFlowsUserPoolClient: false,
ClientId: expect.stringMatching(/^[a-z0-9]{25}$/),
ClientName: "test",
CreationDate: expect.any(Date),
LastModifiedDate: expect.any(Date),
RefreshTokenValidity: 30,
UserPoolId: "test",
},
});
});
})
);
23 changes: 23 additions & 0 deletions integration-tests/aws-sdk/initiateAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { withCognitoSdk } from "./setup";

describe(
"CognitoIdentityServiceProvider.initiateAuth",
withCognitoSdk((Cognito) => {
it("throws for missing user", async () => {
await expect(
Cognito()
.initiateAuth({
ClientId: "test",
AuthFlow: "USER_PASSWORD_AUTH",
AuthParameters: {
USERNAME: "[email protected]",
PASSWORD: "",
},
})
.promise()
).rejects.toMatchObject({
message: "Resource not found",
});
});
})
);
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import * as AWS from "aws-sdk";
import fs from "fs";
import * as http from "http";
import http from "http";
import { promisify } from "util";
import { createServer } from "../src/server";
import { CodeDelivery } from "../src/services";
import { createCognitoClient } from "../src/services/cognitoClient";
import { createDataStore, CreateDataStore } from "../src/services/dataStore";
import { Lambda } from "../src/services/lambda";
import { createTriggers } from "../src/services/triggers";
import { createUserPoolClient } from "../src/services/userPoolClient";
import { Router } from "../src/targets/router";
import { createServer } from "../../src/server";
import { CodeDelivery } from "../../src/services";
import { createCognitoClient } from "../../src/services/cognitoClient";
import { createDataStore, CreateDataStore } from "../../src/services/dataStore";
import { Lambda } from "../../src/services/lambda";
import { createTriggers } from "../../src/services/triggers";
import { createUserPoolClient } from "../../src/services/userPoolClient";
import { Router } from "../../src/targets/router";

const mkdtemp = promisify(fs.mkdtemp);
const rmdir = promisify(fs.rmdir);

describe("AWS SDK usage", () => {
export const withCognitoSdk = (
fn: (cognito: () => AWS.CognitoIdentityServiceProvider) => void
) => () => {
let path: string;
let tmpCreateDataStore: CreateDataStore;
let httpServer: http.Server;
let Cognito: AWS.CognitoIdentityServiceProvider;
let cognitoSdk: AWS.CognitoIdentityServiceProvider;

beforeEach(async () => {
path = await mkdtemp("/tmp/cognito-local:");
Expand Down Expand Up @@ -57,7 +59,7 @@ describe("AWS SDK usage", () => {
? address
: `${address.address}:${address.port}`;

Cognito = new AWS.CognitoIdentityServiceProvider({
cognitoSdk = new AWS.CognitoIdentityServiceProvider({
credentials: {
accessKeyId: "local",
secretAccessKey: "local",
Expand All @@ -67,32 +69,13 @@ describe("AWS SDK usage", () => {
});
});

fn(() => cognitoSdk);

afterEach((done) => {
httpServer.close(() => {
rmdir(path, {
recursive: true,
}).then(done, done);
});
});

describe("createUserPoolClient", () => {
it("can create a new app client", async () => {
const result = await Cognito.createUserPoolClient({
ClientName: "test",
UserPoolId: "test",
}).promise();

expect(result).toEqual({
UserPoolClient: {
AllowedOAuthFlowsUserPoolClient: false,
ClientId: expect.stringMatching(/^[a-z0-9]{25}$/),
ClientName: "test",
CreationDate: expect.any(Date),
LastModifiedDate: expect.any(Date),
RefreshTokenValidity: 30,
UserPoolId: "test",
},
});
});
});
});
};
4 changes: 2 additions & 2 deletions integration-tests/dataStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@ describe("Data Store", () => {
expect(result).toEqual(true);
});

it("returns null if user doesn't exist", async () => {
it("returns null if key doesn't exist", async () => {
const dataStore = await createDataStore("example", {}, path);

const result = await dataStore.get("invalid");

expect(result).toBeNull();
});

it("returns existing user by their sub attribute", async () => {
it("returns existing value", async () => {
const dataStore = await createDataStore("example", {}, path);

await dataStore.set("key", 1);
Expand Down
5 changes: 4 additions & 1 deletion src/bin/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ createDefaultServer()
return server.start({ hostname, port });
})
.then((httpServer) => {
const address = httpServer.address()!;
const address = httpServer.address();
if (!address) {
throw new Error("Server started without address");
}
const url =
typeof address === "string"
? address
Expand Down
2 changes: 1 addition & 1 deletion src/services/codeDelivery/codeDelivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("Code Delivery", () => {
const code = await codeDelivery(user, {
Destination: "0123445670",
DeliveryMedium: "SMS",
AttributeName: "phone",
AttributeName: "phone_number",
});

expect(sender.sendSms).toHaveBeenCalledWith(user, "0123445670", "1234");
Expand Down
6 changes: 5 additions & 1 deletion src/services/codeDelivery/codeDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export type DeliveryDetails =
DeliveryMedium: "EMAIL";
Destination: string;
}
| { AttributeName: "phone"; DeliveryMedium: "SMS"; Destination: string };
| {
AttributeName: "phone_number";
DeliveryMedium: "SMS";
Destination: string;
};

export type CodeDelivery = (
user: User,
Expand Down
2 changes: 1 addition & 1 deletion src/services/cognitoClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("Cognito Client", () => {

const userPool = await cognitoClient.getUserPoolForClientId("testing");

expect(mockDataStore.get).toHaveBeenCalledWith("Clients.testing");
expect(mockDataStore.get).toHaveBeenCalledWith(["Clients", "testing"]);
expect(createUserPoolClient).toHaveBeenCalledWith(
{ Id: "userPoolId", UsernameAttributes: [] },
mockDataStore,
Expand Down
2 changes: 1 addition & 1 deletion src/services/cognitoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const createCognitoClient = async (
},

async getUserPoolForClientId(clientId) {
const appClient = await clients.get<AppClient>(`Clients.${clientId}`);
const appClient = await clients.get<AppClient>(["Clients", clientId]);
if (!appClient) {
throw new ResourceNotFoundError();
}
Expand Down
16 changes: 11 additions & 5 deletions src/services/dataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ const mkdir = promisify(fs.mkdir);

export interface DataStore {
getRoot<T>(): Promise<T | null>;
get<T>(key: string): Promise<T | null>;
get<T>(key: string, defaultValue: T): Promise<T>;
set<T>(key: string, value: T): Promise<void>;
get<T>(key: string | string[]): Promise<T | null>;
get<T>(key: string | string[], defaultValue: T): Promise<T>;
set<T>(key: string | string[], value: T): Promise<void>;
}

export type CreateDataStore = (
Expand Down Expand Up @@ -36,8 +36,14 @@ export const createDataStore: CreateDataStore = async (
return (await db.value()) ?? null;
},

async get(key: string, defaultValue?: unknown) {
return (await db.get(key).value()) ?? defaultValue ?? null;
async get(key: string | string[], defaultValue?: unknown) {
return (
(await (key instanceof Array ? key : [key])
.reduce((acc, k) => acc.get(k), db)
.value()) ??
defaultValue ??
null
);
},

async set(key, value) {
Expand Down
4 changes: 3 additions & 1 deletion src/services/triggers/postConfirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ describe("PostConfirmation trigger", () => {
invoke: jest.fn(),
};
mockUserPoolClient = {
config: {
Id: "test",
},
createAppClient: jest.fn(),
id: "test",
getUserByUsername: jest.fn(),
listUsers: jest.fn(),
saveUser: jest.fn(),
Expand Down
4 changes: 3 additions & 1 deletion src/services/triggers/userMigration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ describe("UserMigration trigger", () => {
invoke: jest.fn(),
};
mockUserPoolClient = {
config: {
Id: "test",
},
createAppClient: jest.fn(),
id: "test",
getUserByUsername: jest.fn(),
listUsers: jest.fn(),
saveUser: jest.fn(),
Expand Down
2 changes: 1 addition & 1 deletion src/services/userPoolClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("User Pool Client", () => {
});

expect(mockClientsDataStore.set).toHaveBeenCalledWith(
`Clients.${result.ClientId}`,
["Clients", result.ClientId],
result
);
});
Expand Down
38 changes: 24 additions & 14 deletions src/services/userPoolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export interface UserAttribute {
Value: string;
}

export interface MFAOption {
DeliveryMedium: "SMS";
AttributeName: "phone_number";
}

export const attributesIncludeMatch = (
attributeName: string,
attributeValue: string,
Expand All @@ -18,6 +23,10 @@ export const attributesInclude = (
attributeName: string,
attributes: readonly UserAttribute[]
) => !!attributes.find((x) => x.Name === attributeName);
export const attributeValue = (
attributeName: string,
attributes: readonly UserAttribute[]
) => attributes.find((x) => x.Name === attributeName)?.Value;
export const attributesToRecord = (
attributes: readonly UserAttribute[]
): Record<string, string> =>
Expand All @@ -34,18 +43,12 @@ export interface User {
Enabled: boolean;
UserStatus: "CONFIRMED" | "UNCONFIRMED" | "RESET_REQUIRED";
Attributes: readonly UserAttribute[];
MFAOptions?: readonly MFAOption[];

// extra attributes for Cognito Local
Password: string;
ConfirmationCode?: string;
}

export interface UserPoolClient {
readonly id: string;
createAppClient(name: string): Promise<AppClient>;
getUserByUsername(username: string): Promise<User | null>;
listUsers(): Promise<readonly User[]>;
saveUser(user: User): Promise<void>;
MFACode?: string;
}

type UsernameAttribute = "email" | "phone_number";
Expand All @@ -56,6 +59,14 @@ export interface UserPool {
MfaConfiguration?: "OFF" | "ON" | "OPTIONAL";
}

export interface UserPoolClient {
readonly config: UserPool;
createAppClient(name: string): Promise<AppClient>;
getUserByUsername(username: string): Promise<User | null>;
listUsers(): Promise<readonly User[]>;
saveUser(user: User): Promise<void>;
}

export type CreateUserPoolClient = (
defaultOptions: UserPool,
clientsDataStore: DataStore,
Expand All @@ -71,10 +82,10 @@ export const createUserPoolClient = async (
Users: {},
Options: defaultOptions,
});
const config = await dataStore.get<UserPool>("Options", defaultOptions);

return {
id: defaultOptions.Id,

config,
async createAppClient(name) {
const id = newId();
const appClient: AppClient = {
Expand All @@ -87,17 +98,16 @@ export const createUserPoolClient = async (
RefreshTokenValidity: 30,
};

await clientsDataStore.set(`Clients.${id}`, appClient);
await clientsDataStore.set(["Clients", id], appClient);

return appClient;
},

async getUserByUsername(username) {
console.log("getUserByUsername", username);

const options = await dataStore.get<UserPool>("Options", defaultOptions);
const aliasEmailEnabled = options.UsernameAttributes?.includes("email");
const aliasPhoneNumberEnabled = options.UsernameAttributes?.includes(
const aliasEmailEnabled = config.UsernameAttributes?.includes("email");
const aliasPhoneNumberEnabled = config.UsernameAttributes?.includes(
"phone_number"
);

Expand Down
4 changes: 3 additions & 1 deletion src/targets/confirmForgotPassword.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ describe("ConfirmForgotPassword target", () => {
advanceTo(now);

mockUserPoolClient = {
config: {
Id: "test",
},
createAppClient: jest.fn(),
id: "test",
getUserByUsername: jest.fn(),
listUsers: jest.fn(),
saveUser: jest.fn(),
Expand Down
2 changes: 1 addition & 1 deletion src/targets/confirmForgotPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const ConfirmForgotPassword = ({
source: "PostConfirmation_ConfirmForgotPassword",
username: user.Username,
clientId: body.ClientId,
userPoolId: userPool.id,
userPoolId: userPool.config.Id,
userAttributes: user.Attributes,
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/targets/confirmSignUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ describe("ConfirmSignUp target", () => {
advanceTo(now);

mockUserPoolClient = {
config: {
Id: "test",
},
createAppClient: jest.fn(),
id: "test",
getUserByUsername: jest.fn(),
listUsers: jest.fn(),
saveUser: jest.fn(),
Expand Down
Loading

0 comments on commit f16afe6

Please sign in to comment.