61

When signing in a user with the same email address through the Google and Facebook identity providers, AWS Cognito creates multiple entries in the user pool, one entry per identity provider used:

Screenshot of AWS Cognito user pool

I have used the example code provided in this tutorial to set up AWS Cognito: The Complete Guide to User Authentication with the Amplify Framework

  • How can I create just one user instead of multiple users?
  • Is it possible to have AWS Cognito automatically combine (federate) the entries from multiple providers into one entry or should AWS Lambda functions be used to accomplish this?
AlexR
  • 5,514
  • 9
  • 75
  • 130

5 Answers5

53

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.

Phan Việt
  • 1,253
  • 11
  • 11
  • This sounds like a great solution. I have also read the docs. However, since I am quite new to Cognito, where would I put the code? Any guidance would be appreciated a lot. Thank you! – AlexR Jan 13 '20 at 12:14
  • You can make use of Lambda triggers in Amazon Cognito User Pools to make sure that the users (with the same E-Mail) are linked as required. – Arka Mukherjee Jan 14 '20 at 07:39
  • 1
    @AlexR I've just added PreSignUp Lambda Trigger example code for the idea. – Phan Việt Jan 14 '20 at 07:49
  • 8
    I couldn't make this work for a SAML provider, getting user already exists error – Karthick Selvam Aug 25 '20 at 07:42
  • 6
    First attempt to login using a social provider generates "Already found an entry for username " error. Here's AWS Support forum open issues (since 2017): https://forums.aws.amazon.com/thread.jspa?threadID=267154&start=25&tstart=0 and detailed question from SO https://stackoverflow.com/questions/47815161/cognito-auth-flow-fails-with-already-found-an-entry-for-username-facebook-10155 – agoldis Aug 14 '21 at 08:14
  • @PhanViệt could you please help me understand what is `Cognito_Subject` in your code? Is that the name of App Client? – Mahesh Nov 25 '21 at 11:58
  • @Mahesh `Cognito_Subject` is the keyword: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html – Phan Việt Nov 26 '21 at 12:10
  • 1
    I'm getting syntax error: "errorType": "Runtime.UserCodeSyntaxError", "errorMessage": "SyntaxError: Cannot use import statement outside a module" – Nigel Yong Dec 01 '21 at 16:09
  • 3
    I replaced the first line with const { CognitoIdentityServiceProvider } = require('aws-sdk'); – Nigel Yong Dec 01 '21 at 16:30
  • 2
    I was getting error of 'SourceProviderName must match a Provider that is configured for the User Pool'. Problem: My google users had a sub of `google_x` and the providername was expecting `Google` Solution: add `const capitalizedProviderName = providerName.charAt(0).toUpperCase() + providerName.slice(1);` just before `linkProviderToUser()` – Robbie Cook Jul 03 '22 at 03:08
24

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 a username and password. Currently, Cognito does not allow linking Cognito users to external users.

My scenarios are as follows:

Scenarios

  1. When the user signs up with a 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 a 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 a username/password in the future, they will get a user already exists error. In this case, they can use the 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(async 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
};


Subash
  • 7,098
  • 7
  • 44
  • 70
  • 2
    I've been battling this flow for days now. It is such a pain to get this to work. Every time I successfully walk through this flow and try to login/signup with a Social Provider I am getting an invalid_grant error. Cognito is of securing the real cause so it is impossible to find the real reason. So frustrating! – tvb Nov 29 '21 at 19:45
  • 1
    I fixed the invalid grant, I was using the wrong Username (email address instead of the username of the AdminCreateUser response which should be the one) However, I still have issues with this flow as my email address never is getting status verified, meaning that the request for resetting the password is not being send out by Cognito. I tried everything I could find to set the email address verified. It is impossible. – tvb Nov 30 '21 at 09:34
  • I'm getting syntax error on expected await word – Nigel Yong Dec 02 '21 at 16:06
  • 1
    @NigelYong add an async keyword to the function – Himanshu Patil Feb 07 '22 at 11:29
  • I have updated the answer to include missing `async` keyword. – Subash Feb 08 '22 at 00:19
  • Is it just me or does this solution still create the user in the case where they already have an email password at least? – chris stevens Jun 01 '22 at 13:34
8

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);
      });

  }

};

F_SO_K
  • 13,640
  • 5
  • 54
  • 83
  • 1
    I am still facing this issue [ERROR] 06:54.698 OAuth - Error handling auth response. Error: Already+found+an+entry+for+username+google_102979183231414988328+ – skpaik Jul 17 '21 at 19:09
  • I have faced Already found an entry for username google_**** . Do have any idea about this error? – Iman Shafiei Dec 22 '21 at 16:53
3

If you want to allow the user to continue login with email & password ("Option 1: User Signs Up with Username and Signs In with Username or Alias)") besides identity provider (google, facebook, etc) then the accepted solution won't be enough as Cognito can only have one email as verified.

I solve this by adding a Post Confirmation trigger which automatically verify user email if needed:

const AWS = require('aws-sdk');
const cognitoIdp = new AWS.CognitoIdentityServiceProvider();

