Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrating from Rules to Actions #165

Open
ADTC opened this issue Sep 29, 2023 · 23 comments
Open

Migrating from Rules to Actions #165

ADTC opened this issue Sep 29, 2023 · 23 comments

Comments

@ADTC
Copy link

ADTC commented Sep 29, 2023

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
Copy link

EgilSandfeld commented Oct 9, 2023

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:

  • "APP_CLIENT_ID": "The ID of web app making the link between the user identities in the end",
  • "APP_CLIENT_SECRET": "The SECRET of web app making the link between the user identities in the end",
  • "AUTH0_DOMAIN": "dev-xxxxxx.us" or something similar, without leading https:// and without trailing .com
  • "BASE_URL": "https://dev-xxxxxx.us.auth0.com/api/v2",
  • "M2M_CLIENT_ID": "The ID of auth0-account-link app",
  • "M2M_CLIENT_SECRET": "The SECRET of auth0-account-link app"

@ADTC
Copy link
Author

ADTC commented Oct 9, 2023

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
Copy link
Author

ADTC commented Oct 9, 2023

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
Copy link
Author

ADTC commented Oct 9, 2023

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

@EgilSandfeld
Copy link

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
Copy link
Author

ADTC commented Oct 9, 2023

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
Copy link

EgilSandfeld commented Oct 9, 2023 via email

@ADTC
Copy link
Author

ADTC commented Oct 9, 2023

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
Copy link

@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
Copy link
Author

ADTC commented Oct 11, 2023

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:

  • First level
    • Second level

Thank you!

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

@dschloesser-twain
Copy link

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

Could you please adjust [email protected] to [email protected] 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
Copy link
Author

ADTC commented Oct 18, 2023

🤣 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
  • First level
    • Second level

(compared to:)

* First level
  * Second level
  • First level
    • Second level

Dear GitHub, WTH? 😂

@ben-propflo
Copy link

ben-propflo commented Nov 26, 2023

@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
Copy link
Author

ADTC commented Dec 14, 2023

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
Copy link

glebignatieff commented Jan 25, 2024

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
Copy link

@ADTC Where does this come from?

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

@pmorelli92
Copy link

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
Copy link
Author

ADTC commented Mar 25, 2024

@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
Copy link

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!

@stefansundin
Copy link

Hello,

I almost got my migration to an action working, but I ran into a scope issue and I can't figure it out. I think I used the wrong application for the APP_CLIENT_ID and APP_CLIENT_SECRET variables, or perhaps even M2M_CLIENT_ID and M2M_CLIENT_SECRET. Can someone explain which applications I should use for them?

The error I'm getting is this:

{
  "logs": "[ACTION_ACCOUNT_LINK]  API call failed:  {\n  statusCode: 403,\n  error: 'Forbidden',\n  message: 'Insufficient scope, expected any of: update:users,update:current_user_identities',\n  errorCode: 'insufficient_scope'\n}\n[ACTION_ACCOUNT_LINK]  [object Object] Er"
}

This error happens after I login to the primary account and it tries to complete the account linking.

I can't really find any information on how to grant my application these scopes. I can find update:users in my "Auth0 Management API" API, but I can't find update:current_user_identities anywhere! 😢

Also, does anyone know how I can get the full logs of the action execution? Or is that only possible when testing it in the action editor?

Thank you for any help you can provide!

I'm quite disappointed that this extension is abandoned. I wish I could just uninstall the extension and then install it from scratch and it would automatically use The New Thing.

@kezzyhko
Copy link

@stefansundin
Try the following:

  1. Open auth0-account-link app
  2. Open APIs tab
  3. Check that Auth0 Management API is enabled/authorized
  4. Check that read:users and update:users are selected
    (update:current_user_identities is not really needed)
  5. For both APP_CLIENT_* and M2M_CLIENT_* use the same app auth0-account-link

You can also use this version
https://gist.github.com/kezzyhko/37260ad2a5c2a470ad41243df73ccbc1
I have added some logs because I had the same problem

@stefansundin
Copy link

Thank you, that seems to work! 🥳

I think what confused me the most was that it wasn't possible to grant update:current_user_identities so I didn't even try with just update:users.

@sharkzp
Copy link

sharkzp commented Nov 5, 2024

"APP_CLIENT_ID": "The ID of web app making the link between the user identities in the end",
"APP_CLIENT_SECRET": "The SECRET of web app making the link between the user identities in the end",
"AUTH0_DOMAIN": "dev-xxxxxx.us" or something similar, without leading https:// and without trailing .com
"BASE_URL": "https://dev-xxxxxx.us.auth0.com/api/v2",
"M2M_CLIENT_ID": "The ID of auth0-account-link app",
"M2M_CLIENT_SECRET": "The SECRET of auth0-account-link app"

Thank you a lot for writing it down!

As far as I understand, M2M_CLIENT_ID and M2M_CLIENT_SECRET are taken from the auto-generated rule. While APP_CLIENT_ID and APP_CLIENT_SECRET are taken from Auth0 Advanced config(keys you, as a developer, provided to Auth0 for authentication with your app).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants