Skip to content

Token Refresh

Automatic JWT refresh with request deduplication.

Overview

sequenceDiagram
    participant C as Component
    participant F as Fetch Interceptor
    participant A as Auth Store
    participant API as Backend

    C->>F: API Request
    F->>API: Request + Bearer Token
    API-->>F: 401 Unauthorized

    F->>A: refreshAccessToken()
    A->>API: POST /refresh (cookie)
    API-->>A: New Access Token
    A->>A: Store Token

    F->>API: Retry Original Request
    API-->>F: Success
    F-->>C: Response

Token Storage

Token Storage Lifetime
Access Token localStorage 15 min
Refresh Token HTTP-only cookie 1-30 days

Fetch Interceptor

Global fetch override handles 401 responses:

const originalFetch = window.fetch;

window.fetch = async (input, init) => {
  const response = await originalFetch(input, init);

  if (response.status === 401 && !isAuthEndpoint(input)) {
    const newToken = await refreshAccessToken();

    if (newToken) {
      // Retry with new token
      return originalFetch(input, {
        ...init,
        headers: {
          ...init?.headers,
          Authorization: `Bearer ${newToken}`,
        },
      });
    }
  }

  return response;
};

Refresh Deduplication

Prevents multiple concurrent refresh requests:

let refreshPromise: Promise<string | null> | null = null;

export async function refreshAccessToken(): Promise<string | null> {
  if (refreshPromise) {
    return refreshPromise;
  }

  refreshPromise = doRefresh();

  try {
    return await refreshPromise;
  } finally {
    refreshPromise = null;
  }
}

async function doRefresh(): Promise<string | null> {
  const response = await fetch("/api/v1/auth/refresh", {
    method: "POST",
    credentials: "include",
  });

  if (!response.ok) {
    useAuth.getState().clearAuthState();
    return null;
  }

  const { accessToken } = await response.json();
  useAuth.getState().setAccessToken(accessToken);
  return accessToken;
}

Auth Store Integration

const useAuth = create<AuthState>((set, get) => ({
  isLoggedIn: false,
  accessToken: null,

  setAccessToken: (token) => {
    storage.set(AUTH.ACCESS_TOKEN_KEY, token);
    const data = parseTokenData(token);
    set({
      isLoggedIn: true,
      userId: data.userId,
      groupId: data.groupId,
      role: data.role,
      modules: data.modules,
    });
  },

  clearAuthState: () => {
    storage.remove(AUTH.ACCESS_TOKEN_KEY);
    set({ isLoggedIn: false, userId: null });
  },

  getAccessToken: () => storage.get(AUTH.ACCESS_TOKEN_KEY),
}));

JWT Parsing

export function parseTokenData(token: string): TokenData {
  const payload = parseJwt(token);

  return {
    userId: payload[AUTH.USER_ID_CLAIM],
    groupId: payload[AUTH.GROUP_ID_CLAIM],
    currency: payload[AUTH.CURRENCY_CLAIM],
    role: payload[AUTH.ROLE_CLAIM],
    modules: JSON.parse(payload[AUTH.MODULES_CLAIM] || "[]"),
    permsVersion: payload[AUTH.PERMS_VERSION_CLAIM],
  };
}

function parseJwt(token: string): Record<string, string> {
  const base64 = token.split(".")[1];
  const json = atob(base64);
  return JSON.parse(json);
}

Policy Change Handling

When permissions change, backend returns special error:

if (response.status === 403) {
  const data = await response.json();

  if (data.code === "AUTH.POLICY_CHANGED") {
    // Force token refresh
    const newToken = await refreshAccessToken();
    if (newToken) {
      return fetch(input, init); // Retry
    }
  }
}

Logout Flow

async function logOut() {
  await fetch("/api/v1/auth/logout", {
    method: "POST",
    credentials: "include",
  });

  useAuth.getState().clearAuthState();
  queryClient.clear();
  Sentry.setUser(null);
}