amplify-cli icon indicating copy to clipboard operation
amplify-cli copied to clipboard

Automatic Social Account Merging

Open techdragon opened this issue 4 years ago • 37 comments

Is your feature request related to a problem? Please describe. Automatic Social Account Merging does not occur by default, https://github.com/aws-amplify/amplify-cli/issues/4208 is the original report, but the author closed it after finding their own solution.

Describe the solution you'd like Automatically merge social accounts based on one of two criteria:

  • They both define the same email address in their social account metadata.( https://github.com/aws-amplify/amplify-cli/issues/4208 )
  • Simultaneous Logins. (This was typically called something similar to "Social account linking" in most of the libraries and tools I've used.)

Describe alternatives you've considered I could build this myself with a bunch of lambdas and other stuff.

Additional context This sort of functionality is the default in the majority of authentication services I have used. It was genuinely shocking to discover this was not the case while I was searching through the repo's GithHub issues for a different problem and came across #4208. While I'm sure this is primarily a case of terrible default behaviour in the underlying Cognito service, there is a workaround, and from what I can tell, Amplify is trying to be a "we do the boilerplate stuff for you" type of tool, so handling this kind of workaround/boilerplate feels like something Amplify should be doing.

techdragon avatar Jun 01 '20 10:06 techdragon

Thanks for the feature request @techdragon we will investigate merging social accounts by default

nikhname avatar Jun 02 '20 02:06 nikhname

Any update on this, as we are have deployed our app and currently, this is an issue for us and now we are stuck at login with apple issue. we do have a website so this feature is mandatory because the user logged in with apple cannot use that in web or android, so the user should be able to login using the given email without any issue.

rahulje9 avatar Jul 05 '20 06:07 rahulje9

This seems like such a basic problem that I was also kind of shocked to discover it's not supported by default. There are a bunch of other issues and comments on blog posts asking about this as well.

Apparently the workaround is to use AdminLinkProviderForUser in a pre signup trigger to link to an existing user, but it definitely feels like something Amplify should be handling. As it stands, the same person can sign up with email or an identity provider, forget how they signed up, then sign in with a different identity provider and a new account would be created for them, which is definitely not ideal.

ianmartorell avatar Jul 24 '20 19:07 ianmartorell

How to verify an account with many social networks?

Then start a session with the different social networks?

Here is an example with the Badoo Social Network.

ex

Mersmith avatar Jul 27 '20 14:07 Mersmith

Any update on this issue?

xitanggg avatar Oct 29 '20 00:10 xitanggg

The way I'm currently handling this is:

In the PreSignup hook, if event.triggerSource is PreSignUp_ExternalProvider, I look for an existing user with email event.request.userAttributes.email using listUsers. Then:

  • If there's an existing user, call adminLinkProviderForUser and throw an error LINKED_EXTERNAL_USER: ${providerName}. In the frontend I catch this error and reinitiate the OAuth flow, which will log the user in. Normally the end user won't even realize, unless they're logging in with Google and they're signed in to multiple Google accounts on that browser. In that case they'll see the account selector twice, but this will only happen the first time they try to sign in with Google. I haven't been able to work around this.
  • If there's no existing user, I create a native user first and then link it with adminLinkProviderForUser. I do this because otherwise there's no way to link an existing user created with an external provider to a new native (email&password) user. So if the user decided to set a password for their account in the future they wouldn't be able to do that. I do this using adminCreateUser with a random temporary password and MessageAction: 'SUPRESS'. After the user is created you need to call adminSetUserPassword with Permanent: true to change the user state from FORCE_CHANGE_PASSWORD to CONFIRMED. Otherwise they won't be able to reset their password with the normal reset password flow (pretty stupid, I know).

ianmartorell avatar Oct 29 '20 09:10 ianmartorell

@ianmartorell Thank you so much for sharing your implementation. It is extremely helpful. I test it and work well. For others who are interested, below is a sample code of mine used for pre sign up lambda based on @ianmartorell's implementation

const aws = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');

exports.handler = async (event, context, callback) => {
        //  ... skip other codes

	// If trigger source is external provider
	if (event.triggerSource === 'PreSignUp_ExternalProvider') {
		const cognitoProvider = new aws.CognitoIdentityServiceProvider({
			apiVersion: '2016-04-18',
		});

		try {
			// Get user based on email
			const listUserParams = {
				UserPoolId: event.userPoolId,
				AttributesToGet: null, //null returns all attributes
				Filter: `email = \"${event.request.userAttributes.email}\"`,
				Limit: 1,
			};
			const listUsersRes = await cognitoProvider
				.listUsers(listUserParams)
				.promise();

			let destinationAttributeValue;
			// If user not found, create user
			if (listUsersRes.Users.length === 0) {
				console.log('User not found');
				const {
					email = '',
					given_name = '',
					family_name = '',
					phone_number = '',
				} = event.request.userAttributes;
				const newPassword = uuidv4(); // or use your own implementation
				const newUserParams = {
					UserPoolId: event.userPoolId,
					Username: email || phone_number,
					MessageAction: 'SUPPRESS',
					TemporaryPassword: newPassword,
					UserAttributes: [
						{
							Name: 'email',
							Value: email,
						},
						{
							Name: 'email_verified',
							Value: String(!!email), //auto verify email if provided
						},
						{
							Name: 'given_name',
							Value: given_name,
						},
						{
							Name: 'family_name',
							Value: family_name,
						},
						{
							Name: 'phone_number',
							Value: phone_number,
						},
						{
							Name: 'phone_number_verified',
							Value: String(!!phone_number),
						},
					],
				};

				const newUser = await cognitoProvider
					.adminCreateUser(newUserParams)
					.promise();

				// Confirm new user
				const setPasswordParams = {
					Password: newPassword,
					UserPoolId: event.userPoolId,
					Username: newUser.User.Username,
					Permanent: true,
				};
				await cognitoProvider.adminSetUserPassword(setPasswordParams).promise();

				destinationAttributeValue = newUser.User.Username;
			}
			// If user found, simply set username
			else {
				console.log('User found');
				destinationAttributeValue = listUsersRes.Users[0].Username;
			}

			// Link User
			console.log('Link user');
			let [sourceProviderName, sourceAttributeValue] = event.userName.split(
				'_'
			);
			sourceProviderName =
				sourceProviderName[0].toUpperCase() + sourceProviderName.slice(1);
			const adminLinkParams = {
				DestinationUser: {
					ProviderAttributeValue: destinationAttributeValue,
					ProviderName: 'Cognito',
				},
				SourceUser: {
					ProviderAttributeName: 'Cognito_Subject',
					ProviderAttributeValue: sourceAttributeValue,
					ProviderName: sourceProviderName,
				},
				UserPoolId: event.userPoolId,
			};
			await cognitoProvider.adminLinkProviderForUser(adminLinkParams).promise();

			// Finish linking, throw error to frontent
			callback(new Error(`LINKED_EXTERNAL_USER_${sourceProviderName}`), event);
		} catch (error) {
			callback(error, event);
		}
	}

	callback(null, event);
};

Also make sure to add permission to your pre sign up lambda for ListUsers and other Admins call

    "lambdaexecutionpolicy": {
      ...
      "Properties": {
        ...
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              ...
            },
            {
              "Effect": "Allow",
              "Action": [
                "cognito-idp:ListUsers",
                "cognito-idp:AdminLinkProviderForUser",
                "cognito-idp:AdminCreateUser",
                "cognito-idp:AdminSetUserPassword"
              ],
              "Resource": {
                "Fn::Sub": [
                  "arn:aws:cognito-idp:${region}:${account}:*",
                  {
                    "region": {
                      "Ref": "AWS::Region"
                    },
                    "account": {
                      "Ref": "AWS::AccountId"
                    }
                  }
                ]
              }
            }

xitanggg avatar Nov 29 '20 20:11 xitanggg

@ianmartorell One of the problem I run into is that calling Auth.federatedSignIn overwrites Cognito native user attributes. I wonder if you encounter similar issue. Basically, when I created the native user for a user logged in with Facebook, I set email_verified to true, but when Auth.federatedSignIn is called again, email_verified is set to false because it reloads the attribues from Facebook to Cognito and Facebook doesn't have the email_verified attribute apparently. I just created an issue at Amplify.js: https://github.com/aws-amplify/amplify-js/issues/7300

xitanggg avatar Nov 29 '20 20:11 xitanggg

@xitanggg Glad to hear it works for you too! Oh yes, I have that issue with email_verified as well, I forgot to mention it in my comment. What I do is I automatically confirm and set email_verified to true for the user in the PostAuthentication trigger, if they signed in using an external provider. I can check the code I used if that would be helpful.

ianmartorell avatar Nov 29 '20 21:11 ianmartorell

@ianmartorell Thanks for your prompt response. That is actually what I am thinking as well using AdminUpdateUserAttributes. I have tried it and it works but I am hesitated to include it in the code because it is so stupid and inefficient since it gets called for every sign in event.

xitanggg avatar Nov 29 '20 21:11 xitanggg

Yeah it's pretty stupid... Unfortunately I don't think there's any other way. As email_verified comes as false from external providers for some reason, it gets overwritten on every login. It's quite bothersome. Honestly we have to go through so many hoops and hacks to make social login work with Amplify, it's crazy.

ianmartorell avatar Dec 01 '20 10:12 ianmartorell

I was able to map google's email_verified to cognito and it comes with true. So I only need to do it for facebook. The problem in this case is more in Cognito than Amplify. I am not sure why Cognito is set up to overwrite the attributes https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-specifying-attribute-mapping.html. Sad, but I will just move on

Amazon Cognito must be able to update your mapped user pool attributes when users sign in to your application. When a user signs in through an identity provider, Amazon Cognito updates the mapped attributes with the latest information from the identity provider. Amazon Cognito updates each mapped attribute, even if its current value already matches the latest information. If Amazon Cognito can't update the attribute, it throws an error. To ensure that Amazon Cognito can update the attributes, check the following requirements:

xitanggg avatar Dec 01 '20 17:12 xitanggg

When the Pre sign up hook run it will just create the link but then users will have to sign in again in order to complete the process and this run in two steps, how can i sign in user with external provider right after the link is complete?

oahmaro avatar Feb 02 '21 13:02 oahmaro

When the Pre sign up hook run it will just create the link but then users will have to sign in again in order to complete the process and this run in two steps, how can i sign in user with external provider right after the link is complete?

I ran into the same issue and explained my workaround in the first bullet point here: https://github.com/aws-amplify/amplify-cli/issues/4427#issuecomment-718549881.

ianmartorell avatar Feb 02 '21 15:02 ianmartorell

Can you please be more specific on how you are catching this error on the Frontend? since using the Amplify api will force a redirect and the error wont be presented on the function that triggered the sign in.

oahmaro avatar Feb 02 '21 15:02 oahmaro

The exact implementation varies based on the front end framework you use and the error message you throw. The high level idea is that

  1. Once the native user is created and linked with the external provider, it throws an error to the front end. The error appears as a query parameter in the url, like this ?error_description=LINKED_EXTERNAL_USER_{providerName}
  2. You can create something that monitor the url, extract the providerName, and reinitiate the auto sign in for that provider.

In React, I simply create a useEffect hook to do so and use the hook at my redirect page.

import { useRef, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import queryString from 'query-string';
import { Auth } from 'aws-amplify';

const autoExternalSignIn = async (provider) => {
	try {
		await Auth.federatedSignIn({ provider: provider });
	} catch (error) {
		console.log('Error auto sign in: ', error);
	}
};

/**
 * useAutoExternalSignIn hook attempts to auto sign in user after users register
 * an account with Google / Facebook external provider.
 *
 * When user first registers an account using Google / Facebook, a native user is created
 * in AWS Cognito and an error is throwed to the front end when the process is succeeded.
 */

const useAutoExternalSignIn = () => {
	const numRetry = useRef(0);
	const history = useHistory();
	const params = queryString.parse(history.location.search);
	const errorDes = params.error_description;
	useEffect(() => {
		if (numRetry.current < 2 && errorDes) {
			if (errorDes.includes('LINKED_EXTERNAL_USER_Facebook')) {
				autoExternalSignIn('Facebook');
				numRetry.current += 1;
			}
			if (errorDes.includes('LINKED_EXTERNAL_USER_Google')) {
				autoExternalSignIn('Google');
				numRetry.current += 1;
			}
		}
	}, [errorDes]);
};

export default useAutoExternalSignIn;

xitanggg avatar Feb 02 '21 16:02 xitanggg

Testing @xitanggg implementation i sometimes get the following error when first signup with google "Invalid SourceUser: Cognito users with a username/password may not be passed in as a SourceUser, only as a DestinationUser"

oahmaro avatar Feb 03 '21 09:02 oahmaro

@oahmaro Interesting, this is my implementation and I haven't encountered this issue. I am not sure what might cause it but I would suggest console.log various variables in each stage of the code to trace down what might cause it. Would be interested to see what you find out.

xitanggg avatar Feb 04 '21 05:02 xitanggg

@oahmaro I also faced the same issue. But later found out that I already had an account in Cognito with the Google account I'm trying. I tried to signup before setting up the pre signup hook. You'll have to clear them out before trying this.

THPubs avatar Feb 04 '21 08:02 THPubs

hi @xitanggg , can you please share your implementation on post authentication trigger to fix facebook signing overriding the email_verified value?

This would help me a lot as i am not familiar with Cognito API

oahmaro avatar Feb 04 '21 08:02 oahmaro

@oahmaro I also faced the same issue. But later found out that I already had an account in Cognito with the Google account I'm trying. I tried to signup before setting up the pre signup hook. You'll have to clear them out before trying this.

Well not really, this issue happens to me even if i don't have any account, i am still investigating the cause

oahmaro avatar Feb 04 '21 08:02 oahmaro

Testing @xitanggg implementation i sometimes get the following error when first signup with google "Invalid SourceUser: Cognito users with a username/password may not be passed in as a SourceUser, only as a DestinationUser"

I was getting this too! It drove me insane for days, I checked the API calls over and over and I was definitely passing the Cognito user as a DestinationUser. In the end it stopped happening, and as weird as it sounds I think it was because I increased the CPU and memory used by the lambda functions. I did it through editing the CloudFormation templates, I can check what I changed exactly in a few hours.

ianmartorell avatar Feb 04 '21 09:02 ianmartorell

@oahmaro You can verify the email in the pre-signup hook right after linking the accounts like this:

await cognitoProvider
  .adminUpdateUserAttributes({
    UserAttributes: [
      {
        Name: 'email_verified',
        Value: 'true',
      },
    ],
    Username: destinationAttributeValue,
    UserPoolId: event.userPoolId,
  })
  .promise();

To confirm and enable the user account, you can do the following:

await cognitoProvider
  .adminConfirmSignUp({
    Username: destinationAttributeValue,
    UserPoolId: event.userPoolId,
  })
  .promise();

Make sure you set the proper permissions in the cloudformation-template.json:

"Action": [
  "cognito-idp:ListUsers",
  "cognito-idp:AdminLinkProviderForUser",
  "cognito-idp:AdminConfirmSignUp",
  "cognito-idp:AdminUpdateUserAttributes"
],

THPubs avatar Feb 04 '21 09:02 THPubs

Testing @xitanggg implementation i sometimes get the following error when first signup with google "Invalid SourceUser: Cognito users with a username/password may not be passed in as a SourceUser, only as a DestinationUser"

I was getting this too! It drove me insane for days, I checked the API calls over and over and I was definitely passing the Cognito user as a DestinationUser. In the end it stopped happening, and as weird as it sounds I think it was because I increased the CPU and memory used by the lambda functions. I did it through editing the CloudFormation templates, I can check what I changed exactly in a few hours.

Hi @ianmartorell this would help a lot. Thanks

oahmaro avatar Feb 04 '21 09:02 oahmaro

Hi @ianmartorell this would help a lot. Thanks

So basically in my PreSignup template I have this:

{
	"Resources": {
		"LambdaFunction": {
			"Properties": {
				"Handler": "index.handler",
				"MemorySize": 2048,   <-- Add this line
			}
		},
	},
}

Note that although this increases memory usage and it sounds like your functions will be more expensive, they actually run a lot faster because CPU speeds are tied to memory size in lambda functions, and the faster they run the cheaper they are. I haven't run actual benchmarks, but the speed increase was reeeally noticeable in my PostAuthentication hook. I used to have to wait a good couple of seconds after logging in, and now it feels instant.

ianmartorell avatar Feb 04 '21 09:02 ianmartorell

@ianmartorell this seems to fix the issue, speaking of speed this whole signup trigger takes 15 seconds to sign in user in the first time, and 8 seconds after account is linked, which seems to be long. anyone tested the time it takes?

oahmaro avatar Feb 04 '21 09:02 oahmaro

@ianmartorell this seems to fix the issue, speaking of speed this whole signup trigger takes 15 seconds to sign in user in the first time, and 8 seconds after account is linked, which seems to be long. anyone tested the time it takes?

Actually nevermind my metrics as they are inaccurate, after building my app it actually was much faster.

oahmaro avatar Feb 04 '21 10:02 oahmaro

Haha, good catch @ianmartorell! I actually suspect memory might be the cause, but wasn't 100% sure. I always have my lambda memory size up, in this case to 512MB "MemorySize": "512", so I never actually encounter what you two encounter, got lucky.

The default lambda memory size is 128MB, which is stupid, because the aws-sdk is 57.9 MB big. I suspect it might run out of memory using it therefore causing the error. Amplify team should consider changing the default memory for lambda function whenever it uses the aws-sdk.

@oahmaro my users are coming from google provider and they got mapped correctly, so I haven't written the code to overwrite facebook provider yet (at least not now, may be I will in the next month or so). The Cognito API isn't too difficult to understand. You just need to spend some time reading the docs. As mentioned, AdminUpdateUserAttributes would do the trick, make sure to set proper permission as well.

xitanggg avatar Feb 04 '21 16:02 xitanggg

@palpatim @elorzafe FYI for Auth tracking

undefobj avatar Feb 28 '22 19:02 undefobj

@xitanggg I'm using forceAliasAuth=true so that I can sign-in user through username(preferred username) or verified email. My only required attribute is email for sign-up a user. Your code is working for 1st sign-up with email then use same email as signin with Google(but I'm not getting error that ) but when I'm trying fresh new sign in with google it is not working(it is not creating an user entry). my code: and one thing username can't be email as forceAliasAuth=true. `const aws = require('aws-sdk'); const { v4: uuidv4 } = require('uuid');

exports.handler = async (event, context, callback) => { // ... skip other codes

// If trigger source is external provider
if (event.triggerSource === 'PreSignUp_ExternalProvider') {
	const cognitoProvider = new aws.CognitoIdentityServiceProvider({
		apiVersion: '2016-04-18',
	});

	try {
		// Get user based on email
		const listUserParams = {
			UserPoolId: event.userPoolId,
			AttributesToGet: null, //null returns all attributes
			Filter: `email = \"${event.request.userAttributes.email}\"`,
			Limit: 1,
		};
		const listUsersRes = await cognitoProvider
			.listUsers(listUserParams)
			.promise();

		let destinationAttributeValue;
		// If user not found, create user
		if (listUsersRes.Users.length === 0) {
			console.log('User not found');
			const {
				email = '',
			
			} = event.request.userAttributes;
			const newPassword = uuidv4(); // or use your own implementation
			const newUserParams = {
				UserPoolId: event.userPoolId,
				Username: event.userName,
				MessageAction: 'SUPPRESS',
				TemporaryPassword: newPassword,
				UserAttributes: [
					{
						Name: 'email',
						Value: email,
					},
					{
						Name: 'email_verified',
						Value: String(!!email), //auto verify email if provided
					},
		
				],
			};

			const newUser = await cognitoProvider
				.adminCreateUser(newUserParams)
				.promise();

			// Confirm new user
			const setPasswordParams = {
				Password: newPassword,
				UserPoolId: event.userPoolId,
				Username: newUser.User.Username,
				Permanent: true,
			};
			await cognitoProvider.adminSetUserPassword(setPasswordParams).promise();

			destinationAttributeValue = newUser.User.Username;
		}
		// If user found, simply set username
		else {
			console.log('User found');
			destinationAttributeValue = listUsersRes.Users[0].Username;
		}

		// Link User
		console.log('Link user');
		let [sourceProviderName, sourceAttributeValue] = event.userName.split(
			'_'
		);
		sourceProviderName =
			sourceProviderName[0].toUpperCase() + sourceProviderName.slice(1);
		const adminLinkParams = {
			DestinationUser: {
				ProviderAttributeValue: destinationAttributeValue,
				ProviderName: 'Cognito',
			},
			SourceUser: {
				ProviderAttributeName: 'Cognito_Subject',
				ProviderAttributeValue: sourceAttributeValue,
				ProviderName: sourceProviderName,
			},
			UserPoolId: event.userPoolId,
		};
		await cognitoProvider.adminLinkProviderForUser(adminLinkParams).promise();

		// Finish linking, throw error to frontent
		callback(new Error(`LINKED_EXTERNAL_USER_${sourceProviderName}`), event);
	} catch (error) {
		callback(error, event);
	}
}

