go-auth

Admin Client Integration

Setting up an admin dashboard to call go-auth — cross-origin concerns, role checking, and the admin API surface.

Admin Client Integration

An admin dashboard is a separate frontend that manages users, sessions, and audit logs. It usually runs on a different host than the API, which brings cross-origin cookie and CSRF concerns that a same-origin user app doesn't have.

Fixed-role examples: app permissions disabled

The role checks and user role bodies on this page assume WithAppPermissions is disabled. Enabled mode omits the account role field, allows selectively delegated administrative actions, and uses appRoleId plus expected revisions for assignment. Use the app permissions client guide for /auth/access, permission-aware UI, and role assignment examples.

File convention

Recommended file names and locations in your project:

lib/
  api/
    http.ts          ← the fetch wrapper (apiRequest, getCSRFToken, setUnauthorizedHandler)
    auth.ts          ← admin auth methods (login, verifyTwoFactor, resendTwoFactor, me)
    admin.ts         ← admin API methods (users, sessions, stats, invites, audit logs)
providers/
  auth-provider.tsx  ← admin AuthProvider with requireAdmin() + useAuth() hook

Keep the admin client separate from the user-facing client — they share the same pattern but have different API surfaces and role requirements.

How it differs from a user-facing app

ConcernUser-facing appAdmin dashboard
OriginDirect API or optional proxyDirect API or optional proxy
CSRF tokenJSON bootstrap; HttpOnly cookieSame JSON bootstrap; HttpOnly cookie
SessionAny authenticated userMust check role === "admin"
ContextDefault app contextX-GoAuth-Session: admin on every request
Typical stackNext.js + rewritesVite/React + VITE_API_URL

Server-side requirements

One backend can serve both the app and this panel on sibling subdomains:

goauth.WithBrowser(goauth.BrowserConfig{
    APIURL: "https://api.example.com",
    AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
    Cookies: goauth.CookieConfig{
        Domain: "",
        Path: "/",
        SameSite: http.SameSiteLaxMode,
        Secure: goauth.SecureAlways(),
    },
})

Place this inside NewConfig and import net/http. Cookie names can retain their defaults. Both frontends use JSON bootstrap; no readable cookie or shared domain is required. The panel selects the independent admin context on every request. Both frontends can call the same API host directly without replacing each other's login. See Deployment for HTTP development and different-site HTTPS deployments.

The fetch wrapper

Same pattern as the user-facing client, but with cross-origin notes:

// Set VITE_API_URL for direct API access, or leave it empty for a same-origin
// proxy. Both use HttpOnly cookies and the same JSON CSRF bootstrap contract.
const API_BASE = import.meta.env.VITE_API_URL ?? ""

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

let csrfToken: string | null = null
let csrfRequest: Promise<string> | null = null

async function getCSRFToken(force = false): Promise<string> {
  if (force) csrfToken = null
  if (csrfToken) return csrfToken
  csrfRequest ??= (async () => {
    const res = await fetch(`${API_BASE}/auth/csrf-token`, {
      credentials: "include",
      cache: "no-store",
      headers: { "X-GoAuth-Session": "admin" },
    })
    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")
    }
    csrfToken = payload.token
    return csrfToken
  })().finally(() => { csrfRequest = null })
  return csrfRequest
}

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>(
  method: string,
  path: string,
  body?: unknown,
  signal?: AbortSignal
): Promise<T> {
  const headers: Record<string, string> = { "X-GoAuth-Session": "admin" }
  if (body !== undefined) headers["Content-Type"] = "application/json"

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

  const send = () => fetch(`${API_BASE}${path}`, {
    method,
    credentials: "include",
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal,
  })
  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(true)
      res = await send()
    }
  }
  const replacement = res.headers.get("X-CSRF-Token")
  if (replacement) csrfToken = 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()
}

Authentication API methods

// lib/api/auth.ts
import { apiRequest } from "./http"

export interface AuthUser {
  id: string
  email: string
  name: string
  role: "user" | "admin" // this page assumes app permissions are disabled
}
export interface LoginResult {
  user: AuthUser
  step: "authenticated" | "verify_email" | "two_factor";
  codeSent?: boolean
  challengeId?: string
  expiresAt?: string
}
export const authApi = {
  login: (input: { email: string; password: string }) =>
    apiRequest<LoginResult>("POST", "/auth/admin/login", input),
  verifyTwoFactor: (challengeId: string, code: string) =>
    apiRequest<{ user: AuthUser }>("POST", "/auth/2fa/verify", { challengeId, code }),
  resendTwoFactor: (challengeId: string) =>
    apiRequest<{ challengeId: string; expiresAt: string; codeSent: boolean }>(
      "POST", "/auth/2fa/resend", { challengeId }
    ),
  me: () => apiRequest<AuthUser>("GET", "/auth/me"),
  logout: () => apiRequest("POST", "/auth/logout"),
}

