Validating and Organising User Sign Ups in AWS with Cognito Lambda Triggers and Dynamo DB using Amplify

Written by Tom Wilkins
Thu, 11 Jun 2020

In this post, I'll demonstrate how you can quickly scaffold a customised registration flow that allows you to control which users can register, and how to organise users that are able to sign up.

Validating and Organising User Sign Ups in AWS with Cognito Lambda Triggers and Dynamo DB using Amplify

AWS Amplify is a development platform from Amazon, that allows you to build full stack serverless applications - applications that include authentication, databases, APIs and of course your front-end code.

I'm currently using it to a build a new B2B application that requires authentication with tight restrictions on access, and Amplify has made the process a lot simpler than it otherwise would have been.

In this walkthrough, we'll be using Amplify and create-react-app to scaffold a demo app that:

  • Allows users to register if the suffix of their email address matches a key in the database
  • Maps users to User Groups in AWS Cognito once their email address is confirmed

Setting Up

Amplify will be using the following AWS resources:

  • Cognito, for user management
  • Lambda, for backend logic
  • Dynamo DB, for data storage

If you want to follow along, you'll need:

  • Node.js
  • An AWS account, with suitable permissions for creating resources
  • Amplify CLI

Initialising Your Project

Let's start by creating a new React application. Create a new folder where you would like your application to live, and run the following command in your terminal, inside your new folder:

npx create-react-app .

Once that has completed, we can initialise our Amplify project. Assuming the Amplify CLI is installed and configured, run the following command, in the same terminal as before:

amplify init

This will set up Amplify and allow you to start adding resources. It will ask you questions about your project, but it's highly intuitive - for example it will recognise that you are building a React application and use settings most applicable. This is also true if your project is written in Vue, or Angular.

Adding Authentication

Now we are ready to start adding resources. Let's start with authentication, by running:

amplify add auth

This will ask you questions about the authentication you want to implement, for this walkthrough we'll choose manual configuration.

It'll run through all configuration options, feel free to pick what's appropriate for you or just go with defaults. The key question for this walkthrough though is Do you want to enable any of the following capabilities?

For this question, we'll deviate from the defaults and select two of the options presented:

  • Add User to Group
  • Email Domain Filtering (whitelist)

A couple of questions later it'll ask if you want to configure Lambda triggers - the answer to this is yes. It'll then ask which triggers you want enabled, it should have preselected the following:

  • Post Confirmation
  • Pre Sign-up

Accept these, and the associations it makes between the capabilities and the triggers. When asked for the name of a group, and the list of domains, these can be left blank as we won't be using them.

After following these steps, you'll notice there is an amplify folder in your project, and inside it there is a backend folder containing auth and function folders. What's happened is we have initiliased auth (AWS Cognito) for our project, and added two Lambda functions that will be used with the Post Confirmation and Pre Sign-up triggers.

What are these triggers?

When a user registers, they'll typically follow a process like this:

  1. Provide details
  2. Receive Verification Code
  3. Enter Verification Code
  4. Registered

The Pre Sign-up trigger will invoke a Lambda function between steps 1 and 2, while the Post Confirmation trigger will invoke a Lambda function between steps 3 and 4.

These Lambda functions will intercept the registration process and allow us to perform custom logic, capable of rejecting a registration and bucketing users into groups.

Amplify has provided templates based on the options chosen - Add to Group and Domain Whitelist. These templates have been chosen as the functionality the provide is not too far away from our use-case, however we'll be using Dynamo DB to drive the logic, so let's add that as a resource now.

Adding Storage

Again, the Amplify CLI gives us a simple command to set up a database:

amplify add storage

From the options, we'll choose NoSql Database, which means we'll be setting up a table in Dynamo DB. We'll name the resource and the table name UserGroups as this is what we'll be storing in the database.

When prompted to add columns, we'll add the first column as EmailDomain, which will be of type string. We'll then add a second column of UserGroup, also of type string.

EmailDomain will be our partition key, and we don't need to add sorting keys or secondary global indexes at this point.

We also don't want to add a Lambda trigger to this table. This does not correspond to the triggers we created earlier, but gives you the option to trigger a Lambda function when the table changes. Very useful, but not applicable here!

Setup of storage is now be complete, so you should see a storage folder alongside your function and auth folders. Now we need to start using this in our Lambda functions.

Linking Resources

We now have all of the AWS resources we need to get an authentication flow going, however we need the Lambda functions to be able to access our Dynamo DB database. This isn't enabled by default - in AWS it's best practice to give resources permission to access what they need to operate, and nothing more.

This is something that can be quite painful in the AWS console, particularly when something changes. For example, if we wanted to remove the database table and replace it with a new one, we'd need to update all the resources that use it.

