Borislav Hadzhiev
Reading time·7 min
Photo from Unsplash
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.
First, we'll take a look at the event
object the function is invoked with, for
Cognito Native users, who registered with an email:
{ 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:
{ 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.
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:
If the user is an external user, but there aren't any other users in our User Pool with the same email:
FORCE_CHANGE_PASSWORD
to
CONFIRMED
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(); }
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:
It's quite the flow to link user accounts and we have to consider multiple scenarios:
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.