# Module: `src/auth/AuthContext.tsx`

> **Source**: `src/auth/AuthContext.tsx` (~120 lines after AZ-510)
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
> **Last refresh**: 2026-05-13 — AZ-510 consolidated bootstrap onto POST refresh + chained `/users/me`; closes Vision P3 / Finding B3.

## Purpose

The single source of truth for the SPA's authentication state. Wraps the bearer-token plumbing from `api/client.ts` in a React context, exposes `useAuth()` for any descendant component, and bootstraps the session on app start by attempting a refresh. Together with `ProtectedRoute.tsx` and `LoginPage.tsx`, this is the WPF-era `LoginWindow.xaml` + auth service replacement (`_docs/legacy/wpf-era.md` §3 / §4).

## Public interface

```ts
interface AuthState {
  user: AuthUser | null
  loading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  hasPermission: (perm: string) => boolean
}

export function useAuth(): AuthState
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element
```

`AuthContext` itself is module-private (`createContext<AuthState>(null!)`). Consumers must go through `useAuth()`.

## Internal logic

State:

- `user: AuthUser | null` — `null` when unauthenticated.
- `loading: boolean` — `true` until the initial refresh attempt resolves (success or failure). Renders should gate on this.

**Bootstrap effect (mount-only)** — AZ-510 wire shape:

```ts
async function runBootstrap(): Promise<AuthUser | null> {
  const refreshRes = await fetch(getApiBase() + endpoints.admin.authRefresh(), {
    method: 'POST',
    credentials: 'include',
  })
  if (!refreshRes.ok) return null
  const refreshData = (await refreshRes.json()) as { token: string }
  setToken(refreshData.token)
  try {
    return await api.get<AuthUser>(endpoints.admin.usersMe())
  } catch (err) {
    console.error('[AuthContext] Refresh succeeded but /users/me failed:', err)
    setToken(null)
    return null
  }
}
```

A module-scoped `bootstrapInflight: Promise | null` guard is consulted before invoking `runBootstrap`, so two concurrent `useEffect` mounts (React 18+ StrictMode dev double-mount, or rapid re-mount in tests) share a single network round-trip and avoid racing the backend's refresh-cookie rotation. A test-only escape hatch `__resetBootstrapInflightForTests()` is exported via the `src/auth` barrel and called in `tests/setup.ts`'s `afterEach` to keep the module-scoped promise from leaking between tests.

The bootstrap and the existing 401-retry path in `api/client.ts:73` now share a single wire shape — both POST `/api/admin/auth/refresh` with `credentials:'include'` and rely on the HttpOnly refresh cookie. The chained `GET /api/admin/users/me` request fetches the user payload (the POST refresh response is `{ token }` only). On any failure path (refresh 401, refresh network error, refresh 200 → `/users/me` 401, refresh 200 → `/users/me` network error) the bootstrap clears the bearer first then sets `user: null` + `loading: false`, so an in-flight re-render never sees `(user: null, accessToken: <stale>)`. Closes Vision principle P3 ("bearer in memory, refresh in HttpOnly cookie") and Finding B3.

**`login(email, password)`**:

```ts
const data = await api.post<{ token; user }>(endpoints.admin.authLogin(), { email, password })
setToken(data.token); setUser(data.user)
```

Throws to caller (LoginPage) on bad credentials.

**`logout()`**:

```ts
try { await api.post(endpoints.admin.authLogout()) } catch {}
setToken(null); setUser(null)
```

Network failure on logout is silently swallowed because we want to clear local auth state regardless.

**`hasPermission(perm)`**: returns `user?.permissions?.includes(perm) ?? false`. Defensively handles legacy `/users/me` payloads that omit `permissions` (older backend builds; some test fixtures returning the bare `User` shape). Permission strings are not constrained at the type level — any string passes. Backend-defined; UI uses this only for affordance show/hide, never for security gates (the server is the authority — see `_docs/02_document/architecture.md` Vision P12 / O4).

