exanubes

Setup Cognito User Pool with custom emailing service

In this article we will go over creating a user pool, along with a user pool client to use for connecting to Cognito from our application. Instead of using the built-in AWS SES service for sending emails to the user, we will create a lambda that will use SendGrid for sending customer emails. However, because data is encrypted we will have to create a Customer Managed Key in KMS to use for encryption which the lambda will later use for decryption. Once we're done with the infrastructure, we will use AWS SDK v3 to create an API for users to register, verify and sign in. Then we will use the JWT Access Token from Cognito for authorizing users to use protected API endpoints.

Here's a github repo if you're here for the code.

Creating a user pool #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2export class CognitoStack extends Stack {
3 private createUserPool(cmk: Key): UserPool {
4 const userPool = new UserPool(this, 'exanubes-user-pool', {
5 userPoolName: 'exanubes_user_pool',
6 selfSignUpEnabled: true,
7 signInAliases: {
8 username: true,
9 email: true,
10 },
11 removalPolicy: RemovalPolicy.DESTROY,
12
13 standardAttributes: {
14 email: { required: true, mutable: false },
15 },
16 autoVerify: { email: true },
17 customSenderKmsKey: cmk,
18 });
19 }
20}

When creating a userpool we need decide a couple of things, like for example if we want users to be able to signup on their own. Without this option enabled, only an administrator will be able to create users and send invitations to them. We can also set what users will be able to sign in with, here we're gonna allow username and email.

There's a list of standard attributes available, however, we can also define custom attributes with customAttributes property. The most important prop here though, would be the customSenderKmsKey. This is the key that we want Cognito to use for encrypting the verification codes and temporary passwords so that we can decrypt them using that same key and make them available to users while at the same time not being tied to using SES.

Creating a CMK #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2private createCustomManagedKey(): Key {
3 return new Key(this, 'KMS-Symmetric-Key', {
4 keySpec: KeySpec.SYMMETRIC_DEFAULT,
5 alias: keyAlias,
6 enableKeyRotation: false,
7 });
8}

In order to complete the creation of a user pool, we're gonna need a symmetric key. This means that we're using a single key for encryption and decryption. Important to note that we're passing keyAlias variable not a string as we're gonna need that alias later.

Adding a client #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2userPool.addClient('exanubes-user-pool-client', {
3 userPoolClientName: 'exanubes-cognito-app',
4 authFlows: {
5 userPassword: true,
6 },
7 accessTokenValidity: Duration.days(1),
8 idTokenValidity: Duration.days(1),
9 refreshTokenValidity: Duration.days(30),
10 preventUserExistenceErrors: true,
11});

A user pool in and of itself does not provide any access for our application. For that we need to create a client. This is useful as we can create different clients depending on the application we want to connect with and configure them differently. Aside from deciding token validity and different authentication flows. We can also setup 0Auth or decide what attributes a client can read or write. The preventUserExistenceErrors obfuscates the error when a user does not exist to avoid giving that information to the end user.

Creating a custom mailer resource #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2 private createCustomEmailer(cmk: Key): Function {
3 return new Function(this, "custom-emailer-lambda", {
4 code: Code.fromAsset(
5 join(__dirname, "..", "lambdas/custom-email-sender")
6 ),
7 runtime: Runtime.NODEJS_14_X,
8 handler: "index.handler",
9 environment: {
10 KEY_ID: cmk.keyArn,
11 KEY_ALIAS: `arn:aws:kms:${region}:${accountId}:alias/${keyAlias}`,
12 SENDGRID_API_KEY: String(process.env.SENDGRID_API_KEY),
13 },
14 });
15 }

Run-of-the-mill lambda function setup. One thing to note are the environment variables. Considering that our lambda is supposed to decrypt sensitive data sent from cognito, it need the CMK ARN and the CMK alias ARN. In the documentation they claim that the key alias should suffice, however, it's been throwing errors for me when following their instructions. In this example I'll be using sendgrid so that's the key I'm passing here, change it to your own liking.

Creating custom mailer lambda #

Setup #

typescript
Double click to copy
1// lambdas/custom-email-sender/index.ts
2const b64 = require('base64-js');
3const encryptionSdk = require('@aws-crypto/client-node');
4const emailClient = require('@sendgrid/mail');
5
6const { encrypt, decrypt } = encryptionSdk.buildClient(
7 encryptionSdk.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT
8);
9const generatorKeyId = String(process.env.KEY_ALIAS);
10const keyIds = [String(process.env.KEY_ID)];
11const keyring = new encryptionSdk.KmsKeyringNode({ generatorKeyId, keyIds });
12const sendgridApiKey = String(process.env.SENDGRID_API_KEY);
13emailClient.setApiKey(sendgridApiKey);

Starting off with setting up all the keys and clients. We're using the @aws-crypto module to get our encryption and decryption methods as well as a keyring which we create by pointing to the CMK environment variables created in the previous step. This keyring will be used for decryption/encryption operations. Last but not least, we also have to setup the SendGrid client.

