Skip to content

Commit

Permalink
feat(api): support for CustomEmailSender
Browse files Browse the repository at this point in the history
  • Loading branch information
kylefix committed May 18, 2022
1 parent 4920c19 commit 8dcaf10
Show file tree
Hide file tree
Showing 19 changed files with 1,230 additions and 433 deletions.
82 changes: 81 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A _Good Enough_ offline emulator for [Amazon Cognito](https://aws.amazon.com/cog
- [Updating your application](#updating-your-application)
- [Creating your first User Pool](#creating-your-first-user-pool)
- [Configuration](#configuration)
- [Custom Email Sender Trigger](#custom-email-sender-trigger)
- [HTTPS endpoints with self-signed certificates](#https-endpoints-with-self-signed-certificates)
- [User Pools and Clients](#user-pools-and-clients)
- [Known Limitations](#known-limitations)
Expand Down Expand Up @@ -179,7 +180,13 @@ cognito-local how to connect to your local Lambda server:
| Trigger | Operation | Support |
| --------------------------- | ------------------------------------ | ------- |
| CreateAuthChallenge | \* ||
| CustomEmailSender | \* ||
| CustomEmailSender | CustomEmailSender_SignUp ||
| CustomEmailSender | CustomEmailSender_ResendCode ||
| CustomEmailSender | CustomEmailSender_ForgotPassword ||
| CustomEmailSender | CustomEmailSender_UpdateUserAttribute||
| CustomEmailSender | CustomEmailSender_VerifyUserAttribute||
| CustomEmailSender | CustomEmailSender_AdminCreateUser ||
| CustomEmailSender | CustomEmailSender_AccountTakeOver... ||
| CustomMessage | AdminCreateUser ||
| CustomMessage | Authentication ||
| CustomMessage | ForgotPassword ||
Expand Down Expand Up @@ -314,6 +321,13 @@ You can edit that `.cognito/config.json` and add any of the following settings:
| `UserPoolDefaults` | `object` | | Default behaviour to use for the User Pool |
| `UserPoolDefaults.MfaConfiguration` | `string` | | MFA type |
| `UserPoolDefaults.UsernameAttributes` | `string[]` | `["email"]` | Username alias attributes |
| `KMSConfig` | `object` | | Any setting you would pass to the AWS.KMS Node.js client |
| `KMSConfig.KMSKeyId` | `string` | `local` | The KMSKeyId to pass to encrypt the code |
| `KMSConfig.KMSKeyAlias` | `string` | `local` | The KMSKeyAlias to pass to encrypt the code |
| `KMSConfig.credentials.accessKeyId` | `string` | `local` | |
| `KMSConfig.credentials.secretAccessKey` | `string` | `local` | |
| `KMSConfig.endpoint` | `string` | `local` | |
| `KMSConfig.region` | `string` | `local` | |

The default config is:

Expand All @@ -332,10 +346,76 @@ The default config is:
"TriggerFunctions": {},
"UserPoolDefaults": {
"UsernameAttributes": ["email"]
},
"KMSConfig": {
"credentials": {
"accessKeyId": "local",
"secretAccessKey": "local",
},
"region": "local",
}
}
```

### Custom Email Sender Trigger
To use a the custom email sender trigger you **must** provide the `KMSKeyID` and `KMSKeyAlias` properties in the `KMSConfig` property in the `.cognito/config.json` file.

One way of setting this up locally is as follows:

You can use the (local kms)[https://github.com/nsmithuk/local-kms] package to simulate a locally running KMS service.

Create a `./local-kms/seed.yml` file and populate it with the KMS Key and the KMS Alias:

```yml
Keys:
Symmetric:
Aes:
- Metadata:
KeyId: bc436485-5092-42b8-92a3-0aa8b93536c
BackingKeys:
- 5cdaead27fe7da2de47945d73cd6d79e36494e73802f3cd3869f1d2cb0b5d7a9
Aliases:
- AliasName: alias/testing
TargetKeyId: bc436485-5092-42b8-92a3-0aa8b93536c
```
We can use docker-compose to start local-kms:
```yml
local-kms:
image: nsmithuk/local-kms
volumes:
- ./local-kms/:/init
environment:
KMS_ACCOUNT_ID: '999999999'
KMS_REGION: 'us-west-2'
```
This will expose the `local-kms` service in the docker network at `http://local-kms:8080`. It will also create a KMS Key with arn: `arn:aws:kms:us-west-2:999999999:key/bc436485-5092-42b8-92a3-0aa8b93536c` and an KMS Alias with arn `arn:aws:kms:us-west-2:999999999:alias/testing`.

Now in our cognito-local `.cognito/config.json` file we just need to populate it with these values:

```yml
"TriggerFunctions": {
"CustomEmailSender": "your-custom-email-sender-trigger-function-here"
},
"KMSConfig": {
"KMSKeyId": "arn:aws:kms:us-west-2:999999999:key/bc436485-5092-42b8-92a3-0aa8b93536c",
"KMSKeyAlias": "arn:aws:kms:us-west-2:999999999:alias/testing",
"endpoint": "http://local-kms:8080"
}
```

Your custom email sender trigger should now be called with the encrypted code. You can then decrypt it following (aws' documentation)[https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-email-sender.html]. However, make sure to use the same `local-kms` endpoint, KMS Key and KMS Alias when decrypting the code:

```ts
const kmsKeyringNode = new kmsSdk.KmsKeyringNode({
generatorKeyId: 'arn:aws:kms:us-west-2:999999999:alias/testing',
keyIds: ['arn:aws:kms:us-west-2:999999999:key/bc436485-5092-42b8-92a3-0aa8b93536c'],
clientProvider: () => new AWS.KMS({ endpoint: 'http://local-kms:8080' }),
});
```

### HTTPS endpoints with self-signed certificates

If you need your Lambda endpoint to be HTTPS with a self-signed certificate, you will need to disable certificate
Expand Down
5 changes: 3 additions & 2 deletions integration-tests/aws-sdk/adminCreateUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe(
const createUserResult = await client
.adminCreateUser({
UserAttributes: [{ Name: "phone_number", Value: "0400000000" }],
Username: "abc",
Username: "[email protected]",
UserPoolId: "test",
})
.promise();
Expand All @@ -31,12 +31,13 @@ describe(
Value: expect.stringMatching(UUID),
},
{ Name: "phone_number", Value: "0400000000" },
{ Name: "email", Value: "[email protected]" },
],
Enabled: true,
UserCreateDate: roundedDate,
UserLastModifiedDate: roundedDate,
UserStatus: "FORCE_CHANGE_PASSWORD",
Username: "abc",
Username: "[email protected]",
},
});
});
Expand Down
15 changes: 11 additions & 4 deletions integration-tests/aws-sdk/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { otp } from "../../src/services/otp";
import { JwtTokenGenerator } from "../../src/services/tokenGenerator";
import { UserPoolServiceFactoryImpl } from "../../src/services/userPoolService";
import { Router } from "../../src/server/Router";
import { CryptoService } from "../../src/services/crypto";

const mkdtemp = promisify(fs.mkdtemp);
const rmdir = promisify(fs.rmdir);
Expand Down Expand Up @@ -57,10 +58,16 @@ export const withCognitoSdk =
new UserPoolServiceFactoryImpl(clock, dataStoreFactory)
);
const cognitoClient = await cognitoServiceFactory.create(ctx, {});
const triggers = new TriggersService(clock, cognitoClient, {
enabled: jest.fn().mockReturnValue(false),
invoke: jest.fn(),
});
const triggers = new TriggersService(
clock,
cognitoClient,
{
enabled: jest.fn().mockReturnValue(false),
invoke: jest.fn(),
},
new CryptoService({ KMSKeyId: "", KMSKeyAlias: "" })
);

const router = Router({
clock,
cognito: cognitoClient,
Expand Down
38 changes: 26 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
"@types/jest": "^25.2.1",
"@types/jsonwebtoken": "^8.5.6",
"@types/lodash.mergewith": "^4.6.6",
"@types/node": "^16.11.11",
"@types/supertest": "^2.0.11",
"@types/uuid": "^8.3.3",
"@types/node": "^16.11.11",
"@typescript-eslint/eslint-plugin": "^2.27.0",
"@typescript-eslint/parser": "^2.27.0",
"esbuild": "^0.14.2",
Expand All @@ -60,7 +60,8 @@
"typescript": "^4.5.2"
},
"dependencies": {
"aws-sdk": "^2.1045.0",
"@aws-crypto/client-node": "^3.1.1",
"aws-sdk": "2.1136.0",
"body-parser": "^1.19.0",
"boxen": "^5.1.2",
"cors": "^2.8.5",
Expand Down Expand Up @@ -102,21 +103,34 @@
"url": "https://github.com/jagregory/cognito-local.git"
},
"release": {
"branches": ["master"],
"branches": [
"master"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
["@semantic-release/exec", {
"publishCmd": "./scripts/dockerBuildPush.sh ${nextRelease.version}"
}],
["@semantic-release/github", {
"addReleases": "top"
}],
["@semantic-release/git", {
"assets": ["CHANGELOG.md"]
}]
[
"@semantic-release/exec",
{
"publishCmd": "./scripts/dockerBuildPush.sh ${nextRelease.version}"
}
],
[
"@semantic-release/github",
{
"addReleases": "top"
}
],
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md"
]
}
]
]
}
}
1 change: 1 addition & 0 deletions src/__tests__/mockTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Triggers } from "../services";

