Skip to content

AWS Cognito User Pools: Create Resources with CDK

Sign in page
Pretty picture of the Login UI we build in the React Cognito User Pools article.

Cognito User Pools is an AWS service that provides applications with user identity and auth. This series of articles cover a full stack solution that enables users to sign in with their Email + Password, Google Sign In, or SSO (SAML), and link all methods to the same user within the app:

  1. Create Resources with CDK 👈 You are here
  2. Sign in with Email, Google, or SAML and link to a single user
  3. Add Auth to a React App

This article focuses on using CDK to create our Cognito User Pool resources as Infrastructure as Code (IAC). If you’re already familiar with this you should consider moving onto the next part of this series Sign in with Email, Google, or SAML and link to a single user, since IAC can be a little lengthy (and boring).

Cognito User Pool CDK (IAC)

Let’s start with the basic setup of the Auth construct before we dive into the details defined in the class methods:

packages/cdk/lib/constructs/Auth.ts
import { existsSync, readFileSync } from 'fs'
import path = require('path')
import { Aws, CfnOutput, Duration } from 'aws-cdk-lib'
import {
ProviderAttribute,
UserPool,
UserPoolClient,
UserPoolEmail,
UserPoolIdentityProviderGoogle,
UserPoolIdentityProviderSaml,
UserPoolIdentityProviderSamlMetadata,
UserPoolOperation,
} from 'aws-cdk-lib/aws-cognito'
import { Runtime } from 'aws-cdk-lib/aws-lambda'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import { Construct } from 'constructs'
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'
import { ITable } from 'aws-cdk-lib/aws-dynamodb'
import { LogGroup } from 'aws-cdk-lib/aws-logs'
// These methods are responsible for getting environment-specific config (.e.g dev, staging, prod)
import {
getEnvironmentConfig,
getIsDeletionProtectionEnabled,
getIsSourceMapsEnabled,
getRemovalPolicy
} from '../environment-config'
import type { StringMap } from '../../../common/types'
// SAML-related variables
const GOOGLE_SAML_IDENTITY_PROVIDER_NAME = 'GoogleSaml'
const googleIDPMetadataPath = path.resolve(__dirname, '../../GoogleIDPMetadata.xml')
const googleIDPMetadataExists = existsSync(googleIDPMetadataPath)
// Cognito Lambda Functions path
const cognitoPackageDir = path.resolve(__dirname, '../../../cognito')
interface AuthProps {
userTable: ITable
webAppUrl: string
}
export default class Auth extends Construct {
readonly userPool: UserPool
readonly userPoolClient: UserPoolClient
constructor(scope: Construct, id: string, props: AuthProps) {
super(scope, id)
this.userPool = this.createUserPool()
this.userPoolClient = this.createUserPoolClient({ additionalOauthCallbackUrls: props.additionalOauthCallbackUrls })
this.addDomainName()
this.addTriggers({ userTable: props.userTable })
}
...additional methods defined below
}

User Pool

packages/cdk/lib/constructs/Auth.ts
createUserPool() {
const userPool = new UserPool(this, 'UserPool', {
signInCaseSensitive: false,
deletionProtection: getIsDeletionProtectionEnabled({ node: this.node }),
removalPolicy: getRemovalPolicy({ node: this.node }),
passwordPolicy: {
minLength: 8,
},
selfSignUpEnabled: true,
signInAliases: {
username: false,
email: true,
},
})
new CfnOutput(this, 'UserPoolId', { key: 'UserPoolId', value: userPool.userPoolId })
return userPool
}

User Pool Client

packages/cdk/lib/constructs/Auth.ts
createUserPoolClient({ webAppUrl }: { webAppUrl: string }) {
const environmentConfig = getEnvironmentConfig(this.node)
const googleIdentityProvider = this.createGoogleIdentityProvider()
const googleSamlIdentityProvider = this.createGoogleSamlIdentityProvider()
const supportedIdentityProviders: {name: string}[] = []
if (googleIdentityProvider) {
supportedIdentityProviders.push({ name: googleIdentityProvider.providerName })
}
if (googleSamlIdentityProvider) {
supportedIdentityProviders.push({ name: googleSamlIdentityProvider.providerName })
}
const callbackUrls = ['http://localhost:3001/']
if (webAppUrl) {
callbackUrls.push(webAppUrl)
}
const userPoolClient = this.userPool.addClient('UserPoolClient', {
idTokenValidity: Duration.days(1),
refreshTokenValidity: Duration.days(90),
supportedIdentityProviders,
oAuth: {
callbackUrls: callbackUrls,
logoutUrls: callbackUrls,
},
})
if (googleIdentityProvider) {
userPoolClient.node.addDependency(googleIdentityProvider)
}
if (googleSamlIdentityProvider) {
userPoolClient.node.addDependency(googleSamlIdentityProvider)
}
new CfnOutput(this, 'UserPoolClientId', { key: 'UserPoolClientId', value: userPoolClient.userPoolClientId })
return userPoolClient
}

