Files
ui/_docs/02_tasks/done/AZ-510_auth_bootstrap_consolidation.md
T
Oleksandr Bezdieniezhnykh 70fb452805 [AZ-510] Auth bootstrap: POST refresh + chained /users/me
Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`)
mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials)
chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly
refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3.

- Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety)
  + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts`
  resets it in `afterEach` to prevent pending-promise leakage between tests.
- Defensive `hasPermission` against legacy `/users/me` payloads omitting
  `permissions`; default MSW handler now seeds `permissions` explicitly.
- Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal).
- Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh
  override so intentional bootstrap-fail tests still fail correctly.
- Update auth component description; mark B3 closed.
- Code review verdict PASS; static + fast suites green (231 / 13 skipped).

Batch report: _docs/03_implementation/batch_13_cycle3_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 02:59:31 +03:00

12 KiB

Consolidate AuthContext bootstrap onto POST refresh + /users/me chain

Task: AZ-510_auth_bootstrap_consolidation Name: Auth bootstrap refresh consolidation Description: Replace the broken GET /api/admin/auth/refresh bootstrap path in AuthContext.tsx with the same POST /api/admin/auth/refresh (credentials-included) path the 401-retry already uses, chaining GET /api/admin/users/me to fetch the user shape. Closes the long-standing Finding B3 logged against Architecture Vision principle P3. Complexity: 3 points Dependencies: None (POST refresh path already lives in api/client.ts:88 and is exercised by tests) Component: 02_auth (primary); 03_shared-ui (Header.test.tsx MSW handlers); 01_api-transport (no source change, but tests reference api/client.ts) Tracker: AZ-510 Epic: AZ-509

Problem

The SPA has two refresh-token paths and they disagree:

  • Bootstrap (broken)src/auth/AuthContext.tsx:24 issues GET /api/admin/auth/refresh WITHOUT credentials: 'include'. The Secure HttpOnly refresh cookie set by POST /api/admin/auth/login is therefore never sent on the bootstrap call; the server cannot recognise the session; the request fails; the .catch(() => {}) swallows the error; setLoading(false) resolves to "no user"; ProtectedRoute redirects to /login. A returning user with a perfectly valid refresh cookie is silently bounced to login on every page load.

  • 401-retry (works)src/api/client.ts:88 issues POST /api/admin/auth/refresh WITH credentials: 'include'. This path runs only when a subsequent authenticated request hits a 401; it does NOT run on bootstrap because line 73's if (res.status === 401 && accessToken) short-circuits when accessToken is null (which it always is on cold boot).

The broken path was flagged in the architecture documentation review (Architecture Vision principle P3 — "bearer in memory, refresh in HttpOnly cookie") and again in _docs/02_document/architecture_compliance_baseline.md as downstream item B3. Step 4 (Testability) chose to leave it for a behaviour cycle because the fix changes the bootstrap response handling, not just hardcoded strings — outside the testability-revision allowed-changes list.

Observable failure mode today: every page reload by an authenticated user shows a brief /login redirect followed by a forced re-login. Operators have learned to ignore it; the behaviour normalises a UX regression that violates P3.

Outcome

  • A returning user with a valid refresh cookie loads any URL (/, /flights, /dataset, …) and lands on the intended route without redirecting through /login.
  • A returning user with an expired/invalid refresh cookie sees /login exactly once — no flash of the protected shell, no infinite redirect loop.
  • The GET /api/admin/auth/refresh request disappears from network traces in the bootstrap window.
  • POST /api/admin/auth/refresh (with credentials) followed by GET /api/admin/users/me (with bearer) appears in network traces on every successful bootstrap.
  • Existing MSW tests pass against the new code path; no test handler relies on the deprecated GET bootstrap.

Scope

Included

  • src/auth/AuthContext.tsx — rewrite the useEffect mount handler to:
    1. await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' }) — direct call (not api.post(), because api.post does not carry credentials: 'include' and adding it there would change every callsite's CORS posture).
    2. On !res.ok → set user: null + loading: false + return.
    3. On success → setToken(data.token), then api.get<AuthUser>('/api/admin/users/me') to fetch the user shape, setUser(authUser), setLoading(false).
    4. On the /users/me failure path → setToken(null), setUser(null), setLoading(false). Do not throw silently — a 401 here is a genuine "refresh succeeded but the user record is gone" edge case worth surfacing through console.error.
  • Tests (in-task; not deferred to a separate test-spec sync ticket):
    • src/auth/AuthContext.test.tsx — update bootstrap tests to assert POST /api/admin/auth/refresh then GET /api/admin/users/me. Drop GET-bootstrap expectations.
    • src/auth/ProtectedRoute.test.tsx — same MSW handler swap.
    • src/components/Header.test.tsx — same MSW handler swap (the test fires a full app render that exercises bootstrap).
  • New i18n strings: NONE (the user-visible behaviour change is the absence of the spurious redirect, not new copy).
  • A small note added to _docs/02_document/components/02_auth/description.md recording that bootstrap and 401-retry now share a single wire shape.

Excluded

  • Refresh-cookie rotation backend changes — server keeps its existing rotate-on-refresh policy unchanged.
  • SSE bearer-rotation hardening (ADR-008 consequences) — separate ticket scope; the ?token=... query-string refresh problem is not addressed here.
  • Changing api.post to default credentials: 'include' — out of scope; would expand the test matrix to every POST callsite.
  • Embedding the user payload in the POST refresh response — would be a backend wire-contract change; the chained /users/me GET is intentional and matches existing semantics.

Acceptance Criteria

AC-1: Bootstrap uses POST refresh with credentials Given a fresh app mount (no in-memory bearer) When AuthProvider renders Then exactly one outbound request is made to POST /api/admin/auth/refresh with credentials: 'include'; no GET /api/admin/auth/refresh request occurs.

AC-2: Successful refresh chains to /users/me Given the POST refresh returns 200 with { token: '<bearer>' } When the response resolves Then setToken('<bearer>') is called, then GET /api/admin/users/me is requested with Authorization: Bearer <bearer>; on its 200 response the returned AuthUser is exposed via useAuth().user; loading flips to false.

AC-3: Failed refresh shows /login without flash Given the POST refresh returns 401 (no valid cookie) or a network error occurs When the response is handled Then setUser(null) + setLoading(false) are called; ProtectedRoute renders the spinner during the in-flight bootstrap and then renders /login exactly once; no protected route component renders even momentarily; no second redirect fires.

AC-4: /users/me failure after refresh success clears the bearer Given the POST refresh returns 200 but the subsequent GET /users/me returns 401 or fails When the failure is handled Then setToken(null) is called, setUser(null) + setLoading(false) are called, the user lands on /login, and console.error carries a diagnostic message identifying the edge case (refresh OK / user GET failed).

AC-5: Returning user is not bounced through /login Given a refresh cookie that the backend considers valid When the user reloads any protected URL (e.g. /flights) Then no /login route is rendered (verified via a Playwright e2e check or via the React-Router history not containing a /login entry); the user sees the protected route immediately after the bootstrap spinner.

AC-6: No regression in the 401-retry path Given an authenticated session with an expired bearer (accessToken non-null but server-side expired) When the user makes any API call from a feature page Then the existing api/client.ts:73 401-retry path is unchanged, calls POST /api/admin/auth/refresh with credentials, rotates the bearer, and replays the original request — behaviour identical to today.

Non-Functional Requirements

Performance: bootstrap latency added by the chained /users/me GET is observable but acceptable — both calls hit the same nginx, same auth, same machine in prod; budget: under 200 ms p95 for the chain on the suite dev compose stack.

Compatibility: no change to the backend contract. The chained /users/me GET already exists and is the only source of user shape today; tests prove it.

Reliability: every failure mode (refresh 401, refresh network error, refresh 200 + users/me 401, refresh 200 + users/me network error) must resolve loading to false and put the user on /login. No path may leave loading: true indefinitely.

