Nest Authbeta

HTTP Adapters

Swap fetch for axios — or any HTTP transport.

The client's HTTP layer is pluggable. Default is fetch; axios ships as an option.

The contract

interface HttpAdapter {
  request<T>(options: HttpRequestOptions): Promise<HttpResponse<T>>;
}
 
interface HttpRequestOptions {
  url: string;
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  headers?: Record<string, string>;
  body?: any;                                 // auto-serialized to JSON
  credentials?: 'include' | 'omit' | 'same-origin';
  timeout?: number;                           // ms
  signal?: AbortSignal;
}
 
interface HttpResponse<T> {
  status: number;
  ok: boolean;
  data: T;
  headers: Record<string, string>;
}

FetchAdapter (default)

import { FetchAdapter } from '@ackplus/nest-auth-client';
 
new AuthClient({ baseUrl, httpAdapter: new FetchAdapter() });

Uses the global fetch — works in all modern browsers, Node 18+, React Native, Cloudflare Workers, Deno, Bun.

JSON request bodies are automatically JSON.stringify-ed; JSON responses are automatically parsed when Content-Type: application/json.

createAxiosAdapter(axiosInstance)

Wraps an axios instance so you keep the rest of your app's HTTP setup (interceptors, retries, baseURL) while letting the auth client share the transport.

import axios from 'axios';
import { createAxiosAdapter } from '@ackplus/nest-auth-client';
 
const api = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true,
});
 
api.interceptors.request.use((config) => {
  // Your existing request interceptor — Authorization header, tracing, etc.
  return config;
});
 
new AuthClient({
  baseUrl: 'https://api.example.com',
  httpAdapter: createAxiosAdapter(api),
});

Custom adapter

Wrap whatever client you like. As long as you satisfy the contract, the library doesn't care.

class GotAdapter implements HttpAdapter {
  async request<T>(opts) {
    const response = await got(opts.url, {
      method: opts.method,
      headers: opts.headers,
      json: opts.body,
      timeout: { request: opts.timeout },
      signal: opts.signal,
    });
    return {
      status: response.statusCode,
      ok: response.statusCode < 400,
      data: response.body as T,
      headers: response.headers,
    };
  }
}

Sharing auth with your own HTTP client

createAxiosAdapter is for letting AuthClient use your axios instance for its own auth calls. The opposite direction — making your app's axios/fetch carry the auth token — is attachToAxios / attachToFetch.

These wire interceptors that attach getAuthHeaders() to every outgoing request and (by default) retry once on 401 after a refresh. They exist as both standalone helpers and instance methods on AuthClient.

import axios from 'axios';
 
const api = axios.create({ baseURL: 'https://api.example.com' });
 
// Instance method (the common case):
const detach = auth.attachToAxios(api);
 
// ...or the standalone helper (identical behaviour):
import { attachToAxios } from '@ackplus/nest-auth-client';
const detach2 = attachToAxios(auth, api);

attachToAxios returns an unsubscribe function — call it on logout/unmount to eject the interceptors. The same instance can be re-attached afterward.

Sharing one axios for both? It is safe to point AuthClient's httpAdapter at the same axios you attachToAxios: the interceptor automatically skips AuthClient's own requests (createAxiosAdapter tags them) and never refresh-retries the auth endpoints (/auth/refresh-token, /auth/login, /auth/logout, /auth/logout-all), so an expired-session boot can't deadlock the refresh call. As a result, attachToAxios/attachToFetch never attach the bearer to — or refresh-retry — those auth paths: do login/logout via the AuthClient/AccountManager methods, and if you renamed your auth endpoints, pass the custom paths in skipPaths. Even so, the cleanest setup is two instances — a plain one as AuthClient's transport, and a separate app instance with attachToAxios + onRefreshFailed for your API calls:

// Transport for AuthClient / AccountManager — no attach.
const authApi = axios.create({ baseURL });
const auth = new AuthClient({ baseUrl: baseURL, httpAdapter: createAxiosAdapter(authApi) });
 
