5

I've encountered a very strange problem, implementing axios interceptors for handling the expired token and refreshing it.

Setting

I'm implementing the JWT authentication with access and refresh tokens.

When the request is being sent to the API route that requires JWT authentication, request interceptor is here to make sure the headers contain an Authorization with Bearer token. The response interceptor checks if the new access token is needed, sends a request to refresh it, and finally updates the axios instance with the new config.

I wrote the code following the Dave Gray's video, but with TypeScript.

Problem

When testing this code, I set the refresh token lifetime to be very long, while setting the access token lifetime to be 5 seconds. After it expires, when the request to the protected route is happening, everything goes according to the plan—the logs from the backend contain two successfully completed requests: (1) to the protected route with 401 response and then (2) the refresh request.

At this point, I see the DOMException in the browser console (Chrome and Safari), which states that setRequestHeader fails to execute because a source code function is not a valid header value. Which, of course, it is not! The piece of code is this.

Code

const axiosPrivate = axios.create({
  baseURL: BASE_URL,
  headers: { "Content-Type": "application/json" },
  withCredentials: true,
});

interface IRequestConfig extends AxiosRequestConfig {
  sent?: boolean;
}

const useAxiosPrivate = () => {
  const { auth } = useAuth()!;
  const refresh = useRefreshToken();

  React.useEffect(() => {
    const requestInterceptor = axiosPrivate.interceptors.request.use(
      (config: AxiosRequestConfig) => {
        config.headers = config.headers ?? {};
        if (!config.headers["Authorization"]) {
          config.headers["Authorization"] = `Bearer ${auth?.token}`;
        }
        return config;
      },
      async (error: AxiosError): Promise<AxiosError> => {
        return Promise.reject(error);
      }
    );

    const responseInterceptor = axiosPrivate.interceptors.response.use(
      (response: AxiosResponse) => response,
      async (error: AxiosError): Promise<AxiosError> => {
        const prevRequestConfig = error.config as IRequestConfig;
        if (error?.response?.status === 401 && !prevRequestConfig?.sent) {
          const newAccessToken = await refresh();
          prevRequestConfig.sent = true;
          prevRequestConfig.headers = prevRequestConfig.headers!;
          prevRequestConfig.headers[
            "Authorization"
          ] = `Bearer ${newAccessToken}`;
          return axiosPrivate(prevRequestConfig);
        }
        return Promise.reject(error);
      }
    );

    return () => {
      axiosPrivate.interceptors.request.eject(requestInterceptor);
      axiosPrivate.interceptors.response.eject(responseInterceptor);
    };
  }, [auth, refresh]);

  return axiosPrivate;
};

Error

DOMException: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': 'function (header, parser) {
    header = normalizeHeader(header);
    if (!header) return undefined;
    const key = findKey(this, header);

    if (key) {
      const value = this[key];

      if (!parser) {
        return value;
      }

      if (parser === true) {
        return parseTokens(value);
      }

      if (_utils_js__WEBPACK_IMPORTED_MODULE_0__["default"].isFunction(parser)) {
        return parser.call(this, value, key);
      }

      if (_utils_js__WEBPACK_IMPORTED_MODULE_0__["default"].isRegExp(parser)) {
        return parser.exec(value);
      }

      throw new TypeError('parser must be boolean|regexp|function');
    }
  }' is not a valid HTTP header field value.

Research

So far, I've only found one similar issue in the internet, which has links to some others. One of them gives me a hint, that it may be the problem with how axios reads the configuration given to an axios instance.

I'm not sure if the problem is indeed somewhere in axios. I'll be extremely grateful for any useful thoughts on this problem!

Lev Pleshkov
  • 363
  • 1
  • 14
  • 3
    I'm seeing the same issue. I think it only started with a recent version update of axios so if you're following older tutorials, that might be the reason. I haven't looked into what changed exactly but it's on my backlog... – EShy Oct 24 '22 at 04:48
  • 1
    Thanks to [@chrno1209's](https://github.com/axios/axios/issues/5055#issuecomment-1273192790) reply, I've managed to get rid of this error by resetting all the headers to an empty object (error is triggered by the headers with `null` value). But this raises another problem—either preserve all the initial headers in the state, or clean up `null`ish headers deliberately. – Lev Pleshkov Oct 24 '22 at 18:08
  • I'm facing exactly same issue! – Terra Incognita Oct 29 '22 at 16:30
  • really weird, probably a bug? – Bersan Nov 06 '22 at 23:45

3 Answers3

6

I had the same problem, I solved it by manually giving value to axiosPrivate instead of axiosPrivate(prevRequestConfig).

const responseIntercept = axiosPrivate.interceptors.response.use(
        response => response,
        async (error)=>{
            const prevRequest = error?.config;
            if (error?.response?.status === 403 && !prevRequest?.sent){
                const newAccessToken = await refresh();
                // console.log(prevRequest);
                return axiosPrivate({
                    ...prevRequest,
                    headers: {...prevRequest.headers, Authorization: `Bearer ${newAccessToken}`},
                    sent: true
                });
            }
            return Promise.reject(error);
        }
    );
Daniel Dan
  • 86
  • 3
  • While this does not trigger that error, it seems like we are loosing some headers that may be in the request/response cycle apart from the `Authorization` header. – Lev Pleshkov Nov 03 '22 at 19:35
  • You have to add the headers manually.... – Daniel Dan Nov 03 '22 at 19:36
  • 1
    problem occured during settingHeaders – Daniel Dan Nov 03 '22 at 19:37
  • Yes, but when the application grows, various requests that are sent to protected routes (hence may be intercepted on token expiry) may have different headers. I think this solution requires a mechanism to preserve those headers that cannot be known in the interceptor before the request and response occur. – Lev Pleshkov Nov 03 '22 at 19:42
  • 1
    @LevPleshkov Check out the solution. It doesn't produce any error now and working as expected. It also solves the preserving problem – Daniel Dan Nov 03 '22 at 19:55
  • I've tested you solution and it does work! – Lev Pleshkov Nov 03 '22 at 19:57
  • how I fixed it `await axiosPrivate({ ...prevRequest, headers: config.headers.toJSON() }); // retry the request` The request method needs a plain Javascript headers object – hane Smitter Nov 19 '22 at 23:01
4

Thanks to Daniel Dan's solution I could modify Dave's tutorial code:

const responseInterceptor = axiosPrivate.interceptors.response.use(
      (response: AxiosResponse) => {
        return response;
      },
      async (error: AxiosError): Promise<AxiosError> => {
        const prevRequestConfig = error.config as AxiosRequestConfig;
        if (error?.response?.status === 401 && !prevRequestConfig.sent) {
          prevRequestConfig.sent = true;
          const newAccessToken = await refresh();

          /* --- The modified line --- */
          prevRequestConfig.headers = { ...prevRequestConfig.headers };
          /* ------------------------- */

          prevRequestConfig.headers[
            "Authorization"
          ] = `Bearer ${newAccessToken}`;
          return axiosPrivate(prevRequestConfig);
        }
        return Promise.reject(error);
      }
    );
Lev Pleshkov
  • 363
  • 1
  • 14
2

Just Do This in your response interceptor

const responseInterceptor = axiosPrivate.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error: AxiosError): Promise<AxiosError> => {
    const prevRequestConfig = error.config as IRequestConfig;
    if (error?.response?.status === 401 && !prevRequestConfig?.sent) {
      const newAccessToken = await refresh();

      prevRequestConfig.sent = true;
      prevRequestConfig.headers["Authorization"] = `Bearer ${newAccessToken}`;

      return axiosPrivate({
        ...prevRequestConfig,
        ...{
          headers: prevRequestConfig.headers.toJSON(),
        },
      });
    }
    return Promise.reject(error);
  }
);

When re-sending the request with updated creds, i.e axiosPrivate(config), the headers property needs to be a plain javascript Object but instead it is converted internally to be an AxiosInstance object.

To fix it, just pass a plain Javascript object to the headers property of your prevRequestConfig object.

hane Smitter
  • 516
  • 3
  • 11