const markUserEmailAsVerified = async (username, userPoolId) => {
  console.log('marking email as verified for user with username: ' + username);
  const params = {
    UserAttributes: [
      {
        Name: 'email_verified',
        Value: 'true'
      }
      // other user attributes like phone_number or email themselves, etc
    ],
    UserPoolId: userPoolId,
    Username: username
  };

  const result = await new Promise((resolve, reject) => {
    cognitoIdp.adminUpdateUserAttributes(params, (err, data) => {
      if (err) {
        console.log(
          'Failed to mark user email as verified with error:\n' +
            err +
            '\n. Manual action is required to mark user email as verified otherwise he/she cannot login with email & password'
        );
        reject(err);
        return;
      }
      resolve(data);
    });
  });

  return result;
};

exports.handler = async (event, context, callback) => {
  console.log('event data:\n' + JSON.stringify(event));

  const isEmailVerified = event.request.userAttributes.email_verified;
  if (isEmailVerified === 'false') {
    await markUserEmailAsVerified(event.userName, event.userPoolId);
  }

  return callback(null, event);
};

Note: This doesn't seem standard development or common requirement so take as it.

Jonathan Irwin
  • 5,009
  • 2
  • 29
  • 48
r.pedrosa
  • 709
  • 5
  • 12
  • 1
    Since the verify_email attribute toggle its value during sign in this should be in the [Post Authentication Trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-post-authentication.html). – artidataio Nov 15 '21 at 17:19
2

In aws-sdk-js-v3 I'm using @subash approach. I find that when you make an error callback, no extra user is created. Just the one that you create with your email.

const {
  CognitoIdentityProviderClient,
  ListUsersCommand,
  AdminCreateUserCommand,
  AdminLinkProviderForUserCommand,
  AdminSetUserPasswordCommand,
} = require('@aws-sdk/client-cognito-identity-provider')
const client = new CognitoIdentityProviderClient({
  region: process.env.REGION,
})
const crypto = require("crypto")

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

  try {

    const {
      triggerSource,
      userPoolId,
      userName,
      request: {
        userAttributes: { email, name }
      }
    } = event

    if (triggerSource === 'PreSignUp_ExternalProvider') {

      const listParam = {
        UserPoolId: userPoolId,
        Filter: `email = "${email}"`,
      }
      const listData = await client.send(new ListUsersCommand(listParam))


      let [providerName, providerUserId] = userName.split('_')
      providerName = providerName.charAt(0).toUpperCase() + providerName.slice(1)

      let linkParam = {
        SourceUser: {
          ProviderAttributeName: 'Cognito_Subject',
          ProviderAttributeValue: providerUserId,
          ProviderName: providerName,
        },
        UserPoolId: userPoolId,
      }
      //check whether the email already exist, if exist, simply link it, if not create the user first then link.
      if (listData && listData.Users.length > 0) {

        linkParam['DestinationUser'] = {
          ProviderAttributeValue: listData.Users[0].Username,
          ProviderName: 'Cognito',
        }

      }
      else {

        const createParam = {
          UserPoolId: userPoolId,
          Username: email,
          MessageAction: 'SUPPRESS',
          UserAttributes: [{
            //optional name attribute. 
            Name: 'name', 
            Value: name,
          }, {
            Name: 'email',
            Value: email,
          }, {
            Name: 'email_verified',
            Value: 'true',
          }],
        }
        const createData = await client.send(new AdminCreateUserCommand(createParam))

        const pwParam = {
          UserPoolId: userPoolId,
          Username: createData.User.Username,
          Password: crypto.randomBytes(40).toString('hex'),
          Permanent: true,
        }
        await client.send(new AdminSetUserPasswordCommand(pwParam))

        linkParam['DestinationUser'] = {
          ProviderAttributeValue: createData.User.Username,
          ProviderName: 'Cognito',
        }

      }
      await client.send(new AdminLinkProviderForUserCommand(linkParam))
      
    }   
    return event
  }
  catch (err) {
    console.error(err)
  }
}

No longer true in 2023 - START

However, it is a bad UX as the first sign in with federated identity will only create the user but not allowing it to authenticate. However, the subsequent sign in with federated identity will show no such issue. Let me know, if you get any other solution for that first sign in.

No longer true in 2023 - END

It's also useful to keep email_verified as true so that user can recover their password. Especially true if you are using aws-amplify authenticator. This should be in your post authentication trigger.

const {
  CognitoIdentityProviderClient,
  AdminUpdateUserAttributesCommand,
} = require('@aws-sdk/client-cognito-identity-provider')
const client = new CognitoIdentityProviderClient({
  region: process.env.REGION,
})

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

  try {

    const {
      userPoolId,
      userName,
      request: {
        userAttributes: { email_verified }
      }
    } = event

    if (!email_verified) {

      const param = {
        UserPoolId: userPoolId,
        Username: userName,
        UserAttributes: [{
          Name: 'email_verified',
          Value: 'true',
        }],
      }
      await client.send(new AdminUpdateUserAttributesCommand(param))

    }
    return event
  }
  catch (err) {
    console.error(err)
  }
}
artidataio
  • 316
  • 4
  • 8