Pular para o conteúdo

Token Refresh

O RefreshCoordinatorService é um componente core que gerencia o refresh de tokens de autenticação (access token e impersonate token) com deduplicação automática de requisições concorrentes.


Quando múltiplas requests simultâneas detectam que o token expirou, sem coordenação cada uma tentaria fazer refresh independentemente:

// ❌ SEM COORDENAÇÃO (problema)
Request A: token expirado → POST /refresh-token
Request B: token expirado → POST /refresh-token // duplicado!
Request C: token expirado → POST /refresh-token // duplicado!
// Resultado: 3 chamadas ao servidor, race conditions, tokens inconsistentes
// ✅ COM COORDENAÇÃO (solução)
Request A: token expirado → POST /refresh-token
Request B: token expirado → aguarda o refresh de A
Request C: token expirado → aguarda o refresh de A
// Resultado: 1 chamada, todas recebem o mesmo token

Implementa o padrão single-flight (também conhecido como request deduplication):

“Múltiplas operações idênticas são consolidadas em uma única execução, com todas as chamadas compartilhando o mesmo resultado.”

Benefícios:

  • Previne race conditions
  • Reduz carga no servidor (1 request ao invés de N)
  • Melhora latência (requests não esperam na fila HTTP)
  • Garante consistência (todos recebem o mesmo token)

No Gaio Console, admins podem “impersonate” usuários finais:

  1. Admin faz login → recebe accessToken
  2. Admin impersona User X → recebe impersonateToken para User X
  3. Todas as requests usam impersonateToken (se existir) ou accessToken

Cenário comum no app:

setInterval(() => fetchChats(), 5000); // Request A
setInterval(() => fetchMessages(), 5000); // Request B
setInterval(() => fetchNotifications(), 5000); // Request C

O que acontece quando impersonateToken expira?

t=0: Requests A, B, C detectam token expirado
t=0: A, B, C tentam fazer refresh simultaneamente
t=100: Servidor recebe 3 requests de refresh idênticas
t=200: 3 novos tokens gerados (race condition!)
t=300: Apenas o último token salvo é válido
→ Requests que salvaram tokens anteriores FALHAM
→ Usuário é deslogado indevidamente
t=0: Requests A, B, C detectam token expirado
t=0: A chama runSingleFlight() → inicia refresh
t=1: B chama runSingleFlight() → retorna promise de A (dedup!)
t=2: C chama runSingleFlight() → retorna promise de A (dedup!)
t=100: Servidor recebe 1 request
t=200: 1 novo token gerado
t=300: A, B, C recebem o mesmo token
Todas as requests continuam com token válido

┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────┤
│ Components fazendo requests autenticadas │
│ (Chat, Messages, Notifications, etc.) │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Axios Private API Instance │
├─────────────────────────────────────────────────────────────┤
│ Request Interceptor: │
│ 1. await refreshCoordinator.waitForActiveRefresh() │
│ 2. add Authorization header │
│ │
│ Response Interceptor: │
│ - Handle 401 → retry with refreshed token │
│ - Handle 403 → redirect │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Session Service │
├─────────────────────────────────────────────────────────────┤
│ getSessionToken(): │
│ 1. Check refresh token validity │
│ 2. Check access token validity → refresh if needed │
│ 3. Check impersonate token validity │
│ ├─ if expired → runSingleFlight(refresh) │
│ └─ if valid → return it │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ RefreshCoordinatorService │
├─────────────────────────────────────────────────────────────┤
│ runSingleFlight(): │
│ - If refresh active → return existing promise │
│ - Else → start new refresh → track promise │
│ │
│ waitForActiveRefresh(): │
│ - If refresh active → wait (with 3s timeout) │
│ - Else → return null (continue immediately) │
└─────────────────────────────────────────────────────────────┘

