Logo Blog Newsletters

Amazon Cognito Triggers: All You Need To Know About Them | Part 1

Published on October 2, 2023
~ 10 min read
AWS
Cognito
Serverless
Article cover

Amazon Cognito lets you add user sign-up, sign-in, and access control to your web and mobile apps quickly and easily. Amazon Cognito scales to millions of users and supports sign-in with social identity providers, such as Apple, Facebook, Google, and Amazon, and enterprise identity providers via SAML 2.0 and OpenID Connect.

First and foremost we will call our dear user Frodo (if you don't know him I got good news for you), we will follow him through the registration and login process, doing so we will better understand how we can personalize Cognito and make it safer for him….and us. We will use 9 out of 12 Cognito triggers, which means: when Frodo does some action like signing up or verify his email, it triggers a specific Lambda function which runs custom logic and send it's results to Cognito.

Note: we will use serverless services under the AWS Free Tier, except for AWS KMS and SMS delivery which you will need to opt-in if you want to test it out.

Yes we talked about real world scenario, that's why in this article we will cover your questions😉:

  • How can we put a cooldown period after Frodo wrongly attempts too many times to login?
  • Can we keep some sort of audit when Frodo logs in?
  • Well you know Frodo, he's forgetful and right after receiving the confirmation email he goes his way with The Fellowship, so how can we remind him to confirm his account?
  • Only users with our corporate email can sign up, how can we handle that?
  • We need to personalize every emails Frodo receives, is it possible?
  • I know you have your Amazon SES to send emails and it integrates natively, but I got my own SMTP and I want to use it, how can we do this?
  • Alright you got me, your post is cool 😎, I want to migrate Frodo and his friends from my old provider to Cognito, is there any way to do it? I know you have more questions but this is part 1 out of (probably) 2, we will answer them all in the next article…. yeah you got me, that's why we are missing 3 out of 12 Cognito triggers.

Introduction

Let's start listing the requirements, what we are going to do and how we could do it:

  • As Frodo has so many enemies we need a way to block them from logging in, after too many attempts, just for a few minutes. To do so we could use the Pre Authentication trigger with help from Amazon DynamoDB.
  • We must collect some audit like last login date, we can save it using the Post Authentication trigger directly to Amazon DynamoDB.
  • Frodo went on an adventure before confirming it's email, we could remind him he needs to confirm his email by scheduling a reminder and sending an email to him. To do so we can use the Pre Signup trigger joined by AWS Step Function and AWS Lambda. Using the same trigger we can block users from registering if they don't use our corporate email domain, like we could block Frodo from registering if he's not using a @gmail.com address.
  • To personalize every email sent by Cognito we can use the Custom Message trigger and Amazon SES. Moreover after Frodo confirms his email we may want to send a confirmation email, to do so we use the Post Confirmation trigger and Amazon SES.
  • We have our own SMTP service and we do not want to use Amazon SES to send emails or Amazon SNS to send SMS. We can use our own SMTP provider by writing custom logic in the Custom Email Sender trigger and the Custom SMS Sender trigger, also we need Amazon KMS to decrypt Cognito's secret like temporary password or verification code🤫, meanwhile to send SMS we will use Twilio and to send emails we will use SendGrid.
  • We made a good first impression, now we can't mess it up. Quite convenient we could use the Migrate User trigger to migrate user from an old provider to Cognito.
  • I know you didn't ask for it but for the sake of completeness I must add the Pre Token Generation trigger which will allow us to add or remove attributes to Frodo's identity token.

Solution overview

solution overview

The architecture is not so complex as it may seem, we will create the following components:

  • Cognito and triggers: contains Cognito user pools and lambda functions;
  • Step functions: contains the state machine and the lambda functions;
  • Database: which is just a DynamoDB table. Now that we know the components let's explore them more in details.

Requirements

Before we start we need to do a bit of setup.

  1. Creating and setting up the CDK project. If you need any help with that you can read my previous article here Geofencing and tracking areas of interest using Amazon Location

  2. To lean custom sender triggers we need providers for sending emails and SMS, I did choose Twilio and SendGrid as providers so if you want to enable those triggers you should copy and paste your credentials (see snippet below). Well you could also do something different to test them out, use the SES and SNS APIs, you will need to write your own code for that;

  "cognito": {
    "enableCustomSender": false,
    "customProvider": {
      "email": {
        "api-key": "SENDGRID_API_KEY",
        "sender": "SENDGRID_SENDER_EMAIL"
      },
      "sms": {
        "api-key": "TWILIO_API_KEY",
        "senderId": "TWILIO_SENDER_ID",
        "sender": "TWILIO_SENDER_NUMBER"
      }
    }
  }
  1. I did use SES in sandbox mode so I've wrote a CDK Stack which creates as many identities as you specify inside the context variables. Doing so you will receive confirmation emails for each email you specified.
  "ses": {
    "identity": "youremail@address.com",
    "receivers": [
      "youremail1@address.com",
      "youremail2@address.com",
      "youremail3@address.com",
      "youremail4@address.com"
    ]
  }

Alright let's explore Cognito triggers one by one.

Note: I won't show every single line of code, for that you can find the repository at the bottom 👇


Signup

This is a little sequence diagram for better understanding of what is going to happen when Frodo signups

Signup sequence diagram

Pre Signup trigger

The function get triggered before the actual signup, doing so we can do custom logic like allowing only Gmail, iCloud and Outlook email domains and then start a step function which will remind Frodo to confirm and, if he doesn't, the next step will delete Frodo's account.

  const address = event.request.userAttributes.email.split("@");
  if (address[1] !== 'gmail.com' && address[1] !== 'icloud.com' && address[1] !== 'outlook.com') {
    throw new Error('You must use gmail, icloud or outlook');
  }

  await sfn.startExecution({
    input: JSON.stringify(JSON.stringify({ id: event.userName })),
    name: event.userName,
    stateMachineArn: process.env.REMINDER_STATE_MACHINE
  }).promise();

  return event;

This is just an example, in fact the step function timer is set to 2 minutes to send a reminder and another 2 minutes to delete the unconfirmed user.

Custom message trigger

The trigger gets fired every time an email has to be sent by Cognito though SES, it let us personalize those emails, that's when the backend guy must use its skills to center the div 😜.

  if (event.triggerSource === 'CustomMessage_SignUp') {
    event.response.emailMessage = `Welcome, use this code ${event.request.codeParameter}`;
    event.response.emailSubject = 'Welcome to my blog';
  }

  return event;

There are a lot more trigger source for this one, just take a look here https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-message.html

Post Confirmation trigger

This is an interesting one, it gets fired when Frodo confirms his signup or successfully change his password. We can do a lot of custom logics in here, the snippet below will save Frodo to DynamoDB and then send him an email welcoming him.

  if (event.triggerSource === 'PostConfirmation_ConfirmSignUp') {
    await dynamodb
      .put({
        TableName: process.env.USERS_TABLE,
        Item: {
          id: event.request.userAttributes.sub,
          email: event.request.userAttributes.email,
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
          lastLoginAt: new Date().toISOString(),
        },
        ConditionExpression: 'attribute_not_exists(id)',
      })
      .promise();
    console.info(`Successfully executed put in ${process.env.USERS_TABLE}`);

    const params = {
      Source: process.env.SES_IDENTITY,
      Destination: { ToAddresses: [ event.request.userAttributes.email, ], },
      Message: {
        Subject: { Data: 'Welcome confirmed user', Charset: 'UTF-8', },
        Body: { Html: { Data: 'Glad you are here', Charset: 'UTF-8', }, },
      },
    };
    await ses.sendEmail(params).promise();
    console.info(`Successfully sent email to user ${event.request.userAttributes.sub}`);
  }
  return event;

Login

Here is how Cognito triggers behave when Frodo logs in

Login sequence diagram

Pre Authentication trigger

Cognito invokes the trigger when Frodo attempts to sign in, as such we could use this trigger to block Frodo from logging in after 5 login attempts. Let's see how it's done

  // if last attemped login was more than 5 minutes ago, restart with countdown
  if (!result.Item.lastAttemptedLoginAt || new Date().getTime() > new Date(result.Item.lastAttemptedLoginAt).getTime() + 60 * 5 * 1000) {
    result.Item.totalAttempts = 0;
    result.Item.status = null;
  }
  result.Item.totalAttempts++;
  result.Item.status = result.Item.totalAttempts <= 5 ? null : 'blocked';
  result.Item.lastAttemptedLoginAt = new Date().toISOString();

  const paramsUpdate = {
    TableName: process.env.USERS_TABLE,
    Key: {
      id: event.request.userAttributes.sub,
    },
    UpdateExpression: `set ${makeUpdateExpression(result.Item)}`,
    ...getQueryExpressions(result.Item),
    ReturnValues: 'UPDATED_NEW',
  };
  await dynamodb.update(paramsUpdate).promise();
  console.info(`Successfully update user in ${process.env.USERS_TABLE}`);

  if (result.Item.status === 'blocked') {
    throw new Error('Too much attempts, user has been blocked, please wait 5 minutes ☕');
  }

  return event;

We use DynamoDB to track how many attempts and when the last one was. If the state happens to be blocked then Frodo won't access unless he waits 5 minutes.

I left the condition at the bottom on purpose, I want Frodo to just stop for 5 minutes even after he has been blocked. Moreover we could have a trigger after it reaches a certain amount of attempts and maybe send an email to Frodo.

Post Authentication trigger

This trigger gets invoked right after Frodo signs in but before the Pre Token Generation Trigger. We used this trigger to save Frodo's last successful login in our DynamoDB users table.

  const params = {
    TableName: process.env.USERS_TABLE,
    Key: { id: event.request.userAttributes.sub, },
    UpdateExpression: `set #l = :l`,
    ExpressionAttributeNames: { '#l' : 'lastLoginAt' },
    ExpressionAttributeValues: { ':l' : new Date().toISOString(), },
    ReturnValues: 'UPDATED_NEW',
  };
  await dynamodb.update(params).promise();
  console.info(`Successfully update user in ${process.env.USERS_TABLE}`);

  return event;

Pre Token Generation trigger

This trigger is the easiest of them all, it simply suppresses the email attribute from the generated token and it does it every time a new token is issued

  event.response = {
      claimsOverrideDetails: {
          claimsToSuppress: ['email']
      }
  }
  return event;

There are a few claims you can't modify, take a look straight to the docs here https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html


Migrate users

This is how you can migrate users to Amazon Cognito, here you can find a sequence of all the triggers involved

Migrate users sequence diagram

Migrate User trigger

This one is really interesting, basically it gets triggered when Frodo tries to login (or change his password) but the email doesn't exist within the user pool. If this trigger return a successful response Cognito will create Frodo's account automatically. As the name suggests the trigger scope is to migrate from an old provider to a new one, here's what we need to do to migrate Frodo from the old provider to Cognito:

  1. authenticate Frodo with the old provider, we user Cognito user pool as our "old provider" so we can test it out
  const resInitAuth = await cognitoIDP.adminInitiateAuth({
    AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
    AuthParameters: {
      PASSWORD: password,
      USERNAME: email,
    },
    ClientId: process.env.OLD_USER_POOL_CLIENT_ID,
    UserPoolId: process.env.OLD_USER_POOL_ID,
  }).promise();
  console.info(`Successfully adminInitiateAuth`);

  const payload = await verifier.verify(resInitAuth.AuthenticationResult.IdToken);

  const user = await cognitoIDP.adminGetUser({
    Username: payload.sub,
    UserPoolId: process.env.OLD_USER_POOL_ID,
  }).promise();
  console.info(`Successfully get user ${user.Username}`);

  return { email: user.UserAttributes.find(e => e.Name === 'email').Value, };
  1. return success to Cognito
  event.response.userAttributes = {
    email: user.email,
    email_verified: 'true',
  };
  event.response.finalUserStatus = "CONFIRMED";
  event.response.messageAction = "SUPPRESS";
  return event;

And voilà Frodo is migrated from the old provider to Cognito.

Remember it also triggers the pre authentication, we need to write some code over there otherwise it wont find any users in our table and it will fail.

Custom Sender trigger

If this trigger is enabled then you will have complete ownership of notifying users, which could be by email, phone number, push notification etc. For this blogpost we are using SendGrid for email notification and Twilio for SMS, which means every email and every SMS won't be sent though SES nor SNS.

First of all we need to decrypt the code Cognito sends to us, to do so we use a bunch of libraries which will use the KMS key to decrypt it

  const { buildClient, CommitmentPolicy, KmsKeyringNode } = require('@aws-crypto/client-node');
  const { toByteArray } = require('base64-js');

  // Configure the encryption SDK client with the KMS key from the environment variables.  
  const { decrypt } = buildClient(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT);
  const generatorKeyId = process.env.KEY_ALIAS_ARN;
  const keyIds = [process.env.KEY_ARN];
  const keyring = new KmsKeyringNode({ generatorKeyId, keyIds })

  let secret;
  if (event.request.code) {
    const decryptRes = await decrypt(keyring, toByteArray(event.request.code));
    secret = decryptRes.plaintext.toString();
  }

Now that we have our secret value in plaintext we can send it to Frodo by email with SendGrid

  const sendEmail = async (subject, body, toAddress) => {
    const params = {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.EMAIL_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        'personalizations': [{ 'to': [{ 'email': toAddress, }] }],
        'from': { 'email': process.env.EMAIL_SENDER, },
        'subject': subject,
        'content': [{ 'type': 'text/plain', 'value': body, }]
      })
    };

    await fetch('https://api.sendgrid.com/v3/mail/send', params)
  }

  const [subject, body] = composeEmail(event.triggerSource, secret);
  await sendEmail(subject, body, event.request.userAttributes.email);
  console.info(`Successfully sent email to ${event.request.userAttributes.sub}`);