We specify up to 2 OAuth callback URLs:

  1. http://localhost:3001/ for local development. Consider removing this for production.
  2. webAppUrl The live web app URL. In Code Genie, this is passed in as either the web app’s custom domain name (if one is defined in cdk.json for the environment we’re deploying) or the default Amplify Hosting URL.

Identity Providers

packages/cdk/lib/constructs/Auth.ts
createGoogleIdentityProvider() {
const { auth } = getEnvironmentConfig(this.node)
if (!auth.googleClientId && !auth.googleClientId) return
const googleIdentityProvider = new UserPoolIdentityProviderGoogle(this, 'GoogleIdp', {
userPool: this.userPool,
clientId: auth.googleClientId,
clientSecret: auth.googleClientSecret,
scopes: ['profile', 'email', 'openid'],
attributeMapping: {
email: ProviderAttribute.GOOGLE_EMAIL,
fullname: ProviderAttribute.GOOGLE_NAME,
familyName: ProviderAttribute.GOOGLE_FAMILY_NAME,
givenName: ProviderAttribute.GOOGLE_GIVEN_NAME,
profilePicture: ProviderAttribute.GOOGLE_PICTURE,
},
})
new CfnOutput(this, 'GoogleIdpName', { key: 'GoogleIdpName', value: googleIdentityProvider.providerName })
return googleIdentityProvider
}

Define the scopes and attributes from the External IDP that we want to store against our User in our User Pool. We’re grabbing the profilePicture so that we can display the user’s profile picture against their name.

packages/cdk/lib/constructs/Auth.ts
createGoogleSamlIdentityProvider() {
if (!googleIDPMetadataExists) return null
const googleIdpMetadataContents = readFileSync(googleIDPMetadataPath, 'utf-8')
const userPoolIdentityProviderSamlMetadata = UserPoolIdentityProviderSamlMetadata.file(googleIdpMetadataContents)
const googleSamlIdentityProvider = new UserPoolIdentityProviderSaml(this, 'GoogleSamlIdp', {
name: GOOGLE_SAML_IDENTITY_PROVIDER_NAME,
userPool: this.userPool,
metadata: userPoolIdentityProviderSamlMetadata,
attributeMapping: {
email: ProviderAttribute.GOOGLE_EMAIL,
fullname: ProviderAttribute.GOOGLE_NAME,
familyName: ProviderAttribute.GOOGLE_FAMILY_NAME,
givenName: ProviderAttribute.GOOGLE_GIVEN_NAME,
},
})
new CfnOutput(this, 'GoogleSamlIdpName', { key: 'GoogleSamlIdpName', value: googleSamlIdentityProvider.providerName })
return googleSamlIdentityProvider
}

There’s a circular dependency between the Cognito User Pool and the SAML App you create in Google Workspace. Exit early if no GoogleIDPMetadata.xml file exists. When you create the SAML App, you’ll need to provide the UserPoolRedirectUrlACS and UserPoolEntityId values from cdk-outputs.development.json. Download the SAML App’s metadata file and redeploy your CDK Stack.

We must hardcode the SAML Identity Provider name (GOOGLE_SAML_IDENTITY_PROVIDER_NAME) to prevent a second circular dependency between the SAML Identity Provider and the PreSignup Cognito Trigger (which we’ll meet soon). It doesn’t seem like this should be a circular dependency since both resources are attached to the User Pool and the SAML Identity Provider has nothing to do with Triggers, but CDK will give you a “Circular dependency between resources” nonetheless.

Our attribute mappings for the SAML Identity Provider are similar to the Google Identity Provider. Unfortunately we don’t have access to a profile picture, and even though we’re requesting fullname here, Google doesn’t allow you to map it when creating the SAML App. We’ll handle this later.

Triggers

