Link social provider during another login session

I use a auth0 Database with a custom login script to authenticate my user base against a legacy authentication system.

When a user logs in via a social provider, if they do not already have an identity for my Database, I call my backend to verify if there is an existing user with the same email address. If not, I create a trial account in my backend (that has a randomly generated password set), initiate a password grant flow (with that password) to create the user in the Auth0 database, and change the context.primaryUser. This works great and ensures that my Database user is the primary user of every login session.

If the user does exist in my backend, I send the user through the Universal login flow by setting the context.redirect.url to the https://mycustomdomain/authorize with the “connections” query string param set to the list of social connections, the redirect_url set to https://mycustomdomain/continue and the client_id set to a special regular web application designed just for linking that has all social connections and my database connection enabled and https://mycustomdomain/continue as an allowed callback URL.

I’m using almost the default Universal Login flow however I’ve added handlings of a “connections” query string parameter to specify the list of allowedConnections for Lock.

This properly displays the universal login with the proper list of social connections (I’m using a Custom social connection as the account the user initiated the login session). If I login via the Database login (which goes against my custom backend) it properly authenticates and redirects back to the http://customdomain/continue, passing the “state” (from the context.redirect) and the “code” from the oauth login. I then have another rule which detects context.protocol === "redirect-callback" and exchanges the code for a token which it uses to determine which users to link. It then calls the management API and links the two user accounts. This works great and we love the experience!

The problem happens when they try to link using a social provider. If instead of entering the username/password in the Database login in lock, you click login on a social provider (google for example). I can successfully login with the social provider (for the correct email) and am redirected back to my custom domain with a blank screen with the error message “Unauthorized”.

It appears the “state” being passed to the /continue endpoint is the “state” from the originally initiated universal login session and not the one that was passed to the 2nd the second /authorize endpoint.

Thoughts? Is this a bug in Auth0?

Here’s the list of URLs and the respective screens

Universal Login Screen Shown after initiating login from the application at https://myapplication:

https://mycustomdomain/login?state=g6Fo2SBHRG5zZ0ZuRUdCZDZFajZNY0gwQkQyOEtXRjI3MGxOSKN0aWTZIG42eVBnRzMyZzB4VGl0aUJwYkVLX05pUl9acjJWQllho2NpZNkgRlMxMmdWdTFSZ3k2RUVWMkk1MFZBM3NpUlZPSzdmZEw&client=FS12gVu1Rgy6EEV2I50VA3siRVOK7fdL&protocol=oauth2&response_type=token&redirect_uri=https%3A%2F%2Fmyapplication%2Fopenapi%2Foauth2-redirect.html&scope=openid%20profile%20read%3Atickets

Universal Login showing an error messaging telling the user to login with another social provider or their database credentials

https://mycustomdomain/login?state=g6Fo2SB6LTdiWFVwRGtmeEROZEhUY0pIZlpyT0IzcDFvYUNKN6N0aWTZIGRYQTEyNGEySnBPS1R0VTNleHUtTGxPVUNmcXhIWDR2o2NpZNkgWkswdUttNTJXVEFJbDlURU15MjV1dHNHM05MNkp3RDI&client=ZK0uKm52WTAIl9TEMy25utsG3NL6JwD2&protocol=oauth2&response_type=code&error=There%20is%20already%20an%20existing%20user%20with%20this%20email.%20Login%20with%20your%20MyDatabase%20or%201%2FST%20username%2Femail%20and%20password%20or%20another%20linked%20account&connection=MyDatabase&connections=MyDatabase%20google-oauth2&audience=https%3A%2F%2Fmyaudience&redirect_uri=https%3A%2F%2Fmycustomdomain%2Fcontinue

Google Sign in Screen

https://accounts.google.com/signin/oauth/oauthchooseaccount?client_id=mygoogleclientid&as=z7-ZIntOR44v9XOGCBH6Xg&destination=https%3A%2F%2Flogin.auth0.com&approval_state=!ChQyX1lGOE9GYUZFc1BWeEdub0pqSBIfczVXRXVSX202SGdlSU5vbEVpbVNxcUZsclYzYjhCWQ∙AJDr988AAAAAXfiKSx2JfqNQ-jLvByCnxphJllPctNFz&oauthgdpr=1&xsrfsig=ChkAeAh8T5i_RKLMhUoD2HVIlIlKmyrDVUspEg5hcHByb3ZhbF9zdGF0ZRILZGVzdGluYXRpb24SBXNvYWN1Eg9vYXV0aHJpc2t5c2NvcGU&flowName=GeneralOAuthFlow
Last Redirect resulting in Unauthorized

https://mycustomdomain/continue?code=SANqAP2tRc6qYN60&state=g6Fo2SB3N2MwQmpJZHpBTHhrY1BIRWEwSm1RWUU5Y2NQeHJUM6N0aWTZIG42eVBnRzMyZzB4VGl0aUJwYkVLX05pUl9acjJWQllho2NpZNkgRlMxMmdWdTFSZ3k2RUVWMkk1MFZBM3NpUlZPSzdmZEw

