--- spec: docs/specs/auth/requirements/fix-multi-instance-cookie-isolation.md created: 2026-08-17 status: pending --- # Implementation Plan: Isolate Kandev Cookies Between Instances on One Host ## Root cause (confirmed) Cookies are scoped by scheme + host only; the browser ignores port when deciding which cookies to send. Multiple kandev instances on one IP (different ports) therefore share one cookie jar, and kandev stores per-instance identity in host-scoped cookies: - `kandev_session` (auth token): last login wins; every other auth-enabled instance rejects the foreign token (per-instance session store) and 401s, and the SPA's `onUnauthorized` handler clears auth state and redirects to `/login`. Reproduced live: two auth-enabled instances on :48429/:48529 each return `{"authenticated":false}` for the other's token, and logging into one yanks the other's open UI to the Sign in page. Maintainer-documented in #2689. - `kandev-active-workspace` / `office-active-workspace`: foreign workspace ids are read at boot by every other instance. Both sides validate and fall back today, so this path is graceful, but it churns cross-instance workspace/mode selection and is the same unsegregated-storage class. Fix: port-scope the cookie **names** (suffix `_`), derived from the API origin port on both sides — request Host / `X-Forwarded-Host` (backend) and `getBackendConfig().apiBaseUrl` (frontend; `window.location.port` only equals it in same-origin deployments). Legacy unprefixed workspace cookies stay readable (validated fallback); the session cookie has no fallback (one re-login, bug stays dead during migration). ## Changes ### Backend - **New package `apps/backend/internal/common/httpcookie`**: `PortSuffix(r)` and `ScopedName(r, base)`; honors `X-Forwarded-Host` (first value) before `Host`; no suffix when the host has no port; IPv6 bracketed hosts handled. Unit tests. Shared by the auth service and the boot-state builder. - **Session cookie scoping** (`apps/backend/internal/auth`): login/setup/ logout handlers (`httpapi/cookie.go`, `handlers.go`), invite accept (`handlers_admin.go` — there is no separate admin-login handler), the auth middleware (`httpmw/middleware.go`), and the plugin SSO bridge (`backendapp/auth.go`) resolve the cookie name from the request via a new request-aware service method `CookieNameForRequest(r)`. The config default for `auth.cookieName` becomes **empty** (the service keeps the `kandev_session` base fallback, so the effective name is unchanged for default deployments); a non-empty configured value is returned verbatim, an empty one is port-suffixed. The gateway production code does **not** read the session cookie (it only handles the path-scoped `kandev_port_proxy` capability); its `svc.CookieName()` uses are test request headers in `gateway/websocket/port_proxy_auth_test.go` and must be updated to the scoped name for their request Host. - **Workspace cookie scoping** (`backendapp/boot_state_routes.go`): `readActiveWorkspaceCookie` reads the scoped names first, then the legacy unprefixed names. All downstream `firstValidID` validation is unchanged. The suffix comes from the request Host (XFH precedence) — the same port the SPA derives from the API base URL. ### Frontend - `apps/web/lib/routing/route-bootstrap.ts`: add `scopedCookieName(name, port?)` / `readScopedCookie(name, port?)` — the port defaults to the API origin port from `getBackendConfig().apiBaseUrl` (not `window.location.port`, which mismatches in split-origin dev), and the explicit parameter makes the helpers pure and unit-testable without stubbing; legacy fallback. `readActiveWorkspaceCookie` and the office read path use it. - `apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts`: `writeWorkspaceCookie` writes the scoped name (same API-origin port). - Server-component reads that go through `readCookies()`/`cookieStore`: `apps/web/app/page.tsx`, `apps/web/app/office/layout.tsx`, `apps/web/app/settings/layout.tsx`, `apps/web/app/office/lib/get-active-workspace.ts` (not `lib/get-active-workspace.ts`), and the separate legacy read in `apps/web/src/office-routes.tsx` read the scoped name first, legacy fallback. ### Docs - **Deployment note** (`docs/public/authentication.md`): multiple auth-enabled instances on one host are isolated per port; the proxy MUST preserve the browser **hostname** — either preserve the full `Host` (host:port) or rewrite only the port and forward a correct `X-Forwarded-Host` (the CORS/WS origin gate compares `Origin` to `Host` and ignores XFH, so rewriting a non-loopback hostname 403s before auth). A custom `auth.cookieName` does not solve or alter the origin gate (it compares hostnames, independent of cookie names) — such deployments must still use a compliant Host/Origin setup, and the custom name disables automatic port isolation (must be unique per cookie host). **Session-cookie migration**: old auth-enabled builds conflict with other old builds; an upgraded instance ignores the legacy session token and requires one re-login; workspace selections keep their validated legacy read fallback. - **Stale references** (ADR 0050, ADR 2026-07-24-opt-in-authentication, `apps/backend/AGENTS.md`, `apps/backend/internal/auth/service_sso.go`, `apps/backend/internal/plugins/auth_login.go`, `apps/backend/internal/auth/store/models.go`): wording that pins the literal `kandev_session` cookie name becomes "the session cookie (name derived from the request host)". - Already amended with this package: `docs/specs/auth/requirements/auth.md`, `docs/decisions/0023-active-workspace-cookie.md`, `docs/public/plugins-authoring.md`. ## Tests Regression tests that must fail before the change: - **Backend, session cookie** (`auth/httpapi/handlers_test.go` + `httpmw/middleware_test.go`): login via a request with Host `127.0.0.1:8443` sets `kandev_session_8443`; a request carrying only the unprefixed `kandev_session` cookie is unauthenticated; a request carrying the scoped cookie authenticates. Pre-fix: only `kandev_session` exists, so the scoped assertions fail. - **Backend, session set/clear coverage**: setup, login, logout, invite accept (there is no separate admin-login handler — the first admin's setup/login flows through `handlers.go`), and plugin SSO all set/clear the scoped name; a configured custom `auth.cookieName` stays verbatim (no suffix) on the same ported host. Gateway `port_proxy_auth_test.go` request headers use the scoped name for their Host. - **Backend, config-default regression**: with the **loaded** default config (empty `auth.cookieName`), login on a ported Host still sets `kandev_session_`; a seeded non-empty default must not suppress suffixing. Custom-name tests must exercise both an empty config and an explicitly configured name. - **Backend, env contract**: `auth.cookieName` gets an explicit `BindEnv("auth.cookieName", "KANDEV_AUTH_COOKIE_NAME")` (the automatic camelCase mapping would otherwise expose the undocumented `KANDEV_AUTH_COOKIENAME`). Precedence tests: loaded default (empty), config.yaml custom value, env custom value, env-over-file. - **Backend, conflicting Host/XFH**: resolver regression — Host `public.example:8443` + `X-Forwarded-Host: public.example:9443` yields `_9443` (XFH wins; pinned as the proxy-misconfiguration contract). - **Backend, custom-name negative**: two routers on one cookie host with the **same** configured custom name still conflict (documented exception — custom names disable automatic port isolation); the test pins the documented behavior. - **Backend, mixed-version coverage**: tier 1 failing-before — ported Host with a valid token under the unprefixed name 401s (old→new rejection); re-upgrade — login writes the scoped name and a scoped request authenticates. Tier 1b invariant back-compat guards — rollback (no scoped cookie, scoped request 401s, 401 both sides) and no-port unprefixed authentication (identical both sides) protect against post-change regressions. Tier 2 — old-old conflict and the old side's unprefixed success are old-build wire behaviors, documented in the spec and explicitly NOT faked by a synthetic legacy adapter. - **Backend, legacy-absence**: every session set/clear path (setup, login, logout, invite accept, plugin SSO) asserts the exact Set-Cookie name set contains only the scoped name — no unprefixed `kandev_session` emitted; positive-only assertions would miss an implementation emitting both names. - **Frontend, boot-reader coverage**: the four CookieStore readers (`app/page.tsx`, `app/office/layout.tsx`, `app/settings/layout.tsx`, `app/office/lib/get-active-workspace.ts`) must use `readScopedCookieStoreValue`; the sync office effect (`office-routes.tsx`) uses the document-cookie `readScopedCookie` (both share `scopedCookieName`). A structural guard test asserts both the absence of direct lookups — literal names **or** the exported constants — (negative) and the presence of each file's mandated helper reference (positive), so a boot path cannot silently stay unscoped (task 04). - **Backend, plugin SSO bridge**: the real bridge (`backendapp/auth.go`) sets the scoped name for a ported Host; the existing fake-bridge test (`plugins/auth_login_test.go`, which hardcodes `kandev_session`) is not the only coverage. - **Backend, two-instance isolation** (integration, `auth` package or `backendapp`): two routers with different request Hosts and independent stores; logging into A authenticates only `kandev_session_`; B rejects A's cookie and authenticates only its own; workspace cookie reads in `boot_state_routes.go` resolve per-Host with both scoped cookies in the jar (non-first fixtures, failing-before). - **Backend, proxy contract**: full-router tests — the CORS/WS origin gate (`httpmw.AllowedOrigin`) compares `Origin` to `r.Host` hostnames and ignores ports and `X-Forwarded-Host`. Three cases: preserved `Host` (`public.example:8443`) reaches login — **reachability is invariant** (current code authenticates under a preserved Host), so the failing-before assertion is the paired `Set-Cookie: kandev_session_8443`; **same-hostname port rewrite** (`Host: public.example:38429` + `XFH: public.example:8443`) passes the gate — the failing-before assertion is the cookie resolver deriving `_8443` from XFH; **non-loopback hostname rewrite** (`Host: internal:38429`, a different non-loopback hostname) is rejected 403 before auth — invariant branch. The 403 fixture uses a different non-loopback hostname, never a different port; loopback-alias rewrites pass the existing gate and are unchanged. - **Backend, workspace cookie** (`backendapp/boot_state_workspace_test.go`): `readActiveWorkspaceCookie` with Host `127.0.0.1:8443` prefers `kandev-active-workspace_8443` over the unprefixed name, and falls back to the legacy name when no scoped one exists. Pre-fix: the scoped name is never read. **Family separation**: the general reader ignores the office-family cookies (pre-change the backend generic reader consults the legacy office name — fails pre-change); `readOfficeWorkspaceCookie` reads the office family only; backend/frontend parity tests. **Downstream regression** (scenario 3): through `bootPayload`/resolver — invariant/back-compat (valid unprefixed legacy id selected when no scoped cookie exists; foreign/invalid legacy id falls through to the instance-owned fallback) and failing-before (scoped-over-legacy precedence when both exist). **Office precedence**: canonical frontend resolver (route → general-if-office-valid → office → settings → first, with `getActiveWorkspaceId` passing its URL override; wrappers pass null only when none exists; convergence is client-eventual via the OfficeRoutes bootstrap effect; route is a client/page-only candidate — backend boot payload and server layout are cookie-first by design). The backend Go resolver maps the general/office/settings vectors (settings wired from `b.userSettings`), regression-tested through `officeWorkspaces`/`addOfficeRouteState` with distinct valid O1/O2, office-vs-settings, and office-absent cases; a downstream generic-route regression (every generic `RouteName` that resolves an active workspace id — Unknown, Home, Tasks, Settings, integrations, Stats — plus the quick-chat fallback, with only office cookies → settings/first; TaskDetail asserted separately via routeData/task-workspace, not the settings/first matrix) pins the family separation (tasks 03/04). - **Frontend, cookie helpers** (`apps/web/lib/routing/route-bootstrap.test.ts`, `apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts`): `scopedCookieName(name, port)` is pure — tests pass `"8443"` explicitly and assert the `kandev-active-workspace_8443` read/write plus legacy fallback (no jsdom Location stubbing required). A split-origin regression asserts the default port comes from the API base URL (`:38429`) and not from `window.location.port` (`:37429`). **Legacy-absence**: the sidebar write tests (`app-sidebar-workspace-navigation.test.ts` and `app-sidebar-workspace-picker.test.tsx` — the picker drives the same `rememberWorkspaceSelection` path and today asserts unprefixed writes) assert `rememberWorkspaceSelection` does NOT write the unprefixed names (legacy read fallback stays; no new legacy writes). - **E2E** (`apps/web/e2e/tests/...`): auth project asserts the login response sets `kandev_session_`; the workspace-switch spec asserts `kandev-active-workspace_` is written on selection. The Playwright fixture runs one backend per worker (no second backend), so the two-instance behavioral regression is proven by the Go integration test above; a two-backend Playwright fixture is explicitly out of scope. ## Implementation Waves And Parallel Candidates Execution stays sequential in the primary conversation. Waves are a human decision aid; only disjoint tasks are labelled parallel-safe. Wave 1: - [x] [task-01-shared-port-cookie-helper](task-01-shared-port-cookie-helper.md) - [x] [task-04-workspace-cookie-port-scope-frontend](task-04-workspace-cookie-port-scope-frontend.md) `task-01` (Go helper) and `task-04` (frontend) touch disjoint trees and contracts; parallel-safe. Wave 2: - [x] [task-02-session-cookie-port-scope-backend](task-02-session-cookie-port-scope-backend.md) - [x] [task-03-workspace-cookie-port-scope-backend](task-03-workspace-cookie-port-scope-backend.md) Both consume `httpcookie` from `task-01`; disjoint files (auth vs boot-state), parallel-safe after Wave 1. Wave 3: - [x] [task-05-e2e-cookie-scope-assertions](task-05-e2e-cookie-scope-assertions.md) - [x] [task-06-docs-and-stale-references](task-06-docs-and-stale-references.md) Task 05 depends on the shipped behavior of tasks 02 and 04. Task 06 (docs only) is parallel-safe with 05. ## Validation commands ```bash make fmt cd apps/backend && go test ./internal/common/config/... ./internal/common/httpmw/... ./internal/auth/... ./internal/backendapp/... && make lint cd apps && pnpm --filter @kandev/web test -- lib/routing/route-bootstrap.test.ts components/app-sidebar/app-sidebar-workspace-navigation.test.ts cd apps/web && pnpm run typecheck && pnpm run lint cd apps/web && pnpm e2e:raw --project=auth # or the auth project's exact command ``` ## Invariant-negative pairing The non-loopback-hostname-rewrite 403 and same-custom-name-conflict tests are invariant branches that pass before the fix too; pair each with a change-specific assertion (the XFH suffix derivation, the scoped Set-Cookie name) so the suite still fails pre-change where a scenario demands it. ## Risks - The proxy contract is the trap: the CORS/WS origin gate compares `Origin` to `r.Host` hostnames and ignores ports and XFH, so the deployment rule is "the proxy MUST preserve the browser Origin **hostname**". Compliant: preserved `Host` (full host:port), or a same-hostname port rewrite with a `X-Forwarded-Host` carrying the browser host:port (the resolver takes the public port from XFH). Only a **non-loopback hostname** rewrite 403s before auth (loopback-alias rewrites like `localhost` → `127.0.0.1` pass the gate by existing `AllowedOrigin` design and stay unchanged); neither XFH nor `auth.cookieName` can pass the gate, and `AllowedOrigin` must not be weakened to trust XFH (CSRF boundary). When Host and XFH conflict on port, XFH wins for cookie naming (pinned by a resolver regression test). - One-time re-login after upgrade for every auth-enabled instance. Accepted tradeoff; the alternative (legacy fallback) resurrects the cross-instance bleed during mixed-version periods. - The `kandev_session` cookie name is referenced by gateway **tests** (`port_proxy_auth_test.go`); every read/set site must use the scoped name or WS/port-proxy auth tests break. Production gateway code only handles the path-scoped `kandev_port_proxy` capability cookie and needs no change. - Changing the `auth.cookieName` config default to empty must not change the effective name for default deployments (the service keeps the base fallback); any fixture or docs that assert the config value itself needs a review (task 06 covers docs). - Split-origin dev is the trap: any frontend port derivation not based on the API origin silently degrades to the legacy fallback. The split-origin regression test is the guard. - Custom `auth.cookieName` values disable automatic port isolation; the documented exception and the negative test keep this from being rediscovered as a "fix".