Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

title: Authentication order: 6#

Authentication#

Most APIs include some type of authentication, usually in the form of an auth token that is sent with each request header.

The purpose of the authExchange is to provide a flexible API that facilitates the typical JWT-based authentication flow.

Note: You can find a code example for @urql/exchange-auth in an example in the urql repository.

Typical Authentication Flow#

Initial login — the user opens the application and authenticates for the first time. They enter their credentials and receive an auth token. The token is saved to storage that is persisted though sessions, e.g. localStorage on the web or AsyncStorage in React Native. The token is added to each subsequent request in an auth header.

Resume — the user opens the application after having authenticated in the past. In this case, we should already have the token in persisted storage. We fetch the token from storage and add to each request, usually as an auth header.

Forced log out due to invalid token — the user's session could become invalid for a variety reasons: their token expired, they requested to be signed out of all devices, or their session was invalidated remotely. In this case, we would want to also log them out in the application, so they could have the opportunity to log in again. To do this, we want to clear any persisted storage, and redirect them to the application home or login page.

User initiated log out — when the user chooses to log out of the application, we usually send a logout request to the API, then clear any tokens from persisted storage, and redirect them to the application home or login page.

Refresh (optional) — this is not always implemented; if your API supports it, the user will receive both an auth token, and a refresh token. The auth token is usually valid for a shorter duration of time (e.g. 1 week) than the refresh token (e.g. 6 months), and the latter can be used to request a new auth token if the auth token has expired. The refresh logic is triggered either when the JWT is known to be invalid (e.g. by decoding it and inspecting the expiry date), or when an API request returns with an unauthorized response. For graphQL APIs, it is usually an error code, instead of a 401 HTTP response, but both can be supported. When the token has been successfully refreshed (this can be done as a mutation to the graphQL API or a request to a different API endpoint, depending on implementation), we will save the new token in persisted storage, and retry the failed request with the new auth header. The user should be logged out and persisted storage cleared if the refresh fails or if the re-executing the query with the new token fails with an auth error for the second time.

Installation & Setup#

First, install the @urql/exchange-auth alongside urql:

yarn add @urql/exchange-auth
# or
npm install --save @urql/exchange-auth

You'll then need to add the authExchange, that this package exposes to your Client. The authExchange is an asynchronous exchange, so it must be placed in front of all fetchExchanges but after all other synchronous exchanges, like the cacheExchange.

import { Client, cacheExchange, fetchExchange } from 'urql';
import { authExchange } from '@urql/exchange-auth';

const client = new Client({
  url: 'http://localhost:3000/graphql',
  exchanges: [
    cacheExchange,
    authExchange(async utils => {
      return {
        /* config... */
      };
    }),
    fetchExchange,
  ],
});

You pass an initialization function to the authExchange. This function is called by the exchange when it first initializes. It'll let you receive an object of utilities and you must return a (promisified) object of configuration options.

Let's discuss each of the configuration options and how to use them in turn.

Configuring the initializer function (initial load)#

The initializer function must return a promise of a configuration object and hence also gives you an opportunity to fetch your authentication state from storage.

async function initializeAuthState() {
  const token = localStorage.getItem('token');
  const refreshToken = localStorage.getItem('refreshToken');
  return { token, refreshToken };
}

authExchange(async utils => {
  let { token, refreshToken } = initializeAuthState();
  return {
    /* config... */
  };
});

The first step here is to retrieve our tokens from a kind of storage, which may be asynchronous as well, as illustrated by initializeAuthState.

In React Native, this is very similar, but because persisted storage in React Native is always asynchronous and promisified, we would await our tokens. This works because the function that authExchange is async, i.e. must return a Promise.

async function initializeAuthState() {
  const token = await AsyncStorage.getItem(TOKEN_KEY);
  const refreshToken = await AsyncStorage.getItem(REFRESH_KEY);
  return { token, refreshToken };
}