packages/cdk/lib/constructs/Auth.ts
addTriggers({ userTable }: { userTable: ITable }) {
this.addPreSignupTrigger()
this.addPreTokenGenerationTrigger({ userTable })
}
// Pre Signup https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html
addPreSignupTrigger() {
const { auth, logRetentionInDays } = getEnvironmentConfig(this.node)
const isSourceMapsEnabled = getIsSourceMapsEnabled({ node: this.node })
const cognitoPreSignupLogGroup = new LogGroup(this, 'PreSignupLogGroup', {
retention: logRetentionInDays,
})
const environment: StringMap = {}
if (isSourceMapsEnabled) {
environment.NODE_OPTIONS = '--enable-source-maps'
}
if (auth?.autoVerifyUsers) {
environment.AUTO_VERIFY_USERS = '1'
}
environment.GOOGLE_SAML_IDENTITY_PROVIDER_NAME = GOOGLE_SAML_IDENTITY_PROVIDER_NAME
const cognitoPreSignupFunction = new NodejsFunction(this, 'PreSignupFunction', {
runtime: Runtime.NODEJS_20_X,
handler: 'handler',
entry: path.join(cognitoPackageDir, 'cognito-pre-signup.ts'),
timeout: Duration.seconds(10),
memorySize: 1024,
logGroup: cognitoPreSignupLogGroup,
bundling: {
sourceMap: isSourceMapsEnabled,
},
environment,
})
const updateCognitoUserPoolPolicyStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'cognito-idp:AdminUpdateUserAttributes',
'cognito-idp:AdminLinkProviderForUser',
'cognito-idp:AdminCreateUser',
'cognito-idp:AdminSetUserPassword',
'cognito-idp:ListUsers',
],
resources: [
`arn:aws:cognito-idp:${Aws.REGION}:${Aws.ACCOUNT_ID}:userpool/*`,
],
})
cognitoPreSignupFunction.addToRolePolicy(updateCognitoUserPoolPolicyStatement)
this.userPool.addTrigger(UserPoolOperation.PRE_SIGN_UP, cognitoPreSignupFunction)
}

Setting AUTO_VERIFY_USERS in development and testing environments is really convenient. It’s just as convenient in production, but there are good reasons to require email confirmation (such as GDPR compliance).

We add our GOOGLE_SAML_IDENTITY_PROVIDER_NAME environment variable since we’ll need that in the Lambda Function’s code.

Our Lambda Function requires 5 permissions for the User Pool. Unfortunately we can’t specify this.userPool.userPoolArn as the resource since CDK once again complains about a circular dependency. We’re left with granting access to all User Pools within the same account and region.

packages/cdk/lib/constructs/Auth.ts
addPreTokenGenerationTrigger({ userTable }: { userTable: ITable }) {
// Pre Token Generation https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html
const { logRetentionInDays } = getEnvironmentConfig(this.node)
const isSourceMapsEnabled = getIsSourceMapsEnabled({ node: this.node })
const cognitoPreTokenGenerationLogGroup = new LogGroup(this, 'PreTokenGenerationLogGroup', {
retention: logRetentionInDays,
})
const environment: StringMap = {
USER_TABLE: userTable.tableName,
}
if (isSourceMapsEnabled) {
environment.NODE_OPTIONS = '--enable-source-maps'
}
const cognitoPreTokenGenerationFunction = new NodejsFunction(this, 'PreTokenGenerationFunction', {
runtime: Runtime.NODEJS_20_X,
handler: 'handler',
entry: path.join(cognitoPackageDir, 'cognito-pre-token-generation.ts'),
timeout: Duration.seconds(10),
memorySize: 1024,
logGroup: cognitoPreTokenGenerationLogGroup,
bundling: {
sourceMap: isSourceMapsEnabled,
},
environment,
})
// Give the Lambda function permission to read and write to DynamoDB
const dynamoDBReadWritePolicy = new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'dynamodb:GetItem',
'dynamodb:PutItem',
'dynamodb:UpdateItem',
],
resources: [
userTable.tableArn,
],
})
cognitoPreTokenGenerationFunction.addToRolePolicy(dynamoDBReadWritePolicy)
// Give the Lambda function permission to update Cognito User Attributes
const updateCognitoUserPoolPolicyStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'cognito-idp:AdminUpdateUserAttributes',
],
resources: [
`arn:aws:cognito-idp:${Aws.REGION}:${Aws.ACCOUNT_ID}:userpool/*`,
],
})
cognitoPreTokenGenerationFunction.addToRolePolicy(updateCognitoUserPoolPolicyStatement)
this.userPool.addTrigger(UserPoolOperation.PRE_TOKEN_GENERATION, cognitoPreTokenGenerationFunction)
}

Environment Config

