How to setup cross-account Cognito User Pool migration with the Migrate User Lambda Trigger

14 minute read
Content level: Advanced
0

I would like to setup cross-account user migration between two of my Cognito user pools. How can I configure the migrate user Lambda trigger to seamlessly implement this migration? Are there any code templates available for the migrate user Lambda trigger?

Short description

Cognito User Pools are able to integrate with the Lambda service using a feature known as Lambda Triggers. These are Lambda Functions that can be automatically executed at specific stages of User Pool authentication in order to enhance the behavior of the authentication flow. This article addresses one specific Lambda Trigger; the Migrate User Lambda Trigger. This is a Lambda Function which executes during a user's sign-in attempt (or a password reset attempt) if the user does not already exist in the user pool. If the Lambda Function completes successfully, then it creates the user in the new user pool.

This functionality of the Migrate User Lambda Trigger has several applications, but a very common use of this Lambda Trigger is in implementing a "just-in-time" (JIT) user migration flow from one User Pool to another. While it is possible to manually import users into a new User Pool with a CSV file, this Migrate User Lambda Trigger method is useful when you would like to seamlessly migrate existing user accounts from an old User Pool to a new User Pool during their normal sign-in process. This article provides step-by-step instructions (with template Python and NodeJS scripts) to setup this cross-account user migration flow between two User Pools.

Resolution

This user migration implementation will perform the following steps:

  1. A user tries to authenticate or reset password with the new User Pool
  2. The new User Pool triggers the Migrate User Lambda Trigger function if the user is not found in the new User Pool
  3. The Migrate User Lambda Trigger function assumes a Role in the old User Pool's Account
  4. The Migrate User Lambda Trigger function uses the assumed Role to authenticate the user in the old User Pool, and get the user's attributes
  5. If the user is successfully authenticated with the old User Pool, then the validated user is created in the new User Pool

To implement this user migration setup, you must first have the following AWS resources deployed:

NOTE: In these steps, we will refer to the old/source User Pool's Account as '111111111111' and the new/destination User Pool's Account as '222222222222'. These Account numbers will be different in your actual AWS environment.

STEP 1: Setup configuration in the old/source User Pool's Account (Account 111111111111)

  1. Enable the ALLOW_ADMIN_USER_PASSWORD_AUTH authentication flow for the old/source User Pool:
    • AWS Cognito Console -> User Pools -> (select old User Pool) -> App integration tab -> App client list -> (select old App Client) -> App client information -> select Edit -> Add ALLOW_ADMIN_USER_PASSWORD_AUTH in Authentication flows -> Finish by selecting Save changes
  2. Create an IAM Role for the Migrate User Lambda Function to assume and authenticate users in the old User Pool:
    • AWS IAM Console -> Roles -> Create role
      • Trusted entity type -> Account -> 222222222222
      • Role name: CognitoCrossAccountMigrationRole
      • Finish by selecting Create role
  3. Assign the appropriate permissions to the IAM Role:
    • AWS IAM Console -> Roles -> (select CognitoCrossAccountMigrationRole) -> Add permissions -> Create inline policy
      • Policy name: CognitoCrossAccountMigrationPolicy
      • Create the following policy in the JSON Policy Editor, and finish by selecting Create policy
	{
		"Version": "2012-10-17",
		"Statement": [
			{
				"Sid": "PermissionForUserPoolActions",
				"Effect": "Allow",
				"Action": [
					"cognito-idp:AdminInitiateAuth",
					"cognito-idp:AdminGetUser"
				],
				"Resource": "*"
			}
		]
	}