// Your app's calls — attach the active session and react when refresh finally fails.
const api = axios.create({ baseURL });
auth.attachToAxios(api, {
  onRefreshFailed: () => {
    // refresh token expired → send the user to /login (or removeAccount() in multi-account)
    redirectToLogin();
  },
});

For fetch, the wrapper returns a new fetch-shaped function; cleanup is just "stop calling it":

const myFetch = auth.attachToFetch();          // wraps globalThis.fetch
const myFetch2 = auth.attachToFetch(customFetch);
 
const res = await myFetch('/api/data');

In cookie mode both helpers also set withCredentials: true (axios) / credentials: 'include' (fetch) automatically, mirroring shouldSendCookies().

AttachOptions

Both helpers (and instance methods) take an optional third argument. It extends GetAuthHeadersOptions (so authHeaderName, skipAuthHeader, etc. are also valid) and adds:

OptionTypeDefaultPurpose
retryOn401booleantrueRefresh + retry the request once on a 401. Set false for third-party APIs that shouldn't share refresh semantics.
skipPathsArray<string | RegExp | ((url: string) => boolean)>[]URLs to skip — no auth header, no retry. String entries match as a URL suffix.
onRefreshFailed(error: unknown) => void | Promise<void>no-opCalled when the in-interceptor refresh fails (e.g. the refresh token expired). React by redirecting to login, clearing state, etc. The original 401 still propagates.
auth.attachToAxios(api, {
  retryOn401: true,
  skipPaths: ['/auth/refresh-token', /^\/public\//, (u) => u.includes('legacy')],
  onRefreshFailed: () => { window.location.href = '/login'; },
});

Attaching to an account manager

The first argument of attachToAxios / attachToFetch is anything matching AuthHeaderProvider:

interface AuthHeaderProvider {
  getAuthHeaders(opts?: GetAuthHeadersOptions): Promise<Record<string, string>>;
  shouldSendCookies(): boolean;
  refresh(...args: any[]): Promise<unknown>;
}

AuthClient implements it — and so do the multi-account managers (AccountManager / CookieAccountManager), which delegate to whichever account is currently active. That means you can attach a single shared axios/fetch instance to the manager and it always sends the active account's bearer — with no re-attach when you switchAccount():

import { AccountManager } from '@ackplus/nest-auth-client';
 
const accounts = new AccountManager({ baseUrl, accessTokenType: 'header' });
const detach = accounts.attachToAxios(api);   // one-liner — follows the active account
 
await accounts.switchAccount(otherId);        // api now sends the new account's token, no re-attach

The same one-liner works on the manager instances directly (accounts.attachToFetch()) or via the standalone helpers (attachToAxios(accounts, api)). See Multi-account login & switching.

When no account is active. A manager with no active account resolves no headers, so requests would go out anonymous (and 401) — while an auth provider fed a separate bootstrap client still shows the user signed in. Avoid that split-brain by giving the manager a fallbackClient and feeding resolveActiveClient() (not getActiveClient()) to your provider, so both resolve the same client. Pass onNoActiveAccount to be told when auth resolves to nothing instead of silently 401ing:

const accounts = new AccountManager({
  baseUrl,
  accessTokenType: 'header',
  fallbackClient: bootstrapClient,                    // used when no account is active
  onNoActiveAccount: ({ method }) => log.warn('anonymous request', method),
});
 
accounts.attachToAxios(api);
 
// Feed the SAME resolution to your provider — and read it REACTIVELY, so it
// updates when the index finishes loading and on every switchAccount().
const client = useSyncExternalStore(
  (onChange) => accounts.subscribe(onChange),
  () => accounts.resolveActiveClient() ?? bootstrapClient,
);
 
<AuthProvider client={client}>{children}</AuthProvider>

A one-shot accounts.resolveActiveClient() read at render time is not enough: the persisted account index loads asynchronously, so an early read returns the fallback and never updates — re-introducing the very divergence this avoids.

When the client is in cookie mode, every request goes out with credentials: 'include'. Make sure your CORS config has Access-Control-Allow-Credentials: true and an explicit origin allowlist (* is forbidden with credentials).

On this page