authExchange(async utils => {
  let { token, refreshToken } = initializeAuthState();
  return {
    /* config... */
  };
});

Configuring addAuthToOperation#

The purpose of addAuthToOperation is to apply an auth state to each request. Here, we'll use the tokens we retrieved from storage and add them to our operations.

In this example, we're using a utility we're passed, appendHeaders. This utility is a simply shortcut to quickly add HTTP headers via fetchOptions to an Operation, however, we may as well be editing the Operation context here using makeOperation.

authExchange(async utils => {
  let token = await AsyncStorage.getItem(TOKEN_KEY);
  let refreshToken = await AsyncStorage.getItem(REFRESH_KEY);

  return {
    addAuthToOperation(operation) {
      if (!token) return operation;
      return utils.appendHeaders(operation, {
        Authorization: `Bearer ${token}`,
      });
    },
    // ...
  };
});

First, we check that we have a non-null token. Then we apply it to the request using the appendHeaders utility as an Authorization header.

We could also be using makeOperation here to update the context in any other way, such as:

import { makeOperation } from '@urql/core';

makeOperation(operation.kind, operation, {
  ...operation.context,
  someAuthThing: token,
});

Configuring didAuthError#

This function lets the authExchange know what is defined to be an API error for your API. didAuthError is called by authExchange when it receives an error on an OperationResult, which is of type CombinedError.

We can for example check the error's graphQLErrors array in CombinedError to determine if an auth error has occurred. While your API may implement this differently, an authentication error on an execution result may look a little like this if your API uses extensions.code on errors:

{
  data: null,
  errors: [
    {
      message: 'Unauthorized: Token has expired',
      extensions: {
        code: 'FORBIDDEN'
      },
    }
  ]
}

If you're building a new API, using extensions on errors is the recommended approach to add metadata to your errors. We'll be able to determine whether any of the GraphQL errors were due to an unauthorized error code, which would indicate an auth failure:

authExchange(async utils => {
  // ...
  return {
    // ...
    didAuthError(error, _operation) {
      return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
    },
  };
});

For some GraphQL APIs, the authentication error is only communicated via a 401 HTTP status as is common in RESTful APIs, which is suboptimal, but which we can still write a check for.

authExchange(async utils => {
  // ...
  return {
    // ...
    didAuthError(error, _operation) {
      return error.response?.status === 401;
    },
  };
});

If didAuthError returns true, it will trigger the authExchange to trigger the logic for asking for re-authentication via refreshAuth.

Configuring refreshAuth (triggered after an auth error has occurred)#

If the API doesn't support any sort of token refresh, this is where we could simply log the user out.

authExchange(async utils => {
  // ...
  return {
    // ...
    async refreshAuth() {
      logout();
    },
  };
});

Here, logout() is a placeholder that is called when we got an error, so that we can redirect to a login page again and clear our tokens from local storage or otherwise.

If we had a way to refresh our token using a refresh token, we can attempt to get a new token for the user first:

authExchange(async utils => {
  let token = localStorage.getItem('token');
  let refreshToken = localStorage.getItem('refreshToken');

  return {
    // ...
    async refreshAuth() {
      const result = await utils.mutate(REFRESH, { refreshToken });

      if (result.data?.refreshLogin) {
        // Update our local variables and write to our storage
        token = result.data.refreshLogin.token;
        refreshToken = result.data.refreshLogin.refreshToken;
        localStorage.setItem('token', token);
        localStorage.setItem('refreshToken', refreshToken);
      } else {
        // This is where auth has gone wrong and we need to clean up and redirect to a login page
        localStorage.clear();
        logout();
      }
    },
  };
});

Here we use the special mutate utility method provided by the authExchange to do the token refresh. This is a useful method to use if your GraphQL API expects you to make a GraphQL mutation to update your authentication state. It will send the mutation and bypass all authentication and prior exchanges.

If your authentication is not handled via GraphQL but a REST endpoint, you can use the fetch API here however instead of a mutation.

