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!

jjbenitez026 picture

2023 This help me a lot!. Thanks @AnasT

Bilguun Zorigt picture

This solution works, but i ended up not using the pendingRequests, because apollo seems to handle that part very well. Instead returned the same promise instance to all concurrent requests. I posted the approach here. https://stackoverflow.com/a/70324598/14849657

Artem Tereshchenko picture

Hi, first of all Thanks for your solution. It is very helpful. The only problem that I have is that I don't understand how the part of the code where we store requests to array works. I have a small experience with rxjs and observables but still do not understand why do we push () => { resolve(); } to an array. Maybe you are able to help me with that? I would really appreciate that.

vincentri picture

hi anas,

I read your article and test it. I manage to make it work for refresh the token but it's not continue to refetch my failed query.

return forward$.flatMap(() => { console.log(123); return forward(operation); });

this part not called. I test on react native. I use vue js and it works but why react not work.

Vildan Softic picture

Hey there, with the help of your article I managed to get it working for v3. There is just an issue. It only works for the first time. Imagine the Accesstoken expires after 5mins chances are high I need to issue/request multiple refreshed. But according to the link Docs the errorLink handler isnt called a second time from a inline Forward in order to avoid endless loops. How did you work around this?

Anas Taha picture

Hey,

The way this should work is that anytime a request fails due to an invalid access token, the errorLink refresh logic will be triggered and the previously failed request will be retried. But if the retry (.i.e the authorized request) fails, the errorlink won't be triggered as you said, and in that case you should handle the error on your query / mutation results.

Vildan Softic picture

Right thats what I expected but it's not happening. As mentioned in my example I've set the expiry time to 5secs and the first time it got called but not again. The interesting part is that no graphqlError but a network error is thrown. So perhaps thats an indicator

Anas Taha picture

Hi, you can check this for reference https://github.com/AnasT/jwt-auth-poc.

The access token is stored in memory and expires in 2s.

Vildan Softic picture

Ok I think I figured it out. I've really badly copyied your example and didn't even get to the point of forward(operation). everything undo, back to the workbench :)

Vildan Softic picture

Here's an issue I've opened including the sample code https://github.com/apollographql/apollo-client/issues/7483

Vildan Softic picture

Thx for the repo link. I figured meanwhile my issue is related to the websocket link used for subscriptions. Since handling refresh tokens there seems only to ne doable by forcing a disconnect there is no point in using an Error Link and instead refreshing by oneself and reconnecting manually. Anyways thanks again for your article, it helped me understand the topic and find a suitable solution.

Anas Taha picture

I'm glad it helped to some extent.

Will check the scenario you described (ws) and update the article according to my findings.

Thanks a lot for your feedback, really appreciate it.

kirantripathi picture

Hello Anas, I am trying the concurrent request one in react-native.I am facing the issue as I can hit API and get new token but after that I am not able to re-hit the fail query due to expire token. I think my issue is :authLink doesn't have the new token, probably because AsyncStorage is set async and 2. filter and flatMap functions do not get called. Any idea on how to solve this