STEP 2: Setup configuration in the new/destination User Pool's Account (Account 222222222222)

  1. Enable the ALLOW_ADMIN_USER_PASSWORD_AUTH authentication flow for the new/destination User Pool:
    • AWS Cognito Console -> User Pools -> (select new User Pool) -> App integration tab -> App client list -> (select new App Client) -> App client information -> select Edit -> Add ALLOW_ADMIN_USER_PASSWORD_AUTH in Authentication flows -> Finish by selecting Save changes
  2. Create the Migrate User Lambda Function new/destination User Pool's Account:
    • AWS Lambda Console -> Functions -> Create function -> Author from scratch
      • Function name: CognitoCrossAccountMigrationFunction
      • Runtime: Node.js 20.x or Python 3.12
      • Finish by selecting Create function
  3. Add the following code to the Migrate User Lambda Function. Select the appropriate code for your specific setup:
    • AWS Lambda Console -> Functions -> (select CognitoCrossAccountMigrationFunction) -> Code tab -> Select and copy the appropriate code from the following list into the Lambda function's index.mjs or lambda_function.py file. NOTE: Code templates are provided at the bottom of this article in the 'Code' section
      • MigrateUserLambdaFunction-NodeJS (Node.js 20.x function runtime, App Client without Client Secret)
      • MigrateUserLambdaFunction-NodeJS-ClientSecret (Node.js 20.x function runtime, App Client with Client Secret)
      • MigrateUserLambdaFunction-Python (Python 3.12 function runtime, App Client without Client Secret)
      • MigrateUserLambdaFunction-Python-ClientSecret (Python 3.12 function runtime, App Client with Client Secret)
    • Update the following variables in the code with the values from the old/source User Pool:
      • sourceAccountRoleARN (CognitoCrossAccountMigrationRole in this example)
      • sourceAccountUserPoolId
      • sourceAccountClientId
      • sourceAccountRegion (Only necessary if User Pools are cross-region)
      • sourceAccountClientSecret (Only necessary if the old App Client has a Client Secret configured)
    • Finish by selecting Deploy
  4. Assign the appropriate permissions to the Lambda Function's Execution Role:
    • AWS Lambda Console -> Functions -> (select CognitoCrossAccountMigrationFunction) -> Configuration tab -> Permissions -> (select Execution Role) -> Add permissions -> Create inline policy
      • Policy name: CognitoCrossAccountMigrationAssumeRolePolicy
      • Create the following policy in the JSON Policy Editor, and finish by selecting Create policy
	{
		"Version": "2012-10-17",
		"Statement": [
			{
				"Sid": "PermissionToAssumeRole",
				"Effect": "Allow",
				"Action": "sts:AssumeRole",
				"Resource": "arn:aws:iam::111111111111:role/CognitoCrossAccountMigrationRole"
			}
		]
	}
  1. Set the Lambda Function as the Migrate User Lambda Trigger in the new/destination User Pool:
    • AWS Cognito Console -> User Pools -> (select new User Pool) -> User pool properties tab -> Lambda triggers -> Add Lambda trigger -> (Sign-up, Migrate user trigger) -> Lambda function -> (select CognitoCrossAccountMigrationFunction) - Finish by selecting Add Lambda trigger

You should now have a completed user migration setup! Now it's time to test it.

STEP 3: Test the cross-account user migration

  1. (Optional) Setup and execute a test event in Lambda to test the Migrate User Lambda Trigger function:
    • AWS Lambda Console -> Functions -> (select CognitoCrossAccountMigrationFunction) -> Code tab -> Test -> Configure test event -> Create new event
      • Create the following test event in the JSON Event Editor, and finish by selecting Save (Be sure to replace the appropriate values with the parameters from your old User Pool)
{
  "version": "1",
  "triggerSource": "UserMigration_Authentication",
  "region": "ENTER-SOURCE-REGION",
  "userPoolId": "ENTER-SOURCE-APP-CLIENT-ID",
  "userName": "ENTER-SOURCE-TEST-USERNAME",
  "callerContext": {
    "awsSdkVersion": "aws-sdk-unknown-unknown",
    "clientId": "ENTER-SOURCE-APP-CLIENT-ID"
  },
  "request": {
    "password": "ENTER-SOURCE-TEST-USERS-PASSWORD",
    "validationData": "None",
    "userAttributes": "None"
  },
  "response": {
    "userAttributes": "None",
    "forceAliasCreation": "None",
    "enableSMSMFA": "None",
    "finalUserStatus": "None",
    "messageAction": "None",
    "desiredDeliveryMediums": "None"
  }
}
  1. Attempt to sign-in to the new User Pool with a user from the old User Pool. Use the Hosted UI of the new User Pool to sign-in:
    • AWS Cognito Console -> User Pools -> (select new User Pool) -> App integration tab -> App client list -> (select new App Client) -> Hosted UI -> select View Hosted UI
    • Sign-in with credentials from the old/source User Pool
  2. If the user successfully authenticates, then the user is also created in the new User Pool:

