Passing context to Auth0 Actions by abusing the authorize endpoint

Recently, I was tasked with a small utility task at my job - we wanted to get a Slack message for every login event in any of our SaaS applications. Our login system uses Auth0, and since we didn't want to pay extra for another Log stream, we decided that we could achieve the same task by using Auth0 actions - after all, a Slack bot is their example of an action!

The Problem

However, I quickly encountered a problem - due to a design decision we took a while ago, an intermediate app is responsible for the login process of our apps - meaning that when accessing our SaaS, a server-side redirection takes the user to a dedicated login application, which handles the authentication process using Auth0. I will not go into the design decisions that inspired this process, but this is what I had to work with.

This poses a problem with passing data to Auth0 from the SaaS application. Mainly, the data I wanted to pass was what application initiated the login process - we have many apps that are all using the login intermediate, and since the redirect_uri parameter passed to Auth0 is the URI of the login application (not the originating app), I had to find a way to pass data from the SaaS application to Auth0 using the two primitives I have - the redirection, and the /authorize endpoint.

Passing data between the SaaS application and the login application is simple - since a simple redirect does the call, I can use URL query parameters to pass data - which is what I did. Instead of redirecting to https://login.app/, it is now redirected to https://login.app/?src=myapp.io.

It was the /authorize endpoint that was the real pain.

/authorize

As the Auth0 code flow documentation explains, /authorize is the endpoint that apps wishing to use Auth0 authentication services should redirect to in order to start the process.

Since this is the first endpoint that the Auth0 server sees, I figured that Actions should be able to get data from this request - and they do.

When scouting the Auth0 docs, I found an example that mentioned that Actions can access the initial request in an object available as event.request, and by looking at the sample data available in the "Try" section of the action builder, we can see that it supplies our action with query params!

This is great! It means that if we can pass an additional query param to the /authorize endpoint, we can access it from the Action and win. Unfortunately, this is not that simple.

Auth0 React SDK

Since our applications are written in React, we used Auth0's React SDK. When using this SDK, a react hook supplies different functions to control the authentication flow.

const {
    isAuthenticated,
    loginWithRedirect,
    getAccessTokenSilently,
    isLoading,
} = useAuth0();

Since our authentication flow requires us to return a JWT token from the login application back to the SaaS application that initiated the connection, we use a simple logic to choose what to do when a user reaches the login application - something like this (pseudocode):

if (!isAuthenticated) {
  loginWithRedirect();
} else {
  return getAccessTokenSilently();
}

This means we have two flows that can lead to an /authorize call - loginWithRedirect() and getAccessTokenSilently(). If we can figure out how to pass query parameters in those, we are good to do.

loginWithRedirect()

This one was the easier one.

After looking at the docs for this function's params, we can see that one of the options that this function accepts is called openUrl. As per the docs, this function can be used to control the redirect!

This is great - we can use this to add our query parameters! Now, instead of simply calling loginWithRedirect, we can pass this as an option:

    if (!isAuthenticated) {
      loginWithRedirect({
        openUrl: function (url) {
          // Add a query param to the url
          url = new URL(url);
          url.searchParams.append("initiatingApp", loginSource);
          // Open the url in the browser
          window.location = url;
        },
      });
    }

And indeed, we can take a look at the Action's logs:

Success!

getAccessTokenSilently()

This one's trickier. Since loginWithRedirect takes the user somewhere, it makes sense for the SDK to allow the app writer to control how the redirections will be done. But getAccessTokenSilently is an internal process done by Auth0 - it shouldn't concern the coder how the token is obtained.

We can confirm this is the case by taking a look at the SDK docs for this function - where we will see that no similar functionality is exposed:

Since all the available parameters are relevant to the auth process and can affect it, we can't use those to pass data - we need to go deeper.

Reading the source

Luckily, the SDK is open source - we can look at the code and try to understand how the token is obtained - we know that a call to the /authorize endpoint is made. If we can find out how the call is made, maybe we can intervene with the process and add our query param.

After taking a quick look at the react SDK, we can quickly see that it is only a wrapper around the auth0-spa-js SDK:

So, let's go into this instead. I opened up the repo for the SPA SDK and tracked down the getTokenSilently function, which I figured is the place to go:

By going into this function, we can see a few interesting insights about how tokens are obtained - first, we can use a cache to locally store tokens:

If the cache is disabled, or no cache entry is found, we do one of two things - either use a refresh token or call a function named _getTokenFromIFrame - which sounds intriguing!

I don't know the internals of refresh tokens and how they work, but as we see we can control the flow - and force it to use the iFrame option (by passing cacheMode: off and useRefreshTokens: false). Let's take a look at what happens in this flow:

