auth0-extensions / auth0-account-link-extension

An extension aimed to help link accounts easily
MIT License
28 stars 76 forks source link

Migrating from Rules to Actions #165

Open ADTC opened 1 year ago

ADTC commented 1 year ago

Since Rules are getting deprecated, there should be a migration from using a rule to using an action.

Hopefully a guide can be provided to migrate existing installations, and perhaps this can be done already for new installs so that we don't have to manually do it.

PS: Hopefully this also resolves #163

EgilSandfeld commented 1 year ago

Tested conversion of auth0-account-link-extension from Rule to Action below. Remember to

const request = require("request");
const queryString = require("querystring");
const Promise = require("native-or-bluebird");
const jwt = require("jsonwebtoken");
const axios = require("axios");

exports.onExecutePostLogin = async (event, api) => {
  var LOG_TAG = '[ACTION_ACCOUNT_LINK] ';

  var CONTINUE_PROTOCOL = 'redirect-callback';
  var Auth0ManagementAccessToken = '';

  event.request.query = event.request.query || {};

  var config = {
    endpoints: {
      linking: `https://${event.secrets.AUTH0_DOMAIN}.webtask.run/4cb95bf92ced903b9b84ebedbf5ebffd`,
      userApi: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/api/v2/users`, 
      usersByEmailApi: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/api/v2/users-by-email`
    },
    token: {
      clientId: event.secrets.M2M_CLIENT_ID,
      clientSecret: event.secrets.M2M_CLIENT_SECRET,
      issuer: `${event.secrets.AUTH0_DOMAIN}.auth0.com`
    }
  };

  if (event.user.email === undefined) {
    console.log(LOG_TAG, 'Account Link Action: No event.user.email');
    return;
  }

  await createStrategy().then(callbackWithSuccess).catch(callbackWithFailure);

  async function createStrategy() 
  {
    if (shouldLink()) 
    {
      await setManagementAccessToken(true);
      return linkAccounts();
    } 

    if (shouldPrompt()) 
    {
      await setManagementAccessToken(false);
      return promptUser();
    }

    return continueAuth();

    function shouldLink() {
      return !!event.request.query.link_account_token;
    }

    function shouldPrompt() {
      return !insideRedirect() && !redirectingToContinue() && firstLogin();

      function insideRedirect() {
        return event.request.query.redirect_uri &&
          event.request.query.redirect_uri.indexOf(config.endpoints.linking) !== -1;
      }

      function firstLogin() {
        return event.stats.logins_count <= 1;
      }

      function redirectingToContinue() {
        return event.protocol === CONTINUE_PROTOCOL;
      }
    }
  }

  async function setManagementAccessToken(shouldLink) 
  {
    if (Auth0ManagementAccessToken !== ''){
      return;
    }

    if (shouldLink) {
      var options = {
        method: 'POST',
        url: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/oauth/token`,
        headers: {'content-type': 'application/x-www-form-urlencoded'},
        data: new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: event.secrets.APP_CLIENT_ID,
          client_secret: event.secrets.APP_CLIENT_SECRET,
          audience: event.secrets.BASE_URL + "/"
        })
      };
    }
    else {
      var options = {
        method: 'POST',
        url: `https://${event.secrets.AUTH0_DOMAIN}.auth0.com/oauth/token`,
        headers: {'content-type': 'application/x-www-form-urlencoded'},
        data: new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: event.secrets.M2M_CLIENT_ID,
          client_secret: event.secrets.M2M_CLIENT_SECRET,
          audience: event.secrets.BASE_URL + "/"
        })
      };
    }

    try {
      const response = await axios.request(options);
      Auth0ManagementAccessToken = response.data.access_token;
    } catch (error) {
      console.error(LOG_TAG, "axios error:" + error + " for options: " + JSON.stringify(options, null, 2));
      return;
    }
  }

  function verifyToken(token, secret) {
    return new Promise(function(resolve, reject) {
      jwt.verify(token, secret, function(err, decoded) {
        if (err) {
          console.error(LOG_TAG, `verifyToken error: ${err}`);
          return reject(err);
        }

        return resolve(decoded);
      });
    });
  }

  function linkAccounts() {
    var secondAccountToken = event.request.query.link_account_token;

    return verifyToken(secondAccountToken, config.token.clientSecret)
      .then(function(decodedToken) {
        // Redirect early if tokens are mismatched
        if (event.user.email !== decodedToken.email) {
          console.error(LOG_TAG, 'User: ', decodedToken.email, 'tried to link to account ', event.user.email);
          event.redirect = {
            url: buildRedirectUrl(secondAccountToken, event.request.query, 'accountMismatch')
          };

          return event.user;
        }

        var headers = {
          Authorization: 'Bearer ' + Auth0ManagementAccessToken,
          'Content-Type': 'application/json',
          'Cache-Control': 'no-cache'
        };

        var getUrl = config.endpoints.userApi+'/'+decodedToken.sub+'?fields=identities';

        return apiCall({
          method: 'GET',
          url: getUrl,
          headers: headers
        })
          .then(function(secondaryUser) {
            var provider = secondaryUser &&
              secondaryUser.identities &&
              secondaryUser.identities[0] &&
              secondaryUser.identities[0].provider;

            var linkUri = config.endpoints.userApi + '/' + event.user.user_id + '/identities';

            return apiCall({
              method: 'POST',
              url: linkUri,
              headers,
              json: { user_id: decodedToken.sub, provider: provider }
            });
          })
          .then(function(_) {
            console.info(LOG_TAG, 'Successfully linked accounts for user: ', event.user.email);
            return _;
          });
      });
  }

  function continueAuth() {
    return Promise.resolve();
  }

  function promptUser() {
    return searchUsersWithSameEmail().then(function transformUsers(users) {
      return users.filter(function(u) {
        return u.user_id !== event.user.user_id;
      }).map(function(user) {
        return {
          userId: user.user_id,
          email: user.email,
          picture: user.picture,
          connections: user.identities.map(function(identity) {
            return identity.connection;
          })
        };
      });
    }).then(function redirectToExtension(targetUsers) {
      if (targetUsers.length > 0) {
        event.redirect = {
          url: buildRedirectUrl(createToken(config.token), event.request.query)
        };
      }
    });
  }

  function callbackWithSuccess(_) {
    if (api.redirect.canRedirect() && event.redirect) {
      api.redirect.sendUserTo(event.redirect.url);
    }

    return;
  }

  function callbackWithFailure(err) {
    console.error(LOG_TAG, err.message, err.stack);
    api.access.deny(err.message);
  }

  function createToken(tokenInfo, targetUsers) {
    var options = {
      expiresIn: '5m',
      audience: tokenInfo.clientId,
      issuer: qualifyDomain(tokenInfo.issuer)
    };

    var userSub = {
      sub: event.user.user_id,
      email: event.user.email,
      base: event.secrets.BASE_URL
    };

    return jwt.sign(userSub, tokenInfo.clientSecret, options);
  }

  function searchUsersWithSameEmail() {
    return apiCall({
      url: config.endpoints.usersByEmailApi,
      qs: {
        email: event.user.email
      }
    });
  }

  // Consider moving this logic out of the rule and into the extension
  function buildRedirectUrl(token, q, errorType) {
    var params = {
      child_token: token,
      audience: q.audience,
      client_id: q.client_id,
      redirect_uri: q.redirect_uri,
      scope: q.scope,
      response_type: q.response_type,
      response_mode: q.response_mode,
      auth0Client: q.auth0Client,
      original_state: q.original_state || q.state,
      nonce: q.nonce,
      error_type: errorType
    };

    return config.endpoints.linking + '?' + queryString.encode(params);
  }

  function qualifyDomain(domain) {
    return 'https://'+domain+'/';
  }

  function apiCall(options) {
    return new Promise(function(resolve, reject) {
      var reqOptions = Object.assign({
        url: options.url,
        headers: {
          Authorization: 'Bearer ' + Auth0ManagementAccessToken,
          Accept: 'application/json'
        },
        json: true
      }, options);

      request(reqOptions, function handleResponse(err, response, body) {
        if (err) {
          reject(err);
        } else if (response.statusCode < 200 || response.statusCode >= 300) {
          console.error(LOG_TAG, 'API call failed: ', body);
          reject(new Error(body));
        } else {
          resolve(response.body);
        }
      });
    });
  }
};

