Case Study: Tokens & Axios Interceptors
SITUATION
The application a knowledge center for cancer patients and their families, utilized a headless CMS and SSR React. Authentication was a standard flow. Client --> Server --> API Gateway --> OAuth Server. (TODO: Replace with diagram) The returned token would be stored in memory server side and the access token with a short TTL was persisted in the browser's local storage. A session cookie permitted requests from the client to the node server. For client side requests, a valid access token was required for all calls to the API gateway so that data could be fetched and components hydrated. The problem, tokens were being requested individually prior to each service call. As a result the token server was being taxed unnecssarily and the repeated calls caused intermittent errors (race conditions).
TASK
Find a solution for ensuring that the intial access token request and refresh token request were only being made 1X when the user context provider mounted or if a 403 / 401 status code was returned in the response.
ACTION
Access Token Request
First thing I needed to do was to move the initial accesss token request to the User Context Provider from the request interceptor. This would accomplish two things.
- Ensure that there was a current user which would be passed through the provider for dependancies to pull from.
- If there was no token in local storage, fetch prior to rendering the provider's children. This also ensured that one initial access token request would be made.
const UserContextProvider: FC<{
children: ReactNode;
}> = (props: { children: ReactNode }) => {
const [stateUser, setStateUser] = useState<IUserInfoProps | null>(null);
useEffect(() => {
const token = getToken();
const currentUser = getCurrentUser();
const fetchAndSetTokenAndUser = async () => {
try {
const { data } = await tokenService.fetchAccessToken();
// Allow data to persist in local storage
setToken(data.gateway_token);
setCurrentUser(data.currentUser);
// Local state refresh to re-render
setStateUser(data.currentUser);
} catch (ex) {
console.error("Unable to retrieve access token");
}
};
!token ? fetchAndSetTokenAndUser() : setStateUser(currentUser);
}, []);
return (
<UserContext.Provider value={stateUser}>
{
!stateUser ? null : props.children
}
</UserContext.Provider>
);
};
One thing to note, is that since we are rendering most of the components on the server, the local storage checks need to go into useEffect otherwise local storage would be undefined.
Refresh Token Request
After doing some initial research, I discovered that there were two approaches for handling 403 / 401 response errors. Both involved modifying the response error interceptor in our axios instance.
Approach 1: Create a reference to the initial promise and check.
Of the two approaches this was the easiest to wrap my head around and easier to implement. First I declare a variable which would hold the reference to the refresh token promise. Then all I needed to do was wait for the reference to be resolved prior to re-attemping the original request with the resolved token promise in the header. This would work with all requests in flight made prior to the token request being returned.
let getRefreshTokenPromise;
class RequestClient {
...
private handleResponseError = async (error: IErrorResponse) => {
const { config: originalRequest } = error;
if (!error.response) {
return Promise.reject(error);
}
if (
(error.response.status === 401 || error.response.status === 403) &&
!originalRequest._retry
) {
originalRequest._retry = true;
if (!getRefreshTokenPromise) {
getRefreshTokenPromise = this.fetchRefreshToken();
}
const { data } = await getRefreshTokenPromise;
this.setToken(data.gateway_token);
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${this.getToken()}`,
};
return this.service(originalRequest);
}
return Promise.reject(error);
};
}
While the benefit of this approach is the simplistic implmentation, the tradeoff was that visibility & readability in terms of what requests were being made. Also lets say we wanted transform the requests based on specific logic. It would be difficult to do that with this approach. It's more of one size fit all.
Approach 2: Build a queue of promises to be resolved when the refresh token is returned.
The mechanics for this approach is similar to the first one but instead of holding onto a promise reference, we'll use a queue of subscribers and a flag to indiciate if the token request is in flight. Also there are two main functions, refreshTokenAndRepeat and onAccessTokenFetched. Lets look at the implementation.
private async refreshTokenAndRepeat(error: IErrorResponse) {
const { config: originalRequest } = error;
const retryOriginalRequest = new Promise((resolve) => {
this.addSubscriber(() => {
resolve(this.service(originalRequest));
});
});
if (!isFetchingRefreshToken) {
isFetchingRefreshToken = true;
const { data } = await tokenService.fetchRefreshToken();
if (!data) {
return Promise.reject(error);
}
setToken(data.gateway_token);
isFetchingRefreshToken = false;
this.onAccessTokenFetched();
}
return retryOriginalRequest;
}
private onAccessTokenFetched() {
subscribers.forEach((callback) => callback());
subscribers = [];
}
Here's whats happening with these two function:
- We are pulling out the original request from the error object.
- Then we create a Promise which does two things. First, it instantiates the promise. Then thanks to closure, it passes the resolve callback and pushes it into the subscriber queue. Which when resolves will call the original requests.
- If a token request isn't in flight, set the flag to true and fetch.
- Once the token resolves, setToken (this will be used in the request interceptor), flip the token flag for a future request and call onAccessTokenFetched. This will then invoke all the callback functions in the order it was received.
- Then reset the queue.
While this approach is much more involved and has more moving pieces compared to approach one, I think it's a lot more readable and transparent. Also in onAccessTokenFetched, it gives you a much more modular way of doing any additional transformations on that original requests without jamming all that logic into the response error interceptor.
RESULT
Discussing with teammates, we decided to go with approach #2. I was able to accomplish both tasks that included a flow to get the initial access and wait until refresh token was resolved before each individual service call. Looking at the network tab, both flows look correct! Nice!
Initial Access Token Flow
Refresh Token Flow