This is interesting - we can see that the /authorize call is done via an iFrame! If we take a look into runIframe, we can see how it is done:

export const runIframe = (
  authorizeUrl: string,
  eventOrigin: string,
  timeoutInSeconds: number = DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
) => {
  return new Promise<AuthenticationResult>((res, rej) => {
    const iframe = window.document.createElement('iframe');

    iframe.setAttribute('width', '0');
    iframe.setAttribute('height', '0');
    iframe.style.display = 'none';

    const removeIframe = () => {
      if (window.document.body.contains(iframe)) {
        window.document.body.removeChild(iframe);
        window.removeEventListener('message', iframeEventHandler, false);
      }
    };

    let iframeEventHandler: (e: MessageEvent) => void;

    const timeoutSetTimeoutId = setTimeout(() => {
      rej(new TimeoutError());
      removeIframe();
    }, timeoutInSeconds * 1000);

    iframeEventHandler = function (e: MessageEvent) {
		/* ... */
	};

    window.addEventListener('message', iframeEventHandler, false);
    window.document.body.appendChild(iframe);
    iframe.setAttribute('src', authorizeUrl);
  });
};

And this is what we were after - the function that makes the /authorize call!

This function creates an iFrame (by adding a new element to the body) and points it to the /authorize endpoint, at which point it waits until the iFrame sends a message with the code used to generate the token - and removes the iframe.

And those are great news - because if we can intervene in this process, we can pass our query param to /authorize! Let's figure out how to do so.

Hooking the iFrame

Since we now know how the /authorize endpoint is called, we can hook the functions used by runIframe and add custom logic that will add our query param to the URL that is being set as the src for the iframe.

First of all, we hook the iFrame creation function. We aim to get an opportunity to hook the Iframe methods before it's pointed at /authorize. This could also be done by using a Mutation Observer, but for our case, it was easier to hook the createElement function:

const hookIframe = (redirectTo) => {
  const create = window.document.createElement;
  window.document.createElement = function (elementType) {
    if (elementType !== "iframe") {
      return create.call(this, elementType);
    }
    const newIframe = create.call(this, elementType);
    // TODO: Hook the setAttribute function of the iframe
    return newIframe;
  };
};

Notice how we only hook if the element being created is an iFrame, and how we use the original function to create the element. Now, let's write the hook logic - remember, we want to hook the setAttribute function to patch the src being set by runIframe and use this hook to add our query param:

    const setAttribute = newIframe.setAttribute;
    newIframe.setAttribute = function (attributeName, attributeValue) {
      if (attributeName !== "src") {
        return setAttribute.call(this, attributeName, attributeValue);
      }
      const url = new URL(attributeValue);
      if (url.pathname !== "/authorize") {
        return setAttribute.call(this, attributeName, attributeValue);
      }

      if (url.searchParams.get("initiatingApp")) {
        return setAttribute.call(this, attributeName, attributeValue);
      }

      url.searchParams.append("initiatingApp", redirectTo);
      return setAttribute.call(this, attributeName, url.toString());
    };

Note how we always keep checking everything to avoid edge cases that will cause problems. This is a good practice when writing hooks this general - make an effort to make sure that what you're patching is what you want to patch rather than some edge case.

The complete hook looks like this:

const hookAutorize = (initiatingApp) => {
  const create = window.document.createElement;
  window.document.createElement = function (elementType) {
    if (elementType !== "iframe") {
      return create.call(this, elementType);
    }
    const newIframe = create.call(this, elementType);
    const setAttribute = newIframe.setAttribute;
    newIframe.setAttribute = function (attributeName, attributeValue) {
      if (attributeName !== "src") {
        return setAttribute.call(this, attributeName, attributeValue);
      }
      const url = new URL(attributeValue);
      if (url.pathname !== "/authorize") {
        return setAttribute.call(this, attributeName, attributeValue);
      }

      if (url.searchParams.get("initiatingApp")) {
        return setAttribute.call(this, attributeName, attributeValue);
      }

      url.searchParams.append("initiatingApp", redirectTo);
      return setAttribute.call(this, attributeName, url.toString());
    };
    return newIframe;
  };
};

And, after running this, it works!

This hook can be improved by using things like a Proxy Object, but it was good enough for now.

Conclusion

This is another excellent example of why using open-source software is so great - even if the SDK does not provide users with ways to do things, the mere fact that its code is available allows users to understand what's happening under the hood and figure out workarounds and solutions that would be much harder to find if the SDK was closed source.

It was a fun afternoon rabbit hole to go into - and now, I can go back to work. Victorious!

The resulting Slack bot