Secrets explanations:

ADTC commented 1 year ago

Thanks for this @EgilSandfeld, ~but if I understand you correctly, we just have to copy the code of our existing Rule, and create a new Action with it, with the dependencies set correctly and some minor code changes done.~

~There are only minor changes from the code of the Rule to the new code of the Action, while most of the code remains intact. I suggest to revise your answer to remove the unchanged code, and only instruct the changes to be done.~ Mistaken, please ignore.

ADTC commented 1 year ago

Maybe I am mistaken. I did a comparison and it seems a lot of global variables are changed in Actions, so we'll need to do plenty of search and replace. I think the code above is the result of that.

ADTC commented 1 year ago

Why isn't Promise = require('native-or-bluebird') but now require("promise") ?

EgilSandfeld commented 1 year ago

Why isn't Promise = require('native-or-bluebird') but now require("promise") ?

You're right, missed that the Rule already had versions and package names. Fixed in the code example

ADTC commented 1 year ago

I think most of the changes in variable names could be avoided if we just use the following instead of exports.onExecutePostLogin = async (event, api):

exports.onExecutePostLogin = async (context, api) => {
  const user = context.user;
  const auth0 = {
    baseUrl: context.tenant.baseUrl, // or is it api.baseUrl ?
    domain: context.tenant.id,
    accessToken: api.accessToken,
  };

  // ...

}

