AWS Cognito: Best practice to handle same user (with same email address) signing in from different identity providers (Google, Facebook)

Yes. You can do it by using AdminLinkProviderForUser https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html

The idea is:

  1. In PreSignUp lambda hook, we Link Provider to User if User already signed up. E.g:
import CognitoIdentityServiceProvider from 'aws-sdk/clients/cognitoidentityserviceprovider'

const cognitoIdp = new CognitoIdentityServiceProvider()
const getUserByEmail = async (userPoolId, email) => {
 const params = {
   UserPoolId: userPoolId,
   Filter: `email = "${email}"`
 }
 return cognitoIdp.listUsers(params).promise()
}

const linkProviderToUser = async (username, userPoolId, providerName, providerUserId) => {
 const params = {
   DestinationUser: {
     ProviderAttributeValue: username,
     ProviderName: 'Cognito'
   },
   SourceUser: {
     ProviderAttributeName: 'Cognito_Subject',
     ProviderAttributeValue: providerUserId,
     ProviderName: providerName
   },
   UserPoolId: userPoolId
 }

 const result = await (new Promise((resolve, reject) => {
   cognitoIdp.adminLinkProviderForUser(params, (err, data) => {
     if (err) {
       reject(err)
       return
     }
     resolve(data)
   })
 }))

 return result
}

exports.handler = async (event, context, callback) => {
 if (event.triggerSource === 'PreSignUp_ExternalProvider') {
   const userRs = await getUserByEmail(event.userPoolId, event.request.userAttributes.email)
   if (userRs && userRs.Users.length > 0) {
     const [ providerName, providerUserId ] = event.userName.split('_') // event userName example: "Facebook_12324325436"
     await linkProviderToUser(userRs.Users[0].Username, event.userPoolId, providerName, providerUserId)
   } else {
     console.log('user not found, skip.')
   }

 }
 return callback(null, event)
}
  1. Then when user use OAuth with Facebook/Google with User Pool, the Pool will return this User linked.

Note: You may see 2 records in User Pool UI, but when access User record detail, They already merged.


I have been fiddling around with the same issue for a bit. Accepted answer sort of works but does not cover all scenarios. The main one is that once the user signs up with the external login, they will never be able to sign up with username and password. Currently, Cognito does not allow to link Cognito user to external user.

My scenarios are as follows:

Scenarios

  1. When the user signs up with username password and signs up with an external provider, link them.
  2. When the user signs up with an external provider allow them to signup with username and password.
  3. Have a common username between all linked users to use it as a unique id in other services.

My proposed solution is to always create the Cognito user first and link all external users to it.

Proposed solution

  1. user signs up with username/password first then with an external user. No dramas, just link the external user with the Cognito user.
  2. user signs up with external user first then wants to sign up with username/password. In this scenario, create a Cognito user first then link the external user to this new Cognito user. If the user tries to signup with username/password in future, they will get user already exists error. In this case, they can use forgot password flow to recover then log in.
const {
  CognitoIdentityServiceProvider
} = require('aws-sdk');


const handler = async event => {
  const userPoolId = event.userPoolId;
  const trigger = event.triggerSource;
  const email = event.request.userAttributes.email;
  const givenName = event.request.userAttributes.given_name;
  const familyName = event.request.userAttributes.family_name;
  const emailVerified = event.request.userAttributes.email_verified;
  const identity = event.userName;
  const client = new CognitoIdentityServiceProvider();

  if (trigger === 'PreSignUp_ExternalProvider') {

    await client.listUsers({
        UserPoolId: userPoolId,
        AttributesToGet: ['email', 'family_name', 'given_name'],
        Filter: `email = "${email}"`
      })
      .promise()
      .then(({
        Users
      }) => Users.sort((a, b) => (a.UserCreateDate > b.UserCreateDate ? 1 : -1)))
      .then(users => users.length > 0 ? users[0] : null)
      .then(user => {
        // user with username password already exists, do nothing
        if (user) {
          return user;
        }

        // user with username password does not exists, create one
        const newUser = await client.adminCreateUser({
            UserPoolId: userPoolId,
            Username: email,
            MessageAction: 'SUPPRESS', // dont send email to user
            UserAttributes: [{
                Name: 'given_name',
                Value: givenName
              },
              {
                Name: 'family_name',
                Value: familyName
              },
              {
                Name: 'email',
                Value: email
              },
              {
                Name: 'email_verified',
                Value: emailVerified
              }
            ]
          })
          .promise();
          // gotta set the password, else user wont be able to reset it
          await client.adminSetUserPassword({
              UserPoolId: userPoolId,
              Username: newUser.Username,                                                      
              Password: '<generate random password>',                                                       
              Permanent: true
          }).promise();
    
          return newUser.Username;
      }).then(username => {
        // link external user to cognito user
        const split = identity.split('_');
        const providerValue = split.length > 1 ? split[1] : null;
        const provider = ['Google', 'Facebook'].find(
          val => split[0].toUpperCase() === val.toUpperCase()
        );

        if (!provider || !providerValue) {
          return Promise.reject(new Error('Invalid external user'));
        }

        return client.adminLinkProviderForUser({
            UserPoolId: userPoolId,
            DestinationUser: {
              ProviderName: 'Cognito',
              ProviderAttributeValue: username
            },
            SourceUser: {
              ProviderName: provider,
              ProviderAttributeName: 'Cognito_Subject',
              ProviderAttributeValue: providerValue
            }
          })
          .promise()
      });
  }

  return event;
};