This is the key problem that Amplify is solving for us. All resources our application needs are within our Amplify project, so when we want to give our Lambda functions permission to access Dynamo DB, it's a case of running this command:

amplify update function

It will ask you which function you want to update out of the two we have created, so let's start with PostConfirmation. It'll then ask:

Do you want to update permissions granted to this Lambda function to perform on other resources in your project?

Pick yes, as this is exactly what we want to do. Then out of the options, select storage. It'll ask which operations are permitted - the Lambda function will only be reading from the database, so select read.

We don't want to invoke on a recurring schedule or edit the Lambda now, so select "no" for both of those. You can then repeat the very same process for the Pre Sign-up Lambda function.

Adding Backend Logic to the Lambda Functions

Up to now, all of the work has been done in the terminal - we haven't written a single line of code! Let's do something about that.

If you open the function folder, you'll see folders for the two Lambdas. Let's start with Pre Sign-up, as chronologically that will be invoked first.

Pre Sign-up Trigger

There are auto-generated configuration files in the folder, but all the code is contained in src. Let's look at index.js first of all.

At the top of the file, you should notice the following:

/* Amplify Params - DO NOT EDIT
ENV
REGION
STORAGE_USERGROUPS_ARN
STORAGE_USERGROUPS_NAME
Amplify Params - DO NOT EDIT *//*
this file will loop through all js modules which are uploaded to the lambda resource,
provided that the file names (without extension) are included in the "MODULES" env variable.
"MODULES" is a comma-delimmited string.
*/

Amplify has provided us with some handy environment variables, relating to our storage resource. This means that we can reference these instead of hardcoding the table name when we connect to Dynamo DB. If you named your table differently to this example, your variable names will differ to those shown above so make sure to use the names provided to you.

We won't be editing this index file though. Instead, we'll open the email-filter-white-list.js file beside it, as that's where the whitelisting logic template is.

Let's replace it with the following code:

const aws = require('aws-sdk');
const dynamoDb = new aws.DynamoDB({ region: process.env.REGION })

exports.handler = (event, context, callback) => {
const { email } = event.request.userAttributes;
const emailSuffix = email.split('@')[1];
const params = {
Key: {
"EmailSuffix": {
S: emailSuffix
},
},
TableName: process.env.STORAGE_USERGROUPS_NAME
};
dynamoDb.getItem(params).promise().then(response => {
if (response.Item && response.Item.UserGroup && response.Item.UserGroup.S) {
callback(null, event);
}
else {
return Promise.reject(new Error('Invalid domain, no User Group found'))
}
}).catch(callback);
};

If you compare this to the template file Amplify generated, We've brought in the AWS SDK (a Node module, available to all Node.js Lambda functions) and created a new Dynamo DB class to work with. Notice how it's being initialised specifying the region using the environment variable Amplify provided us with. This is really important as we want to ensure we aren't doing cross-region data transfer which is unnecessary and costly.

We're then extracting the domain from the email address and checking it against the database, again using the environment variable to specify the table name.

If the domain is in the database, the response should return a value under Item.UserGroup.S, so if that property exists we'll invoke the callback with null and event. In a Node.js Lambda function, invoking the callback is essentially returning a response. The first argument is the error (if applicable), and in this case we are returning the event. For this particular Lambda, this means the registration is good to continue.

If the domain is not found in the database, a rejected promise is returned specifying an error. Being a promise rejection, this error will be passed to the .catch which will then invoke the callback with the error the promise was rejected with. We're not including the event in the callback in this case, which means registration cannot continue. This will result in an error shown to the user which we'll come to later.

Post Confirmation Trigger

The Post Confirmation Lambda will be invoked when a user has confirmed their email address, which is a good time to consider adding them to a User Group. My B2B example is a good use case for groups because each business will have several employees, and we'll want to add them all to the same group. This will help control what they can access in our application.

In the PostConfirmation folder, open src and index.js. If you have gone through the steps of adding storage permissions, you should see the same environment variables as we saw earlier.

However again, we won't be editing this file but instead, the add-to-group.js file beside it. For this, we'll use the following code:

const aws = require('aws-sdk');
const dynamoDb = new aws.DynamoDB({region: process.env.REGION })