Admin API methods

// admin.ts
import { apiRequest } from "./http"

export const adminApi = {
  login: (email: string, password: string) =>
    apiRequest("POST", "/auth/admin/login", { email, password }),

  // --- Users ---

  listUsers: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/users?${new URLSearchParams(params as Record<string, string>)}`),

  countUsers: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/users/count?${new URLSearchParams(params as Record<string, string>)}`),

  getUser: (userId: string) =>
    apiRequest("GET", `/admin/users/${userId}`),

  createUser: (input: { email: string; password: string; name: string; role?: "user" | "admin" }) =>
    apiRequest("POST", "/admin/users", input),

  updateRole: (userId: string, role: "user" | "admin") =>
    apiRequest("PATCH", `/admin/users/${userId}/role`, { role }),

  ban: (userId: string) =>
    apiRequest("PATCH", `/admin/users/${userId}/ban`),

  unban: (userId: string) =>
    apiRequest("PATCH", `/admin/users/${userId}/unban`),

  deleteUser: (userId: string) =>
    apiRequest("DELETE", `/admin/users/${userId}`),

  // --- User sessions ---

  listUserSessions: (userId: string, offset = 0, limit = 20) =>
    apiRequest("GET", `/admin/users/${userId}/sessions?offset=${offset}&limit=${limit}`),

  revokeUserSession: (userId: string, sessionId: string) =>
    apiRequest("DELETE", `/admin/users/${userId}/sessions/${sessionId}`),

  revokeUserSessions: (userId: string) =>
    apiRequest("DELETE", `/admin/users/${userId}/sessions`),

  // --- Stats ---

  getStats: () =>
    apiRequest("GET", "/admin/stats"),

  getRegistrationTrend: (from: string, to: string) =>
    apiRequest("GET", `/admin/stats/registrations?from=${from}&to=${to}`),

  getLoginActivity: (from: string, to: string, userId?: string) =>
    apiRequest("GET", `/admin/stats/logins?from=${from}&to=${to}${userId ? `&userId=${userId}` : ""}`),

  // --- Audit logs ---

  listAuditLogs: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),

  countAuditLogs: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),

  // --- Platform invites ---

  createInvite: (email: string) =>
    apiRequest("POST", "/admin/invites", { email }),

  listInvites: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/invites?${new URLSearchParams(params as Record<string, string>)}`),

  revokeInvite: (inviteId: string) =>
    apiRequest("DELETE", `/admin/invites/${inviteId}`),

  resendInvite: (inviteId: string) =>
    apiRequest("POST", `/admin/invites/${inviteId}/resend`),

  deleteInvite: (inviteId: string) =>
    apiRequest("DELETE", `/admin/invites/${inviteId}/hard`),
}

This context example assumes verified administrators. A backend can also return step: "verify_email"; the complete verification and continuation contract is documented in Authentication outcomes.

React context with role checking

The admin AuthProvider adds a critical check: a valid session is not necessarily an admin session.

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
import { authApi, type AuthUser } from "../lib/api/auth"
import { setUnauthorizedHandler } from "../lib/api/http"


interface TwoFactorChallenge {
  challengeId: string
  expiresAt: string
  codeSent: boolean
}

interface AuthContextValue {
  user: AuthUser | null
  loading: boolean
  challenge: TwoFactorChallenge | null
  login: (input: { email: string; password: string }) => Promise<void>
  verifyTwoFactor: (code: string) => Promise<void>
  resendTwoFactor: () => Promise<void>
  logout: () => Promise<void>
}

const AuthContext = createContext<AuthContextValue | null>(null)

/**
 * A valid session is not necessarily an admin session. Cookies are scoped to
 * the host and ignore the port, so a regular user signed into the app on
 * another localhost port arrives here authenticated with no admin rights.
 * Without this the panel renders its whole shell and then 403s on every
 * /admin/* call it makes.
 */
function requireAdmin(user: AuthUser): AuthUser | null {
  return user.role === "admin" ? user : null
}

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AuthUser | null>(null)
  const [loading, setLoading] = useState(true)
  const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null)

  const refresh = useCallback(async () => {
    try {
      setUser(requireAdmin(await authApi.me()))
    } catch {
      setUser(null)
    } finally {
      setLoading(false)
    }
  }, [])

  useEffect(() => {
    setUnauthorizedHandler(() => setUser(null))
    refresh()
  }, [refresh])

  const login = useCallback(async (input: { email: string; password: string }) => {
    setChallenge(null)
    const result = await authApi.login(input)
    if (result.step === "verify_email") {
      throw new Error("Verify your email before admin login; follow the authentication-outcomes verification flow.")
    }
    if (result.step === "two_factor") {
      setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt!, codeSent: result.codeSent ?? false })
      return
    }
    await refresh()
  }, [refresh])

  const verifyTwoFactor = useCallback(async (code: string) => {
    if (!challenge) throw new Error("No active 2FA challenge")
    const result = await authApi.verifyTwoFactor(challenge.challengeId, code)
    setChallenge(null)
    setUser(requireAdmin(result.user))
  }, [challenge])

  const resendTwoFactor = useCallback(async () => {
    if (!challenge) throw new Error("No active 2FA challenge")
    const result = await authApi.resendTwoFactor(challenge.challengeId)
    setChallenge(result)
  }, [challenge])

  const logout = useCallback(async () => {
    await authApi.logout()
    setUser(null)
    setChallenge(null)
    if (typeof window !== "undefined") window.location.href = "/login"
  }, [])

  return (
    <AuthContext.Provider value={{ user, loading, challenge, login, verifyTwoFactor, resendTwoFactor, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error("useAuth must be used within an AuthProvider")
  return ctx
}

Why requireAdmin?

The panel's selector keeps an app login from being mistaken for a panel login. An existing admin-context session can still lose access when its account role, grants, ban status, or session assurance changes. requireAdmin helps the UI return to sign-in when current access no longer permits the panel; the backend remains responsible for every authorization decision.

With app permissions disabled, /admin/* checks the admin role and required session assurance server-side. The browser role check improves navigation only; it cannot authorize an operation. Enabled mode uses exact library permissions or protected-admin policy, rather than a fixed account role.

// Check once, on session load:
const user = requireAdmin(await authApi.me())
setUser(user) // null if not admin → shows login screen

This turns a dashboard full of failed requests into a clean login screen.

Admin login flow

Admin login always requires 2FA (unless DisableAdminTwoFactor is set):

const login = useCallback(async (input: { email: string; password: string }) => {
  setChallenge(null)
  const result = await authApi.login(input)
  if (result.step === "two_factor") {
    setChallenge({ challengeId: result.challengeId!, expiresAt: result.expiresAt!, codeSent: result.codeSent ?? false }) // show emailed-code input
    return
  }
  await refresh() // only if 2FA is disabled
}, [refresh])

const verifyTwoFactor = useCallback(async (code: string) => {
  if (!challenge) throw new Error("No active 2FA challenge")
  const result = await authApi.verifyTwoFactor(challenge.challengeId, code)
  setChallenge(null)
  setUser(requireAdmin(result.user))
}, [challenge])

Vite environment variables

# .env.local
VITE_API_URL=https://api.myapp.com

Vite exposes VITE_ variables through import.meta.env automatically; no custom define entry is needed. Build the panel and deploy dist/ to the admin subdomain, with SPA fallback to index.html.

Build time, not runtime

VITE_API_URL is baked into the bundle at build time. A different API URL means a different build. For runtime configuration, use a small config endpoint or environment injection.

Organizations admin

Admin endpoints for organization management:

export const orgAdminApi = {
  listOrgs: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/orgs?${new URLSearchParams(params as Record<string, string>)}`),

  countOrgs: (params: Record<string, string | number> = {}) =>
    apiRequest("GET", `/admin/orgs/count?${new URLSearchParams(params as Record<string, string>)}`),

  getOrg: (orgId: string) =>
    apiRequest("GET", `/admin/orgs/${orgId}`),

  listMembers: (orgId: string) =>
    apiRequest("GET", `/admin/orgs/${orgId}/members`),

  addMember: (orgId: string, userId: string, role: string) =>
    apiRequest("POST", `/admin/orgs/${orgId}/members`, { userId, role }),

  removeMember: (orgId: string, userId: string) =>
    apiRequest("DELETE", `/admin/orgs/${orgId}/members/${userId}`),

  deleteOrg: (orgId: string) =>
    apiRequest("DELETE", `/admin/orgs/${orgId}`),
}

Complete admin panel coming soon

A full-featured admin dashboard built with Vite is on the way — user management, session revocation, audit logs, organization admin, and role-based access, all wired up and ready to fork.

Next

On this page