Deploy Cognito Triggers Using Serverless Framework

Deploy Cognito Triggers Using Serverless Framework

Recently, I posted an article about pre signup triggers for AWS Cognito user pools. The article went over setting up pre signup validation using a lambda function.

In this article, I'm going to demonstrate how to achieve the same goal, but using the serverless framework instead of the AWS console to develop and deploy the lambdas and policies.

Setup

First install the serverless framework using the following command: npm install -g serverless.

Once serverless is installed, create a project with the following command; serverless create --template aws-nodejs -n cognito-triggers.

With the command above, we're creating a nodejs serverless project intended to be hosted on the AWS cloud platform. We then pass the name of cognito-triggers for the project.

Feel free to use whatever language you want in this project, just be sure to follow your language's package installation and build steps where necessary.

Implementation

First, let's create a .env file at the root of the project with the following content:

COGNITO_USER_POOL_NAME=<user_pool_name>
COGNITO_USER_POOL_ID=<user_pool_id>
COGNITO_USER_POOL_ARN=<user_pool_arn>
REGION=us-west-2

If you're deploying to multiple environments (e.g. testing, staging, production) then you should have multiple env files in your project named in the format .env.{stage}. So the production env file will be .env.production.

For this project, we will stick to one env file for the sake of simplicity.

Let's install a few packages we're going to need for this project with the following command: npm install @aws-sdk/client-cognito-identity-provider dotenv serverless-dotenv-plugin

We will need the dotenv and serverless-dotenv-plugin for loading environment variables. We also need the serverless-offline package if we want to invoke our lambda functions locally. We won't be going into that in this article but you can install it with npm install -D serverless-offline.

You'll notice that there's a serveless.yml file at the root of the project. Open the file and add the following code:

service: cognito-triggers

useDotenv: true

plugins:
  - serverless-dotenv-plugin
  - serverless-offline

frameworkVersion: '3'

In the code above, we're setting the service name, setting useDotenv: true to allow us to load environment variables.

The plugins section has 2 plugins:

  1. serverless-dotenv-plugin for loading environment variables.
  2. serverless-offline for running the project on the local machine (in case you want to invoke the functions locally).

Now let's define the provider:

provider:
  name: aws
  runtime: nodejs14.x
  stage: ${opt:stage, 'dev'}
  region: ${env:REGION}
  profile: default
  iam:
    role:
      statements:
        - Effect: 'Allow'
          Action: 'cognito-idp:ListUsers'
          Resource: '${env:COGNITO_USER_POOL_ARN}'

package:
  patterns:
    - '!.gitignore'

We set the provider name to aws because we're using the AWS platform. Our runtime of choice is nodejs14.x. Be sure to use a runtime that's available for the platform you're deploying to.

When setting the stage, we prefer the provided --stage in the command options. If one is not provided, default to dev. This will dictate which environment file to use when running the service offline or deploying. For example sls offline --stage staging will run the service on your local machine using .env.staging while sls deploy --stage production will deploy the service with the .env.production file.

Now for the interesting part, the actual lambda functions! At the top level of the serverless.yml file right below the provider section, create a function section with the following code:

functions:
  pre_signup:
    handler: ./src/pre_signup.handler
    events:
      - cognitoUserPool:
          pool: ${env:COGNITO_USER_POOL_NAME}
          trigger: PreSignUp
          existing: true

The functions section is where we declare the lambda functions in our service. Here we have a pre_signup function.

The handler property of the function points to the handler function exported by a js file. Make sure the path matches the location of your file. Here we have the file in an src folder that's located at the root of the project.

The events property determines what kind of events can trigger this lambda function. This can be an HTTP request through an API gateway, or in our case, a Cognito signup to a user pool.

If you already have an existing user pool, you have to set the existing property to true for the cognitoUserPool while specifying the user pool's name in the pool property.

Let's create the js function to handle all the pre signup logic.

Create an src folder at the root of the project and then create a file called pre_signup.js within that folder. The file will have the following content:

'use strict';
require("dotenv").config({});

const { COGNITO_USER_POOL_ID } = process.env;

const {
  CognitoIdentityProviderClient,
  ListUsersCommand
} = require("@aws-sdk/client-cognito-identity-provider");

module.exports.handler = async (event, context, callback) => {
  const client = new CognitoIdentityProviderClient();

  const listUsersCommand = new ListUsersCommand({
    UserPoolId: COGNITO_USER_POOL_ID,
    Filter: `email = "${event.request.userAttributes.email}"`
  });

  const result = await client.send(listUsersCommand);

  if (result.Users.length > 0) return callback(new Error("Email is already in use."), event);

  callback(null, event);
};

This code is very familiar if you read my previous article on Pre Signup Validation on AWS Cognito. Basically, we're listing the users in our user pool that have the same email address as the one provided in this signup attempt. If we have more than 0, then throw an error stating that the email address is already in use.

Notice, we're exporting a handler function. This is the function that we reference in the serverless.yml file.

While we're here, let's create another function to edit the messages sent to the user's email address. Cognito user pools have a Custom message trigger that allows us to intercept a message before it's sent and edit its content.

In the functions section of serverless.yml, create a function called custom_message with the following properties:

  custom_message:
    handler: ./src/custom_message.handler
    events:
      - cognitoUserPool:
          pool: ${env:COGNITO_USER_POOL_NAME}
          trigger: CustomMessage
          existing: true

It is identical to the pre_signup functions except it's referencing a different handler and hooking into the CustomMessage trigger.

In the src folder create a custom_message.js file with the following content:

'use strict';
require("dotenv").config({});

module.exports.handler = async (event, context, callback) => {
  switch(event.triggerSource) {
    case "CustomMessage_SignUp":
      event.response.smsMessage = `Hi ${event.userName}, your signup code is ${event.request.codeParameter}`;
      event.response.emailSubject = `Your registration code`;
      event.response.emailMessage = `Hi ${event.userName}, your signup code is ${event.request.codeParameter}`;
      break;
    case "CustomMessage_ForgotPassword":
      event.response.smsMessage = `Hi ${event.userName}, your password reset code is ${event.request.codeParameter}. If you did not request this code, ignore this message. Please DO NOT share this code with anyone.`;
      event.response.emailSubject = `Your password reset code`;
      event.response.emailMessage = `Hi ${event.userName}, your password reset code is ${event.request.codeParameter}. If you did not request this code, ignore this email. Please DO NOT share this code with anyone.`;
      break;
    case "CustomMessage_ResendCode":
      event.response.smsMessage = `Hi ${event.userName}, your requested code is ${event.request.codeParameter}`;
      event.response.emailSubject = `Your requested code`;
      event.response.emailMessage = `Hi ${event.userName}, your requested verification code is ${event.request.codeParameter}`;
      break;
    default:
      event.response.smsMessage = `Hi ${event.userName}, your requested code is ${event.request.codeParameter}`;
      event.response.emailSubject = `Your requested code`;
      event.response.emailMessage = `Hi ${event.userName}, your requested code is ${event.request.codeParameter}`;
  }
  callback(null, event);
}

The handler captures different message events and displays a relevant message depending on the message event. CustomMessage_SignUp is the trigger source when the signup verification email is triggered, CustomMessage_ForgotPassword for the password reset email and CustomMessage_ResendCode when a manual code request is triggered (e.g attempting to log in when unconfirmed).

You can find more information about the different trigger sources here.

The event object for this trigger looks like this:

{
  version: '1',
  region: 'us-west-2',
  userPoolId: '<user_pool_id>',
  userName: '<username>',
  callerContext: {
    awsSdkVersion: 'aws-sdk-js-3.58.0',
    clientId: '<client_id>'
  },
  triggerSource: 'CustomMessage_SignUp',
  request: {
    userAttributes: {
      sub: 'd98dad2a-c2f3-4f97-bc49-a3ed3c81f27a',
      email_verified: 'false',
      'cognito:user_status': 'UNCONFIRMED',
      email: '<user_email_address>'
    },
    codeParameter: '{####}',
    linkParameter: '{##Click Here##}',
    usernameParameter: null
  },
  response: { smsMessage: null, emailMessage: null, emailSubject: null }
}

Make sure you include the codeParameter in your custom message.

Deployment

To deploy the app, run: sls deploy. If you are deploying to a specific environment, specify it with the --stage option (e.g. sls deploy --stage staging or sls deploy --stage production).

If you'd like to remove the deployed service, run sls remove or sls remove --stage <stage> and the service along with any resources you might have created in this service will be destroyed.