Token Refresh Manager

Prev Next

Token Refresh Manager

TokenRefreshManager is a static utility class that handles transparent access-token refresh when a 401 or UNAUTHENTICATED response is detected. It provides mutex deduplication so that concurrent callers share a single in-flight refresh, preventing token-rotation race conditions.

API

Image

Method Signature Description
refresh () → Promise<UserRefreshTokenResponseType> Reads current cookies, calls IdentityService.refreshToken(), writes new tokens, returns result.
reset () → void Clears the in-flight promise. Exposed for test isolation only.

How It Works

Image

  1. The first call creates an in-flight promise and stores it.
  2. Any concurrent call while the promise is pending receives the same promise — no duplicate API calls.
  3. On resolution (success or failure), the in-flight promise is cleared so subsequent calls start fresh.

Integration Points

TokenRefreshManager.refresh() is called automatically by both transport layers when they detect a 401:

Transport Detection Behaviour
Fetch.json HTTP res.status === 401 Refresh → retry the request once with fresh auth headers
GraphQLClient networkError.statusCode === 401 or graphQLErrors[].extensions.code === 'UNAUTHENTICATED' Refresh → retry the query/mutation once

Both transports enforce a retry-once guard (_isRetry flag) to prevent infinite loops if the refreshed token is also rejected.

Error Handling

Scenario Behaviour
Missing site or vl-refreshToken cookie Throws AppError('auth.token_refresh_failed') immediately — no API call made.
IdentityService.refreshToken rejects Error propagates to all waiting callers. In-flight promise is cleared.
Retry itself returns 401 The transport throws the original error — no second refresh attempt.

Cookie TTLs

After a successful refresh, cookies are updated with their canonical TTLs from CookieTTL:

Cookie TTL
vl-accessToken CookieTTL.identity.accessToken — 12 hours
vl-refreshToken CookieTTL.identity.refreshToken — 7 days

Design Decisions

  • Singleton, not hook. The manager is a static class because it must be accessible from both Fetch (a static class) and GraphQLClient (also static). A React hook would not be reachable from these non-React call sites.
  • No automatic logout. If refresh fails, the error propagates to the caller. The caller's own error handling (typically the AsyncHandler → hook → toast chain) is responsible for surfacing the failure. A forced redirect to / on refresh failure could interrupt in-progress user work.
  • Promise-lock, not queue. A simple shared-promise pattern is sufficient because there are only two token slots to rotate. A full queue would add complexity without benefit.

Testing

Test file: src/__tests__/lib/internal/TokenRefreshManager.test.ts

The test suite mocks IdentityService and CookieManager and validates:

  • Happy path — correct arguments and cookie writes
  • Mutex deduplication — three concurrent calls produce one service call
  • Sequential calls — fresh call after completion triggers a new refresh
  • Missing cookies — throws before calling the service
  • Error propagation — all concurrent waiters receive the same rejection
  • Recovery — a new call after a failed refresh retries successfully