All other requests will be paused while refreshAuth runs, so we won't have to deal with multiple authentication errors or refreshes at once.

Configuring willAuthError#

willAuthError is an optional parameter and is run before a request is made.

We can use it to trigger an authentication error and let the authExchange run our refreshAuth function without the need to first let a request fail with an authentication error. For example, we can use this to predict an authentication error, for instance, because of expired JWT tokens.

authExchange(async utils => {
  // ...
  return {
    // ...
    willAuthError(_operation) {
      // Check whether `token` JWT is expired
      return false;
    },
  };
});

This can be really useful when we know when our authentication state is invalid and want to prevent even sending any operation that we know will fail with an authentication error.

However, we have to be careful on how we define this function, if some queries or login mutations are sent to our API without being logged in. In these cases, it's better to either detect the mutations we'd like to allow or return false when a token isn't set in storage yet.

If we'd like to detect a mutation that will never fail with an authentication error, we could for instance write the following logic:

authExchange(async utils => {
  // ...
  return {
    // ...
    willAuthError(operation) {
      if (
        operation.kind === 'mutation' &&
        // Here we find any mutation definition with the "login" field
        operation.query.definitions.some(definition => {
          return (
            definition.kind === 'OperationDefinition' &&
            definition.selectionSet.selections.some(node => {
              // The field name is just an example, since signup may also be an exception
              return node.kind === 'Field' && node.name.value === 'login';
            })
          );
        })
      ) {
        return false;
      } else if (false /* is JWT expired? */) {
        return true;
      } else {
        return false;
      }
    },
  };
});

Alternatively, you may decide to let all operations through if your token isn't set in storage, i.e. if you have no prior authentication state.

Handling Logout by reacting to Errors#

We can also handle authentication errors in a mapExchange instead of the authExchange. To do this, we'll need to add the mapExchange to the exchanges array, before the authExchange. The order is very important here:

import { createClient, cacheExchange, fetchExchange, mapExchange } from 'urql';
import { authExchange } from '@urql/exchange-auth';

const client = createClient({
  url: 'http://localhost:3000/graphql',
  exchanges: [
    cacheExchange,
    mapExchange({
      onError(error, _operation) {
        const isAuthError = error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
        if (isAuthError) {
          logout();
        }
      },
    }),
    authExchange(async utils => {
      return {
        /* config */
      };
    }),
    fetchExchange,
  ],
});

The mapExchange will only receive an auth error when the auth exchange has already tried and failed to handle it. This means we have either failed to refresh the token, or there is no token refresh functionality. If we receive an auth error in the mapExchange's onError function (as defined in the didAuthError configuration section above), then we can be confident that it is an authentication error that the authExchange isn't able to recover from, and the user should be logged out.

Cache Invalidation on Logout#

If we're dealing with multiple authentication states at the same time, e.g. logouts, we need to ensure that the Client is reinitialized whenever the authentication state changes. Here's an example of how we may do this in React if necessary:

import { createClient, Provider } from 'urql';

const App = ({ isLoggedIn }: { isLoggedIn: boolean | null }) => {
  const client = useMemo(() => {
    if (isLoggedIn === null) {
      return null;
    }

    return createClient({ /* config */ });
  }, [isLoggedIn]);

  if (!client) {
    return null;
  }

  return {
    <Provider value={client}>
      {/* app content  */}
    <Provider>
  }
}

When the application launches, the first thing we do is check whether the user has any authentication tokens in persisted storage. This will tell us whether to show the user the logged in or logged out view.

The isLoggedIn prop should always be updated based on authentication state change. For instance, we may set it to true after the user has authenticated and their tokens have been added to storage, and set it to false once the user has been logged out and their tokens have been cleared. It's important to clear or add tokens to a storage before updating the prop in order for the auth exchange to work correctly.

This pattern of creating a new Client when changing authentication states is especially useful since it will also recreate our client-side cache and invalidate all cached data.