How to Link User Accounts in AWS Cognito - Complete Guide

avatar

Borislav Hadzhiev

7 min

banner

Photo from Unsplash

# Linking OAuth to Email Accounts in AWS Cognito

Cognito doesn't link the Federated (Facebook and Google) and the native Cognito (Email account) identities by default, even though they have the same email.

Let's say you have an e-commerce site and you provide multiple ways to register - users can register with both Facebook and email.

If the user initially registers with their email, adds some products to their cart, then visits the site on their phone and logs in via Facebook, the cart would be empty. That is because the default behavior is to create two separate accounts, even though they have the same email.

In order to link the Google / Facebook OAuth accounts to the Cognito Email account, we have to add a pre-sign-up lambda trigger, which runs before a user's registration event and links the accounts that have the same email.

The lambda function will be set as a pre sign up trigger and it needs permissions for the following cognito-idp actions:

  • cognito-idp:AdminAddUserToGroup
  • cognito-idp:AdminUpdateUserAttributes
  • cognito-idp:ListUsers
  • cognito-idp:AdminLinkProviderForUser
  • cognito-idp:AdminCreateUser
  • cognito-idp:AdminSetUserPassword

It's quite a long list of permissions, however, we have to handle multiple flows in our pre-sign-up trigger.

# Implementation of the Pre-Sign-up Trigger

First, we'll take a look at the event object the function is invoked with, for Cognito Native users, who registered with an email:

cognito-native-event
{ version: "1", region: "eu-central-1", userPoolId: "eu-central-1_7HPNLvT", userName: "c8302f6d-4469-b31e-36ee6239e267", callerContext: { awsSdkVersion: "aws-sdk-unknown-unknown", clientId: "741igvddli8mdl2v0bpsqc" }, triggerSource: "PreSignUp_SignUp", request: { userAttributes: { given_name: "Test", family_name: "User", email: "test@gmail.com" }, validationData: null }, response: { autoConfirmUser: false, autoVerifyEmail: false, autoVerifyPhone: false } }

Note that the request.userAttributes vary between User Pool implementations, these are the attributes you require the user to provide when they register.

Let's now look at an event that comes from a user who registered with Google OAuth:

cognito-google-oauth-event
{ version: "1", region: "eu-central-1", userPoolId: "eu-central-1_7HLvRT", userName: "Google_1147527301736", callerContext: { awsSdkVersion: "aws-sdk-unknown-unknown", clientId: "741if0rmdidv0bpsqc" }, triggerSource: "PreSignUp_ExternalProvider", request: { userAttributes: { email_verified: "false", cognito:email_alias: "", cognito:phone_number_alias: "", given_name: "test", family_name: "user", email: "test@gmail.com" }, validationData: {} }, response: { autoConfirmUser: false, autoVerifyEmail: false, autoVerifyPhone: false } }

Note how the triggerSource property is PreSignUp_ExternalProvider for the google user, whereas it is PreSignUp_SignUp for the native cognito user.

This is what we'll use to differentiate between the users who register with email and the ones who register with Google / Facebook, etc.

Now let's move on to the implementation of the function.

I'll post the entry point of the function that links the accounts and then we'll go over the code.