Unit Tests

AC Ref What to Test Required Outcome
AC-1 AuthContext mount with no prior bearer exactly one POST /api/admin/auth/refresh is made; no GET refresh
AC-2 POST refresh 200 → users/me 200 bearer set + user set + loading: false
AC-3 POST refresh 401 setUser(null) + loading: false + no further requests
AC-3 POST refresh network error (MSW HttpResponse.error()) same as 401 case
AC-4 POST refresh 200 → users/me 401 setToken(null) + setUser(null) + loading: false; console.error called
AC-6 request → 401 → POST refresh 200 → replay → 200 unchanged 401-retry behaviour (regression guard)

Blackbox Tests

AC Ref Initial Data/Conditions What to Test Expected Behavior NFR References
AC-1 Browser with valid refresh cookie Reload /flights DevTools Network panel shows POST /api/admin/auth/refresh followed by GET /users/me — no GET refresh
AC-5 Browser with valid refresh cookie Reload /flights /flights renders directly; no /login is visible at any point
AC-3 Browser with expired refresh cookie Reload / Spinner briefly visible; then /login; no flash of the protected shell Reliability

Constraints

  • The getApiBase() helper is the ONLY source for the base URL — do not bypass it.
  • The new bootstrap path must NOT use api.post() because that helper does not carry credentials: 'include'. Direct fetch(..., { method: 'POST', credentials: 'include' }) is intentional; the comment in api/client.ts:88 documents the same pattern.
  • The MSW test handlers must run against the production code paths — no vi.mock('api/client') or equivalent allowed.
  • setToken(null) must precede setUser(null) on every failure path so that an in-flight component re-render does not see a partial state where user: null but accessToken: <stale-bearer>.

Risks & Mitigation

Risk 1: POST refresh response shape varies across environments

  • Risk: The 401-retry path assumes { token }; production may also return { token, user } (unverified). If so, the chained /users/me GET is wasted work.
  • Mitigation: Inspect the live response shape during implementation; if user is present, skip the chained GET. The contract is single-source in the backend Admin API spec — verify there first, not by guessing.

Risk 2: Tests assume GET-bootstrap fail-soft behaviour

  • Risk: Some current tests may assert the broken behaviour as the expected outcome ("when bootstrap fails the user lands on /login"). Re-pointing those tests at the POST path may surface assertion bugs that have been masking real regressions.
  • Mitigation: Read each test's assertions before swapping the handler; if the test was asserting the broken behaviour as a feature, replace the assertion with the AC-3 behaviour from this spec. Do not preserve a test that documents the bug.

Risk 3: Bootstrap latency regression

  • Risk: Two sequential GETs on every page load is more network than one. For very slow refresh cookies (e.g., over slow links), the user perceives a longer spinner.
  • Mitigation: NFR Performance budget (200 ms p95 on dev compose) is the gate. If a real-world deployment exceeds it, the next iteration may embed user in the POST refresh response (Excluded scope above).

Risk 4: Concurrent <StrictMode> double-mount fires bootstrap twice

  • Risk: React 18+ StrictMode dev mode mounts effects twice; two concurrent POST refresh requests could race the cookie rotation (the backend rotates on every refresh).
  • Mitigation: Add a module-scoped in-flight guard (a Promise<void> | null ref) so the second mount awaits the first. The guard is small enough to live inside AuthContext.tsx without a new helper.

References

  • src/auth/AuthContext.tsx:23-31 — broken bootstrap path being replaced.
  • src/api/client.ts:88-98 — working POST refresh path that informs the new bootstrap.
  • _docs/02_document/components/02_auth/description.md — component spec; F2 (two refresh paths) is the documented finding this task closes.
  • _docs/02_document/architecture_compliance_baseline.md — downstream item B3 (will move to RESOLVED).
  • _docs/02_document/architecture.md Architecture Vision P3 — "bearer in memory, refresh in HttpOnly cookie".