amazon-web-servicesaws-lambdaamazon-cognitofederated-identity

AWS Cognito: email unverified on main account after AdminLinkProviderForUser


I am implementing linking of user accounts in cognito that have the same email. So if someone signs up e.g. with Google and the email is already in cognito, I will link this new account to existing with AdminLinkProviderForUser. I have basically been following this answer here: https://stackoverflow.com/a/59642140/13432045. Linking is working as expected but afterwards email_verified is switched to false (it was verified before). Is this an expected behavior? If yes, then my question is why? If no, then my question is what am I doing wrong? Here is my pre sign up lambda:

const {
  CognitoIdentityProviderClient,
  AdminLinkProviderForUserCommand,
  ListUsersCommand,
  AdminUpdateUserAttributesCommand,
} = require("@aws-sdk/client-cognito-identity-provider");

exports.handler = async (event, context, callback) => {
  if (event.triggerSource === "PreSignUp_ExternalProvider") {
    const client = new CognitoIdentityProviderClient({
      region: event.region,
    });

    const listUsersCommand = new ListUsersCommand({
      UserPoolId: event.userPoolId,
      Filter: `email = "${event.request.userAttributes.email}"`,
    });

    try {
      const data = await client.send(listUsersCommand);
      if (data.Users && data.Users.length) {
        const [providerName, providerUserId] = event.userName.split("_"); // event userName example: "Facebook_12324325436"
        const provider = ["Google", "Facebook", "SignInWithApple"].find(
          (p) => p.toUpperCase() === providerName.toUpperCase()
        );
        const linkProviderCommand = new AdminLinkProviderForUserCommand({
          DestinationUser: {
            ProviderAttributeValue: data.Users[0].Username,
            ProviderName: "Cognito",
          },
          SourceUser: {
            ProviderAttributeName: "Cognito_Subject",
            ProviderAttributeValue: providerUserId,
            ProviderName: provider,
          },
          UserPoolId: event.userPoolId,
        });

        await client.send(linkProviderCommand);

        /* fix #1 - this did not help */
        // const emailVerified = data.Users[0].Attributes.find(
        //   (a) => a.Name === "email_verified"
        // );
        // if (emailVerified && emailVerified.Value) {
        //   console.log("updating");
        //   const updateAttributesCommand = new AdminUpdateUserAttributesCommand({
        //     UserAttributes: [
        //       {
        //         Name: "email_verified",
        //         Value: "true",
        //       },
        //     ],
        //     UserPoolId: event.userPoolId,
        //     Username: data.Users[0].Username,
        //   });

        //   await client.send(updateAttributesCommand);
        // }

        /* fix #2 - have no impact on the outcome */
        // event.response.autoConfirmUser = true;
        // event.response.autoVerifyEmail = true;
      }
    } catch (error) {
      console.error(error);
    }
  }

  callback(null, event);
};

As you can see, I tried passing autoConfirmUser and autoVerifyEmail which had no impact. And I also tried to manually update email_verified after calling AdminLinkProviderForUser which also did not help. So I think email_verified is set to false only after the lambda is finished.


Solution

  • I've actually find a solution for this issue! It's tricky, but it works quite well.

    The main problem i was having is the fact that pre/post auth lambdas do not trigger if the user is logging in with the connected provider account. Nor does the post confirmation lambda triggers after the signUp link. On top of that, if you manually try to alter the provider's user inside cognito, you still end up with the email_verified = false.

    So whats the solution here?

    Well, it's actually two in one.

    1. Attribute Mapping

    Some of the providers supported by Cognito already have an 'email verified' attribute we can directly map in the Attribute Mapping section. That is the case with Google. Simply map it to Cognito's attribute and you are done!

    2. PreToken trigger

    For the other providers, mainly Facebook (i didnt use any provider other than Facebook and Google, but it should all work the same), that don't natively have the email verified attribute, the only way i could find to properly enforce it to be verified was through the pre-token trigger. The logic goes: once the lambda is invoked, verify if the user getting authenticated have a provider linked to it and, at the same time, have its email_verified options to false. If you meet this condition, update the user with the adminUpdateUserAttributes method before returning to Cognito. That way, it doesnt matter if any subsequent provider login flips the attribute back to false, this lambda ensures it's flipped back on.

    If you want a sample code for the solution, here's mine pre-token handler:

    const AWS = require('aws-sdk');
    
    class PreTokenHandler {
      constructor({ cognitoService }) {
        this.cognitoService = cognitoService;
      }
    
      async main(event, _context, callback) {
      const {
          userPoolId,
          userName: Username,
          request: { userAttributes: { identities, email_verified } }
        } = event;
    
        const emailVerified = ['true', true].some(value => value === email_verified);
    
        if (!identities?.length || emailVerified) return callback(null, event);
    
        await this.cognitoService.adminUpdateUserAttributes({
          UserPoolId: userPoolId,
          Username,
          UserAttributes: [{ Name: 'email_verified', Value: 'true' }]
        }).promise();
    
        callback(null, event);
      }
    }
    
    const cognitoService = new AWS.CognitoIdentityServiceProvider({ apiVersion: '2016-04-18' });
    
    const handler = new PreTokenHandler({ cognitoService });
    
    module.exports = handler.main.bind(handler);
    
    

    I've added the 'true' x true check to ensure i'm safe on possibly inconsistencies on the attribute value, although it's just being extra safe and probably not necessary.