# 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 logout: () => Promise hasPermission: (perm: string) => boolean } export function useAuth(): AuthState export function AuthProvider({ children }: { children: ReactNode }): JSX.Element ``` `AuthContext` itself is module-private (`createContext(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 { 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(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: )`. 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(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 `` re-renders all `useAuth` consumers on every state update — fine here because there's no high-frequency state.