AWS Cognito User Pools: Create Resources with CDK

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:
- Create Resources with CDK 👈 You are here
- Sign in with Email, Google, or SAML and link to a single user
- 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:
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 variablesconst GOOGLE_SAML_IDENTITY_PROVIDER_NAME = 'GoogleSaml'const googleIDPMetadataPath = path.resolve(__dirname, '../../GoogleIDPMetadata.xml')const googleIDPMetadataExists = existsSync(googleIDPMetadataPath)
// Cognito Lambda Functions pathconst 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
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
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:
http://localhost:3001/
for local development. Consider removing this for production.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
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.
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
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.htmladdPreSignupTrigger() { 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.
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
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 prodexport 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:
{ "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
export interface StringMap { [name: string]: string}
Create External Identity Providers
Google Sign-in
- Login to Google Cloud Console and select or create the relevant project.
- Create OAuth client ID:
- Select “Web application” as the “Application type” and enter a name
- Click “Add URI” under “Authorised JavaScript origins”. Copy the value for
UserPoolRedirectUrlACS
fromcdk-outputs.dev.json
and paste in only the domain part (i.e remove the ‘/saml2/idpresponse’). - Click “Add URI” under “Authorised redirect URIs”. Copy the same value from above, but this time add “/oauth2/idpresponse” to the end.
- Click Save
- Configure OAuth consent screen:
- Select User Type “External” and mark it for “Production”
- Under “Authorised domains” add “amazoncognito.com”
- Save
Google SAML
- Login to Google Workspace Admin and navigate to Apps => Web and Mobile Apps.
- Click “Add app” => “Add custom SAML App”. Enter a name for your app.
- Download the metadata file to
./packages/cdk
and click Continue. - From
cdk-outputs.development.json
, copy the values forUserPoolRedirectUrlACS
andUserPoolEntityId
and paste them into the respective fields in the Service Provider Details form and click Continue. - Add attribute mappings: Primary email => email; First name => first_name; Last name => family_name. Click Finish.
- Click the User Access card; select “ON for everyone” and click Save.
- 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.