Skip to content

Refresh must be bound to an account — one cookie can hand a tab another farm's session #547

Description

@mforce

Coding brief — read first. Start with epic #530 (the canonical decision record). This body is complete and current as of 2026-08-16 — no comments you must cross-reference. Repo conventions in AGENTS.md apply automatically. Definition of done = the Verify section at the end.

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:

  1. Tab A logs into Farm A. The cookie holds RT-A; tab A holds AT-A.
  2. Tab B logs into Farm B. The single cookie is overwritten with RT-B. Tab A still holds AT-A and has been told nothing.
  3. AT-A expires while the operator is submitting a Farm A form.
  4. Tab A transparently calls /auth/refresh, which sends RT-B — the only cookie there is.
  5. The server rotates it and returns AT-B. The client accepts it and retries the original request (client.ts:392).
  6. An account-implicit write — "save farm settings", "create expense" — executes Farm A's form values against Farm B.

The existing cross-tab machinery does not stop this. The Web Lock serialises cookie rotation; it does not bind a tab to an account. sessionGeneration is 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

  • A refresh initiated by an authenticated tab carries the account it expects — an account_id or session-family identifier.
  • The server compares it with the stored RefreshToken.AccountId before rotating anything.
  • A mismatch returns a distinct Auth.SessionChanged response, 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.
  • BroadcastChannel pushes 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.
  • The client must not blindly retry the original request with whatever token a refresh returned.

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:

  • Refresh with a mismatched expected account returns Auth.SessionChanged, rotates nothing, and leaves the stored token usable by its rightful owner.
  • Refresh with a matching account rotates normally.
  • A refresh with no expected account (the load-time bootstrap, before any tab knows its farm) still behaves — define and test that path explicitly rather than leaving it to fall through.

Verify

dotnet test Cluckwork.sln
cd tools/simulation/ui && npm test

Activity

  1. added
    sliceThin vertical work item
    area:apiAPI/endpoint layer
    epic-1.6Phase 1.6 — Multi-farm tenancy
    on Aug 16, 2026
  2. mforce commented on Aug 20, 2026

    @mforce
    OwnerAuthor

    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's AccountId before 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:

    1. BroadcastChannel was 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.
    2. 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.

    Follow-ups from the final review round: #569, #570.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerarea:frontendReact/Vite web clientepic-1.6Phase 1.6 — Multi-farm tenancypriority:criticalBlocks the vertical slicesliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions