Apollo GraphQL : Async Access Token Refresh

The Problem

I’ve been working on a React Native app recently and one of the things I struggled quite a bit with was automatically refreshing OAuth2 access tokens.

The example given in the the Apollo Error Link documentation is a good starting point but assumes that the getNewToken() operation is synchronous.

import { onError } from 'apollo-link-error';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            // modify the operation context with a new token
            const oldHeaders = operation.getContext().headers;
            operation.setContext({
              headers: {
                ...oldHeaders,
                authorization: getNewToken(),
              },
            });
            // retry the request, returning the new observable
            return forward(operation);
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

Initial Solution

We’ll be using the fromPromise utility function from the apollo-link package to transform our Promise to an Observable.

A couple of things to note :

  • The ErrorHandler should either return an Observable or nothing.

  • The forward function returns an Observable.

  • Apollo is using the zen-observable implementation for Observables.

import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            return fromPromise(
              getNewToken().catch(error => {
                // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                return;
              })
            ).filter(value => Boolean(value))
             .flatMap(accessToken => {
              const oldHeaders = operation.getContext().headers;
              // modify the operation context with a new token
              operation.setContext({
                headers: {
                  ...oldHeaders,
                  authorization: `Bearer ${accessToken}`,
                },
              });

              // retry the request, returning the new observable
              return forward(operation);
            });
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

In a real world scenario :

  • You’ll be using a refresh token to get and store a new pair of access and refresh tokens.

  • Your Auth Link will be responsible for setting the Authorization header for the outgoing requests and will be running after your Error Link.

import { onError } from 'apollo-link-error';
import { fromPromise, concat } from 'apollo-link';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            return fromPromise(
              getNewToken()
                .then(({ accessToken, refreshToken }) => {
                  // Store the new tokens for your auth link
                  return accessToken;
                })
                .catch(error => {
                  // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                  return;
                })
            ).filter(value => Boolean(value))
             .flatMap(() => {
              // retry the request, returning the new observable
              return forward(operation);
            });
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

const apolloLink = concat(errorLink, concat(authLink, httpLink));

Concurrent Requests

I initially stopped at the above implementation which worked correctly until two or more requests failed concurrently.

A couple of changes were made to tackle that scenario :

  • Only refresh the token once.

  • Keep track of the remaining failed requests and retry them once the token is refreshed.

import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...

let isRefreshing = false;
let pendingRequests = [];

const resolvePendingRequests = () => {
  pendingRequests.map(callback => callback());
  pendingRequests = [];
};

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver
            let forward$;

            if (!isRefreshing) {
              isRefreshing = true;
              forward$ = fromPromise(
                getNewToken()
                  .then(({ accessToken, refreshToken }) => {
                    // Store the new tokens for your auth link
                    resolvePendingRequests();
                    return accessToken;
                  })
                  .catch(error => {
                    pendingRequests = [];
                    // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                    return;
                  })
                  .finally(() => {
                    isRefreshing = false;
                  })
              ).filter(value => Boolean(value));
            } else {
              // Will only emit once the Promise is resolved
              forward$ = fromPromise(
                new Promise(resolve => {
                  pendingRequests.push(() => resolve());
                })
              );
            }

            return forward$.flatMap(() => forward(operation));
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

const apolloLink = concat(errorLink, concat(authLink, httpLink));

Even if this solves my problem for now, there is still room for improvement and your feedback is greatly appreciated.


Anas Taha picture

Thanks Soumyajit, your feedback really helps.

Will elaborate more on authentication in a future post.

Soumyajit Pathak picture

Welcome to Able, Anas! Looks great for an initial solution.

Would love to see a more detailed post on authentication in GraphQL context. I have seen a lot of people struggle with authentication (both on the client / server) in the GraphQL ecosystem.

Kudos, man!

Ulterior 🏔️ picture

I'm trying to recreate this. The only difference is that I keep token and refresh token in local storage. I can get new token and then I store it to local storage, however, after that filter and flatMap doesn't fire, I console log inside of these two functions, but these never get executed.

Any ideas what's going on?

Anas Taha picture

Hey,

