Wrap up cycle 3 across the autodev existing-code Phase B steps that follow Implement (Steps 12-15), plus the cross-workspace prerequisite ticket filed for AZ-512. Step 12 - Test-Spec Sync: - Un-quarantine FT-P-01 in traceability-matrix (closed by AZ-510) - Add AZ-510 chained /users/me failure-path test reference under AC-23 - Note AZ-512 deferral status under O9 (P12 Phase B target) Step 13 - Update Docs (task mode): - Refresh src__auth__AuthContext module doc with AZ-510 wire shape (POST refresh + chained /users/me + bootstrapInflight guard) - Add usersMe() to src__api__endpoints module doc + consumer note - Rename src__features__annotations__classColors module doc to src__class-colors__classColors (matches AZ-511 git mv); refresh header - Refresh src__components__DetectionClasses + src__features__annotations module group doc for the new class-colors barrel import path - Update components/11_class-colors Module Inventory to point at the renamed module doc filename - Rewrite system-flows.md Flow F2 (Bearer auto-refresh) with the AZ-510 POST + chained /users/me sequence; close Finding B3 references - Generate ripple_log_cycle3 documenting all changed source files, their reverse-dependency search results, and the docs touched Step 14 - Security Audit (cycle-3 delta): - Resume mode against cycle-2 baseline; cycle-2 artifacts untouched - Re-run bun audit on both roots: clean (cycle-2 inline fix held) - Re-rate OWASP A06: FAIL -> PASS; A07: PASS_WITH_KNOWN -> PASS (B3 closed by AZ-510) - New finding F-SAST-CY3-1 (LOW): __resetBootstrapInflightForTests exposed via src/auth public barrel; defer to hygiene cycle - Verdict: FAIL -> PASS_WITH_WARNINGS; one HIGH (F-SAST-1 mission-planner git-history key, unchanged) remains - Add amendment banner to cycle-2 security_report.md Step 15 - Performance Test: - Static profile NFT-PERF-01 PASS (290 575 B gzipped vs 2 MB budget; ~14% of budget; no regression from AZ-510 surface additions) - E2E profile SKIP (Playwright perf project still pending AZ-457..AZ-482); legitimate skip per test-run skill, gap acknowledged in report - AZ-510 200ms p95 chain NFR verified at spec level only - no CI gate yet (covered by future AZ-457..AZ-482 work) Cross-workspace prerequisite (AZ-513 just filed): - Updated _docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md to reflect AZ-513 filing on admin/ workspace (parent epic AZ-509, Blocks link to AZ-512). Companion task spec added in admin/ repo (separate commit there, owned by admin/ workspace). State file: advanced to Step 16 (Deploy) per autodev existing-code flow. Co-authored-by: Cursor <cursoragent@cursor.com>
8.2 KiB
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
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—nullwhen unauthenticated.loading: boolean—trueuntil the initial refresh attempt resolves (success or failure). Renders should gate on this.
Bootstrap effect (mount-only) — AZ-510 wire shape:
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):
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():
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—AuthUsertype.
- External:
react(createContext,useContext,useState,useCallback,useEffect,ReactNode).
Consumers (intra-repo)
From the §7a dependency graph:
src/auth/ProtectedRoute.tsx— gates routed children onuser !== null.src/components/Header.tsx— shows current user, exposes Logout.src/features/login/LoginPage.tsx— callslogin(...), redirects on success.src/App.tsx— mountsAuthProvidernear 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.tsat 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). hasPermissionruns client-side only: the server is the authority;hasPermissionis for UI affordances (hide vs. show buttons). The backend MUST re-check permissions on every endpoint. Document insecurity_approach.md(Step 6).- Silent error swallowing on bootstrap and logout is intentional but obscures real failures. A dev-only
console.errorwould 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
localStorageor a non-HttpOnly cookie. (Confirmed inclient.tsdoc.)
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 threadcredentials:'include'; the bootstrap callsfetch()directly with the same explicit-credentials pattern documented inapi/client.ts:88. Finding B3 closed.AuthContext = createContext<AuthState>(null!): the non-null assertion meansuseAuth()will throw at the destructuring site if it's used outsideAuthProvider. Acceptable givenApp.tsxmountsAuthProviderat the top, but a guardif (!ctx) throw new Error(...)would be friendlier. Defer.- The
loadingflag is never re-set totrueafter the initial bootstrap.loginandlogoutcomplete synchronously from the React tree's perspective (theawaitis inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8. useAuthreturns the raw context value (no memoisation wrapper). React 18+ behaviour means<AuthProvider>re-renders alluseAuthconsumers on every state update — fine here because there's no high-frequency state.