Excelente post, Gabriel!
Esse é um assunto bem legal, que já me deparei algumas vezes e creio que tenho algo a acrescentar.
Há um fluxo no código não previsto, que já me pegou de surpresa algumas vezes, que é o caso de multiplas requests autenticadas falharem ao mesmo tempo. Nesse fluxo tenho uma sugestão e vou aproveitar para reescrever o código em TypeScript que é o que eu uso =P.
Como é um fluxo extenso, vou tentar deixar tudo bem comentado, para ajudar a explicar a motivação de cada decisão.
Criação do axios básica
import axios, { AxiosError } from 'axios';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL //Url base da API
})
Enviar o token em todas requests
api.interceptors.request.use(request => {
//Por algum motivo os headers podem vir undefined
//Para não ter problemas adiciono um valor default
const headers = request.headers ?? {}
//Uma sugestão é o uso de cookies ao invés de localstorage
//especialmente em NextJS, pois nele você consegue acessar
//os cookies do lado do cliente e no serverside
//Uma sugestão para get/set de cookies é esse artigo:
//https://www.w3schools.com/js/js_cookies.asp
const token = Cookies.get("token")
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
request.headers = headers
return request
})
Definindo uma estratégia de refresh token
//Cria um tipo que irá servir de base para as requests falhadas
type FailedRequestQueue = {
onSuccess: (newToken: string) => void
onFailure: () => void
}
//Cria uma array para salvar essas requests
let failedRequestsQueue: FailedRequestQueue[] = []
//Cria uma variável que irá determinar se já está
//acontecendo um processo de refresh, para não gerar duplicidade
let isRefreshing = false
api.interceptors.response.use(response => {
return response //Em caso de sucesso só retorna a resposta
}, (error: AxiosError) => {
//Detecta se foi um erro de autorização
if (error.response?.status === 401) {
const refreshToken = Cookies.get("refreshToken")
//Uma sugestão aqui é verificar o tipo do erro retornando
//pois um token pode ser inválido, por exemplo, e nesse caso
//gerar um novo token usando o refreshtoken pode ser um
//problema de segurança. Logo o token tem que ser válido
//porém está expirado.
//E para fechar com chave de ouro esse ponto,
//ao fazer o refresh token o backend deverá invalidar o token.
//Somado a isso verificar se há um refresh token salvo
if (error.response.data?.code === "token.expired" && refreshToken) {
//Busca as informações da request. Com elas é possível
//repetir a request passando as mesmas informações
const originalRequest = error.config
//Verifica se já está acontecendo um processo
//de refresh
if (!isRefreshing) {
//Seta como true para acontecer só 1 refresh por vez
isRefreshing = true
//Chama a API de refresh token
refreshTokenAPI(refreshToken)
.then(({data}) => {
//Em caso de sucesso atualiza os tokens nos cookies
Cookies.set("token", data.token)
Cookies.set("refreshToken", data.refreshToken)
//Retenta todas requests falhadas com o novo token
failedRequestsQueue.forEach(request => {
request.onSuccess(data.token)
})
}).catch(_ => {
//Em caso de falha, podemos tomar diferentes
//estratégias dependendo do contexto.
//Tenho duas sugestões para esse cenário:
//A. O famoso "Sai do fusca, entra no fusca".
//Limpo os tokens e mando o usuário para o login
Cookies.delete("token")
Cookies.delete("refreshToken")
window.location.href = "/login"
//B. Retorno o erro nas requests e cada uma faz um
//tratamento próprio, como mostra um pop-up, enviar para
//uma página de erro etc.
failedRequestsQueue.forEach(request => {
request.onFailure()
})
}).finally(() => {
//Limpo a fila de requests falhadas
failedRequestsQueue = []
//Informo que o processo de refreshing já terminou, para caso
//ocorra outro erro de token expirado na mesma sessão
isRefreshing = false
})
}
//Retorna uma promise que vai aguardar o processo de
//refresh token ser realizado.
return new Promise((resolve, reject) => {
//Adiciona na lista de requests falhadas a request atual
failedRequestsQueue.push({
onSuccess: (newToken: string) => {
//Em caso de sucesso será recebido o novo token
//Basta substituir ele na request e tentar novamente
originalRequest.headers!['Authorization'] = `Bearer ${newToken}`
resolve(api(originalRequest))
},
onFailure: () => {
//Em caso de erro, repassa o erro original
//para que seja feito um tratamento específico
//de acordo com o contexto da chamada.
reject(error)
}
})
})
} else {
//Em caso de erro 401 que não seja token expirado
//ou não haja refresh token.
//Limpa os tokens e manda para a rota de login
Cookies.delete("token")
Cookies.delete("refreshToken")
window.location.href = "/login"
}
}
//Caso o tipo do erro seja outro, retorna ele normalmente
return Promise.reject(error)
})