My rules:

/* eslint-disable no-console */
/**
 * This rule checks if the user being logged in is from a social provider and if so,
 * checks if there is an existing MyDatabase user with the same email.
 *
 * If not, a trial account is created, logged in, then the context.currentUser is set to the newly created user.
 *
 * If the email belongs to an existing account, the user is redirected to the link account flow
 *
 * **NOTE** For debugging purposes, the lines reported by the Rule Debugger in auth0 are 30 more than the actual line
 * For example if you get an exception that shows:
 * at runRule (/data/io/6255c445-09fb-452f-9aac-5180528c17c4/webtask.js:78:49)
 *
 * The actual error is on line 48
 *
 * @param user
 * @param context
 * @param callback
 */
function runRule(user, context, callback) {
  //If the user is not a database user (i.e. the provider is not "auth0")
  if (!user.user_id.startsWith("auth0|")) {
    const domain = configuration.domain;
    const email = user.email;
    const connection = "MyDatabase";
    const audience = "https://myaudience";
    const apiBase = `https://${configuration.API_BASE}`;
    const existingUserError =
      "There+is+already+an+existing+user+with+this+email.+Login+with+your+MyDatabase+username/email+and+password";
    const apiClient = (global.apiClient =
      global.apiClient ||
      new (require("auth0@2.19.0")).ManagementClient({
        domain,
        scope: "admin",
        audience,
        clientId: configuration.client_id,
        clientSecret: configuration.client_secret
      }));
    const management = (global.managementClient =
      global.managementClient ||
      new (require("auth0@2.19.0")).ManagementClient({
        domain: auth0.domain,
        scope:
          "read:roles read:users update:users create:users read:user_idp_tokens",
        clientId: configuration.client_id,
        clientSecret: configuration.client_secret
      }));
    const auth =
      global.authClient ||
      (global.authClient = new (require("auth0@2.19.0")).AuthenticationClient({
        domain,
        clientId: configuration.client_id,
        clientSecret: configuration.client_secret
      }));

    const postAsync = require("util").promisify(require("request").post);
    apiClient
      .getAccessToken()
      .then(async accessToken => {
        const headers = {
          Authorization: `Bearer ${accessToken}`
        };
        try {
          const resp = await postAsync({
            uri: `${apiBase}/getuserbyemail`,
            body: {
              email,
              user_mask: {
                paths: ["account_number"]
              }
            },
            headers,
            json: true
          });
          const existing = resp.body;
          if ("message" in existing) {
            if (!existing.message.includes("unknown account")) {
              console.error(
                "Unable to verify existing user exists for social account email %s: %s",
                email,
                resp.statusCode,
                resp.body
              );
              return callback(
                new Error(
                  "Unable to verify existing user exists for social account"
                )
              );
            }
            console.log(
              "Unknown existing MyDatabase user with email: %s. Creating trial account.",
              email
            );
            //create trial user and change current user context
            try {
              const password = `Abc${Math.round(
                Math.random() * 100000000 + 100000000
              ).toString(10)}`;
              const resp2 = await postAsync({
                uri: `${apiBase}/createaccount`,
                body: {
                  desired_account_type: "DESIRED_ACCOUNT_TYPE_TRIAL",
                  customer_data: {
                    email,
                    username: email,
                    password,
                    first_name: user.given_name || user.first_name,
                    last_name: user.family_name || user.last_name,
                    source: context.connection.replace(
                      /[^A-Za-z0-9]/g,
                      ""
                    )
                  }
                },
                headers,
                json: true
              });
              const newAcct = resp2.body;
              if (!("account_info" in newAcct)) {
                console.error(
                  "Bad response while creating trial account: %s -> %s",
                  email,
                  resp2.statusCode,
                  resp2.body
                );
                return callback(
                  new Error("Bad response while creating trial account")
                );
              }
              console.log(
                "Created new trial user with account_number: %s",
                newAcct.account_info.account_number
              );
              const newUserId = `auth0|${newAcct.account_info.account_number}`;
              try {
                await auth.oauth.passwordGrant({
                  audience,
                  connection,
                  username: email,
                  password
                });
                console.log(
                  "Signed in new trial user with email: %s.  Linking user with with %s and connection %s",
                  email,
                  user.user_id,
                  context.connection_id
                );
              } catch (err3) {
                console.error(
                  "Error while signing in new trial account: %s",
                  newUserId,
                  err3
                );
                return callback(err3);
              }

              const pipeIndex = user.user_id.indexOf("|");
              const user_id = user.user_id.substring(pipeIndex + 1);
              const provider = user.user_id.substring(0, pipeIndex);
              try {
                await management.linkUsers(newUserId, {
                  user_id,
                  provider
                });
                // eslint-disable-next-line require-atomic-updates
                context.primaryUser = newUserId;
                console.log(
                  "Linking users was successful. Setting session primary user to %s",
                  newUserId
                );
              } catch (err4) {
                console.error(
                  "Error while linking social user: %s for provider: %s to new trial user: %s",
                  user_id,
                  provider,
                  newUserId,
                  err4
                );
                return callback(err4);
              }

              callback(null, user, context);
            } catch (err2) {
              console.error(
                "Error while creating trial account: %s",
                email,
                err2
              );
              callback(new Error("Error while creating trial account"));
            }
          } else if ("user" in existing) {
            try {
              // Until Auth0 can support logging in with a social provider during the same login session as another
              // this functionality cannot be enabled

              let err = existingUserError;

              // -- REMOVE FROM HERE -- //
              const connections = `connection=${connection}`;
              // -- TO HERE -- //
              // -- AND UNCOMMENT THIS SECTION -- //
              // const users = await management.getUsersByEmail(email);
              // const foundUser = users
              //   .filter(
              //     u =>
              //       u.identities &&
              //       u.identities.some(i => i.connection === connection)
              //   )
              //   .reduce((u, l) => u || l, null);
              // const connections = foundUser
              //   ? `connection=${connection}&connections=${foundUser.identities
              //       .map(i => i.connection)
              //       .join("+")}`
              //   : `connection=${connection}`;
              //
              // if (connections !== connection) {
              //   err = err + "+or+another+linked+account";
              // }

              const url =
                `https://${domain}/authorize?response_type=code&error=${err}&${connections}&` +
                `audience=${audience}&client_id=${configuration.client_id}&&` +
                `username=${email}&redirect_uri=https://${domain}/continue`;
              console.log(
                "Existing user with account number %s for email %s. Redirecting to %s",
                existing.user.account_number,
                email,
                url
              );
              context.redirect = {
                url
              };
              callback(null, user, context);
            } catch (err3) {
              console.error(
                "Error while retrieving auth0 users by email:",
                email,
                err3
              );
              callback(err3);
            }
          } else {
            console.error("Bad result", JSON.stringify(resp));
            callback(new Error("Unable to retrieve existing user info"));
          }
        } catch (err) {
          console.error(
            "Error while retrieving existing user by email: %s",
            email,
            err
          );
          callback(new Error("Error while retrieving existing user by email"));
        }
      })
      .catch(err => {
        console.error("Error while getting access token for API", err);
        callback(new Error("Error while getting access token for API"));
      });
  } else {
    callback(null, user, context);
  }
}
/* eslint-disable unicorn/catch-error-name,no-console */
/**
 * This rule runs as the result of a redirect from logging in the MyDatabase user
 * for the purpose of linking them to the current account being logged in
 * @param user
 * @param context
 * @param callback
 */
function runRule(user, context, callback) {
  if (context.protocol === "redirect-callback" && context.request.query.code) {
    const domain = configuration.domain;
    const email = user.email;
    const auth = (global.authClient =
      global.authClient ||
      new (require("auth0@2.19.0")).AuthenticationClient({
        domain: configuration.domain,
        clientId: configuration.client_id,
        clientSecret: configuration.client_secret
      }));
    const management = (global.managementClient =
      global.managementClient ||
      new (require("auth0@2.19.0")).ManagementClient({
        domain: auth0.domain,
        scope:
          "read:roles read:users update:users create:users read:user_idp_tokens",
        clientId: configuration.client_id,
        clientSecret: configuration.client_secret
      }));
    console.log(
      "Resuming prior redirected authentication for user",
      user.user_id,
      "with email",
      email
    );
    auth.oauth
      .authorizationCodeGrant({
        code: context.request.query.code,
        redirect_uri: `https://${domain}/continue`
      })
      .then(async token => {
        const jsonwebtoken = require("jsonwebtoken");
        const claims = jsonwebtoken.decode(
          token.id_token || token.access_token
        );
        const existingUserId = claims.sub;
        console.log(
          "Retrieved token for code exchange for user",
          existingUserId
        );

        const pipeIndex = user.user_id.indexOf("|");
        const user_id = user.user_id.substring(pipeIndex + 1);
        const provider = user.user_id.substring(0, pipeIndex);

        try {
          await management.linkUsers(existingUserId, {
            user_id,
            provider
          });
          context.primaryUser = existingUserId;
          console.log(
            "Linking users was successful. Setting session primary user to %s",
            existingUserId
          );

          callback(null, user, context);
        } catch (err4) {
          console.error(
            "Error while linking social user: %s for provider: %s to existing user: %s",
            user_id,
            provider,
            existingUserId,
            err4
          );
          return callback(err4);
        }
      })
      .catch(err => {
        console.log("Error while exchanging code for token", err);
        callback(err);
      });
  } else {
    callback(null, user, context);
  }
}

Looks like this was just a misconfiguration in my Google provider. I got it all working now!