exports.handler = async (event, context, callback) => {
const cognitoidentityserviceprovider = new aws.CognitoIdentityServiceProvider({ apiVersion: '2016-04-18' });
const { email } = event.request.userAttributes;
const emailSuffix = email.split('@')[1];
const params = {
Key: {
"EmailSuffix": {
S: emailSuffix
},
},
TableName: process.env.STORAGE_USERGROUPS_NAME
};
const GroupName = await dynamoDb.getItem(params).promise().then(response => response.Item.UserGroup.S).catch(callback);
const groupParams = {
GroupName,
UserPoolId: event.userPoolId,
};

const addUserParams = {
...groupParams,
Username: event.userName,
};

try {
await cognitoidentityserviceprovider.getGroup(groupParams).promise();
} catch (e) {
await cognitoidentityserviceprovider.createGroup(groupParams).promise();
}

try {
await cognitoidentityserviceprovider.adminAddUserToGroup(addUserParams).promise();
callback(null, event);
} catch (e) {
callback(e);
}
};

Similarly to the last Lambda, we're initialising a DynamoDB class with the region environment variable, doing some work to extract the domain from the email address, and looking it up in the database.

However this time, we're assigning the UserGroup value to a variable GroupName, which is used to construct the groupParams. This is defining the name of the group and the User Pool the group will belong to.

We then construct addUserParams, this requires the same properties as groupParams with the addition of a Username. Here the ...spread operator can be used to copy those in and specify the Username, which is provided by the event.

The try/catch that follows attempts to get the group specified. If it doesn't exist, we'll enter the catch block that creates it as a new group.

The second try/catch adds the user to the group, knowing by this point that it should exist. If all goes well, we'll invoke the callback with null and event to respond that the user can be registered. If something goes wrong with this, it'll enter the catch black and invoke the callback with the error that occured. Again, this error will appear in the front-end when we get there.

So now, both Lambda functions are set up to read from the database and act accordingly. We had better populate the database otherwise nobody will be able to register! Before we can do that, we'll need to publish our resources.

Publishing Resources

As it stands the authentication, Lambda functions and the database we have created do not exist yet in AWS. It's all defined locally in configuration files.

This is because Amplify is built on top of CloudFormation, a service from AWS that allows you to define infrastructure as code. This is written in YAML or JSON, and Amplify has generated it on our behalf - we'll only need to touch it if we get into advanced usage, which isn't necessary here.

To publish resources to the cloud, enter the following command:

amplify push

This will show what has changed locally vs. what is published in the cloud. As this is the first time publishing, all resources should show as created. Confirm the publish and sit back - it will take some time on the first run.

If you're worried about publishing resources to the cloud, don't be - at the end of this post there are details on how to clean up the project.

Once it's finished, our database will be live which means we can add some records. We'll only be adding one for the purposes of this post, so will do so directly in the console. Amplify gives you a handy shortcut to open it up:

amplify console

Navigate to the area for Dynamo DB and locate your table, you can then add items to it, for example:

{
"EmailDomain": "gmail.com",
"UserGroup": "gmail"
}

The above example is purely for demonstration, in the real world you would create a mapping between business email domains and an appropriate UserGroup name for their business.

If you're following along, I'd recommend adding the email domain for an email address in your posession, so you can register yourself and see the process working.

Adding the UI

We've now got all of our backend resources ready, so let's connect our front end to it! Amplify provides ready made UI components we can use, and it's all open source. For a real world application you may want to create your own, but it's perfect for this demo where the focus is primarily backend.

To use the ready made React components, let's install the aws-amplify/ui-react package:

npm i @aws-amplify/ui-react

Once installed, we can then replace the code for App.js (the main component) with the following:

import React from 'react';
import Amplify from 'aws-amplify';
import { AmplifyAuthenticator, AmplifySignOut } from '@aws-amplify/ui-react';
import awsconfig from './aws-exports';

Amplify.configure(awsconfig);

const App = () => (
<AmplifyAuthenticator>
<div>
My App
<AmplifySignOut />
</div>
</AmplifyAuthenticator>
);

export default App

We can then start the application:

npm start

You should then see a login form like the below. We can use this to try creating an account and see how our application reacts.

Amplify UI Login Form

Try creating an account with an email that isn't in your database. It should return an error containing the message in our Lambda function!

Then, try creating an account with an email address that is in your database. It should prompt you to enter a verification code, and send you an email with said code.

On entering the code, you will be registered. Then in the AWS console, you should be able to look in Cognito, see the UserPool for your application, and then see users.

There should be a single user (you), and under the groups tab, there should be a single group that follows the mapping we set.

Doing all of this without Amplify would take considerably longer, and be more difficult to maintain - so I'd definitely recommend checking it out for your next serverless project.

Deleting Resources

As this has just been a demo, you may want to remove the resources created if you have been following along. Fortunately, Amplify provides a command for this too:

amplify delete

This will remove everything from the cloud and delete all the local files created too.

Thank you for reading

You have reached the end of this blog post. If you have found it useful, feel free to share it on Twitter using the button below.

Tags: TIL, Node.js, JavaScript, Blog, AWS, Amplify, Lambda, Cognito, Dynamo DB, Serverless