# 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('/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: '' }` When the response resolves Then `setToken('')` is called, then `GET /api/admin/users/me` is requested with `Authorization: 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: `. ## 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 `` 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 | 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".