32

Since I want to setup Axios interceptors with React Context, the only solution that seems viable is creating an Interceptor component in order to use the useContext hook to access Context state and dispatch.

The problem is, this creates a closure and returns old data to the interceptor when it's being called.

I am using JWT authentication using React/Node and I'm storing access tokens using Context API.

This is how my Interceptor component looks like right now:

import React, { useEffect, useContext } from 'react';
import { Context } from '../../components/Store/Store';
import { useHistory } from 'react-router-dom';
import axios from 'axios';

const ax = axios.create();

const Interceptor = ({ children }) => {
  const [store, dispatch] = useContext(Context);

  const history = useHistory();

  const getRefreshToken = async () => {
    try {
      if (!store.user.token) {
        dispatch({
            type: 'setMain',
            loading: false,
            error: false,
            auth: store.main.auth,
            brand: store.main.brand,
            theme: store.main.theme,
          });

        const { data } = await axios.post('/api/auth/refresh_token', {
          headers: {
            credentials: 'include',
          },
        });

        if (data.user) {
          dispatch({
            type: 'setStore',
            loading: false,
            error: false,
            auth: store.main.auth,
            brand: store.main.brand,
            theme: store.main.theme,
            authenticated: true,
            token: data.accessToken,
            id: data.user.id,
            name: data.user.name,
            email: data.user.email,
            photo: data.user.photo,
            stripeId: data.user.stripeId,
            country: data.user.country,
            messages: {
              items: [],
              count: data.user.messages,
            },
            notifications:
              store.user.notifications.items.length !== data.user.notifications
                ? {
                    ...store.user.notifications,
                    items: [],
                    count: data.user.notifications,
                    hasMore: true,
                    cursor: 0,
                    ceiling: 10,
                  }
                : {
                    ...store.user.notifications,
                    count: data.user.notifications,
                  },
            saved: data.user.saved.reduce(function (object, item) {
              object[item] = true;
              return object;
            }, {}),
            cart: {
              items: data.user.cart.reduce(function (object, item) {
                object[item.artwork] = true;
                return object;
              }, {}),
              count: Object.keys(data.user.cart).length,
            },
          });
        } else {
          dispatch({
            type: 'setMain',
            loading: false,
            error: false,
            auth: store.main.auth,
            brand: store.main.brand,
            theme: store.main.theme,
          });
        }
      }
    } catch (err) {
      dispatch({
        type: 'setMain',
        loading: false,
        error: true,
        auth: store.main.auth,
        brand: store.main.brand,
        theme: store.main.theme,
      });
    }
  };

  const interceptTraffic = () => {
     ax.interceptors.request.use(
        (request) => {
            request.headers.Authorization = store.user.token
              ? `Bearer ${store.user.token}`
              : '';

            return request;
          },
        (error) => {
          return Promise.reject(error);
        }
      );

      ax.interceptors.response.use(
        (response) => {
          return response;
        },
        async (error) => {
          console.log(error);
          if (error.response.status !== 401) {
            return new Promise((resolve, reject) => {
              reject(error);
            });
          }

          if (
            error.config.url === '/api/auth/refresh_token' ||
            error.response.message === 'Forbidden'
          ) {
            const { data } = await ax.post('/api/auth/logout', {
              headers: {
                credentials: 'include',
              },
            });
            dispatch({
              type: 'resetUser',
            });
            history.push('/login');

            return new Promise((resolve, reject) => {
              reject(error);
            });
          }

          const { data } = await axios.post(`/api/auth/refresh_token`, {
            headers: {
              credentials: 'include',
            },
          });

          dispatch({
            type: 'updateUser',
            token: data.accessToken,
            email: data.user.email,
            photo: data.user.photo,
            stripeId: data.user.stripeId,
            country: data.user.country,
            messages: { items: [], count: data.user.messages },
            notifications:
              store.user.notifications.items.length !== data.user.notifications
                ? {
                    ...store.user.notifications,
                    items: [],
                    count: data.user.notifications,
                    hasMore: true,
                    cursor: 0,
                    ceiling: 10,
                  }
                : {
                    ...store.user.notifications,
                    count: data.user.notifications,
                  },
            saved: data.user.saved,
            cart: { items: {}, count: data.user.cart },
          });

          const config = error.config;
          config.headers['Authorization'] = `Bearer ${data.accessToken}`;

          return new Promise((resolve, reject) => {
            axios
              .request(config)
              .then((response) => {
                resolve(response);
              })
              .catch((error) => {
                reject(error);
              });
          });
        }
      );
  };

  useEffect(() => {
    getRefreshToken();
    if (!store.main.loading) interceptTraffic();
  }, []);

  return store.main.loading ? 'Loading...' : children;
}

export { ax };
export default Interceptor;

The getRefreshToken function is called every time a user refreshes the website to retrieve an access token if there is a refresh token in the cookie.

The interceptTraffic function is where the issue persists. It consists of a request interceptor which appends a header with the access token to every request and a response interceptor which is used to handle access token expiration in order to fetch a new one using a refresh token.

You will notice that I am exporting ax (an instance of Axios where I added interceptors) but when it's being called outside this component, it references old store data due to closure.

This is obviously not a good solution, but that's why I need help organizing interceptors while still being able to access Context data.

Note that I created this component as a wrapper since it renders children that are provided to it, which is the main App component.

Any help is appreciated, thanks.

Syntle
  • 5,168
  • 3
  • 13
  • 34
hakaman
  • 411
  • 1
  • 5
  • 8
  • Actually, I think that the approach you're using is not too bad. If you want to make the code much cleaner, you can extract some of the logic (object mapping, Axios requests) to different functions. Generally, your Auth middleware as a Provider will do the work! – Bayramali Başgül Apr 09 '21 at 14:06
  • 1
    Did you end up with a good solution? I have a similar problem... but for some reason, I get the accessToken from my context, and sometimes I get it right, sometimes I get it NULL and I totally don't understand – Esteban Chornet May 26 '21 at 09:22

2 Answers2

10

Common Approach (localStorage)

It is a common practice to store the JWT in the localStorage with

localStorage.setItem('token', 'your_jwt_eykdfjkdf...');

on login or page refresh, and make a module that exports an Axios instance with the token attached. We will get the token from localStorage

custom-axios.js

import axios from 'axios';

// axios instance for making requests 
const axiosInstance = axios.create();

// request interceptor for adding token
axiosInstance.interceptors.request.use((config) => {
  // add token to request headers
  config.headers['Authorization'] = localStorage.getItem('token');
  return config;
});

export default axiosInstance;

And then, just import the Axios instance we just created and make requests.

import axios from './custom-axios';

axios.get('/url');
axios.post('/url', { message: 'hello' });

Another approach (when you've token stored in the state)

If you have your JWT stored in the state or you can grab a fresh token from the state, make a module that exports a function that takes the token as an argument and returns an axios instance with the token attached like this:

custom-axios.js

import axios from 'axios';

const customAxios = (token) => {
  // axios instance for making requests
  const axiosInstance = axios.create();

  // request interceptor for adding token
  axiosInstance.interceptors.request.use((config) => {
    // add token to request headers
    config.headers['Authorization'] = token;
    return config;
  });

  return axiosInstance;
};

export default customAxios;

And then import the function we just created, grab the token from state, and make requests:

import axios from './custom-axios';

// logic to get token from state (it may vary from your approach but the idea is same)
const token = useSelector(token => token);

axios(token).get('/url');
axios(token).post('/url', { message: 'hello' });
Haseeb Anwar
  • 2,438
  • 19
  • 22
  • Is there any other way to do it when the token is stored in the state? I don't feel like this way is optimized. e.g.: If I have several services that make API calls (different files), I will need to retrieve the state in every single service and pass it as parameter to the instance. Is there any way to make it "global"? – Esteban Chornet Jun 01 '21 at 08:21
  • 1
    @EstebanChornet you should keep only those things in the state that are supposed to change otherwise props/default-props are the best solution. Since we do not know when the token changes in the state we cannot make it global. But if you want to make it global run an effect whenever token in your state changes and set the token in local storage. This way local storage will always contain the latest token. And then you can use the first approach above^ `useEffect(() => { localStorage.setItem('token', 'newToken')) }, [tokenInState])` – Haseeb Anwar Jun 01 '21 at 10:54
  • So for example, if we think on web, I make a login with the checkbox "remember-me" unchecked. So for you, the best approach is to store the user's token in session storage and get the token from it, instead of getting it in the state? I store it in the state in order to manage the authentication flow. Getting the token from the storage doesn't have a bigger performance cost? I added this stackoverflow question yesterday. I'd appreciate if you take a look :-) https://stackoverflow.com/questions/67772582/unable-to-use-react-native-contexts-property-within-axios-interceptors – Esteban Chornet Jun 01 '21 at 11:03
  • do i need to make instance in custom-axios.js and then in every call i have to import that instance only? – Sunil Garg Jun 13 '22 at 07:33
4

I have a template that works in a system with millions of access every day.

This solved my problems with refresh token and reattemp the request without crashing

First I have a "api.js" with axios, configurations, addresses, headers. In this file there are two methods, one with auth and another without. In this same file I configured my interceptor:

import axios from "axios";
import { ResetTokenAndReattemptRequest } from "domain/auth/AuthService";
    
export const api = axios.create({
    baseURL: process.env.REACT_APP_API_URL,
    headers: {
        "Content-Type": "application/json",
    },
});

export const apiSecure = axios.create({
    baseURL: process.env.REACT_APP_API_URL,
    headers: {
        Authorization: "Bearer " + localStorage.getItem("Token"),
        "Content-Type": "application/json",
    },
    
    export default api;
    
    apiSecure.interceptors.response.use(
        function (response) {
            return response;
        },
        function (error) {
            const access_token = localStorage.getItem("Token");
            if (error.response.status === 401 && access_token) {
                return ResetTokenAndReattemptRequest(error);
            } else {
                console.error(error);
            }
            return Promise.reject(error);
        }
    );

Then the ResetTokenAndReattemptRequest method. I placed it in another file, but you can place it wherever you want:

import api from "../api";
import axios from "axios";

let isAlreadyFetchingAccessToken = false;

let subscribers = [];

export async function ResetTokenAndReattemptRequest(error) {
  try {
    const { response: errorResponse } = error;
    const retryOriginalRequest = new Promise((resolve) => {
      addSubscriber((access_token) => {
        errorResponse.config.headers.Authorization = "Bearer " + access_token;
        resolve(axios(errorResponse.config));
      });
    });
    if (!isAlreadyFetchingAccessToken) {
      isAlreadyFetchingAccessToken = true;
      await api
        .post("/Auth/refresh", {
          Token: localStorage.getItem("RefreshToken"),
          LoginProvider: "Web",
        })
        .then(function (response) {
          localStorage.setItem("Token", response.data.accessToken);
          localStorage.setItem("RefreshToken", response.data.refreshToken);
          localStorage.setItem("ExpiresAt", response.data.expiresAt);
        })
        .catch(function (error) {
          return Promise.reject(error);
        });
      isAlreadyFetchingAccessToken = false;
      onAccessTokenFetched(localStorage.getItem("Token"));
    }
    return retryOriginalRequest;
  } catch (err) {
    return Promise.reject(err);
  }
}

function onAccessTokenFetched(access_token) {
  subscribers.forEach((callback) => callback(access_token));
  subscribers = [];
}

function addSubscriber(callback) {
  subscribers.push(callback);
}
Pitter
  • 498
  • 7
  • 15
  • depending on your application you may not want to read `localStorage` every time you needs it content. It would of course be more efficient to read it once and cache the result. If you application is not making thousands of requests per second then it likely will not matter in the end. But just something to keep in mind. – sherrellbc Aug 20 '23 at 15:28