axios – Can an expired JWT token be updated without the user noticing?

Question:

I have the following problem, I created an interceptor to validate the server response and if the response is 401 (expired or invalid token) it automatically requests a new token, stores it in the localstorage and makes the original call again, it is worth mentioning that the SPA it is developed with Laravel and Vuejs.

// Token Refresh
let isAlreadyFetchingAccessToken = false
let subscribers = []

function onAccessTokenFetched(accessToken) {
    subscribers = subscribers.filter(callback => callback(accessToken))
}

function addSubscriber(callback) {
    subscribers.push(callback)
}

export default {
    init() {
        axios.interceptors.response.use(function (response) {
            return response
        }, function (error) {
            const status = error.response ? error.response.status : null
            // const { config, response: { status } } = error
            const { config, response } = error
            const originalRequest = config

            // if (status === 401) {
            if (status === 401) {
                if (!isAlreadyFetchingAccessToken) {
                    isAlreadyFetchingAccessToken = true
                    store.dispatch("auth/fetchAccessToken")
                        .then((accessToken) => {
                            isAlreadyFetchingAccessToken = false
                            onAccessTokenFetched(accessToken)
                        })
                }

                return new Promise((resolve) => {
                    addSubscriber(accessToken => {
                        originalRequest.headers.Authorization = 'Bearer ' + accessToken
                        resolve(axios(originalRequest))
                    })
                })
            }
            return Promise.reject(error)
        })
    },
    login(email, pwd) {
        return axios.post("/api/auth/login", {email: email, password: pwd})
    },
    addCompany(comercial, razon_social, rfc, reg_patronal, address, city, cp, img, url, email, country, giro ) {
        return axios.post("/api/v1/companies", {comercial: comercial, razon_social: razon_social, rfc: rfc, reg_patronal: reg_patronal, address: address,  city: city, cp: cp, img: img, url: url,email: email, country: country, giro: giro, accessToken: axios.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem("accessToken")})
    },
    registerUser(name, email, pwd) {
        return axios.post("/api/auth/register", {displayName: name, email: email, password: pwd})
    },
    refreshToken() {
        return axios.post("/api/auth/refresh-token", {accessToken: localStorage.getItem("accessToken")})
    }
}

fetchAccessToken:

fetchAccessToken() {
        return new Promise((resolve, reject) => {
            jwt.refreshToken()
                .then(response => {
                localStorage.SetItem('accessToken', response.data.access_token)
                resolve(response) })
                .catch(error => { reject(error) })
        })
    }

refreshToken:

refreshToken() {
        return axios.post("/api/auth/refresh-token", {accessToken: localStorage.getItem("accessToken")})
    }

But when I put it into practice from a form knowing that the token has already expired I receive the 401 response:

title: "Error"
text: "token is expired"
iconPack: "feather"
icon: "icon-alert-circle"
color: "danger"

And the interceptor does not do the token renewal if not until I resend the information again and I find it annoying because it should do it since the server replied 401 the first time.

I think it could be an error in the promises of the form but I have been analyzing them for hours and I have not found a fault, I leave the code:

Wizard form, validate 3 steps if everything is correct, send the payload to the AddCompanyJWT action:

// register custom messages
Validator.localize('en', dict);
import jwt from "../../../http/requests/auth/jwt/index.js"

export default {
    data() {
        return {
            comercial: "",
            razon_social: "",
            email: "",
            city: "new-york",
            rfc: "",
            img: "",
            reg_patronal: "",
            address: "",
            giro: "planning",
            country: "chicago",
            cp: "",
            url: "",
            cityOptions: [
                { text: "New York", value: "new-york" },
                { text: "Chicago", value: "chicago" },
                { text: "San Francisco", value: "san-francisco" },
                { text: "Boston", value: "boston" },
            ],
            giroOptions: [
                { text: "Plannning", value: "plannning" },
                { text: "In Progress", value: "in progress" },
                { text: "Finished", value: "finished" },
            ],
            countryOptions: [
                { text: "New York", value: "new-york" },
                { text: "Chicago", value: "chicago" },
                { text: "San Francisco", value: "san-francisco" },
                { text: "Boston", value: "boston" },
            ],
        }
    },
    methods: {
        validateStep1() {
            return new Promise((resolve, reject) => {
                this.$validator.validateAll('step-1').then(result => {
                    if (result) {
                        resolve(true)
                    } else {
                        reject(this.$vs.notify({
                            title: 'Error',
                            text: 'Correct all values.',
                            iconPack: 'feather',
                            icon: 'icon-alert-circle',
                            color: 'danger'
                        }));
                    }
                })
            })
        },
        validateStep2() {
            return new Promise((resolve, reject) => {
                this.$validator.validateAll("step-2").then(result => {
                    if (result) {
                        resolve(true)
                    } else {
                        reject(this.$vs.notify({
                            title: 'Error',
                            text: 'Correct all values.',
                            iconPack: 'feather',
                            icon: 'icon-alert-circle',
                            color: 'danger'
                        }));
                    }
                })
            })
        },
        validateStep3() {
            // Loading
            this.$vs.loading()
            const payload = {
                companyDetails: {
                    comercial: this.comercial,
                    razon_social: this.razon_social,
                    rfc: this.rfc,
                    reg_patronal: this.reg_patronal,
                    address: this.address,
                    city: this.city,
                    cp: this.cp,
                    img: this.img,
                    url: this.url,
                    email: this.email,
                    country: this.country,
                    giro: this.giro,
                }
            }
            return new Promise((resolve, reject) => {
                this.$validator.validateAll("step-3").then(result => {
                    if (result) {
                        this.$store.dispatch('company/AddCompanyJWT', payload)
                            .then(() => {
                                this.$vs.loading.close()
                                resolve(true)})
                            .catch(error => {
                                this.$vs.loading.close()
                                reject(this.$vs.notify({
                                    title: 'Error',
                                    text: error.message,
                                    iconPack: 'feather',
                                    icon: 'icon-alert-circle',
                                    color: 'danger'
                                }))
                            })
                    }
                })
            })
        }
    },
    components: {
        FormWizard,
        TabContent
    }
}

The AddCompanyJWT action receives the data from the form and sends it to the AddCompany action, which is in charge of sending all the information to the server:

AddCompanyJWT({ commit }, payload) {
      return new Promise((resolve,reject) => {
        jwt.addCompany(
            payload.companyDetails.comercial,
            payload.companyDetails.razon_social,
            payload.companyDetails.rfc,
            payload.companyDetails.reg_patronal,
            payload.companyDetails.address,
            payload.companyDetails.city,
            payload.companyDetails.cp,
            payload.companyDetails.img,
            payload.companyDetails.url,
            payload.companyDetails.email,
            payload.companyDetails.country,
            payload.companyDetails.giro,)
          .then(response => {
            // If there's user data in response
            if(response.data.company) {
              // Update company details
              commit("UPDATE_COMPANY_INFO", response.data.company)
              // Update status of WizardVerify
              localStorage.setItem("wizardVerify", true)
              // Navigate User to homepage
              router.push(router.currentRoute.query.to || '/')

                resolve(response)
            }
          })
          .catch(error => { reject(error) })
      })
    },

AddCompany action:

addCompany(comercial, razon_social, rfc, reg_patronal, address, city, cp, img, url, email, country, giro ) {
        return axios.post("/api/v1/companies", {comercial: comercial, razon_social: razon_social, rfc: rfc, reg_patronal: reg_patronal, address: address,  city: city, cp: cp, img: img, url: url,email: email, country: country, giro: giro, accessToken: axios.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem("accessToken")})
    },

Answer:

What you can do is from the backend send the time when the JWT expires.

Every time you make a request or change the route, check if the time is about to expire, then there make the request to update the token. Although the update of the token is done in the background, the requests that are made while you update it will work, and when it finishes the new requests will be made with the updated token.

This seems like a good way to me because if the user stops using the frontend for a long time, exceeding the expiration time of the token, you could request the credentials again, since it may be someone else who wants to access.

Scroll to Top