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>
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:24issuesGET /api/admin/auth/refreshWITHOUTcredentials: 'include'. TheSecure HttpOnlyrefresh cookie set byPOST /api/admin/auth/loginis 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";ProtectedRouteredirects 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:88issuesPOST /api/admin/auth/refreshWITHcredentials: 'include'. This path runs only when a subsequent authenticated request hits a 401; it does NOT run on bootstrap because line 73'sif (res.status === 401 && accessToken)short-circuits whenaccessTokenis 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
/loginexactly once — no flash of the protected shell, no infinite redirect loop. - The
GET /api/admin/auth/refreshrequest disappears from network traces in the bootstrap window. POST /api/admin/auth/refresh(with credentials) followed byGET /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 theuseEffectmount handler to:await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })— direct call (notapi.post(), becauseapi.postdoes not carrycredentials: 'include'and adding it there would change every callsite's CORS posture).- On
!res.ok→ setuser: null+loading: false+ return. - On success →
setToken(data.token), thenapi.get<AuthUser>('/api/admin/users/me')to fetch the user shape,setUser(authUser),setLoading(false). - On the
/users/mefailure 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 syncticket):src/auth/AuthContext.test.tsx— update bootstrap tests to assertPOST /api/admin/auth/refreshthenGET /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.mdrecording 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.postto defaultcredentials: '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/meGET 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 carrycredentials: 'include'. Directfetch(..., { method: 'POST', credentials: 'include' })is intentional; the comment inapi/client.ts:88documents 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 precedesetUser(null)on every failure path so that an in-flight component re-render does not see a partial state whereuser: nullbutaccessToken: <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/meGET is wasted work. - Mitigation: Inspect the live response shape during implementation; if
useris 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> | nullref) so the second mount awaits the first. The guard is small enough to live insideAuthContext.tsxwithout 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.mdArchitecture Vision P3 — "bearer in memory, refresh in HttpOnly cookie".