Refreshing access and refresh tokens via Apollo in React

I am trying to implement the logic for working with refresh and access tokens through Apollo in React. In the process of writing the code, I ran into the following problems.

  1. If the server (Graphql) returns me an error (access token expired), then it is intercepted by ErrorLink. And I have no idea how I can make a GraphQl mutation inside it to receive new tokens.

  2. Even if I receive new tokens, I understand that I have no idea how to send the original request again. mutation to receive new tokens.

And I have not found an example of such functionality in the documentation.

This is applicable to apollo/client v3.4.16

Instantiate the apollo client using link from to compose the request logic:

new ApolloClient({
  link: from([authLink, errorLink, httpLink]),
});

Using setContext api, define authLink like this:

const authLink = setContext(async (_, { headers }) => {
  const token = await getToken();
  return {
    headers: {
      ...headers,
      authorization: token,
    },
  };
});

Where getToken fn contains the logic to retrieve the token which I believe you already have.

@blackfry , what is the difference between the snippet you provided and the following one?

const authLink = new ApolloLink((operation, forward) => {
  const accessToken = localStorage.getItem("accessToken");

  operation.setContext(({ headers }) => ({
    headers: {
      ...headers,
      authorization: accessToken ? `Bearer ${accessToken}` : "",
    },
  }));

  return forward(operation);
});

consider that I read the documentation you provided but I could not solve how to refresh access and refresh token when the access token is expired… I also checked on SO but they implement an errorLink and some of the utility functions are imported and so I do not know what they do (like for example del getToken() of your example)

Hi @francesca_gia,

What is the difference between our code:
You are creating a singular ApolloLink instance which is fine if you do not need additional custom links. In my example I have an additional error handling link.

How to refresh the token:
the async function getToken is an http request to your authorisation endpoint that returns a new token if an existing token is not present or is expired.

const getToken = async (id) => {
  // check that stored token exists (localStorage perhaps) and whether it is expired
  // if it doesn't exist or is expired fetch a new one as below or return the valid existing token

  const response = await fetch("https://serverurl", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: { id },
  });
  return response.json();
};

I hope that helps

Hi, unfortunately I need some more explanations, if you could be so kind.
First, as a general question, what is the difference between defining the authLink as newApolloLink and setContext, that is between this two snippets:

const authLink = new ApolloLink((operation, forward) => {
  const accessToken = localStorage.getItem("accessToken");

  operation.setContext(({ headers }) => ({
    headers: {
      ...headers,
      authorization: accessToken ? `Bearer ${accessToken}` : "",
    },
  }));

  return forward(operation);
});

vs

const authLink = setContext((_, { headers }) => {
 const accessToken = localStorage.getItem("accessToken");
  return {
    headers: {
      ...headers,
      authorization: token,
    },
  };
});

Second, I am in a situation where, when the access token has expired I use the refresh token to ask for a new access token (and refresh token). So this is my implementation of the authLink, errorLink and getNewToken and it would be very helpful you could tell me something since at the moment it is not working :frowning:

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem("accessToken");
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    },
  };
});

const getNewToken = async () => {
  try {
    const { data } = await axios.post(
      "https://fxxxx.ngrok.io/api/v2/refresh",
      { token: localStorage.getItem("refreshToken") }
    );
    localStorage.setItem("refreshToken", data.refresh_token);
    return data.access_token;
  } catch (error) {
    console.log(error);
  }
};

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case "UNAUTHENTICATED":
            return fromPromise(
              getNewToken().catch((error) => {
                return;
              })
            )
              .filter((value) => Boolean(value))
              .flatMap((accessToken) => {
                const oldHeaders = operation.getContext().headers;
                operation.setContext({
                  headers: {
                    ...oldHeaders,
                    authorization: `Bearer ${accessToken}`,
                  },
                });
                return forward(operation);
              });
        }
      }
    }
  }
);

const client = new ApolloClient({
  link: from([authLink, errorLink, httpLink]),
  cache: new InMemoryCache(),
});

@francesca_gia Did you find any solution because I m also trying to implement same thing in my project but it is not working as expected?

1 Like

hi i am also trying to make the logic for working with refresh token and access token , in my case m not able to retry the operation by which i got network error 401 , like i am able to get the new access token by api but after that using forward(operation) that is not working

The above solution seems correct to me, but you can try the solution below (by returning another ApolloLink in onError)

This solution has request control if you open your application with an expired token, if 10 requests are executed at the same time, the 10 will have an expired token, but only one will refresh the token and the others will wait to receive the new token to continue with the request

You may also want to create logic that validates the token expiration before sending

let isRefreshing = false;
let pendingRequests: any[] =[];
let token: string;

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

const getToken = (): Promise<string> => {
/* 
  your logic here
  execute mutation refreshToken or fetch a rest api to get token
  then set the token to your provider, service, store, etc
  return token
*/
}