That probably means that the observable is not subscribed to (https://repl.it/repls/BadBlaringInterfaces).

apollo-link-error is expecting the ErrorHandler to either not return anything or an Observable it can subscribe to (https://github.com/apollographql/apollo-link/blob/master/packages/apollo-link-error/src/index.ts#L53)

Can you make sure you're returning the forward$.flatMap... ?

Otherwise can you share sample code that illustrates the issue so that I'm able to reproduce it on my end ?

Thanks and Regards

Ulterior 🏔️ picture

I've solved the problem, I switched from using map to using a loop. Thank you a lot! Can't believe that I needed so much time to figure it out :/, but happy that's finally resolved. Cheers!

Ulterior 🏔️ picture

I was trying to work it out from the third example in the blog post, not the concurrent one. Do you think that I should do the concurrent one?

Here's my current code:

if (token && refreshToken) {
  return fromPromise(
    getNewToken(client)
      .then(({ data: { refreshToken } }) => {
        console.log("Promise data: ", refreshToken);
        localStorage.setItem("token", refreshToken.token);
        localStorage.setItem("refreshToken", refreshToken.refreshToken);
        return refreshToken.token;
      })
      .catch((error) => {
        // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
        console.log("Error after setting token: ", error);
        return;
      })
  )
    .filter((value) => {
      console.log("In filter: ", value);
      return Boolean(value);
    })
    .flatMap(() => {
      console.log("In flat map");
      // retry the request, returning the new observable
      return forward(operation);
    });
}

Let me know if you spot an error. I'm not sure why filter and flatMap don't fire up.

Based on your comment I'll try to implement the concurrent example and see how that goes.

Thank you for your reply, much appreciated!

Ed Bond picture

Could you show your solution?

Anas Taha picture

Can you check if the condition (token && refreshToken) is satisfied ?

Ulterior 🏔️ picture

I created a StackOberflow question, with the hope that someone can spot the error. https://stackoverflow.com/questions/61736028/apollo-client-async-refresh-token

Ulterior 🏔️ picture

What I notice is that the new token is received after the retry of the request, "Promise Data" console log, with the data from fetching new token comes after a console log in the auth link.

Here's a gist with the whole code https://gist.github.com/bzhr/531e1c25a4960fcd06ec06d8b21f143b

Ulterior 🏔️ picture

Yes it is and the new token is fetched successfully. There are two problems 1. authLink doesn't have the new token, probably because localstorage.setItem is set async and 2. filter and flatMap functions do not get called.

BTW I also tried your concurrent example and the same thing happens...

I am thinking to create a promise that will resolve the two localstorage.setItem calls.

Anas Taha picture

A for..of with a switch case should do it.

Ulterior 🏔️ picture

So I should use something else instead of the map? Like a switch case?

Anas Taha picture

You're returning the observable in the callback when mapping graphql errors so it is not actually returned from the ErrorHandler and apollo-link is not subscribing to it.

Pedro Ferreira picture

Wow i was struggling with this exact issue and you gave me the perfect solution. It works flawlessly!!!

Thank you so much! You're the boss :)

Anas Taha picture

Thanks a lot, I'm glad it helps.

Hartani Yassir picture

Thank you Anas for this awesome blog post, it worked like a charm. One small note: in the case of concurrent requests, the first request will not be forwarded again, I fixed the issue by removing the else condition outside forward$ = fromPromise( new Promise(resolve => { pendingRequests.push(() => resolve()); }) );

Anas Taha picture

Hi Yassir,

Thank you very much for your feedback.

In the case of concurrent requests, the first request blocks further ones and pushes them (to pendingRequests) to be resolved later until it is done refreshing the access token.

Removing the else condition will lead to the first request running twice.

Would be great if you could share a sample where the first request is not retried.

Thanks again.

Fahid picture

Really helpful post Anas, thank you!

I also faced a similar issue as op. Essentially once the token had been successfully refreshed, the first query wasn't rerun again - but the others were retried just fine after.

Removing the else condition outside of forward$ = fromPromise( new Promise(resolve => { pendingRequests.push(() => resolve()); }) ); fixed the issue for me too 🙂

Jorge B picture

Hi Anas, this is looking very good, it's exactly what I was looking for!

Something I've noticed is that, when the token refresh request fails, the first "normal" request that had previously failed is repeated. And it's only the first one, doesn't matter that you have 1, 3, or more concurrent requests.

That repeated request obviously fails silently, so it's not a big issue for this flow, but I can't seem to find out why it is re-triggered even though we do pendingRequests = []...

Thought I could bring it up with you in case you have any ideas!

Anas Taha picture

Hi Jorge,

Thanks for your comment, I'm glad it helps.

Good catch, I didn't pay attention to that.

Since the token refresh request error is caught, forward$ still emits and therefore the operation is retried.

One way to get around that is to filter out falsy values :

// ...
             forward$ = fromPromise(
                getNewToken()
                  .then(({ accessToken, refreshToken }) => {
                    // Store the new tokens for your auth link
                    resolvePendingRequests();
                    return accessToken;
                  })
                  .catch(error => {
                    pendingRequests = [];
                    // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                    return; // Return a falsy value
                  })
                  .finally(() => {
                    isRefreshing = false;
                  })
              ).filter(value => Boolean(value));
// ...

Please let me know if that works for you.

Jorge B picture

Hi Anas,

Nice one there, now the issue with the refetching is gone!

Anas Taha picture

Hey Jorge,

Great, will reflect that on the post.

Thanks again