export const newMockTriggers = (): jest.Mocked<Triggers> => ({
customMessage: jest.fn(),
customEmailSender: jest.fn(),
enabled: jest.fn(),
postAuthentication: jest.fn(),
postConfirmation: jest.fn(),
Expand Down
9 changes: 9 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FunctionConfig } from "../services/lambda";
import { UserPool } from "../services/userPoolService";
import { TokenConfig } from "../services/tokenGenerator";
import mergeWith from "lodash.mergewith";
import { KMSConfig } from "../services/crypto";

export type UserPoolDefaults = Omit<
UserPool,
Expand All @@ -14,6 +15,7 @@ export interface Config {
LambdaClient: AWS.Lambda.ClientConfiguration;
TriggerFunctions: FunctionConfig;
UserPoolDefaults: UserPoolDefaults;
KMSConfig?: AWS.KMS.ClientConfiguration & KMSConfig;
TokenConfig: TokenConfig;
}

Expand All @@ -33,6 +35,13 @@ export const DefaultConfig: Config = {
// TODO: this needs to match the actual host/port we started the server on
IssuerDomain: "http://localhost:9229",
},
KMSConfig: {
credentials: {
accessKeyId: "local",
secretAccessKey: "local",
},
region: "local",
},
};

export const loadConfig = async (
Expand Down
4 changes: 3 additions & 1 deletion src/server/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { UserPoolServiceFactoryImpl } from "../services/userPoolService";
import { Router } from "./Router";
import { loadConfig } from "./config";
import { createServer, Server } from "./server";
import { CryptoService } from "../services/crypto";

export const createDefaultServer = async (
logger: pino.Logger
Expand Down Expand Up @@ -58,7 +59,8 @@ export const createDefaultServer = async (
new LambdaService(
config.TriggerFunctions,
new AWS.Lambda(config.LambdaClient)
)
),
new CryptoService(config.KMSConfig)
);

return createServer(
Expand Down
58 changes: 58 additions & 0 deletions src/services/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
buildClient,
CommitmentPolicy,
KmsKeyringNode,
} from "@aws-crypto/client-node";
import { KMS } from "aws-sdk";
import { Context } from "./context";

export interface KMSConfig {
KMSKeyId?: string;
KMSKeyAlias?: string;
}

const { encrypt } = buildClient(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT);

export class CryptoService {
_keyringNode?: KmsKeyringNode;
config?: KMSConfig & AWS.KMS.ClientConfiguration;

constructor(config?: KMSConfig) {
this.config = config;
}

get keyringNode(): KmsKeyringNode {
if (this._keyringNode) {
return this._keyringNode;
}

if (!this.config || !this.config.KMSKeyAlias || !this.config.KMSKeyId) {
throw new Error(
"KMSConfig.KMSKeyAlias and KMSConfig.KMSKeyId is required when using a CustomEmailSender trigger."
);
}

const { KMSKeyId, KMSKeyAlias, ...clientConfig } = this.config;

const generatorKeyId = KMSKeyAlias;
const keyIds = [KMSKeyId];

return (this._keyringNode = new KmsKeyringNode({
generatorKeyId,
keyIds,
clientProvider: () => new KMS(clientConfig),
}));
}

async encrypt(ctx: Context, plaintext: string): Promise<string> {
ctx.logger.debug({ plaintext }, "encrypting code");

const { result } = await encrypt(this.keyringNode, plaintext);

const encryptedCode = result.toString("base64");

ctx.logger.debug({ encryptedCode }, "code succesfully encrypted");

return encryptedCode;
}
}
Loading

0 comments on commit 8dcaf10

Please sign in to comment.