17 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/auth/requirements/fix-multi-instance-cookie-isolation.md | 2026-08-17 | 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'sonUnauthorizedhandler 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 _<port>), 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)andScopedName(r, base); honorsX-Forwarded-Host(first value) beforeHost; 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 methodCookieNameForRequest(r). The config default forauth.cookieNamebecomes empty (the service keeps thekandev_sessionbase 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-scopedkandev_port_proxycapability); itssvc.CookieName()uses are test request headers ingateway/websocket/port_proxy_auth_test.goand must be updated to the scoped name for their request Host. - Workspace cookie scoping (
backendapp/boot_state_routes.go):readActiveWorkspaceCookiereads the scoped names first, then the legacy unprefixed names. All downstreamfirstValidIDvalidation 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: addscopedCookieName(name, port?)/readScopedCookie(name, port?)— the port defaults to the API origin port fromgetBackendConfig().apiBaseUrl(notwindow.location.port, which mismatches in split-origin dev), and the explicit parameter makes the helpers pure and unit-testable without stubbing; legacy fallback.readActiveWorkspaceCookieand the office read path use it.apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts:writeWorkspaceCookiewrites 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(notlib/get-active-workspace.ts), and the separate legacy read inapps/web/src/office-routes.tsxread 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 fullHost(host:port) or rewrite only the port and forward a correctX-Forwarded-Host(the CORS/WS origin gate comparesOrigintoHostand ignores XFH, so rewriting a non-loopback hostname 403s before auth). A customauth.cookieNamedoes 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 literalkandev_sessioncookie 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 Host127.0.0.1:8443setskandev_session_8443; a request carrying only the unprefixedkandev_sessioncookie is unauthenticated; a request carrying the scoped cookie authenticates. Pre-fix: onlykandev_sessionexists, 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 customauth.cookieNamestays verbatim (no suffix) on the same ported host. Gatewayport_proxy_auth_test.gorequest 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 setskandev_session_<port>; 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.cookieNamegets an explicitBindEnv("auth.cookieName", "KANDEV_AUTH_COOKIE_NAME")(the automatic camelCase mapping would otherwise expose the undocumentedKANDEV_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:9443yields_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_sessionemitted; 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 usereadScopedCookieStoreValue; the sync office effect (office-routes.tsx) uses the document-cookiereadScopedCookie(both sharescopedCookieName). 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 hardcodeskandev_session) is not the only coverage. - Backend, two-instance isolation (integration,
authpackage orbackendapp): two routers with different request Hosts and independent stores; logging into A authenticates onlykandev_session_<portA>; B rejects A's cookie and authenticates only its own; workspace cookie reads inboot_state_routes.goresolve 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) comparesOrigintor.Hosthostnames and ignores ports andX-Forwarded-Host. Three cases: preservedHost(public.example:8443) reaches login — reachability is invariant (current code authenticates under a preserved Host), so the failing-before assertion is the pairedSet-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_8443from 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):readActiveWorkspaceCookiewith Host127.0.0.1:8443preferskandev-active-workspace_8443over 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);readOfficeWorkspaceCookiereads the office family only; backend/frontend parity tests. Downstream regression (scenario 3): throughbootPayload/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, withgetActiveWorkspaceIdpassing 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 fromb.userSettings), regression-tested throughofficeWorkspaces/addOfficeRouteStatewith distinct valid O1/O2, office-vs-settings, and office-absent cases; a downstream generic-route regression (every genericRouteNamethat 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 thekandev-active-workspace_8443read/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 fromwindow.location.port(:37429). Legacy-absence: the sidebar write tests (app-sidebar-workspace-navigation.test.tsandapp-sidebar-workspace-picker.test.tsx— the picker drives the samerememberWorkspaceSelectionpath and today asserts unprefixed writes) assertrememberWorkspaceSelectiondoes NOT write the unprefixed names (legacy read fallback stays; no new legacy writes). - E2E (
apps/web/e2e/tests/...): auth project asserts the login response setskandev_session_<port>; the workspace-switch spec assertskandev-active-workspace_<port>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:
task-01 (Go helper) and task-04 (frontend) touch disjoint trees and
contracts; parallel-safe.
Wave 2:
Both consume httpcookie from task-01; disjoint files (auth vs
boot-state), parallel-safe after Wave 1.
Wave 3:
Task 05 depends on the shipped behavior of tasks 02 and 04. Task 06 (docs only) is parallel-safe with 05.
Validation commands
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
Origintor.Hosthostnames and ignores ports and XFH, so the deployment rule is "the proxy MUST preserve the browser Origin hostname". Compliant: preservedHost(full host:port), or a same-hostname port rewrite with aX-Forwarded-Hostcarrying the browser host:port (the resolver takes the public port from XFH). Only a non-loopback hostname rewrite 403s before auth (loopback-alias rewrites likelocalhost→127.0.0.1pass the gate by existingAllowedOrigindesign and stay unchanged); neither XFH norauth.cookieNamecan pass the gate, andAllowedOriginmust 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_sessioncookie 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-scopedkandev_port_proxycapability cookie and needs no change. - Changing the
auth.cookieNameconfig 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.cookieNamevalues disable automatic port isolation; the documented exception and the negative test keep this from being rediscovered as a "fix".