or sending him a text message with Twilio

  const sendSMS = async (text, to) => {
    const params = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + Buffer.from(`${process.env.SMS_SENDER_ID}:${process.env.SMS_API_KEY}`).toString('base64'),
      },
      body: `Body=${text}&From=${process.env.SMS_SENDER}&To=${to}`
    };

    await fetch(`https://api.twilio.com/2010-04-01/Accounts/${process.env.SMS_SENDER_ID}/Messages.json`, params);
  }

  const text = composeSMS(event.triggerSource, secret);
  await sendSMS(text, event.request.userAttributes.phone_number);
  console.info(`Successfully sent SMS to ${event.request.userAttributes.sub}`);

Integration test

If you want to test it out I've prepared some basic NodeJS scripts within the folder test/. Just remember to change the environment variables, also when you run "migrate-user" there will be a prompt input where you should type the confirmation code received.

  • create-user (you can also specify if you want to receive an email or a text message);
  • confirm-user;
  • initiate-auth;
  • migrate-user.

Conclusion

In this blogpost we deep dive into Amazon Cognito triggers, better yet we learned by doing some real world scenarios. As you read there are a few really handy triggers we can use to customize our identity provider and the best thing it's how fast it was to code and deploy, love it.

You can find the project here https://github.com/Depaa/cognito-triggers 😉

Thank you so much for reading! 🙏 I will keep posting different AWS architecture from time to time so follow me on LinkedIn 👉 https://www.linkedin.com/in/matteo-depascale/.