This will help most of the code remain intact from the original Rule code, as we're ensuring the existing variable names are reused.

Regardless, are your replacements of callback(...) with return; or api.access.deny(err.message); tested and verified correct? That will be my main concern.

EgilSandfeld commented 1 year ago

Again, not tested.

And yes great idea to restore the variable names.

Bedste hilsner / Best regards Egil Sandfeld On Oct 9, 2023, 16:46 +0200, ADTC @.***>, wrote:

I think most of the changes in variable names could be avoided if we just use the following instead of exports.onExecutePostLogin = async (event, api): exports.onExecutePostLogin = async (context, api) => { const user = context.user; const auth0 = { baseUrl: context.tenant.baseUrl, // or is it api.baseUrl ? domain: context.tenant.id, accessToken: api.accessToken, };

// ...

} This will help most of the code remain intact from the original Rule code, as we're ensuring the existing variable names are reused. Regardless, are your replacements of callback(...) with return; or api.access.deny(err.message); tested and verified correct? That will be my main concern. — Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you were mentioned.Message ID: @.***>

ADTC commented 1 year ago

Okay. Thank you anyway, this is a great start. I'll take some time soon to look through the official guides for rule-to-action conversion and do a conversion of my own. Perhaps posting steps here.

Wish I could create a pull request, but that would require deeper dive into the API on how to create an Action programmatically. And if that's not possible or only partially possible, then instructions would have to be added in the Readme or the installation interface.

EgilSandfeld commented 1 year ago

@ADTC I've updated and tested my code today. Works now with Actions, so I was able to successfully link a Twitch account into an email account, and I could therefore disable the existing Rule doing the same things.

Obviously the code can be improved, but it works for now 🙌

ADTC commented 1 year ago

Thank you for testing it! BTW, may I suggest listing the dependencies here in text form, rather than out-linking to Imgur? It can be second level bullet points.

* First level
  * Second level

Looks like:

Thank you!

PS: Also, it's possible to paste images here in GitHub comments if you really want to.

dschloesser-twain commented 11 months ago

@EgilSandfeld Thank you for your comment with the adjusted action.

Could you please adjust axious@1.5.1 to axios@1.5.1 in your comment. That should prevent others from searching for a solution why the action build in auth0 fails when simply copy pasting your dependencies from the comment.

ADTC commented 11 months ago

🤣 It's funny how the double asterisks makes the list look weird, with both first level and second level bullets on the same line:

* First level
* * Second level

(compared to:)

* First level
  * Second level

Dear GitHub, WTH? 😂

ben-propflo commented 10 months ago

@EgilSandfeld thank you for the action!

Auth0 appear to hide rules for new tenants, so when enabling the extension you get the broken rule added and then are stuck as you can't disable it.

I found you could still list the rules with the management API and then disable the rule with the update API. https://auth0.com/docs/api/management/v2/rules/get-rules https://auth0.com/docs/api/management/v2/rules/patch-rules-by-id

Hopefully auth0 will come up with something official for actions.

ADTC commented 10 months ago

Honestly I wish Auth0 made the feature native and not have to rely on extensions (and this one looks abandoned). I cannot use the New Universal Login Experience if I also want this feature simultaneously. It's terrible.

glebignatieff commented 8 months ago

Does it make sense to rework only the Rule leaving out the rest? As I understand it, the extension is based on the webtask that ties together the initial login, login to the primary account and error handling. Couldn't it be dropped in the future as well once the extension is completely abandoned?

Also an Auth0 employee says the following in AMA:

Account linking is now available for Actions and we recommend that you move to Actions-based account linking from extension, as it will follow its own product development cycle that we don’t plan to continue support with.

glebignatieff commented 8 months ago

@ADTC Where does this come from?

I cannot use the New Universal Login Experience if I also want this feature simultaneously.

pmorelli92 commented 6 months ago

Does it make sense to rework only the Rule leaving out the rest? As I understand it, the extension is based on the webtask that ties together the initial login, login to the primary account and error handling. Couldn't it be dropped in the future as well once the extension is completely abandoned?

Also an Auth0 employee says the following in AMA:

Account linking is now available for Actions and we recommend that you move to Actions-based account linking from extension, as it will follow its own product development cycle that we don’t plan to continue support with.

They stated that Account linking is available for actions but what they also tell you that you need to take care of showing user logins, authenticate them etc. Is not like they did a replacement solution with FE included as this extension.

ADTC commented 6 months ago

@glebignatieff if you need to insert custom logic you're forced to switch to the classic login. The "New Universal Login Experience" didn't allow custom code when I last checked.

thomas-beznik commented 1 month ago

Hello @EgilSandfeld, Thank you for providing this example code, really useful!

Could you just help me with something: in our case we are using a custom domain, and I can't seem to be able to make your code work; do you reckon that anything should change in this scenario? Thank you for the help!