Implementation #

typescript
Double click to copy
1// lambdas/custom-email-sender/index.ts
2exports.handler = async (event: CognitoTriggerEvent) => {
3 //Decrypt the secret code using encryption SDK.
4 let plainTextCode;
5 if (event.request.code) {
6 const { plaintext } = await decrypt(
7 keyring,
8 b64.toByteArray(event.request.code)
9 );
10 plainTextCode = plaintext.toString?.();
11 }
12 //PlainTextCode now has the decrypted secret.
13 const user = event.request.userAttributes;
14 const email = {
15 to: `${event.userName} <${user.email}>`,
16 from: 'Exanubes.com <newsletter@exanubes.com>',
17 subject: `[${event.triggerSource}] Welcome to Exanubes.com`,
18 text: `Welcome to exanubes.com,\njoin us by going to http://localhost:3001/verify?code=${plainTextCode}&username=${event.userName}`,
19 };
20
21 switch (event.triggerSource) {
22 case TriggerSource.SignUp:
23 try {
24 await emailClient.send(email);
25 } catch (error) {
26 console.log(error);
27 }
28 break;
29 case TriggerSource.ResendCode:
30 case TriggerSource.ForgotPassword:
31 case TriggerSource.UpdateUserAttribute:
32 case TriggerSource.VerifyUserAttribute:
33 case TriggerSource.AdminCreateUser:
34 case TriggerSource.AccountTakeOverNotification:
35 return;
36 default:
37 const x: never = event.triggerSource;
38 console.log('Unhandled case for trigger source: ', event.triggerSource);
39 return x;
40 }
41};

The meat of the implementation is at the very beginning where we actually have to use the CMK to decrypt data from the request. For that we're using the aforementioned decrypt method and keyring. Because plaintext comes back as Buffer we also had to convert it to a string. Rest of the code is pretty straight-forward were we create an email and send it to the user. In this minimalistic example I'm only sending an email on signup in order to handle user verification, however, as you can see there's a lot of different trigger sources that can be used for sending user emails. I've left out types out of the article for brevity, you can see them on github.

Configuring permissions #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2 private setPermissions(key: Key, lambda: Function): void {
3 // Allow Cognito Service to use key for encryption
4 key.addToResourcePolicy(
5 new PolicyStatement({
6 actions: ["kms:Encrypt"],
7 effect: Effect.ALLOW,
8 principals: [new ServicePrincipal("cognito-idp.amazonaws.com")],
9 resources: ["*"],
10 })
11 );
12
13 // Allow custom emailer lambda to use key
14 lambda.role!.attachInlinePolicy(
15 new Policy(this, "userpool-policy", {
16 statements: [
17 new PolicyStatement({
18 actions: ["kms:Decrypt", "kms:DescribeKey"],
19 effect: Effect.ALLOW,
20 resources: [key.keyArn],
21 }),
22 ],
23 })
24
25 );
26
27 // Allow cognito to use lambda
28 lambda.addPermission("exanubes-cognito-custom-mailer-permission", {
29 principal: new ServicePrincipal("cognito-idp.amazonaws.com"),
30 action: "lambda:InvokeFunction",
31 });
32 }

As for everything in AWS, we still have to explicitly state what service can use which resources and vice versa. First we allow Cognito to use the CMK for encryption. Then We allow our lambda to access the key and use it for decryption. This permission is special though as it is attached inline, this is due to a circular dependency error that occurs when we try to add it in the conventional way – this approach is recommended by the aws-cdk team. Last but not least, we have to allow Cognito to invoke our lambda.

Putting it all together #

typescript
Double click to copy
1// stacks/cognito.stack.ts
2 constructor(scope: Construct, id: string, props: Props) {
3 super(scope, id, props);
4
5 const cmk = this.createCustomManagedKey();
6 const userPool = this.createUserPool(cmk);
7 const customEmailer = this.createCustomEmailer(cmk);
8
9 this.setPermissions(cmk, customEmailer);
10
11 userPool.addTrigger(UserPoolOperation.CUSTOM_EMAIL_SENDER, customEmailer);
12 }

Thus far we have created a user pool and added a connection client to use in our application. We've also created a Customer Managed Key [CMK] for encrypting and decrypting sensitive data sent from cognito to our lambda which will be used as the custom mailer function instead of SES. With all the permissions configured, all that was left was adding a trigger to the user pool to point it to our lambda.

Now, we're going to use the AWS SDK v3 to create a backend API for registering, verifying and signing users in. Then we'll create a passport.js jwt strategy to authorize Cognito Access Tokens to use protected API routes.

Setup SDK Client #

typescript
Double click to copy
1// auth/auth.service.ts
2class AuthService {
3 private readonly client: CognitoIdentityProviderClient;
4 constructor(private readonly awsConfigService: AwsConfigService) {
5 this.client = new CognitoIdentityProviderClient({
6 region: awsConfigService.region,
7 credentials: {
8 accessKeyId: awsConfigService.accessKeyId,
9 secretAccessKey: awsConfigService.secretAccessKey,
10 },
11 });
12 }
13}

