Pular para o conteúdo principal

Refresh de Token

Última atualização: 14 de julho de 2026

Visão Geral

O access token do Keycloak tem vida curta, então o app precisa renová-lo sem interromper o que o vendedor está fazendo. Isso acontece de duas formas diferentes, uma proativa e outra reativa, e as duas convergem para o mesmo mecanismo de renovação, protegido por um lock que impede duas chamadas de refresh concorrentes contra o Keycloak.

Arquivos-chave

ArquivoResponsabilidade
services/auth/auth.service.tsrefreshTokens, getValidAccessToken e o lock que serializa o refresh
services/api.service.tsInterceptor de resposta do Axios que detecta 401 e aciona o refresh antes de repetir a chamada
services/auth/auth.storage.tsOnde os tokens renovados são salvos, disparando os hooks reativos

Gatilho proativo: verificar a expiração antes de usar o token

Todo lugar que precisa efetivamente de um access token válido chama getValidAccessToken(), que primeiro confere se o token atual já está perto de expirar antes de decidir se vale usá-lo direto ou renová-lo:

// auth.service.ts
async getValidAccessToken(): Promise<string | null> {
const tokens = AuthStorage.readTokens();
if (!tokens) return null;

if (!isExpired(tokens.expiresAt)) {
return tokens.accessToken;
}

try {
await this.refreshTokens();
const refreshed = AuthStorage.readTokens();
return refreshed?.accessToken ?? null;
} catch {
const isActive = await this.hasActiveKeycloakSession();
if (isActive === false) {
await this.signOut({ openBrowser: false });
return null;
}
return null;
}
},

A checagem de expiração usa uma margem de segurança de 30 segundos, definida em AUTH_CONFIG.TOKEN_EXPIRY_SKEW_SECONDS, para tratar o token como expirado um pouco antes do prazo exato informado pelo Keycloak. Isso evita que uma chamada de rede comece com um token que expira nos milissegundos seguintes e termine sendo rejeitada no meio do caminho.

Gatilho reativo: renovar depois de um 401

O segundo caminho existe para os casos em que o token parecia válido no cliente, mas o servidor já o considera expirado, ou qualquer outra situação que resulte numa resposta 401. O interceptor de resposta do Axios, em services/api.service.ts, trata isso tentando renovar uma única vez antes de repetir a chamada original:

// api.service.ts
client.interceptors.response.use(async (response) => {
const cfg = (response.config ?? {}) as RetriableAxiosRequestConfig;

if (response.status === 401 && !cfg._retry) {
cfg._retry = true;
try {
await ensureRefreshedOnce();

const accessToken = await AuthService.getValidAccessToken();
if (accessToken) {
cfg.headers.set("Authorization", `Bearer ${accessToken}`);
}

return await client.request(cfg);
} catch {
return response; // devolve o 401 original, sem mascarar o estado de auth
}
}

if (response.status >= 400) {
return Promise.reject(toHttpAxiosError(response));
}

return response;
});

A flag _retry, marcada diretamente na configuração da requisição, garante que cada chamada só tenta esse caminho uma vez. Se depois de renovar o token e repetir a chamada o servidor ainda responder 401, o interceptor não entra num laço, ele simplesmente devolve essa segunda resposta para quem fez a chamada original decidir o que fazer.

Por que existem dois locks para a mesma operação

Tanto AuthService.refreshTokens() quanto o ensureRefreshedOnce() do api.service.ts guardam uma promise em uma variável de módulo, de forma que chamadas concorrentes recebam a mesma promise em andamento em vez de disparar uma nova troca de token cada uma:

// auth.service.ts
let refreshPromise: Promise<void> | null = null;

async refreshTokens(): Promise<void> {
if (refreshPromise) return refreshPromise;

refreshPromise = (async () => {
const tokens = AuthStorage.readTokens();
if (!tokens?.refreshToken) throw new Error("No refresh token");

const refreshed = await refreshTokensWithRefreshToken(tokens.refreshToken);
AuthStorage.saveTokens(refreshed);
await refreshOrganizationContextIfNeeded();
})().finally(() => {
refreshPromise = null;
});

return refreshPromise;
},
// api.service.ts
let refreshInFlight: Promise<void> | null = null;

async function ensureRefreshedOnce(): Promise<void> {
if (refreshInFlight) return refreshInFlight;

refreshInFlight = AuthService.refreshTokens().finally(() => {
refreshInFlight = null;
});

return refreshInFlight;
}

Na prática, o lock de auth.service.ts já seria suficiente por si só, já que ele protege a chamada real ao Keycloak. O lock adicional em api.service.ts existe para o caso de múltiplas requisições HTTP caírem em 401 ao mesmo tempo, permitindo que todas aguardem a mesma promise sem cada uma precisar chamar AuthService.refreshTokens() separadamente, mesmo sabendo que essa chamada já se protegeria internamente. O efeito prático dos dois locks combinados é que, não importa quantas chamadas dispararem um refresh ao mesmo tempo, apenas uma requisição de fato chega ao Keycloak.

O que acontece quando o refresh falha

Uma falha no refresh não vira logout automático na hora. getValidAccessToken() primeiro confirma, através de introspecção do refresh token junto ao Keycloak, se a sessão realmente está morta:

// auth.service.ts
async hasActiveKeycloakSession(): Promise<boolean | null> {
const tokens = AuthStorage.readTokens();
if (!tokens) return null;

try {
if (tokens.refreshToken) {
return await introspectToken(tokens.refreshToken, "refresh_token");
}
// ...fallback via access token
} catch (error) {
return null;
}
},

Só quando essa checagem confirma explicitamente que a sessão não está mais ativa (isActive === false) é que o app chama signOut. Se a introspecção falhar por qualquer motivo, por exemplo uma queda de conexão, o resultado é null, e o código trata isso como "não sei", preferindo devolver null no access token e deixar quem chamou tentar de novo depois, em vez de encerrar a sessão do usuário por causa de um problema de rede. Essa distinção é uma regra explícita do projeto: uma falha transitória nunca deve ser tratada como se fosse uma sessão expirada.

Armadilhas conhecidas

Renovar o token sempre passa por AuthService.refreshTokens(). Chamar o endpoint de refresh do Keycloak diretamente de outro lugar do código quebraria os dois locks descritos aqui e abriria espaço para chamadas concorrentes desnecessárias. Um refresh bem-sucedido também renova o contexto de organização através de refreshOrganizationContextIfNeeded(), então qualquer novo caminho de renovação de token precisaria continuar chamando isso, ou o token de organização ficaria dessincronizado do token principal.

Vale notar que createApiWithoutAccessTokenClient(), o cliente Axios que usa X-Contextual-Token em vez de Authorization, tem exatamente a mesma lógica de retry em 401 do cliente principal, incluindo o trecho que injeta um header Authorization na chamada repetida. Como esse cliente não usa Authorization em nenhum outro momento, vale confirmar o comportamento real antes de assumir que esse retry funciona como o do cliente principal.