go-auth
GuidesClient

Setup

The fetch wrapper every client-side example in these guides is built on: cookies, CSRF, and error handling.

Client

Every Client section across the guides — and every named method on the wrapped clients in this folder (authApi, and whatever gets added alongside future guides) — is built on one small fetch wrapper. It's defined once here rather than repeated in every guide.

Taking an example into production?

Start with Before you ship: practical cards and examples for tenant isolation, delegated permissions, browser credentials, recovery, and deployment settings.

It has to do two things a plain fetch call doesn't: send cookies on every request, and attach the CSRF header on every state-changing request. Both are mandatory — the double-submit token layer is mandatory, so a POST without the header is a 403 before it reaches a handler.

export interface ApiError {
  error: string
  status?: number
  message?: string
  retryAfter?: number
}

const csrfStates = new Map<string, { token: string | null; request: Promise<string> | null }>()
function stateFor(baseUrl: string) {
  let state = csrfStates.get(baseUrl)
  if (!state) { state = { token: null, request: null }; csrfStates.set(baseUrl, state) }
  return state
}

async function getCSRFToken(baseUrl: string, force = false): Promise<string> {
  const state = stateFor(baseUrl)
  if (force) state.token = null
  if (state.token) return state.token
  state.request ??= (async () => {
    const res = await fetch(`${baseUrl}/auth/csrf-token`, {
      credentials: "include",
      cache: "no-store",
    })
    if (!res.ok) throw new Error("Could not initialize CSRF protection")
    const payload: { token?: unknown } = await res.json()
    if (typeof payload.token !== "string" || !payload.token) {
      throw new Error("CSRF bootstrap returned no token")
    }
    state.token = payload.token
    return state.token
  })().finally(() => { state.request = null })
  return state.request
}

const csrfErrors = new Set([
  "csrf_token_missing", "csrf_token_invalid", "csrf_token_mismatch",
])

let onUnauthorized: (() => void) | null = null

/** Lets the rest of the app react the moment any request comes back with a
 * dead session, instead of only the one call that hit it. Wired up in
 * AuthProvider. */
export function setUnauthorizedHandler(handler: (() => void) | null) {
  onUnauthorized = handler
}

/** Best-effort human message from anything thrown by `apiRequest` or `fetch`. */
export function errorMessage(
  err: unknown,
  fallback = "Something went wrong"
): string {
  if (typeof err === "string" && err) return err
  if (err && typeof err === "object") {
    const e = err as { message?: unknown; error?: unknown }
    if (typeof e.message === "string" && e.message) return e.message
    if (typeof e.error === "string" && e.error) return e.error
  }
  return fallback
}

export async function apiRequest<T = unknown>(
  baseUrl: string,
  method: string,
  path: string,
  body?: unknown,
  options: { signal?: AbortSignal; expectedRevision?: number } = {}
): Promise<T> {
  const state = stateFor(baseUrl)
  const headers: Record<string, string> = {}
  if (body !== undefined) headers["Content-Type"] = "application/json"
  if (options.expectedRevision !== undefined) {
    if (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision <= 0) {
      throw new Error("expectedRevision must be a positive safe integer")
    }
    headers["If-Match"] = `"${options.expectedRevision}"`
  }

  const write = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
  if (write) headers["X-CSRF-Token"] = await getCSRFToken(baseUrl)

  const send = () => fetch(`${baseUrl}${path}`, {
    method,
    credentials: "include",
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: options.signal,
    cache: "no-store",
  })
  let res = await send()
  if (write && res.status === 403) {
    const error = await res.clone().json().catch(() => ({})) as { error?: string }
    // These reserved codes mean middleware rejected before executing the handler.
    if (error.error && csrfErrors.has(error.error)) {
      headers["X-CSRF-Token"] = await getCSRFToken(baseUrl, true)
      res = await send()
    }
  }
  const replacement = res.headers.get("X-CSRF-Token")
  if (replacement) state.token = replacement

  if (!res.ok) {
    const err: ApiError = await res
      .json()
      .catch(() => ({ error: res.statusText }))
    err.status = res.status
    if (
      res.status === 401 ||
      (res.status === 403 && (err.error === "user_banned" || err.error === "two_factor_required" || (path.startsWith("/admin/") && err.error === "forbidden")))
    ) {
      onUnauthorized?.()
    }
    if (res.status === 429) {
      err.retryAfter = Number(res.headers.get("Retry-After")) || undefined
    }
    throw err
  }

  return res.status === 204 ? (undefined as T) : res.json()
}

credentials: "include" sends and stores the HttpOnly cookies. The fixed _csrf cookie is echoed through the fixed X-CSRF-Token header using the JSON bootstrap value. Cache it in memory, update it from replacement response headers, and bootstrap once after a reserved CSRF rejection. There are no application-level CSRF overrides or opt-outs. The cache above is scoped by API base URL.

This wrapper selects the ordinary app context. The admin wrapper adds X-GoAuth-Session: admin to every request and keeps its own in-memory token. Do not share one token cache between app and admin contexts. Both frontends may call the same API URL directly; the API uses independent cookie sets.

Use setUnauthorizedHandler to clear stale client state when any request finds that a session was revoked, banned, or expired. The Provider wires it to clear the app-wide user state on 401 or 403 user_banned.

On a 429, the thrown error also carries retryAfter: the Retry-After header value in seconds.

Requests use cache: "no-store" so access and membership reads reach the server. The optional fifth argument accepts an AbortSignal for cancellable reads, as used by the org permissions client. Cancelled requests reject; callers should ignore cancellation during cleanup. Cancelling a write does not establish whether it committed.

What lives where

  • The individual feature guides (e.g. Authentication) show the raw call — apiRequest(API_BASE, "POST", "/auth/login", ...) — right next to the curl and Go examples for that same endpoint, so you can see the request/response map 1:1 to what's documented above it.
  • Authentication, Sessions, Organizations, Security, OAuth linking, Admin, and App roles and permissions wrap those same calls into named methods — authApi.login(...), sessionsApi.list(...), orgApi.create(...), securityApi.changePassword(...), oauthApi.link(...), adminApi.listUsers(...), appPermissionsApi.setUserRole(...) — the form you'd actually want in an app.
  • Provider wraps those in a React context, so components read user/loading from one hook instead of calling the API directly.
  • Middleware is a different layer entirely — it runs on the server, before any of the above, to redirect unauthenticated requests away from protected pages.

Next

On this page