My current code:

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) { console.log(err, "see my own err");

switch (err.message) {
  case "Login session expired.":
    // error code is set to UNAUTHENTICATED
    // when AuthenticationError thrown in resolver
    let forward$;

    if (!isRefreshing) {
      isRefreshing = true;
      forward$ = fromPromise(
        getRefreshToken()
          .then((cmusRefreshToken) => {
            refreshClient
              .query({
                query: GetAccessTokenDocument,
                fetchPolicy: "network-only",
                context: {
                  headers: {
                    authorization: cmusRefreshToken.includes("Bearer")
                      ? cmusRefreshToken
                      : `Bearer ${cmusRefreshToken}`,
                  },
                },
              })
              .then((response: any) => {
                const { getAccessToken } = response.data;
                console.log(response, "my new token");

                storeNewToken(getAccessToken);
                resolvePendingRequests();
                return getAccessToken;
              })
              .catch((error) => {
                pendingRequests = [];
                // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                return;
              })
              .finally(() => {
                isRefreshing = false;
              });
          })
          .catch((err) => {
            console.log("not found token");
          })
      ).filter((value) => Boolean(value));
    } else {
      // Will only emit once the Promise is resolved
      forward$ = fromPromise(
        new Promise((resolve) => {
          pendingRequests.push(() => resolve());
        })
      );
    }
    console.log("at last here");

    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 }

Anas Taha picture

Hi,

Your getRefreshToken().then(...) doesn't return anything, which means forward$ will never emit.

My suggestion would be to use something like this :

getRefreshToken().then(cmusRefreshToken => refreshClient.query(...))
        .then(response => {
          // ...
          return storeNewToken(getAccessToken);
        })
        .then(() => {
          resolvePendingRequests();
          return true;
        })
        .catch(error => {
          pendingRequests = [];
          // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
          return false;
        })
        .finally(() => {
          isRefreshing = false;
        });
kirantripathi picture

Hello, thansk for the quick reply. I try with the above approach too but still once I get the new token , I am not able to refetch the fail query . I update the code after your suggestion.I am using async storage to add and retrieve value , like getRefresh token for getting refresh token. I don't think such operation can be a reason for my operation failure. I think that when I reach up to "now resolve request ====", my fail query need to refetch again, but it's not happening . any idea on how to fix this issue.

switch (err.message) {
      case "Login session expired.":
        // error code is set to UNAUTHENTICATED
        // when AuthenticationError thrown in resolver
        let forward$;

        if (!isRefreshing) {
          isRefreshing = true;
          forward$ = fromPromise(
            getRefreshToken()
              .then((cmusRefreshToken) => {
                refreshClient
                  .query({
                    query: GetAccessTokenDocument,
                    fetchPolicy: "network-only",
                    context: {
                      headers: {
                        authorization: cmusRefreshToken.includes("Bearer")
                          ? cmusRefreshToken
                          : `Bearer ${cmusRefreshToken}`,
                      },
                    },
                  })
                  .then((response: any) => {
                    console.log(response, "fetch response");
                    return storeNewToken(response.data.getAccessToken);
                  })
                  .then(() => {
                    console.log("now resolve request ====");
                    resolvePendingRequests();
                    return true;
                  })
                  .catch((error) => {
                    pendingRequests = [];
                    // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                    return;
                  })
                  .finally(() => {
                    isRefreshing = false;
                  });
              })
              .catch((err) => {
                console.log("not found token");
              })
          ).filter((value) => Boolean(value));
        } else {
          // Will only emit once the Promise is resolved
          forward$ = fromPromise(
            new Promise((resolve) => {
              pendingRequests.push(() => resolve());
            })
          );
        }
        console.log("at last here");

        return forward$.flatMap(() => forward(operation));
    }
shalokhin picture

Hello, how do you iterate graphQLErrors? When i iterate errors with forEach, my fail query doesn't retry. But i changed foreach to "for of" and all right

Anas Taha picture

Hi,

It is still the same issue.

If you notice the example I sent you, the calls are chained on the same level, not nested under getRefreshToken().then(..).

AndresHMosqueda picture

Hi Anas, now with the release of Apollo Client 3.0, is there a major change apart from the import statements? Thank you!

Anas Taha picture

Hi, not really, still using the same approach.

Minh Kha picture

Hey Anas, could u pls show me the code for refreshToken function ?

Minh Kha picture

I mean getNewToken()

Anas Taha picture

Hi,

Sorry for the late reply, please check this https://github.com/AnasT/jwt-auth-poc/blob/master/client/src/graphql/index.ts

Hope it helps.

BitZen 🉐 picture

Hey Anas, Awesome article! I've been struggling to get this working (retrying and refreshing tokens) for days now. Ive tried so many things and scoured the net to find any possible solutions. Nothing seems to work yet! :(

I was wondering, would you at all mind helping me get it sorted? Via discord or some chat or something? The docs on this are really missing.

Anas Taha picture

Hey, thanks for your feedback.

Sure thing, can you please share a gist ?

BitZen 🉐 picture

Thank you so much man! Youre so kind 🙏🏼 It's not in a public repo unfortunately, but do you use any chat app then I can send you the code =)

Rodrigo Fernandez picture

This is a great article saved me a ton of time, and thanks for keeping it updated with the feedback that appears in the comments.

Anas Taha picture

Thanks for your feedback, I'm really glad it helps.

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.

kirantripathi picture

I am also facing the same issue as filter and flat map are not called. How do you solve this ?

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