session.service.ts
import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service";
export const getSessionToken = async (): Promise<string> => {
// ... validações de refresh token e access token ...
if (impersonateToken && !isValid(impersonateToken)) {
const freshToken = await refreshCoordinatorService.runSingleFlight(
async () => {
const response = await consoleApi.post<{ impersonate_token: string }>(
`/impersonate/${accountId}`,
null,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
return response.data.impersonate_token;
},
);
storeSession({ accessToken, refreshToken, impersonateToken: freshToken });
return freshToken;
}
return impersonateToken ?? accessToken;
};
private-api.ts
import {
RefreshWaitTimeoutError,
refreshCoordinatorService,
} from "@/core/auth/services/refresh-coordinator.service";
privateApi.interceptors.request.use(async (config) => {
try {
await refreshCoordinatorService.waitForActiveRefresh();
} catch (error) {
if (error instanceof RefreshWaitTimeoutError) {
throw error;
}
throw error;
}
const headers = await getSessionHeaders();
config.headers.set("Authorization", headers.Authorization);
return config;
});
import { createRefreshCoordinator } from "@/core/auth/services/refresh-coordinator.service";
const customCoordinator = createRefreshCoordinator();
await customCoordinator.runSingleFlight(async () => {
// sua lógica de refresh
});

runSingleFlight<TData>(runner: () => Promise<TData>): Promise<TData>

Seção intitulada “runSingleFlight<TData>(runner: () => Promise<TData>): Promise<TData>”

Executa lógica de refresh com deduplicação automática.

Comportamento:

  1. Se nenhum refresh ativo → executa runner() e retorna o resultado
  2. Se refresh já ativo → retorna a promise do refresh ativo (sem executar runner())
const [token1, token2, token3] = await Promise.all([
refreshCoordinator.runSingleFlight(() => fetchNewToken()),
refreshCoordinator.runSingleFlight(() => fetchNewToken()),
refreshCoordinator.runSingleFlight(() => fetchNewToken()),
]);
// fetchNewToken() foi chamado apenas 1 vez!
console.log(token1 === token2); // true

waitForActiveRefresh<TData>(): Promise<TData | null>

Seção intitulada “waitForActiveRefresh<TData>(): Promise<TData | null>”

Aguarda refresh ativo completar, ou retorna imediatamente se nenhum refresh está acontecendo.

Throws: RefreshWaitTimeoutError se refresh não completar em 3000ms.

Verifica se refresh está ativo no momento.

Reseta o estado interno do coordinator. Apenas em testes!

Erro lançado quando waitForActiveRefresh() excede o timeout.


Use runSingleFlight para Todas as Operações de Refresh

Seção intitulada “Use runSingleFlight para Todas as Operações de Refresh”
// ✅ CORRETO
await refreshCoordinator.runSingleFlight(async () => {
const token = await api.post("/refresh");
localStorage.setItem("token", token);
return token;
});
// ❌ ERRADO (sem coordenação)
const token = await api.post("/refresh");
localStorage.setItem("token", token);
// ✅ CORRETO
axiosInstance.interceptors.request.use(async (config) => {
await refreshCoordinator.waitForActiveRefresh();
const token = getTokenFromStorage();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
try {
await refreshCoordinator.waitForActiveRefresh();
} catch (error) {
if (error instanceof RefreshWaitTimeoutError) {
console.error("Refresh timeout - forcing logout");
forceLogout();
}
throw error;
}
// ✅ CORRETO - propaga para todos os waiters
await refreshCoordinator.runSingleFlight(async () => {
try {
const token = await api.post("/refresh");
return token;
} catch (error) {
trackError("refresh_failed", error);
throw error;
}
});
// ❌ ERRADO - engole erro
await refreshCoordinator.runSingleFlight(async () => {
try {
return await api.post("/refresh");
} catch (error) {
return null; // todos os waiters receberão null
}
});

import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service";
describe("Session Refresh", () => {
afterEach(() => {
refreshCoordinatorService.reset();
});
});
it("should deduplicate concurrent impersonate refresh calls", async () => {
let callCount = 0;
const mockRefresh = vi.fn(async () => {
callCount++;
await new Promise((resolve) => setTimeout(resolve, 100));
return "new-token";
});
const [token1, token2, token3] = await Promise.all([
refreshCoordinatorService.runSingleFlight(mockRefresh),
refreshCoordinatorService.runSingleFlight(mockRefresh),
refreshCoordinatorService.runSingleFlight(mockRefresh),
]);
expect(callCount).toBe(1);
expect(token1).toBe("new-token");
expect(token2).toBe("new-token");
expect(token3).toBe("new-token");
expect(mockRefresh).toHaveBeenCalledTimes(1);
});

Sintoma: User is randomly logged out while using the app

Causa: Multiple polling requests tentando refresh simultaneamente sem coordenação.

Solução: Certifique-se de que TODO refresh passa por runSingleFlight().

Sintoma: RefreshWaitTimeoutError in console

Causa: Refresh está demorando muito (> 3s) ou travado.

Soluções:

  1. Aumente timeout: modifique SESSION_REFRESH_LIMITS.REQUEST_WAIT_TIMEOUT_MS
  2. Otimize endpoint no backend

Causa: Race condition no save do token.

Solução: Garanta que refresh retorna e salva token dentro do runSingleFlight():

// ✅ CORRETO
await refreshCoordinator.runSingleFlight(async () => {
const token = await api.post("/refresh");
localStorage.setItem("token", token); // dentro do flight
return token;
});
// ❌ ERRADO
const token = await refreshCoordinator.runSingleFlight(async () => {
return await api.post("/refresh");
});
localStorage.setItem("token", token); // fora do flight - race condition!