## Dependencies

- **Internal**:
  - `../api` (barrel) — `api`, `endpoints`, `setToken`. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
  - `../types` — `AuthUser` type.
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).

## Consumers (intra-repo)

From the §7a dependency graph:

- `src/auth/ProtectedRoute.tsx` — gates routed children on `user !== null`.
- `src/components/Header.tsx` — shows current user, exposes Logout.
- `src/features/login/LoginPage.tsx` — calls `login(...)`, redirects on success.
- `src/App.tsx` — mounts `AuthProvider` near the root.

(Other features rely on the bearer token implicitly via `api/client.ts` — they don't import `useAuth` directly.)

## Data models

`AuthUser` (from `src/types/index.ts`) — see `_docs/02_document/modules/src__types__index.md`. Carries at minimum `id`, `email`, `permissions: string[]`.

## Configuration

Endpoints (typed builders from `../api/endpoints`, since AZ-486 / F7): `endpoints.admin.authRefresh()`, `endpoints.admin.authLogin()`, `endpoints.admin.authLogout()` — producing `/api/admin/auth/refresh`, `.../login`, `.../logout` respectively. Routed by `nginx.conf` to the `admin/` service.

No env vars consumed directly — token storage policy is defined in `client.ts` (in-memory; not persisted to `localStorage`).

## External integrations

`admin/` (.NET) auth service via `/api/admin/auth/{refresh,login,logout}`.

## Security

- **In-memory token only**: the bearer is held by `client.ts` at module scope. Survives intra-tab navigation, lost on hard reload — at which point the refresh path must restore it (currently broken per the bootstrap-effect note above).
- **`hasPermission` runs client-side only**: the server is the authority; `hasPermission` is for UI affordances (hide vs. show buttons). The backend MUST re-check permissions on every endpoint. Document in `security_approach.md` (Step 6).
- **Silent error swallowing on bootstrap and logout** is intentional but obscures real failures. A dev-only `console.error` would help during the testability pass; do not add a user-visible toast (silent recovery is the correct UX).
- **No XSS exfiltration risk for the bearer**: in-memory only, never written to `localStorage` or a non-HttpOnly cookie. (Confirmed in `client.ts` doc.)

## Tests

`src/auth/AuthContext.test.tsx` — un-quarantined `FT-P-01` (bootstrap POST + `credentials:'include'` + chained `/users/me` regression guard); `FT-P-03` (refresh transparency, child re-render delta ≤ 1); `NFT-SEC-01` (bearer never in localStorage / sessionStorage across the full bootstrap + 401-retry lifecycle); `NFT-SEC-02` (no refresh-prefixed cookie visible via `document.cookie`); `AC-4 (AZ-510)` — POST refresh 200 → `/users/me` 401 clears the bearer + logs a diagnostic console.error.

## Notes / open questions

- ~~**Bootstrap-vs-refresh divergence**~~ — **RESOLVED 2026-05-13 by AZ-510**. Bootstrap now uses POST + `credentials:'include'` + chained `/users/me`, sharing the same wire shape as the 401-retry path. `api.get()` is intentionally NOT used for the refresh itself because it does not thread `credentials:'include'`; the bootstrap calls `fetch()` directly with the same explicit-credentials pattern documented in `api/client.ts:88`. Finding B3 closed.
- **`AuthContext = createContext<AuthState>(null!)`**: the non-null assertion means `useAuth()` will throw at the destructuring site if it's used outside `AuthProvider`. Acceptable given `App.tsx` mounts `AuthProvider` at the top, but a guard `if (!ctx) throw new Error(...)` would be friendlier. Defer.
- The `loading` flag is never re-set to `true` after the initial bootstrap. `login` and `logout` complete synchronously from the React tree's perspective (the `await` is inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8.
- `useAuth` returns the raw context value (no memoisation wrapper). React 18+ behaviour means `<AuthProvider>` re-renders all `useAuth` consumers on every state update — fine here because there's no high-frequency state.