import {Callback, Context, PreSignUpTriggerEvent} from 'aws-lambda'; export async function main( event: PreSignUpTriggerEvent, _context: Context, callback: Callback, ): Promise<void> { try { const { triggerSource, userPoolId, userName, request: { // You won't have given_name and family_name attributes // if you haven't specified them as required when the user registers userAttributes: {email, given_name, family_name}, }, } = event; const EXTERNAL_AUTHENTICATION_PROVIDER = 'PreSignUp_ExternalProvider'; if (triggerSource === EXTERNAL_AUTHENTICATION_PROVIDER) { // --> User has registered with Google/Facebook external providers const usersFilteredByEmail = await listUsersByEmail({ userPoolId, email, }); // userName example: "Facebook_12324325436" or "Google_1237823478" const [providerNameValue, providerUserId] = userName.split('_'); // Uppercase the first letter because the event sometimes // has it as google_1234 or facebook_1234. In the call to `adminLinkProviderForUser` // the provider name has to be Google or Facebook (first letter capitalized) const providerName = providerNameValue.charAt(0).toUpperCase() + providerNameValue.slice(1); if (usersFilteredByEmail.Users && usersFilteredByEmail.Users.length > 0) { // user already has cognito account const cognitoUsername = usersFilteredByEmail.Users[0].Username || 'username-not-found'; // if they have access to the Google / Facebook account of email X, verify their email. // even if their cognito native account is not verified await adminLinkUserAccounts({ username: cognitoUsername, userPoolId, providerName, providerUserId, }); } else { /* --> user does not have a cognito native account -> 1. create a native cognito account 2. change the password, to change status from FORCE_CHANGE_PASSWORD to CONFIRMED 3. merge the social and the native accounts 4. add the user to a group - OPTIONAL */ const createdCognitoUser = await adminCreateUser({ userPoolId, email, // these are attributes that you require upon registration givenName: given_name, familyName: family_name, }); await adminSetUserPassword({userPoolId, email}); const cognitoNativeUsername = createdCognitoUser.User?.Username || 'username-not-found'; await adminLinkUserAccounts({ username: cognitoNativeUsername, userPoolId, providerName, providerUserId, }); // OPTIONALLY add the user to a group await adminAddUserToGroup({ userPoolId, username: cognitoNativeUsername, groupName: 'Users', }); event.response.autoVerifyEmail = true; event.response.autoConfirmUser = true; } } return callback(null, event); } catch (err) { return callback(err, event); } }

Let's go over the code because it's a quite long function and we have not added the helper functions yet.

We first check if the user who is signing up for an account is an External user (i.e. Facebook / Google), if that's not the case and it's a Cognito native user registering with email, we simply return.

If the user is an external user:

  1. We list all the users in the user pool with the same email.
  2. If we get any results, then there is already a Cognito native account in our User Pool that has the same email as the external user.
  3. In that case we want to link the accounts to one another.

If the user is an external user, but there aren't any other users in our User Pool with the same email:

  1. Create a native Cognito account
  2. Change the password, to change the status from FORCE_CHANGE_PASSWORD to CONFIRMED
  3. Merge the social and the native accounts
  4. Optionally add the user to a group

Creating a native account if they don't have one is useful for a couple of reasons, the main one being - we are able to store user attributes that we can't access from their facebook/google account, i.e. shipping address, country, city, etc.

Let's now implement the helper functions, we have used. We'll start with the function that lists all users filtered by email:

import AWS from 'aws-sdk'; export const listUsersByEmail = async ({ userPoolId, email, }: { userPoolId: string; email: string; }): Promise<AWS.CognitoIdentityServiceProvider.ListUsersResponse> => { const params = { UserPoolId: userPoolId, Filter: `email = "${email}"`, }; const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); return cognitoIdp.listUsers(params).promise(); };

Next we'll take care of the function that links the accounts to one another:

import AWS from 'aws-sdk'; export const adminLinkUserAccounts = async ({ username, userPoolId, providerName, providerUserId, }: { username: string; userPoolId: string; providerName: string; providerUserId: string; }): Promise<AWS.CognitoIdentityServiceProvider.AdminLinkProviderForUserResponse> => { const params = { DestinationUser: { ProviderAttributeValue: username, ProviderName: 'Cognito', }, SourceUser: { ProviderAttributeName: 'Cognito_Subject', ProviderAttributeValue: providerUserId, ProviderName: providerName, }, UserPoolId: userPoolId, }; const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); return new Promise((resolve, reject) => { cognitoIdp.adminLinkProviderForUser(params, (err, data) => { if (err) { reject(err); return; } resolve(data); }); }); };

Next, we have the function that is used to create a native Cognito user, in case the registration is of an external user, who doesn't yet have a native email account:

import AWS from 'aws-sdk'; export const adminCreateUser = async ({ userPoolId, email, givenName, familyName, }: { userPoolId: string; email: string; givenName: string; familyName: string; }): Promise<AWS.CognitoIdentityServiceProvider.AdminCreateUserResponse> => { const params = { UserPoolId: userPoolId, // SUPRESS prevents sending an email with the temporary password // to the user on account creation MessageAction: 'SUPPRESS', Username: email, UserAttributes: [ { Name: 'given_name', Value: givenName, }, { Name: 'family_name', Value: familyName, }, { Name: 'email', Value: email, }, { Name: 'email_verified', Value: 'true', }, ], }; const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); return cognitoIdp.adminCreateUser(params).promise(); };

Next is the adminSetUserPassword function, which we use to change the user status from FORCE_CHANGE_PASSWORD to CONFIRMED:

import AWS from 'aws-sdk'; export const adminSetUserPassword = async ({ userPoolId, email, }: { userPoolId: string; email: string; }): Promise<AWS.CognitoIdentityServiceProvider.AdminSetUserPasswordResponse> => { const params = { Password: generatePassword(), UserPoolId: userPoolId, Username: email, Permanent: true, }; const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); return cognitoIdp.adminSetUserPassword(params).promise(); }; function generatePassword() { return `${Math.random() // Generate random number, eg: 0.123456 .toString(36) // Convert to base-36 : "0.4fzyo82mvyr" .slice(-8)}42`; // Cut off last 8 characters : "yo82mvyr" and add a number because the cognito password policy requires a number }

And finally we have the optional step to add the user to a group:

import AWS from 'aws-sdk'; export function adminAddUserToGroup({ userPoolId, username, groupName, }: { userPoolId: string; username: string; groupName: string; }): Promise<{ $response: AWS.Response<Record<string, string>, AWS.AWSError>; }> { const params = { GroupName: groupName, UserPoolId: userPoolId, Username: username, }; const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); return cognitoIdp.adminAddUserToGroup(params).promise(); }

# Attaching the Pre Sign-up trigger in a Cognito User Pool

After you have defined the lambda function you have to attach it as a pre sign-up trigger in your Cognito User Pool.

You can do that by clicking on Triggers in the sidebar of the Cognito User Pool console:

pre sign up trigger

# Discussion

It's quite the flow to link user accounts and we have to consider multiple scenarios:

  • if the user who's signing up is Federated or Cognito native
  • if they have a native account or don't, etc.

It would definitely be nice if it were handled by the Cognito service, but that's the current way to solve the problem of having multiple unlinked users with the same email.

It's important to note that after you link the accounts they will still be displayed as 2 separate accounts in the User Pool UI in the AWS Console, however, their sub and all their properties will be the same and linked to one another.

# Further Reading

I wrote a book in which I share everything I know about how to become a better, more efficient programmer.
book cover
You can use the search field on my Home Page to filter through all of my articles.