Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Breaks on webpack 5 #22

Open
magom001 opened this issue Jun 19, 2021 · 5 comments
Open

Breaks on webpack 5 #22

magom001 opened this issue Jun 19, 2021 · 5 comments

Comments

@magom001
Copy link

magom001 commented Jun 19, 2021

Apparently it uses node.js modules under the hood which are no longer bundled by webpack 5.


After going through the source code I believe the refresh strategy is not the best. Instead of checking the expiration time of the token on each request (parsing jwt is time consuming) one should check the response status for 401 status code or call a user provided callback to check if the token should be refreshed. Initiate a refresh if any condition is met. At the same time queue all the outgoing requests and also queue any request that was fired prior to the refresh and returned with 401 status.

@mvanroon
Copy link
Contributor

Isn't calling an endpoint a lot more time consuming than parsing a token?

@magom001
Copy link
Author

magom001 commented Jun 22, 2021

What do you mean? In the current implementation:

  1. Tokens are stored as object in LS, not primitives. So JSON.parse is called on each API call to retrieve the tokens,
  2. Token is parsed on each API call (expensive),
  3. An external dependency is required in order to parse the token (and this dependency breaks on webpack 5).

Assume that the token is valid and make your fetch request. 401 should occur only when the backend says so. Not necessarily when the token is expired (e.g. could be revoked).

@magom001
Copy link
Author

Here is a very basic implementation:

// TODO: handle concurrent requests with 401 response

import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
import { refreshTokens } from './auth';

const ACCESS_KEY_LS_KEY = 'accessToken';
const REFRESH_KEY_LS_KEY = 'refreshToken';

type Token = string;
interface IAuthTokens {
  accessToken: Token;
  refreshToken: Token;
}

export const setAuthTokens = (tokens: IAuthTokens): void => {
  localStorage.setItem(ACCESS_KEY_LS_KEY, tokens.accessToken);
  localStorage.setItem(REFRESH_KEY_LS_KEY, tokens.refreshToken);
};

/**
 * Clears both tokens
 */
const clearAuthTokens = (): void => {
  localStorage.removeItem(ACCESS_KEY_LS_KEY);
  localStorage.removeItem(REFRESH_KEY_LS_KEY);
};

/**
 * Returns the stored refresh token
 * @returns {string} Refresh token
 */
const getRefreshToken = (): Token | undefined => {
  const tokens = getAuthTokens();
  return tokens ? tokens.refreshToken : undefined;
};

/**
 * Returns the stored access token
 * @returns {string} Access token
 */
const getAccessToken = (): Token | undefined => {
  const tokens = getAuthTokens();
  return tokens ? tokens.accessToken : undefined;
};

export const applyAuthTokenInterceptor = (axios: AxiosInstance, config: IAuthTokenInterceptorConfig): void => {
  if (!axios.interceptors) throw new Error(`invalid axios instance: ${axios}`);
  axios.interceptors.request.use(authTokenInterceptor(config));
  axios.interceptors.response.use(undefined, async (e: AxiosError) => {
    if (e.response?.status === 401) {
      try {
        isRefreshing = true;
        const tokens = await refreshTokens(getRefreshToken());

        setAuthTokens(tokens);

        resolveQueue(tokens.accessToken);

        return axios.request(e.config);
      } catch (error) {
        console.error('applyAuthTokenInterceptor:error', error);
        declineQueue(error);
        clearAuthTokens();

        throw error;
      } finally {
        isRefreshing = false;
      }
    } else {
      return e;
    }
  });
};

const getAuthTokens = (): IAuthTokens | undefined => {
  const accessToken = localStorage.getItem(ACCESS_KEY_LS_KEY);
  const refreshToken = localStorage.getItem(REFRESH_KEY_LS_KEY);

  return { accessToken, refreshToken };
};

interface IAuthTokenInterceptorConfig {
  header?: string;
  headerPrefix?: string;
}

const authTokenInterceptor =
  ({ header = 'Authorization', headerPrefix = 'Bearer' }: IAuthTokenInterceptorConfig) =>
  async (requestConfig: AxiosRequestConfig): Promise<AxiosRequestConfig> => {
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        queue.push({ resolve, reject });
      })
        .then((token: string) => {
          requestConfig.headers[header] = `${headerPrefix}${token}`;
          return requestConfig;
        })
        .catch(Promise.reject);
    }

    const accessToken = getAccessToken();

    requestConfig.headers[header] = `${headerPrefix} ${accessToken}`;

    return requestConfig;
  };

type RequestsQueue = {
  resolve: (value?: unknown) => void;
  reject: (reason?: unknown) => void;
}[];

let isRefreshing = false;
let queue: RequestsQueue = [];

const resolveQueue = (token?: Token) => {
  queue.forEach((p) => {
    p.resolve(token);
  });

  queue = [];
};

const declineQueue = (error: Error) => {
  queue.forEach((p) => {
    p.reject(error);
  });

  queue = [];
};

@magom001
Copy link
Author

  • storing the tokens should be beyond the scope of this library. ITokenStorage interface with default implementation using LocalStorage could be provided.

@mvanroon
Copy link
Contributor

@magom001 Perhaps you could turn this into a PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants