Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to handle JWT refresh token on client-side with multiple requests / API calls in parallel?

I'm having an issue where I do not find a security-first and maintenable answer anywhere.

Imagine a dashboard doing multiple queries at the same time, how do you handle refresh_tokens in a clean and stadard way?

The stack is (even if the stack doesn't matter here):

Backend - Laravel with a JWT token authentification Frontend - Vue JS with axios for the API calls

Endpoints:

  • /auth/login (public)
  • /auth/refresh-token (need auth)
  • /statistics (need auth)
  • /other-statistics (need auth)
  • /event-more-statistics (need auth)
  • /final-statistics (need auth) ...

JWT refresh workflow

  • User navigates to mywebsite.com/login on the client
  • Login page does an API call to the server axios.get('/auth/login').then(res => setTokenAndUser(res))
  • Server responds with access_token (lifetime 1min) and refresh_token (lifetime 1 month or so)
  • User navigates to mywebsite.com/dashboard
  • User clicks on something, Dashboard page does 4 API calls in parallel to the 4 last endpoints above
// ... just some pseudo code
userDidAction() {
  axios.get('/statistics').then(res => handleThis(res.data));
  axios.get('/other-statistics').then(res => handleThat(res.data));
  axios.get('/event-more-statistics').then(res => handleThisAgain(res.data));
  axios.get('/final-statistics').then(res => handleThatAgain(res.data));
}
// ...
  • 1st call finishes, server invalidates old tokens + responds with new access_token & refresh_token
  • 2nd call is blocked by server because it's transporting an outdated token
  • 3rd call is blocked by server because it's transporting an outdated token
  • 4th call is blocked by server because it's transporting an outdated token
  • Client / UI is not updated correctly

This is a very common scenario on SPAs and SaaS apps. Having multiple asynchronous API calls is not an edge case.

What are my options here ?

  • not invalidating the tokens :
    • but then there's a security breach and using JWT tokens becomes useless
  • keeping track of each API calls that failed and replays them when the refresh token changes
    • this is hard to maintain and creates unpredictable behaviours on the UI for the user
    • if the user interacts during the call replays it would messeup the call handlers
    • each axios call has a promise, to expect a good handling we would need to store and delay each promise too for the UI the be handled correctly
    • each new replay would also re-create new tokens each time

My current idea is to make the access_token last 3 days and the refresh_token last a month with the following workflow :

  • When the frontend starts, we check the access_token validity on the client-side
    • if the refresh_token has expired, wipe out tokens from client
    • else do nothing
    • if the access_token expires in more than 12h, send all future request with it
    • else use the refresh token to get new tokens

This makes the refresh_token travel less on the network and makes parallel fails impossible since we change tokens only when the frontend loads initially and therefore, tokens would live for at least 12h before failing.

Despite this solution working, I'm looking for a more secure / standard way, any clues?

like image 930
darkylmnx Avatar asked Mar 30 '20 23:03

darkylmnx


People also ask

How do you handle a refresh JWT token?

For the refresh token, we will simply generate a UID and store it in an object in memory along with the associated user username. It would be normal to save it in a database with the user's information and the creation and expiration date (if we want it to be valid for a limited period of time).

Can I use refresh token multiple times?

So the answer is, Yes you can (and probably should) wait until your access token expires, and then refresh it.

When should I call refresh token API?

When to use Refresh Tokens? The main purpose of using a refresh token is to considerably shorten the life of an access token. The refresh token can then later be used to authenticate the user as and when required by the application without running into problems such as cookies being blocked, etc.


1 Answers

So here is the situation I had in an application and the way I solved it:

application setup

  • Nuxt application
  • Uses axios for API calls
  • Uses Vuex for state management
  • Uses JWT token which expires every 15 minutes so whenever this happens there should be an API call to refresh the token and repeat the failed request

token

I saved the token data in a session storage and update it with refresh token API response each time

Problem

I had three get request in one page, and I wanted this behavior that when token expires ONLY one of them get to call the refresh Token API and the others have to wait for the response, when the refresh token promise is resolved all three of them should repeat the failed request with updated token data

Solution with axios interceptors and vuex

So here is the vuex setup:

// here is the state to check if there is a refresh token request proccessing or not  
export const state = () => ({
  isRefreshing: false,
});

// mutation to update the state
export const mutations = {
  SET_IS_REFRESHING(state, isRefreshing) {
    state.isRefreshing = isRefreshing;
  },
};

// action to call the mutation with a false or true payload
export const actions = {
  setIsRefreshing({ commit }, isRefreshing) {
    commit('SET_IS_REFRESHING', isRefreshing);
  },
};

and here is the axios setup:

import { url } from '@/utils/generals';

// adding axios instance as a plugin to nuxt app (nothing to concern about!)
export default function ({ $axios, store, redirect }, inject) {

  // creating axios instance
  const api = $axios.create({
    baseURL: url,
  });

  // setting the authorization header from the data that is saved in session storage with axios request interceptor
  api.onRequest((req) => {
    if (sessionStorage.getItem('user'))
      req.headers.authorization = `bearer ${
        JSON.parse(sessionStorage.getItem('user')).accessToken
      }`;
  });

  // using axios response interceptor to handle the 401 error
  api.onResponseError((err) => {
    // function that redirects the user to the login page if the refresh token request fails
    const redirectToLogin = function () {
      // some code here
    };

    if (err.response.status === 401) {
      // failed API call config
      const config = err.config;
      
      // checks the store state, if there isn't any refresh token proccessing attempts to get new token and retry the failed request
      if (!store.state.refreshToken.isRefreshing) {
        return new Promise((resolve, reject) => {
          // updates the state in store so other failed API with 401 error doesnt get to call the refresh token request
          store.dispatch('refreshToken/setIsRefreshing', true);
          let refreshToken = JSON.parse(sessionStorage.getItem('user'))
            .refreshToken;

          // refresh token request
          api
            .post('token/refreshToken', {
              refreshToken,
            })
            .then((res) => {
              if (res.data.success) {
                // update the session storage with new token data
                sessionStorage.setItem(
                  'user',
                  JSON.stringify(res.data.customResult)
                );
                // retry the failed request 
                resolve(api(config));
              } else {
                // rediredt the user to login if refresh token fails
                redirectToLogin();
              }
            })
            .catch(() => {
                // rediredt the user to login if refresh token fails
              redirectToLogin();
            })
            .finally(() => {
              // updates the store state to indicate the there is no current refresh token request and/or the refresh token request is done and there is updated data in session storage
              store.dispatch('refreshToken/setIsRefreshing', false);
            });
        });
      } else {
        // if there is a current refresh token request, it waits for that to finish and use the updated token data to retry the API call so there will be no Additional refresh token request
        return new Promise((resolve, reject) => {
          // in a 100ms time interval checks the store state
          const intervalId = setInterval(() => {
            // if the state indicates that there is no refresh token request anymore, it clears the time interval and retries the failed API call with updated token data
            if (!store.state.refreshToken.isRefreshing) {
              clearInterval(intervalId);
              resolve(api(config));
            }
          }, 100);
        });
      }
    }
  });

  // injects the axios instance to nuxt context object (nothing to concern about!)
  inject('api', api);
}

and here is the situation as shown in network tab:

enter image description here

as you can see here there are three failed request with 401 error, then there is one refreshToken request, after that all failed requests get called again with updated token data

like image 106
hamid niakan Avatar answered Oct 24 '22 17:10

hamid niakan