Corrigindo Corridas de Refresh de Token com a Web Locks API
Corrija corridas de refresh de token entre abas com a Web Locks API, navigator.locks.request e uma nova verificação do token.
Quando várias abas compartilham um único refresh token rotativo, a chamada de refresh da primeira aba pode invalidar o token que as outras abas estão prestes a enviar. Os refreshes delas então falham, e o usuário pode acabar deslogado de todas as abas ao mesmo tempo. A solução é encapsular o refresh em navigator.locks.request, para que exatamente uma aba o execute enquanto as demais aguardam, e depois reutilizem o token que ela armazenou.
Se você está investigando um relatório de bug que diz “fui deslogado sem fazer nada” e ele nunca se reproduz na sua máquina, pergunte quantas abas a pessoa tinha abertas. Uma fila de refresh de aba única no seu interceptor está correta até certo ponto, mas ela não consegue enxergar essa corrida.
Este artigo percorre a sequência que produz o logout, por que uma flag no localStorage não é uma solução confiável para isso, e as três partes da correção: o lock, a reverificação dentro dele e a integração com o interceptor.
Pontos Principais
- Quando múltiplas abas fazem refresh de um token rotacionado simultaneamente, apenas a primeira chamada tem sucesso; o servidor invalida o token que todas as outras abas estão prestes a enviar, deslogando o usuário em todos os lugares.
navigator.locks.requestfornece a cada aba, iframe e worker de mesma origem um mutex compartilhado, e é Baseline Widely available, portanto nenhum código de fallback é necessário.- Uma flag no
localStoragenão é um lock: não existe compare-and-set atômico, e uma flag escrita por uma aba que travou permanece presa, enquanto um Web Lock é liberado automaticamente. - As abas em espera precisam reverificar o token armazenado dentro do callback do lock e se basear na expiração do próprio token, não em um timestamp de “atualizado recentemente”.
- Feito corretamente, qualquer quantidade de abas atingindo um token expirado produz exatamente um refresh de rede.
Por Que um Refresh Token Falha Entre Múltiplas Abas?
A corrida precisa de quatro ingredientes: access tokens de curta duração, um endpoint de refresh, rotação de refresh token e mais de uma aba. Todos os quatro são comuns. A RFC 9700, o OAuth 2.0 Security Best Current Practice, deixa duas opções para clientes públicos em relação a refresh tokens: vincular cada um ao cliente ao qual foi emitido, ou entregar um novo a cada uso. Isso torna a rotação a postura padrão para SPAs.
A sequência: o usuário tem três abas abertas e o access token expira. A próxima requisição de cada aba recebe um 401, e o interceptor de cada aba chama o endpoint de refresh de forma independente. A chamada da Aba A chega primeiro e tem sucesso; se o servidor rotaciona e invalida o refresh token anterior no uso, as Abas B e C agora estão enviando uma credencial morta. Seus refreshes falham, seus handlers de erro tratam um refresh falho como uma falha de autenticação terminal, e o usuário é redirecionado para o login no meio da tarefa.
Em um session replay, esse bug tem uma assinatura distinta: um redirecionamento para a tela de login sem nenhuma interação do usuário antes dele, ocorrendo em todas as abas abertas do usuário dentro do mesmo segundo. Essa assinatura, e não qualquer coisa no console, é o que o identifica.
Por Que o Estado Por Aba ou uma Flag no localStorage Não Resolvem?
A flag isRefreshing e a fila de promises do seu interceptor vivem na memória JavaScript de uma única aba. Abas não compartilham memória, então a Aba B nunca vê a flag da Aba A. A coordenação entre abas precisa de uma primitiva no nível do navegador.
A gambiarra tradicional, uma flag de “refresh em andamento” no localStorage, não é um lock. A Web Storage API oferece getItem e setItem, mas nenhum compare-and-set atômico, então duas abas podem ambas ler que nenhum refresh está em andamento e ambas iniciar um antes que qualquer escrita seja concluída. A flag também falha na direção oposta: se a aba que a definiu travar ou fechar no meio do refresh, a flag permanece definida para sempre e todas as abas sobreviventes esperam por um refresh que nunca vai terminar. Um Web Lock é liberado pelo navegador no momento em que o documento de seu detentor desaparece, que é exatamente a falha que emperra uma flag feita à mão.
Como Encapsular o Refresh em navigator.locks.request?
A Web Locks API fornece a cada aba, iframe e worker de uma origem um mutex compartilhado. Com navigator.locks.request(name, callback), apenas um detentor de um determinado nome executa seu callback por vez, e o navegador libera o lock assim que a promise retornada por esse callback é resolvida, seja com sucesso ou com rejeição. Não há chamada de unlock para esquecer e nenhum lock vazado em um fetch com falha. Todos os principais motores implementaram a API desde que o Safari 15.4 a adicionou em março de 2022, razão pela qual o MDN a classifica como Baseline Widely available, portanto nenhuma detecção de recurso ou ramo de fallback se justifica.
async function refreshTokenAcrossTabs() {
return navigator.locks.request("token-refresh", async () => {
const existing = getUsableToken();
if (existing) return existing; // another tab already refreshed
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("refresh_failed");
const { accessToken, expiresAt } = await res.json();
writeToken(accessToken, expiresAt);
return accessToken;
});
}
readToken e writeToken são deliberadamente abstratos: onde os tokens vivem é uma decisão de segurança separada que este artigo não toma, e o lock funciona da mesma forma independentemente disso.
A Reverificação: Teste o Token, Não o Relógio
O passo que a maioria das implementações ignora é a reverificação dentro do callback do lock: uma aba que esperou pelo lock deve primeiro testar se o token armazenado agora é válido e, se for, retorná-lo sem uma segunda chamada de rede. Três abas ficam na fila do lock; a primeira faz o round trip, a segunda e a terceira o adquirem depois, encontram um token utilizável e retornam imediatamente. Um refresh de rede, independentemente da quantidade de abas.
Baseie essa reverificação em se o token armazenado é realmente utilizável, não em quão recentemente um timestamp foi escrito. Um critério de relógio de parede do tipo “atualizado nos últimos 5 segundos” quebra de duas formas: um refresh mais lento que a janela faz as abas em espera concluírem erroneamente que nada aconteceu e disparar duplicatas, e um relógio de cliente desalinhado invalida a comparação em qualquer direção. A expiração do próprio token não pode mentir sobre si mesma.
const SKEW_MS = 30_000; // tolerate modest clock drift
function getUsableToken() {
const stored = readToken(); // { token, expiresAt } or null
if (!stored) return null;
return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}
Integrando o Lock a um Interceptor de 401
O trabalho do interceptor permanece o mesmo: capturar o 401, obter um token novo, reexecutar a requisição original uma vez. A única diferença é que a chamada de refresh agora é refreshTokenAcrossTabs(), então a serialização abrange todas as abas.
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (original.url?.includes("/auth/refresh")) {
await logout(); // the refresh itself failed: session is gone
return Promise.reject(error);
}
original._retry = true;
try {
const token = await refreshTokenAcrossTabs();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (refreshError) {
await logout();
return Promise.reject(refreshError);
}
}
);
A guarda _retry previne loops, e um 401 do próprio endpoint de refresh significa logout, não retry. Da perspectiva de uma aba em espera, o caminho completo é: 401, entrar na fila do lock, adquiri-lo, encontrar um token válido, retornar com zero chamadas de rede, reexecutar a requisição original.
Detalhes Que Vale Conhecer
- Web Locks exige um contexto seguro;
http://localhostse qualifica como uma origem potencialmente confiável. - As regras de encerramento da especificação liberam os locks de um documento no unload, portanto um lock nunca sobrevive a um reload ou navegação e nunca é estado durável.
- Mantenha a seção crítica restrita apenas ao refresh; qualquer outra coisa que você aguarde com await dentro dela bloqueia todas as abas.
- Nunca solicite o mesmo lock dentro do seu próprio callback: a requisição interna fica na fila atrás da posse externa e travará silenciosamente, para sempre.
- Modo shared, eleição de líder com
ifAvailableestealexistem para outros propósitos; o refresh de token não precisa de nenhum deles. - O BroadcastChannel avisa as outras abas de que algo aconteceu; um lock impede que todas elas façam a mesma coisa ao mesmo tempo.
Conclusão
O bug de logout aleatório é uma corrida de sistemas distribuídos rodando na máquina do usuário, e o navegador já fornece o mutex que a encerra. Encapsule seu refresh em navigator.locks.request, faça da primeira linha do callback uma verificação de validade do token e direcione seu handler de 401 por ele. Reproduza o bug primeiro com várias abas e um tempo de vida de token curto, depois aplique o lock e veja N chamadas de refresh colapsarem em uma.
Perguntas Frequentes
O BroadcastChannel pode substituir a Web Locks API para refresh de token entre abas?
Não. O BroadcastChannel é um transporte de mensagens, não um mutex: ele pode anunciar que um refresh aconteceu, mas nada impede que duas abas iniciem um antes que qualquer mensagem chegue, a mesma corrida de ler-e-depois-agir que uma flag no localStorage tem. Use navigator.locks.request para serializar o refresh, e adicione o BroadcastChannel depois apenas se você quiser enviar o novo token para as abas que estão escutando.
Como adiciono um timeout a uma chamada navigator.locks.request?
Passe um AbortSignal pela opção signal. Aborte-o enquanto a requisição ainda está aguardando na fila e a promise será rejeitada com um AbortError, então AbortSignal.timeout dá a você um prazo para a espera. Após o lock ser concedido, o signal deixa de ter qualquer efeito, portanto um timeout não pode interromper um callback que já está em execução. Combinar signal com steal ou ifAvailable resulta em rejeição com um NotSupportedError, então escolha uma estratégia por requisição.
A Web Locks API funciona em web workers e service workers?
Sim. A especificação expõe o LockManager tanto para contextos Window quanto Worker, portanto dedicated workers, shared workers e service workers podem chamar navigator.locks.request. Todo contexto na mesma origem compartilha um único lock manager, o que significa que um worker solicitando o lock 'token-refresh' entra na fila junto com abas solicitando o mesmo nome. O padrão, portanto, permanece correto mesmo que parte da sua lógica de autenticação rode fora da thread principal.