Skip to content

How auth-client works

auth-client is a small session machine with pluggable ports. The key parts:

Core entities

  • AuthClient facade — the public API. Composes the ports into loginWithPassword, loginWithSso, refresh, logout, authenticatedRequest, and the event bus.
  • TokenStore port — persists access + refresh tokens. Default: LocalStorageTokenStore. SSR-friendly alternative: MemoryTokenStore.
  • Transport port — the HTTP layer. Default: FetchTransport. Swap in custom retry, instrumentation, or a mock for tests.
  • Storage port — small KV used for PKCE state, SSO in-flight flags, etc. Default: LocalStorageStorage / MemoryStorage.
  • Crypto port — Web Crypto adapter for PKCE S256 derivation + random verifier.
  • Clock portSystemClock / FixedClock. Mostly for tests.
  • Logger portConsoleLogger / NoOpLogger.
  • Broadcast portBroadcastChannelAdapter (default) / NoOpBroadcast. Cross-tab session sync.

Bootstrap state machine

createAuthClient({ bootstrap }) runs one of three modes at startup:

  • auto — validates the cached access token against /auth/me. Useful for the common case: refresh has happened in another tab, the user’s role changed, etc. Slight cost on every page load.
  • lazy — trusts the cached claims. Faster boot; first failing request triggers a refresh / re-auth.
  • offline — inert. Read methods return null/false; flow methods throw OfflineModeError. Useful for static demos, Storybook, styleguides.

Every adapter gates first render on auth.ready() so consumers don’t flash a logged-out UI for a logged-in user.

Refresh-mutex coalescing

When N concurrent requests arrive just-before-expiry:

Request A → 401 → refresh() →
Request B → 401 → awaits in-flight refresh →
Request C → 401 → awaits in-flight refresh →
single /auth/refresh call
retry A, B, C with new token

This is critical because the auth-server’s family-aware reuse detector (RFC 6819 §5.2.2.3) revokes the entire family if it sees a refresh token presented twice. Without the mutex, N parallel refresh calls would trigger N family-revokes — the user would be logged out.

Cross-tab sync (BroadcastChannel)

Every loginWithPassword / logout / refresh emits a message on the vendidit-auth channel. Other tabs listen and update their internal state without hitting the server. This means:

  • Log in tab A → tab B sees the user immediately.
  • Log out tab A → tab B drops the user immediately.
  • Refresh-rotation in tab A → tab B picks up the new tokens without a duplicate /auth/refresh call.

Where to plug in custom adapters

PortWhen you’d swapExample
TokenStoreSSR; HttpOnly cookies; encrypted at restnew MemoryTokenStore() for SSR; a custom adapter that POSTs to /api/auth/set-cookie
TransportCustom retry policy; instrumentation; mock for testsWrap FetchTransport with retry-after handling
StorageSSR; encrypted storagenew MemoryStorage()
BroadcastWorker / non-browser contextsnew NoOpBroadcast()
CryptoNode test runs without Web CryptoPolyfill
LoggerProduction noise controlnew NoOpLogger()

Error model

Every flow method throws a typed AuthError subclass:

  • UnauthenticatedError — current session is invalid; needs login.
  • ValidationError — input validation failed (with field info).
  • ConflictError — e.g. email already registered.
  • NotFoundError, ForbiddenError, RateLimitedError, ServerError, NetworkError.
  • RequiresTwoFactorError — login succeeded but 2FA code is required.
  • OfflineModeError — bootstrap is offline.

The fromHttpResponse(response) helper maps any HTTP response from the auth-server into the matching AuthError subclass. Adapters surface these directly so consumer UI can try/catch per type.