module.exports = {
  handler
};



The solution I created handles, I think, all cases. It also tackles some common issues with Cognito.

  • If the user is signing up with an external provider, link them to any existing account, including Cognito (username/password) or external provider account.
  • When linking to existing accounts, link only to the oldest account. This is important is you have more than 2 login options.
  • If the user is signing up with Cognito (username/password), if an external provider already exists, reject the signup with a custom error message (because the accounts cannot be linked).

Note that when linking accounts, the Cognito pre-signup trigger returns an "Already found an entry for username" error. Your client should handle this and reattempt authentication, or ask the user to sign in again. More info on this here:

Cognito auth flow fails with "Already found an entry for username Facebook_10155611263153532"

Here is my lambda, executed on the Cognito pre-signup trigger

const AWS = require("aws-sdk");
const cognito = new AWS.CognitoIdentityServiceProvider();

exports.handler = (event, context, callback) => {

  function checkForExistingUsers(event, linkToExistingUser) {

    console.log("Executing checkForExistingUsers");

    var params = {
      UserPoolId: event.userPoolId,
      AttributesToGet: ['sub', 'email'],
      Filter: "email = \"" + event.request.userAttributes.email + "\""
    };

    return new Promise((resolve, reject) =>
      cognito.listUsers(params, (err, result) => {
        if (err) {
          reject(err);
          return;
        }
        if (result && result.Users && result.Users[0] && result.Users[0].Username && linkToExistingUser) {
          console.log("Found existing users: ", result.Users);
          if (result.Users.length > 1){
            result.Users.sort((a, b) => (a.UserCreateDate > b.UserCreateDate) ? 1 : -1);
            console.log("Found more than one existing users. Ordered by createdDate: ", result.Users);
          }
          linkUser(result.Users[0].Username, event).then(result => {
              resolve(result);
            })
            .catch(error => {
              reject(err);
              return;
            });
        } else {
          resolve(result);
        }

      })
    );

  }

  function linkUser(sub, event) {
    console.log("Linking user accounts with target sub: " + sub + "and event: ", event);

    //By default, assume the existing account is a Cognito username/password
    var destinationProvider = "Cognito";
    var destinationSub = sub;
    //If the existing user is in fact an external user (Xero etc), override the the provider
    if (sub.includes("_")) {
      destinationProvider = sub.split("_")[0];
      destinationSub = sub.split("_")[1];
    }
    var params = {
      DestinationUser: {
        ProviderAttributeValue: destinationSub,
        ProviderName: destinationProvider
      },
      SourceUser: {
        ProviderAttributeName: 'Cognito_Subject',
        ProviderAttributeValue: event.userName.split("_")[1],
        ProviderName: event.userName.split("_")[0]
      },
      UserPoolId: event.userPoolId
    };
    console.log("Parameters for adminLinkProviderForUser: ", params);
    return new Promise((resolve, reject) =>
      cognito.adminLinkProviderForUser(params, (err, result) => {
        if (err) {
          console.log("Error encountered whilst linking users: ", err);
          reject(err);
          return;
        }
        console.log("Successfully linked users.");
        resolve(result);
      })
    );
  }

  console.log(JSON.stringify(event));

  if (event.triggerSource == "PreSignUp_SignUp" || event.triggerSource == "PreSignUp_AdminCreateUser") {

    checkForExistingUsers(event, false).then(result => {
        if (result != null && result.Users != null && result.Users[0] != null) {
          console.log("Found at least one existing account with that email address: ", result);
          console.log("Rejecting sign-up");
          //prevent sign-up
          callback("An external provider account alreadys exists for that email address", null);
        } else {
          //proceed with sign-up
          callback(null, event);
        }
      })
      .catch(error => {
        console.log("Error checking for existing users: ", error);
        //proceed with sign-up
        callback(null, event);
      });

  }

  if (event.triggerSource == "PreSignUp_ExternalProvider") {

    checkForExistingUsers(event, true).then(result => {
        console.log("Completed looking up users and linking them: ", result);
        callback(null, event);
      })
      .catch(error => {
        console.log("Error checking for existing users: ", error);
        //proceed with sign-up
        callback(null, event);
      });

  }

};