Published on

Migrate an Enterprise React App to Auth0 with a Phased Approach

Authors

Moving from in-house authentication to a managed auth service provider has become commonplace as a result of the exponential rise in security threats targeting identity systems. For example, the 2021 State of Security Identity Report whitepaper published by Auth0 states that "Auth0 observed more than 87,000 attempts to brute force multifactor authentication just in the first four months of 2021 alone". It is for this reason that managing identity in-house has become a massive drain on resources and far more trouble than it is worth.

The fact is, your company's home-grown solution just isn't going to cut it anymore. The days of in-house federated gateways are over. We now live in a day where we need managed identity service providers like Auth0 for authentication and authorization to protect ourselves against the exponential growth in bad actors. However, overhauling the authentication and authorization system of an existing enterprise production application can be truly daunting. You're likely to encounter challenges that require a bit of creativity and planning to overcome. One such challenge might be supporting both your legacy auth system and your new managed auth provider until you can completely switch to the managed auth service.

There are a couple of reasons you might need to do this. You may have tens or even hundreds of thousdands of users on your home-grown identity server across multiple organizations and switching to a managed auth service provider means taking a phased approach to migration. This is particularly relevant if you are hosting a white-label branded application. Another reason might be that you simply want to still have your legacy identity system in place as a fallback in case something goes horribly wrong after the switch-over and you don't want to have to roll back your entire codebase and have to cherry-pick all of the changes that may have occurred elsewhere in the application.

This article will focus on Auth0 integration in an existing large-scale React application with continued support for your existing OIDC authentication and authorization service.

Creating a Common Interface for the Auth Context

In this application, we'll be using the React Context API to manage global state. In order to make this work, we'll need an AuthContext that is capable of using either one of the auth libraries depending on whether or not the application was able to obtain an Auth0 application configuration. To accomplish this, both auth mechanisms must have a common interface. This way the authentication is abstracted and the rest of the application is oblivious to which auth is being used.

Using Typescript, we'll create a type to define our common interface. We'll use a naming convention that is consistent with the react-auth0 API:

export type TAuthContext = {
  isAuthenticated: () => boolean;
  loginWithRedirect: () => Promise<void>;
  handleRedirectCallback: ({history}: {history: History}) => Promise<void>;
  logout: () => void;
  renewTokenSilently: () => Promise<void>;
  signoutRedirectCallback?: (callback: () => void) => Promise<void>;
};

As you can see, we're using the same naming convention as the API provided by the auth0-react library to establish our common interface, and we'll "adapt" the OIDC implementation to use this same interface using React's Context API to call auth-related methods in the application. We're going to implement adapters to make both auth mechanisms work. The OIDC implementation is a bit more complex, so we're going to deal with that first.

One of the methods in our TAuthContext type is not consistent with the auth0-react API, which is the signoutRedirectCallback. This is only used by the OIDC auth and so it is made optional in this regard.

Interfacing with the Auth Service for the OIDC Client

We're using the oidc-client library as the app's existing authentication, session, and access token management system as it is a spec-compliant OpenID Connect library. Whether you are dealing with a home-grown solution or other library, the concepts will be the same provided that they adhere to the OpenID Connect protocol.

Within the oidc-client javascript library, the UserManager provides an API for managing claims returned from the OIDC provider, handling tokens, and login/logout. An OIDC Auth Service class is needed to set up the UserManager using the environment configuration settings. I should note that I am not particularly fond of using classes in Javascript because I am of the opinion that classes do not belong in a scripting language, but in this particular instance I find it useful as we can set up event listeners for expiring and expired tokens. This could easily be done without using a class where the listeners are set up with useEffect, but I just like this method better. The methods that the OIDC context adapter will be calling from the OIDC Auth Service are as follows:

  1. signinRedirectCallback
    • This method is called from the /callback route after login. It returns a promise to get the access token from the User object by calling the getUser method.
  2. getUser
    • Calls the UserManager getuser() method and returns a promise to load the User object for the currently authenticated user. If there is no user returned (ie. the user is not loggied in), the UserManaager's signinRedirectCallback method is called, which then returns a promise to process the response from the authorization endpoint and the result of the promise is the authenticated user.
  3. signinRedirect
    • This first sets the redirectUri to localStorage, then calls the UserManager's signinRedirect, which returns a promise to trigger a redirect of the current window to the authorization endpoint.
  4. signinSilentCallback
    • Calls the UserManager.signinSilentCallback(), which returns a promise to notify the parent window of a response from the authorization endpoint.
  5. setTokenValues
    • This grabs the token from the user object and uses that to set the bearer token so axios can calls to the application's API.
  6. isAuthenticated
    • For the OIDC auth, this flag is checked from the AuthService, which checks the OIDC token from session storage if one exists.
  7. logout
    • Takes care of the operations necessary for logout and calls the UserManager's signoutRedirect, which returns a promise to trigger a redirect of the current window to the end session endpoint.
  8. signoutRedirectCallback
    • Calls the signoutRedirectCallback from the UserManager, which returns a promise to process the response from the end session endpoint.

