Send AWS Cognito Emails With 3rd Party ESPs
Send AWS Cognito Emails With 3rd Party ESPs
Send AWS Cognito Emails With 3rd Party ESPs
Resources:
...
KmsKey:
Type: AWS::KMS::Key
Properties:
Enabled: true
KeyPolicy:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
Action: 'kms:*'
Resource: '*'
- Effect: Allow
Principal:
AWS: !Ref CallingUserArn
Action:
- "kms:Create*"
- "kms:Describe*"
- "kms:Enable*"
- "kms:List*"
- "kms:Put*"
- "kms:Update*"
- "kms:Revoke*"
- "kms:Disable*"
- "kms:Get*"
- "kms:Delete*"
- "kms:TagResource"
- "kms:UntagResource"
- "kms:ScheduleKeyDeletion"
- "kms:CancelKeyDeletion"
Resource: '*'
CallingUserArn parameter is a little trick to pass calling IAM user's ARN to CloudFormation:
aws cloudformation deploy ... --parameter-overrides CallingUserArn="$(aws sts get-caller-identity --query Arn --ou
Terraform
data "aws_caller_identity" "current" {}
LambdaTriggerRoleKmsPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- "kms:Decrypt"
Resource: !GetAtt KmsKey.Arn
PolicyName: "LambdaKmsPolicy"
Roles:
- !Ref LambdaTriggerRole
Terraform
data "aws_iam_policy_document" "AWSLambdaTrustPolicy" {
version = "2012-10-17"
statement {
actions = ["sts:AssumeRole"]
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
npm i
The function code:
const AWS = require('aws-sdk')
const b64 = require('base64-js')
const encryptionSdk = require('@aws-crypto/client-node')
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
const msg = {
to: event.request.userAttributes.email,
from: "[email protected]",
subject: "Your Cognito code",
text: `Your code: ${plainTextCode.toString()}`,
}
await sgMail.send(msg)
}
It expects the KMS key ARN and ESP API key to be passed as environment variables. The
notification code is decrypted and can be used in the message body sent to the email
provider API for delivery.
Example event object passed to the function:
{
"version": "1",
"triggerSource": "CustomEmailSender_ForgotPassword",
"region": "us-east-1",
"userPoolId": "us-east-1_LnS...",
"userName": "54cf7eb7-0b96-4304-...",
"callerContext": {
"awsSdkVersion": "aws-sdk-nodejs-2.856.0",
"clientId": "6u7c9vr3pkstoog..."
},
"request": {
"type": "customEmailSenderRequestV1",
"code": "AYADeILxywKhhaq8Ys4mh0aHutYAgQACABVhd3MtY3J5c...",
"clientMetadata": null,
"userAttributes": {
"sub": "54cf7eb7-0b96-4304-8d6b-...",
"email_verified": "true",
"cognito:user_status": "CONFIRMED",
"cognito:email_alias": "[email protected]",
"phone_number_verified": "false",
"phone_number": "...",
"given_name": "Max",
"family_name": "Ivanov",
"email": "[email protected]"
}
}
}
Resources:
...
LambdaTrigger:
Type: AWS::Lambda::Function
Properties:
Code: "../lambda"
Environment:
Variables:
KEY_ID: !GetAtt KmsKey.Arn
SENDGRID_API_KEY: !Ref SendgridApiKey
FunctionName: !Sub ${ProjectName}-lambda-custom-email-sender-trigger
PackageType: Zip
Role: !GetAtt LambdaTriggerRole.Arn
Runtime: nodejs12.x
Handler: index.handler
If you're familiar with CloudFormation, there shouldn't be any surprises.
Terraform
data "archive_file" "lambda" {
type = "zip"
source_dir = "../lambda"
output_path = "lambda.zip"
}
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
auto_verified_attributes = ["email"]
password_policy {
minimum_length = 10
temporary_password_validity_days = 7
require_lowercase = false
require_numbers = false
require_symbols = false
require_uppercase = false
}
schema {
attribute_data_type = "String"
developer_only_attribute = false
mutable = true
name = "email"
required = true
string_attribute_constraints {
max_length = "2048"
min_length = "0"
}
}
schema {
attribute_data_type = "String"
developer_only_attribute = false
mutable = true
name = "name"
required = true
string_attribute_constraints {
max_length = "2048"
min_length = "0"
}
}
username_attributes = ["email"]
username_configuration {
case_sensitive = false
}
}
In order to set the Lambda configuration in the user pool, we will use the aws cognito-idp
update-user-pool --lambda-config "CustomEmailSender={LambdaVersion=V1_0,LambdaArn=... AWS CLI
command.
The problem is, if you don't pass all the other relevant pool options to this command, they
will be reset to the default values. Suggested solution:
1. Deploy Terraform stack without setting the lambda config
2. Generate a skeleton of the input variables expected by the update-user-pool command:
aws cognito-idp update-user-pool --user-pool-id us-east-1_evzTb... --generate-cli-skeleton input
3. Fetch the current configuration of the user pool:
aws cognito-idp describe-user-pool --user-pool-id us-east-1_evzTb... --query UserPool > input.json
4. From the fetched config, remove the keys that are not listed in the skeleton. Only
configuration options accepted by the update-user-pool must be left. One can probably
come up with a script to do this automatically... but I edited it manually.
5. Add and deploy the new null_resource with Terraform.
locals {
update_user_pool_command = "aws cognito-idp update-user-pool --user-pool-id ${aws_cognito_user_pool.cog
}
A quick comment on what's happenning here. We define a local value with the update-user-
pool command. It accepts the user pool ID, the JSON file with current user pool
configuration prepared in step 4. , and the lambda config. Terraform null resource executes
the command the first time you run apply and every time the command or the config file
are updated.
If you get the "Error parsing parameter 'cli-input-json': Invalid JSON received." error, make
sure the path to the input parameters json is correct and is prefixed with file:// . I.e. --cli-
input-json file://${var.update_user_pool_config_file} .
If you get the "Parameter validation failed: Unknown parameter in input: "Id", ..." error, make
sure you removed all keys not supported by the update-user-pool from the parameters file.
If you get the "An error occurred (InvalidParameterException) when calling the
UpdateUserPool operation: Please use TemporaryPasswordValidityDays in PasswordPolicy
instead of UnusedAccountValidityDays" error, remove
the AdminCreateUserConfig.UnusedAccountValidityDays setting. It is replaced
by Policies.PasswordPolicy.TemporaryPasswordValidityDays .
Make sure it works
Once all the resources are deployed we can register a new user to make sure the email
with a code is sent by the ESP.
With CloudFormation you can find out the Cognito User Pool Client ID with
aws cloudformation describe-stacks --stack-name cognito-custom-email-sender-cf-stack --query "Stacks[0].Outpu
It worked!