Related information

Code

  1. MigrateUserLambdaFunction-NodeJS
// MigrateUserLambdaFunction-NodeJS
// Migrate User Lambda Trigger Template - Cross-Account User Pool Migration
// NodeJS 20.x runtime
// AWS SDK for Javascript v3

// Declare source Account variables. Enter the information for your User Pool from your source Account
const sourceAccountRoleARN = "ENTER-SOURCE-ROLE-ARN";
const sourceAccountRegion = "ENTER-SOURCE-REGION";
const sourceAccountUserPoolId = "ENTER-SOURCE-USER-POOL-ID";
const sourceAccountClientId = "ENTER-SOURCE-APP-CLIENT-ID";

// Declare global variables and import required AWS SDK libraries
import { CognitoIdentityProvider, AdminGetUserCommand, AdminInitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";
import { STS, AssumeRoleCommand } from "@aws-sdk/client-sts";
const stsclient = new STS();
//const stsclient = new STS({ region: sourceAccountRegion });

// Import cross-account credentials
var paramsAssumeRole = {
    RoleArn: sourceAccountRoleARN,
    RoleSessionName: "CrossAccountCognitoMigration"
};

const {Credentials} = await stsclient.send(new AssumeRoleCommand(paramsAssumeRole));

const tempCredentialsObj = {
    accessKeyId: Credentials.AccessKeyId,
    secretAccessKey: Credentials.SecretAccessKey,
    sessionToken: Credentials.SessionToken
};

const cognitoidpclient = new CognitoIdentityProvider({ credentials: tempCredentialsObj });
//const cognitoidpclient = new CognitoIdentityProvider({ credentials: tempCredentialsObj, region: sourceAccountRegion });

// Main Function
export const handler = async (event, context, callback) => {

    // Logs event to CloudWatch Logs. Useful for basic script troubleshooting	
    console.log(event);

    // Declare local variables
    let user;

    // Basic logic handling
    if (event.triggerSource == "UserMigration_Authentication") {
        // Authenticate the User with the existing source User Pool
        user = await authenticateUser(event.userName, event.request.password);

        let userAttributes = {};

        if (user.Username) {
            user.UserAttributes.forEach(attribute => {
                if (attribute.Name == "sub") {
                    return;
                }
                userAttributes[attribute.Name] = attribute.Value;
            });

            event.response.userAttributes = userAttributes;
            event.response.finalUserStatus = "CONFIRMED";
            event.response.messageAction = "SUPPRESS";
            context.succeed(event);

        } else {
            // Return error to Amazon Cognito
            callback("Bad password");
        }

    } else if (event.triggerSource == "UserMigration_ForgotPassword") {
        // Lookup the user in the existing source User Pool
        user = await getUserPoolUser(event.userName);

        if (user.Username) {
            let userAttributes = {};
            user.UserAttributes.forEach(attribute => {
                if (attribute.Name == "sub") {
                    return;
                }
                userAttributes[attribute.Name] = attribute.Value;
            });

            // NOTE: The 'email_verified' or 'phone_number_verified' attribute must be
            // set to 'true' in order to enable password-reset code to be sent to the
            // user. If the attribute is not already set in the source user pool, you
            // can uncomment the following line of code to set the 'email_verified' or
            // 'phone_number_verified' attribue as verified:
            //     userAttributes['email_verified'] = "true";
            //     userAttributes['phone_number_verified'] = "true";

            event.response.userAttributes = userAttributes;
            event.response.messageAction = "SUPPRESS";
            context.succeed(event);

        } else {
            // Return error to Amazon Cognito
            callback("Bad password");
        }

    } else {
        // Return error to Amazon Cognito
        callback("Bad triggerSource " + event.triggerSource);
    }
};


// Function 1 - authenticateUser - Authenticates the User with the source User Pool
async function authenticateUser(username, password) {

    let rez = "";

    const paramsInitiateAuth = {
        AuthFlow: "ADMIN_USER_PASSWORD_AUTH",
        ClientId: sourceAccountClientId,
        UserPoolId: sourceAccountUserPoolId,
        AuthParameters: {
            USERNAME: username,
            PASSWORD: password
        }
    };

    const authrez = await cognitoidpclient.send(new AdminInitiateAuthCommand(paramsInitiateAuth));

    if (authrez.hasOwnProperty("AuthenticationResult")) {
        rez = getUserPoolUser(username);
    }

    return rez;
}


// Function 2 - getUserPoolUser - Gets User information from source User Pool
async function getUserPoolUser(username) {

    let rez = "";

    const paramsGetUser = {
        UserPoolId: sourceAccountUserPoolId,
        Username: username
    };

    rez = await cognitoidpclient.send(new AdminGetUserCommand(paramsGetUser));

    return rez;
}
  1. MigrateUserLambdaFunction-NodeJS-ClientSecret
// MigrateUserLambdaFunction-NodeJS-ClientSecret
// Migrate User Lambda Trigger Template - Cross-Account User Pool Migration (w/ Client Secret)
// NodeJS 20.x runtime
// AWS SDK for Javascript v3

// Declare source Account variables. Enter the information for your User Pool from your source Account
const sourceAccountRoleARN = "ENTER-SOURCE-ROLE-ARN";
const sourceAccountRegion = "ENTER-SOURCE-REGION";
const sourceAccountUserPoolId = "ENTER-SOURCE-USER-POOL-ID";
const sourceAccountClientId = "ENTER-SOURCE-APP-CLIENT-ID";
const sourceAccountClientSecret = "ENTER-SOURCE-APP-CLIENT-SECRET";

// Declare global variables and import required AWS SDK libraries
import { createHmac } from 'crypto';
import { CognitoIdentityProvider, AdminGetUserCommand, AdminInitiateAuthCommand } from "@aws-sdk/client-cognito-identity-provider";
import { STS, AssumeRoleCommand } from "@aws-sdk/client-sts";
const stsclient = new STS();
//const stsclient = new STS({ region: sourceAccountRegion });

// Import cross-account credentials
var paramsAssumeRole = {
    RoleArn: sourceAccountRoleARN,
    RoleSessionName: "CrossAccountCognitoMigration"
};

const {Credentials} = await stsclient.send(new AssumeRoleCommand(paramsAssumeRole));

const tempCredentialsObj = {
    accessKeyId: Credentials.AccessKeyId,
    secretAccessKey: Credentials.SecretAccessKey,
    sessionToken: Credentials.SessionToken
};

const cognitoidpclient = new CognitoIdentityProvider({ credentials: tempCredentialsObj });
//const cognitoidpclient = new CognitoIdentityProvider({ credentials: tempCredentialsObj, region: sourceAccountRegion });

// Main Function
export const handler = async (event, context, callback) => {

    // Logs event to CloudWatch Logs. Useful for basic script troubleshooting	
    console.log(event);

    // Declare local variables
    let user;

    // Basic logic handling
    if (event.triggerSource == "UserMigration_Authentication") {
        // Authenticate the User with the existing source User Pool
        user = await authenticateUser(event.userName, event.request.password);

        let userAttributes = {};

        if (user.Username) {
            user.UserAttributes.forEach(attribute => {
                if (attribute.Name == "sub") {
                    return;
                }
                userAttributes[attribute.Name] = attribute.Value;
            });

            event.response.userAttributes = userAttributes;
            event.response.finalUserStatus = "CONFIRMED";
            event.response.messageAction = "SUPPRESS";
            context.succeed(event);

        } else {
            // Return error to Amazon Cognito
            callback("Bad password");
        }

    } else if (event.triggerSource == "UserMigration_ForgotPassword") {
        // Lookup the user in the existing source User Pool
        user = await getUserPoolUser(event.userName);

        if (user.Username) {
            let userAttributes = {};
            user.UserAttributes.forEach(attribute => {
                if (attribute.Name == "sub") {
                    return;
                }
                userAttributes[attribute.Name] = attribute.Value;
            });

            // NOTE: The 'email_verified' or 'phone_number_verified' attribute must be
            // set to 'true' in order to enable password-reset code to be sent to the
            // user. If the attribute is not already set in the source user pool, you
            // can uncomment the following line of code to set the 'email_verified' or
            // 'phone_number_verified' attribue as verified:
            //     userAttributes['email_verified'] = "true";
            //     userAttributes['phone_number_verified'] = "true";

            event.response.userAttributes = userAttributes;
            event.response.messageAction = "SUPPRESS";
            context.succeed(event);

        } else {
            // Return error to Amazon Cognito
            callback("Bad password");
        }

    } else {
        // Return error to Amazon Cognito
        callback("Bad triggerSource " + event.triggerSource);
    }
};


// Function 1 - authenticateUser - Authenticates the User with the source User Pool
async function authenticateUser(username, password) {

    let rez = "";

    // Calculate secret hash
    const hasher = createHmac("sha256", sourceAccountClientSecret);
    hasher.update( username + sourceAccountClientId );
    //hasher.update(`${username}${sourceAccountClientId}`);
    const secrethash = hasher.digest("base64");

    const paramsInitiateAuth = {
        AuthFlow: "ADMIN_USER_PASSWORD_AUTH",
        ClientId: sourceAccountClientId,
        UserPoolId: sourceAccountUserPoolId,
        AuthParameters: {
            USERNAME: username,
            PASSWORD: password,
            SECRET_HASH: secrethash
        }
    };

    const authrez = await cognitoidpclient.send(new AdminInitiateAuthCommand(paramsInitiateAuth));

    if (authrez.hasOwnProperty("AuthenticationResult")) {
        rez = getUserPoolUser(username);
    }

    return rez;
}


// Function 2 - getUserPoolUser - Gets User information from source User Pool
async function getUserPoolUser(username) {

    let rez = "";

    const paramsGetUser = {
        UserPoolId: sourceAccountUserPoolId,
        Username: username
    };

    rez = await cognitoidpclient.send(new AdminGetUserCommand(paramsGetUser));

    return rez;
}
  1. MigrateUserLambdaFunction-Python
# MigrateUserLambdaFunction-Python
# Migrate User Lambda Trigger Template - Cross-Account User Pool Migration
# Python 3.12 runtime
# AWS SDK for Python Boto3

# Import required Python libraries
import json
import boto3

# Declare source Account variables. Enter the information for your User Pool from your source Account
sourceAccountRoleARN = 'ENTER-SOURCE-ROLE-ARN'
#sourceAccountRegion = 'ENTER-SOURCE-REGION'
sourceAccountUserPoolId = 'ENTER-SOURCE-USER-POOL-ID'
sourceAccountClientId = 'ENTER-SOURCE-APP-CLIENT-ID'

# Import cross-account credentials
stsclient = boto3.client('sts')

response = stsclient.assume_role(
    RoleArn = sourceAccountRoleARN,
    RoleSessionName = 'CrossAccountCognitoMigration'
)

temp_credentials = response['Credentials']

cognitoidpclient = boto3.client(
    'cognito-idp',
    aws_access_key_id = temp_credentials['AccessKeyId'],
    aws_secret_access_key = temp_credentials['SecretAccessKey'],
    aws_session_token = temp_credentials['SessionToken'],
)

# Main function
def lambda_handler(event, context):

    # Basic logic handling
    if (event['triggerSource'] == 'UserMigration_Authentication'):

        user = cognitoidpclient.admin_initiate_auth(
            UserPoolId = sourceAccountUserPoolId,
            ClientId = sourceAccountClientId,
            AuthFlow = 'ADMIN_USER_PASSWORD_AUTH',
            AuthParameters = {
                'USERNAME': event['userName'],
                'PASSWORD': event['request']['password']
            }
        )

        if (user):

            userAttributes = cognitoidpclient.get_user(
                AccessToken = user['AuthenticationResult']['AccessToken']
            )

            for userAttribute in userAttributes['UserAttributes']:

                if userAttribute['Name'] == 'email':
                    userEmail = userAttribute['Value']

                    event['response']['userAttributes'] = {
                        "email": userEmail,
                        "email_verified": "true"
                    }

            event['response']['messageAction'] = "SUPPRESS"
            print (event)
            return (event)

        else:
            return('Bad Password')

    elif (event['triggerSource'] == 'UserMigration_ForgotPassword'):

        user = cognitoidpclient.admin_get_user(
            UserPoolId = sourceAccountUserPoolId,
            Username = event['userName']
        )

        if (user):

            for userAttribute in user['UserAttributes']:

                if userAttribute['Name'] == 'email':
                    userEmail = userAttribute['Value']

                    event['response']['userAttributes'] = {
                        "email": userEmail,
                        "email_verified": "true"
                    }

            event['response']['messageAction'] = "SUPPRESS"
            print (event)
            return (event)

        else:
            return('Bad Password')

    else:
        return('There was an error')
  1. MigrateUserLambdaFunction-Python-ClientSecret
# MigrateUserLambdaFunction-Python-ClientSecret
# Migrate User Lambda Trigger Template - Cross-Account User Pool Migration (w/ Client Secret)
# Python 3.12 runtime
# AWS SDK for Python Boto3

# Import required Python libraries
import json
import boto3
import sys
import hmac, hashlib, base64

# Declare source Account variables. Enter the information for your User Pool from your source Account
sourceAccountRoleARN = 'ENTER-SOURCE-ROLE-ARN'
#sourceAccountRegion = 'ENTER-SOURCE-REGION'
sourceAccountUserPoolId = 'ENTER-SOURCE-USER-POOL-ID'
sourceAccountClientId = 'ENTER-SOURCE-APP-CLIENT-ID'
sourceAccountClientSecret = 'ENTER-SOURCE-APP-CLIENT-SECRET'

# Import cross-account credentials
stsclient = boto3.client('sts')

response = stsclient.assume_role(
    RoleArn = sourceAccountRoleARN,
    RoleSessionName = 'CrossAccountCognitoMigration'
)

temp_credentials = response['Credentials']

cognitoidpclient = boto3.client(
    'cognito-idp',
    aws_access_key_id = temp_credentials['AccessKeyId'],
    aws_secret_access_key = temp_credentials['SecretAccessKey'],
    aws_session_token = temp_credentials['SessionToken'],
)

# Main function
def lambda_handler(event, context):

    # Basic logic handling
    if (event['triggerSource'] == 'UserMigration_Authentication'):

        # Calculate secret hash
        message = bytes(event['userName'] + sourceAccountClientId,'utf-8')
        key = bytes(sourceAccountClientSecret,'utf-8')
        secrethash = base64.b64encode(hmac.new(key, message, digestmod=hashlib.sha256).digest()).decode()

        user = cognitoidpclient.admin_initiate_auth(
            UserPoolId = sourceAccountUserPoolId,
            ClientId = sourceAccountClientId,
            AuthFlow = 'ADMIN_USER_PASSWORD_AUTH',
            AuthParameters = {
                'USERNAME': event['userName'],
                'PASSWORD': event['request']['password'],
                'SECRET_HASH': secrethash
            }
        )

        if (user):

            userAttributes = cognitoidpclient.get_user(
                AccessToken = user['AuthenticationResult']['AccessToken']
            )

            for userAttribute in userAttributes['UserAttributes']:

                if userAttribute['Name'] == 'email':
                    userEmail = userAttribute['Value']

                    event['response']['userAttributes'] = {
                        "email": userEmail,
                        "email_verified": "true"
                    }

            event['response']['messageAction'] = "SUPPRESS"
            print (event)
            return (event)

        else:
            return('Bad Password')

    elif (event['triggerSource'] == 'UserMigration_ForgotPassword'):

        user = cognitoidpclient.admin_get_user(
            UserPoolId = sourceAccountUserPoolId,
            Username = event['userName']
        )

        if (user):

            for userAttribute in user['UserAttributes']:

                if userAttribute['Name'] == 'email':
                    userEmail = userAttribute['Value']

                    event['response']['userAttributes'] = {
                        "email": userEmail,
                        "email_verified": "true"
                    }

            event['response']['messageAction'] = "SUPPRESS"
            print (event)
            return (event)

        else:
            return('Bad Password')

    else:
        return('There was an error')