callback(null, event);

};my permission:{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "logs:CreateLogStream", "logs:PutLogEvents", "cognito-idp:AdminInitiateAuth", "cognito-idp:ListUsers", "cognito-idp:AdminUpdateUserAttributes", "cognito-idp:AdminLinkProviderForUser", "cognito-idp:AdminCreateUser", "cognito-idp:AdminSetUserPassword" ], "Resource": [ "arn:aws:logs:us-east-1:xxxxxxxxxxxxxx:log-group:/aws/lambda/Auto_Merge_Account:*", "arn:aws:cognito-idp:us-east-1:xxxxxxx:userpool/us-east-1xxxxxxxxxxxx" ] } ] }`

AsitDixit avatar Aug 06 '22 17:08 AsitDixit

@ianmartorell @xitanggg this is not working for me when fresh new sign up with google & im using force alias auth and only requried attirbute is email and user can sign up with email & preferred username and second thing I have mapped username in post conformation function when there is fresh signup with with Google It will not trigger post confirmation as we are throwing error. `if (listUsersRes.Users.length === 0) { console.log('User not found'); const { email = '', } = event.request.userAttributes; const newPassword = uuidv4(); // or use your own implementation const newUserParams = { UserPoolId: event.userPoolId, Username: email, MessageAction: 'SUPPRESS', TemporaryPassword: newPassword, UserAttributes: [ { Name: 'email', Value: email, }, { Name: 'email_verified', Value: String(!!email), //auto verify email if provided },

				],
			};

			const newUser = await cognitoProvider.adminCreateUser(newUserParams).promise();

			// Confirm new user
			const setPasswordParams = {
				Password: newPassword,
				UserPoolId: event.userPoolId,
				Username: newUser.User.Username,
				Permanent: true,
			};
			await cognitoProvider.adminSetUserPassword(setPasswordParams).promise();

			destinationAttributeValue = newUser.User.Username;
		}`

AsitDixit avatar Aug 07 '22 06:08 AsitDixit

@xitanggg @ianmartorell @THPubs specially adminCreateUser showing error: PreSignUp invocation failed due to error TooManyRequestsException. Why I don't know please tell me solution. whether you are facing the same issue or not.

AsitDixit avatar Aug 22 '22 04:08 AsitDixit