export const appendTokenLink = new ApolloLink((operation, forward) => {

    // Your logic to get token from service, store, etc
    const userProvider = Container.get(UserProvider);
    const token = userProvider.getToken();

    if (!token) {
      return fetchTokenLink(operation, forward);
    }

    operation.setContext(({ headers }: { headers: any }) => {
      return {
        headers: {
          ...headers,
          'Authorization': `Bearer ${token}`,
        }
      }
    })

  return forward(operation);
});

export const fetchTokenLink = (operation: Operation, forward: NextLink) => {
  if (!isRefreshing) {
    isRefreshing = true;
    return fromPromise(
      getToken()
      .catch(() => {
        pendingRequests = []
        throw new Error("any")
      })
    )
    .filter(value => Boolean(value))
    .flatMap((accessToken) => {
      token = accessToken as string;
      operation.setContext(({ headers }: { headers: any }) => {
        return {
          headers: {
            ...headers,
            'Authorization': `Bearer ${accessToken}`,
          }
        }
      })
      resolvePendingRequests();
      isRefreshing = false

      return forward(operation);
    });
  } else {
    return fromPromise(
      new Promise<void>(resolve => {
        pendingRequests.push(() => resolve());
      })
    ).flatMap(() => {
      operation.setContext(({ headers }: { headers: any }) => {
        return {
          headers: {
            ...headers,
            'Authorization': `Bearer ${token}`,
          }
        }
      })
      return forward(operation);
    });
  }
}

export const graphqlErrorsLink = onError(({ graphQLErrors, operation, forward, networkError }) => {
  const tokenIsExpired = // your logic, probably getting message or extensions code from graphQLErrors
  if (tokenIsExpired) {
    return fetchTokenLink(operation, forward);

  }
})

// Import only appendTokenLink and graphqlErrorsLink to your ApolloLink array, DO NOT import fetchTokenLink

Here is my approach to refresh the token and recall original requests. Its also helpful if you have multiple requests.

const httpLink = new HttpLink({
  uri: "/graphql",
  credentials: "include",
  fetchOptions: {
    credentials: "include",
  },
})

const EXCHANGE_REFRESH_TOKEN = gql`
  mutation ExchangeRefreshToken {
    exchangeRefreshToken {
      accessToken
    }
  }
`

let refreshTokenPromise = null

const refreshToken = async () => {
  if (refreshTokenPromise) {
    return refreshTokenPromise
  }

  refreshTokenPromise = new Promise(async (resolve, reject) => {
    try {
      const { data } = await client.mutate({
        mutation: EXCHANGE_REFRESH_TOKEN,
        fetchPolicy: "no-cache",
      })

      const { accessToken } = data.exchangeRefreshToken

      if (!accessToken) {
        throw new Error("Failed to refresh token")
      }

      localStorage.setItem("userToken", accessToken)
      resolve(accessToken)
    } catch (error) {
      console.error("Error refreshing token:", error)
      localStorage.removeItem("userToken")
      if (isBrowser) {
        window.location.href = "/login"
      }
      reject(error)
    } finally {
      refreshTokenPromise = null
    }
  })

  return refreshTokenPromise
}

const authLink = setContext((_, { headers }) => {
  const token = isBrowser ? localStorage.getItem("userToken") : ""
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    },
  }
})

const wsAuthLink = setContext(() => {
  const token = isBrowser ? localStorage.getItem("userToken") : ""
  return {
    authToken: token ? `Bearer ${token}` : "",
  }
})

const wsLink = isBrowser
  ? new GraphQLWsLink(
      createClient({
        url: "/graphql",
        lazy: true,
        connectionParams: () => ({
          ...wsAuthLink,
        }),
      })
    )
  : null

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        if (err.extensions.code === "401") {
          return new Observable(observer => {
            refreshToken()
              .then(newToken => {
                const oldHeaders = operation.getContext().headers
                operation.setContext({
                  headers: {
                    ...oldHeaders,
                    authorization: `Bearer ${newToken}`,
                  },
                })
                forward(operation).subscribe({
                  next: observer.next.bind(observer),
                  error: observer.error.bind(observer),
                  complete: observer.complete.bind(observer),
                })
              })
              .catch(error => {
                observer.error(error)
              })
          })
        }
      }
    }
    if (networkError) {
      console.error("Network error:", networkError)
      if (networkError.statusCode === 401) {
        if (isBrowser) {
          window.location.href = "/login"
        }
      }
    }
  }
)

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    )
  },
  wsLink ? wsLink : httpLink,
  from([authLink, errorLink, httpLink])
)

const link = from([removeTypenameLink, splitLink])

const defaultOptions = {
  watchQuery: {
    fetchPolicy: "no-cache",
    errorPolicy: "all",
  },
  query: {
    fetchPolicy: "no-cache",
    errorPolicy: "all",
  },
}

const client = new ApolloClient({
  link,
  cache: new InMemoryCache({
    addTypename: false,
  }),
  defaultOptions: defaultOptions,
  credentials: "include",
})

export default client