Files
ui/_docs/02_document/modules/src__auth__AuthContext.md
T
Oleksandr Bezdieniezhnykh 09449bda2c
ci/woodpecker/push/build-arm Pipeline failed
[AZ-510][AZ-511][AZ-512][AZ-513] Cycle 3 Steps 12-15 + admin prereq
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>
2026-05-13 03:58:21 +03:00

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 | nullnull when unauthenticated.
  • loading: booleantrue until 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.)
    • ../typesAuthUser 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 divergenceRESOLVED 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.