Repository navigation
Refresh must be bound to an account — one cookie can hand a tab another farm's session #547
Copy link
Copy link
Closed
Labels
area:apiAPI/endpoint layerAPI/endpoint layerarea:frontendReact/Vite web clientReact/Vite web clientepic-1.6Phase 1.6 — Multi-farm tenancyPhase 1.6 — Multi-farm tenancypriority:criticalBlocks the vertical sliceBlocks the vertical slicesliceThin vertical work itemThin vertical work item
Milestone
Description
Activity
- addedsliceThin vertical work itemThin vertical work itempriority:criticalBlocks the vertical sliceBlocks the vertical slicearea:apiAPI/endpoint layerAPI/endpoint layerarea:frontendReact/Vite web clientReact/Vite web clientepic-1.6Phase 1.6 — Multi-farm tenancyPhase 1.6 — Multi-farm tenancy
on Aug 16, 2026 Absorbed into #564 and shipped with #532, merged as
68adb621.Closed rather than implemented separately because the review loop on #532 kept surfacing this defect and fixing it partially. The scope here is delivered:
- the tab sends the account it expects (
X-Cluckwork-Account), and the server compares it against the stored token'sAccountIdbefore rotating anything; - a mismatch returns a distinct
Auth.SessionChanged, rotates nothing, and leaves the other farm's cookie alone — the constraint this issue was most explicit about; - the client no longer blindly retries the original request with whatever token a refresh returned.
Delivered differently in two ways, both worth recording:
BroadcastChannelwas built and then deleted. It existed to reconcile tabs contending for one origin-scoped cookie. The cookie is now named per farm (cluckwork_rt_<accountId>), so there is nothing to reconcile — and the receiver had turned out to accept unauthenticated same-origin messages that could clear any tab's session. Removing it closed that surface outright.- The per-farm cookie is the stronger form of this issue's guarantee. This issue asked for a server-side check to catch the wrong farm. Naming the cookie per farm means the caller must name the farm it wants and can only read that one, so cross-farm adoption stops being something guarded against. The server check remains as defence-in-depth.
Not delivered: the two-tab browser test this issue calls load-bearing. Deferred to #536, which owns the two-farm end-to-end matrix. The behaviour is covered by integration and SPA tests plus 33 Playwright specs, but not by two real tabs in one browser — stating that plainly rather than implying otherwise.
- the tab sends the account it expects (
Metadata
Metadata
Assignees
Labels
area:apiAPI/endpoint layerAPI/endpoint layerarea:frontendReact/Vite web clientReact/Vite web clientepic-1.6Phase 1.6 — Multi-farm tenancyPhase 1.6 — Multi-farm tenancypriority:criticalBlocks the vertical sliceBlocks the vertical slicesliceThin vertical work itemThin vertical work item
Slice T4 of epic #530 (Phase 1.6 — Multi-farm tenancy). Depends on #532. Lands before the SPA farm-code picker (#535).
This is the review pass's top blocker. Found by codex, 2026-08-16, and verified against the code.
Problem
The refresh token is one origin-scoped cookie —
cluckwork_rt, path/api/v1/auth(AuthCookies.cs:12). Access tokens are per tab, held in memory. Once two farms exist, one browser can hold sessions for both, and the cookie can only describe one of them.Sequence:
RT-A; tab A holdsAT-A.RT-B. Tab A still holdsAT-Aand has been told nothing.AT-Aexpires while the operator is submitting a Farm A form./auth/refresh, which sendsRT-B— the only cookie there is.AT-B. The client accepts it and retries the original request (client.ts:392).The existing cross-tab machinery does not stop this. The Web Lock serialises cookie rotation; it does not bind a tab to an account.
sessionGenerationis per-tab, so Farm B's login never supersedes tab A's generation.This is #438 ("cross-tab login/refresh race can still restore the wrong session") escalated from wrong session to wrong tenant, which turns a confusing bug into a cross-tenant write.
Scope
account_idor session-family identifier.RefreshToken.AccountIdbefore rotating anything.Auth.SessionChangedresponse, without rotating and without clearing the other farm's cookie. The tab that asked is the one that must recover; it must not damage the session that legitimately owns the cookie.BroadcastChannelpushes other tabs through a clean session bootstrap after a login or an account change, rather than leaving them holding a token for a farm the browser is no longer signed into.Relationship to #438
#438 stays its own issue — it describes the single-farm case and is not closed by this. This slice is the multi-farm half, and the account binding here is the stronger guarantee: it is checked server-side against durable state rather than reconciled between tabs.
Tests
The load-bearing one is a real two-tab browser test, in
tools/simulation/ui/: tab A logs into farm A, tab B logs into farm B, tab A's access token expires, tab A performs a write — and the write must not execute as farm B.Plus, server-side:
Auth.SessionChanged, rotates nothing, and leaves the stored token usable by its rightful owner.Verify