As you can see, these methods are significantly different our common interface, yet we need to be able to reference the same methods regardless of which authentication service is being used. Enter the adapter.

Building the OIDC Context Adapter

The trick is to create a context adapter for the OIDC service that implements the six auth methods that we need for the application. This adapter will initialize the OIDC Auth Service class and and call the above methods using our six common auth methods that the application needs to function. The adapter will export the Auth Service Provider, which initializes the OIDC Auth Service, and passes it to the Auth Context. Did you get all that? Ok, maybe this is getting a little confusing, so let's look at an illustration that demonstrates this, as well as how the application will use the Auth0Provider, which we will get to later.

Auth flow diagram illustrating the application of both Auth0 and OIDC authentication mechanisms in the same application

And here is the code for our OIDC Context Adapter:

OidcContextAdapter.tsx

import React, { useState } from 'react'
import OidcAuthService from './OidcAuthService'
import { UserManagerConfig } from './UserManagerConfig'
import { History } from 'history'
import { setTokenHeader } from 'data-service'
import { TAuthServiceProviderProps, TAuthContext } from '../types'

type OidcAuthServiceContextType = {
  oidcAuthService: OidcAuthService,
  isAuthenticated: boolean,
  setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean>>
}

export const OidcAuthContext =
  (React.createContext < OidcAuthServiceContextType) | (undefined > undefined)

export const useContext = (): TAuthContext => {
  const context = React.useContext(AuthServiceContext)

  if (context === undefined) {
    throw new Error(
      'useContext of OidcContextAdapter should be used inside context provider'
    )
  }

  return {
    renewTokenSilently: async () => {
      const user = await context.oidcAuthService.signinSilentCallback()
      if (user) {
        const token = user.access_token
        setTokenHeader(token)
      } else {
        console.warn('was not able to authenticate user in silent callback')
      }
    },
    logout: () => {
      context.setIsAuthenticated(false)
      context.oidcAuthService.logout()
    },
    isAuthenticated: () => context.isAuthenticated,
    loginWithRedirect: async () => {
      context.oidcAuthService.signinRedirect()
    },
    handleRedirectCallback: async ({ history }: { history: History }) => {
      try {
        const token = await context.oidcAuthService.signinRedirectCallback()
        setTokenHeader(token)
        context.setIsAuthenticated(true)
        const redirectTo = localStorage.getItem('redirectUri')
        history.push(redirectTo || '/')
      } catch (e) {
        context.setIsAuthenticated(false)
        context.oidcAuthService.logout()
      }
    },
    signoutRedirectCallback: async (callback: () => void) => {
      await context.oidcAuthService.signoutRedirectCallback()
      callback()
    }
  }
}

type OidcAuthServiceProviderProps = Pick<TAuthServiceProviderProps, 'children'>
export const AuthServiceProvider: React.FC<OidcAuthServiceProviderProps> = ({
  children
}) => {
  //Instantiate the OIDC Auth Service and
  const [oidcAuthService] =
    useState < OidcAuthService > new OidcAuthService(UserManagerConfig)
  const [isAuthenticated, setIsAuthenticated] = useState < boolean > false
  // The OIDC auth provider is passed to the auth context so the functions in the
  // useContext above can be accessed by the rest of the application through the auth context.
  // Note that the OIDC Auth Service is also passed to the auth context.
  return (
    <OidcAuthServiceContext.Provider
      value={{ oidcAuthService, isAuthenticated, setIsAuthenticated }}
    >
      {children}
    </OidcAuthServiceContext.Provider>
  )
}

As you can see, we're using the method names from our common interface in the useContext to call the functions contained in the OidcAuthService class. Notice that we also pass the OIDC Auth Service to the Auth Context so that the OIDC Auth Service methods are accessed through the context and are therefore available to the rest of the application through the useAuthServiceContext hook. Now we're ready to create the Auth0 Context Adapter, which is considerably easier in comparison.

Creating the Auth0 Context Adapter

The auth0-react library does a great job of making things easy when it comes to implementing auth with Auth0. When you read the code below for the Auth0 Context Adapter, notice that the functions we are making available to the rest of the application from the useContext hook are simply calling the methods that are available from the react-auth0 API through the useAuth0 hook (aren't hooks great?!). This is because the react-auth0 library pretty much takes care of everything auth-related behind the scenes (ie. no need for an additional Auth0 Service!).

Auth0ContextAdapter.tsx

import React from 'react'
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react'
import { setTokenHeader, auth0Config } from 'data-service'
import { TAuthServiceProviderProps, TAuthContext } from '../types'

export const useContext = (): TAuthContext => {
  const context = useAuth0()

  return {
    renewTokenSilently: async () => {
      const token = await context.getAccessTokenSilently()
      setTokenHeader(token)
    },
    logout: () => context.logout({ returnTo: window.location.origin }),
    isAuthenticated: () => context.isAuthenticated,
    loginWithRedirect: async () => {
      localStorage.setItem(
        'redirectUri',
        `${window.location.pathname}${window.location.search}`
      )
      context.loginWithRedirect()
    },
    handleRedirectCallback: async () => {
      const token = await context.getAccessTokenSilently()
      setTokenHeader(token)
      const redirectTo = localStorage.getItem('redirectUri')
      context.handleRedirectCallback(redirectTo || '/')
    }
  }
}

type Auth0ServiceProviderProps = Pick<TAuthServiceProviderProps, 'children'> & {
  auth0Config: Pick<
    auth0Config,
    'authority' | 'clientId' | 'organizationId' | 'audience'
  >
}
export const AuthServiceProvider: React.FC<Auth0ServiceProviderProps> = ({
  children,
  auth0Config
}) => {
  return (
    <Auth0Provider
      {...{
        domain: auth0Config.authority,
        clientId: auth0Config.clientId,
        organization: auth0Config.organizationId,
        audience: auth0Config.audience,
        redirectUri: `${window.location.origin}/callback`
      }}
    >
      {children}
    </Auth0Provider>
  )
}

If you're not super familiar with Typescript, you might be wondering what we're doing when we create the Auth0ServiceProviderProps type. The Pick operation simply constructs a type by picking a set of properties from within another type. That way we don't end up with more properties than we need for what we are trying to accomplish. Here we're selecting the children property from TAuthServiceProviderProps and the authoriy, clientId, organizationId, and audience from our auth0Config type to construct the Auth0ServiceProviderProps type. These values are then passed to the Auth0Provider, which is obtained from the auth0-react library.

Note: You may notice that the Auth0 adapter does not implement the signoutRedirectCallback function. When the application is using the Auth0 Provider, we do not use the LogoutCallback route that calls this function as the auth0-react library handles this internally and redirects to the Auth0 Universal Login page.

Establishing the Auth Service Context

We are finally ready to put together the Auth Service Context, which will wrap the application along with any other contexts that must be globally available.

AuthServiceContext.tsx

import React from 'react'
import { TUseContext, TAuthContext, TAuthServiceProviderProps } from './types'
import * as Auth0Adapter from './Auth0/Auth0ContextAdapter'
import * as OidcAdapter from './FedGateway/OidcContextAdapter'

export * from './types'

let useActiveContext: TUseContext = Auth0Adapter.useContext

export const AuthServiceProvider: React.FC<TAuthServiceProviderProps> = ({
  children,
  auth0Config
}) => {
  if (auth0Config) {
    useActiveContext = Auth0Adapter.useContext
    return (
      <Auth0Adapter.AuthServiceProvider auth0Config={auth0Config}>
        {children}
      </Auth0Adapter.AuthServiceProvider>
    )
  } else {
    useActiveContext = OidcAdapter.useContext
    return (
      <OidcAdapter.AuthServiceProvider>
        {children}
      </OidcAdapter.AuthServiceProvider>
    )
  }
}

export const useAuthServiceContext = (): TAuthContext => {
  const context = useActiveContext()

  if (context === undefined) {
    throw new Error(
      'useAuthServiceContext should be used inside AuthServiceProvider'
    )
  }

  return context
}

The important thing to note here is that the AuthServiceProvider either returns the Auth0Adapter.AuthServiceProvider or the OidcAdapter.AuthServiceProvider depending on whether or not it was able to obtain an auth0Config.

Example Implementation

When the useAuthServiceContext hook is used within the application, the Auth Context will intelligently return the applicable context depending on whether or not an auth0Config object is available. Let's look at an example:

import React, { useEffect } from 'react'
import { useAuthServiceContext } from 'contexts'
import { LoadingSpinner } from 'components'

export type TPrivateAreaProps = {
  children: React.ReactNode
}

export const PrivateArea: React.FC<TPrivateAreaProps> = ({ children }) => {
  const { isAuthenticated, loginWithRedirect } = useAuthServiceContext()

  useEffect(() => {
    if (!isAuthenticated()) {
      loginWithRedirect()
    }
  }, [loginWithRedirect, isAuthenticated])

  if (isAuthenticated()) {
    return <>{children}</>
  } else {
    return <LoadingSpinner />
  }
}

In this example we are calling isAuthenticated and loginWithRedirect from the auth context. If the user is not authenticated, the loginWithRedirect function is called. If the application is using Auth0, the request is made to the auth0-react library which is handling the authenticated status behind the scenes and if the result is false, the auth0-react library's loginWithRedirect function is called from the Auth0ContextAdapter, which redirects the user to the Auth0 Universal Login page. However, if the application is using the OIDC client, the authentication status is checked by validating the token from session storage using the OIDC auth service. If it returns false, the loginWithRedirect is called from the OidcContextAdapter, which calls the oidc-client library's signinRedirect from the OidcAuthService, and that triggers a redirect to the OIDC authorization endpoint.

I know there is a lot to digest here, but hopefully this helps those who might be grappling with how to non-destructively integrate Auth0 into their large-scale React application. Similarly, if you're dealing with an application that spans across multiple organizations and you need to migrate them individually to Auth0, you could feasibly pull different auth configurations for each organization and determine which auth provider to use based on who is using the application with this methodology.