How to Link User Accounts in AWS Cognito - Complete Guide

avatar

Borislav Hadzhiev

Thu Mar 25 20216 min read

banner

Photo by Mesut Kaya

Learn how to link Google/Facebook identities to Cognito Native Email accounts

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 onto 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 [providerName, providerUserId] = userName.split('_');

      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 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.

Would definitely be nice if it were handled by the cognito service, but that's the current way to solve the problem with 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 separated 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 #

Join my newsletter

I'll send you 1 email a week with links to all of the articles I've written that week

Buy Me A Coffee