packages/cdk/lib/environment-config.ts
import * as cdk from 'aws-cdk-lib/core'
import type { Node } from 'constructs'
interface NodeEnvProps {
node?: Node
env?: string
}
export interface CdkJsonEnvironmentConfig {
[environment: string]: CdkJsonEnvironmentConfigEnvironment
}
interface CdkJsonEnvironmentConfigEnvironment {
profile: string
region: string
auth: CdkJsonEnvironmentConfigAuth
logRetentionInDays: number
email: CdkJsonEnvironmentConfigEmail
api?: CdkJsonEnvironmentConfigApi
ui?: CdkJsonEnvironmentConfigUi
isSourceMapsEnabled?: boolean
}
interface CdkJsonEnvironmentConfigAuth {
autoVerifyUsers?: boolean
googleClientId?: string
googleClientSecret?: string
}
interface CdkJsonEnvironmentConfigEmail {
organizationInviteEmail: string
verifyUserEmail?: string
sandboxApprovedToEmails?: string[]
verifiedDomain?: string
}
interface CdkJsonEnvironmentConfigApi {
domainName: string
validationDomain: string
}
interface CdkJsonEnvironmentConfigUi {
domainName: string
}
export function getEnvironmentConfig(node: Node): CdkJsonEnvironmentConfigEnvironment {
const env = getEnvironmentName(node)
const environmentConfig = node.getContext('environmentConfig')[env]
if (!environmentConfig) {
throw new Error(`Missing environment config for ${env}`)
}
return environmentConfig
}
export function getEnvironmentName(node: Node) {
return node.tryGetContext('env') || 'development'
}
export function getIsProd({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
return env === 'production'
}
export function getIsProdish({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
return ['staging', 'production'].includes(env)
}
export function getIsDev({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
return env === 'development'
}
// Source maps are extremely slow; don't run in prod
export function getIsSourceMapsEnabled({
node,
}: { node: Node}) {
const environmentConfig = getEnvironmentConfig(node)
return environmentConfig.isSourceMapsEnabled ?? getIsDev({ node })
}
export function getIsPointInTimeRecoveryEnabled({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
return getIsProdish({ env })
}
export function getRemovalPolicy({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
// NOTE: During initial setup of staging and prod environments, it's beneficial to start with Deletion Protection off and
// and Removal Policy set to Destroy. This way, if things go wrong during setup, it's easy to tear down and start again.
// Once you have stable staging and production environments, remove these early return statements.
return cdk.RemovalPolicy.DESTROY
return getIsDev({ env }) ? cdk.RemovalPolicy.DESTROY : cdk.RemovalPolicy.RETAIN
}
export function getIsDeletionProtectionEnabled({
node,
env = node ? getEnvironmentName(node) : undefined,
}: NodeEnvProps) {
return false
return getIsProdish({ env })
}

The cdk.json looks like this:

packages/cdk/cdk.json
{
"app": "ts-node --prefer-ts-exts bin/cdk.ts",
"context": {
"environmentConfig": {
"development": {...},
"staging": {...},
"production": {
"auth": {
"autoVerifyUsers": false,
"googleClientId": "",
"googleClientSecret": ""
},
"logRetentionInDays": 14,
"ui": {
"domainName": "app.example.com"
}
}
}
}
}

StringMap

packages/common/types.ts
export interface StringMap {
[name: string]: string
}

Create External Identity Providers

Google Sign-in

  1. Login to Google Cloud Console and select or create the relevant project.
  2. Create OAuth client ID:
    1. Select “Web application” as the “Application type” and enter a name
    2. Click “Add URI” under “Authorised JavaScript origins”. Copy the value for UserPoolRedirectUrlACS from cdk-outputs.dev.json and paste in only the domain part (i.e remove the ‘/saml2/idpresponse’).
    3. Click “Add URI” under “Authorised redirect URIs”. Copy the same value from above, but this time add “/oauth2/idpresponse” to the end.
    4. Click Save
  3. Configure OAuth consent screen:
    1. Select User Type “External” and mark it for “Production”
    2. Under “Authorised domains” add “amazoncognito.com”
    3. Save

Google SAML

  1. Login to Google Workspace Admin and navigate to Apps => Web and Mobile Apps.
  2. Click “Add app” => “Add custom SAML App”. Enter a name for your app.
  3. Download the metadata file to ./packages/cdk and click Continue.
  4. From cdk-outputs.development.json, copy the values for UserPoolRedirectUrlACS and UserPoolEntityId and paste them into the respective fields in the Service Provider Details form and click Continue.
  5. Add attribute mappings: Primary email => email; First name => first_name; Last name => family_name. Click Finish.
  6. Click the User Access card; select “ON for everyone” and click Save.
  7. Run npm run deploy:dev

End

Now that we’ve created our AWS Cognito User Pool and both External (Sign in with Google) and Internal (SAML) Google Apps, we’re ready to move onto more interesting topics: Sign in with Email, Google, or SAML and link to a single user. This next article focuses on the Lambda Handler logic of the Lambda Functions we created with CDK.