For this part we're gonna use the @aws-sdk/client-cognito-identity-provider library from aws. V3 is pretty straight forward as the API is very standardized throughout the different services. First off we need to create a client instance, for that we need a couple of things. accessKeyId and secretAccessKey can be generated in IAM service, region should be the same as the region where the user pool is going to be.

All data is fed to awsConfigService through environment variables in .env file

Create Signup, Verify and Signin methods #

typescript
Double click to copy
1// auth/auth.service.ts
2 async signup({ password, username, email }: SignUpDto) {
3 const input: SignUpCommandInput = {
4 Password: password,
5 Username: username,
6 ClientId: this.awsConfigService.userPoolClientId,
7 UserAttributes: [{ Name: 'email', Value: email }],
8 };
9 const command = new SignUpCommand(input);
10 return this.client.send(command);
11 }
typescript
Double click to copy
1// auth/auth.service.ts
2 async verifySignup(code: string, username: string) {
3 const input: ConfirmSignUpCommandInput = {
4 ClientId: this.awsConfigService.userPoolClientId,
5 Username: username,
6 ConfirmationCode: code,
7 };
8 const command = new ConfirmSignUpCommand(input);
9 return this.client.send(command);
10 }
typescript
Double click to copy
1// auth/auth.service.ts
2 async login({ username, password }: LoginDto) {
3 const input: InitiateAuthCommandInput = {
4 ClientId: this.awsConfigService.userPoolClientId,
5 AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
6 AuthParameters: {
7 USERNAME: username,
8 PASSWORD: password,
9 },
10 };
11 const command = new InitiateAuthCommand(input);
12 const response = await this.client.send(command);
13 return response.AuthenticationResult;
14 }

As I mentioned before, the API is very standardized so it's quite simple to use. The crux of it is to find the right command. After that it's just about getting the right props for the input and sending the command.

In this case we have a simple implementation where we're gonna collect user data via the frontend application, however, the ClientId is referring to the client we've added to the user pool. You can either look for it in Cognito user pool section in AWS Console, or you can add a CloudFormation output to the Cognito stack.

typescript
Double click to copy
1// stacks/cognito.stack.ts
2new CfnOutput(this, 'exanubes-user-pool-client-id', {
3 value: client.userPoolClientId,
4});

Implement passport strategy for Cognito tokens #

typescript
Double click to copy
1// auth/auth.service.ts
2export class JwtStrategy extends PassportStrategy(Strategy) {
3 constructor(private readonly awsConfigService: AwsConfigService) {
4 super({
5 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
6 ignoreExpiration: false,
7 secretOrKeyProvider: async (request, jwtToken, done) => {
8 const jwtHeader: { kid: string } = jwt_decode(jwtToken, {
9 header: true,
10 });
11 const res = await fetch(awsConfigService.issuerAddress);
12 const data = await res.json();
13 const jwk = data.keys.find((key) => key.kid === jwtHeader.kid);
14
15 if (!jwk) {
16 throw new Error('Something went wrong');
17 }
18 done(null, jwkToPem(jwk));
19 },
20 algorithms: ['RS256'],
21 });
22 }
23 async validate(payload) {
24 return payload;
25 }
26}

Probably the most important part of the server implementation. After we log a user in and send him the Access Token generated by Cognito, we need to have a way of validating the token in order to either authorize or deny access to the user. As with any jwt, in order to validate it we have to know what secret was used to encode it. The thing is, we don't know 'cause AWS did it for us. Luckily, we can get access to the secrets by calling the issuer address which is the cognito identity provider or cognito-idp. In response we get an array of JSON Web Keys(JWK), one of them is ours. In order to determine which one it is, we use the key id (kid) from the JWT Token and look for a JWK with the same kid. Once we have the right JWK we convert it to PEM and that's the secret for verifying JWT.

Should you want more granular authorization based on token payload, it can be done in the validate function. In this small example we just allow all valid tokens.

WARNING: Implementation details not regarding AWS have been omitted for brevity. If you're interested in the full implementation with NestJS, go to the github repository for this article.

Summary #

In this article we've gone over several important points for working with AWS Cognito. First of all as it turns out there's a distinction between a Cognito User Pool and a Client. Former is the brains of the operation and the latter is the entrypoint and configuration of what the connected application can do via the client. We've also used the KMS service to generate an encryption key which allowed us to replace the SES service and use our own 3rd party mailing service via a Lambda function. After setting up all the permissions between Cognito, KMS and Lambda we've moved on to the backend service where we used the AWS SDK v3 to create all the relevant endpoints for creating and signing users in. Last but not least, we've setup a Passport strategy for authorizing Cognito Access Tokens and allowing or denying access to users on protected API endpoints.

Thanks!

A verification email has been sent to

Keep in Touch

Join other developers in Exanubes Newsletter

© 2022