Appearance
Cookie sessions — plexsphere_session contract
This document is the authoritative bounded-context reference for the plexsphere_session cookie the OIDC callback issues and the per- request authentication middleware honours on every /v1/* route.
For the surrounding identity surface — IdPBindings, claim mapping, ACR/AMR step-up, API tokens — see idp.md. For the dashboard side of the post-callback flow — AuthBootstrap, the SignInSucceeded reducer action, and the /v1/auth/whoami probe — see the SPA's web/src/main.tsx and web/src/features/auth/.
Ubiquitous language
| Term | Meaning |
|---|---|
| plexsphere_session | The HTTP cookie the OIDC callback handler sets after a successful authorization-code + PKCE exchange. The cookie value is an opaque, URL-safe random base64 token of 32 random bytes — never a user identifier, never a JWT. |
| TokenSession | The server-side record keyed by the cookie value. Carries the resolved users.User.ID, the Domain scope, the IdP-supplied subject and email, and the absolute expiry. Lives in internal/transport/http/v1/auth/sessions.go. |
| TokenSessionStore | The narrow port the OIDC callback's Put writer and the AuthnMiddleware's Get reader share. The single shared instance constraint is the load-bearing wiring rule (see "Wiring receipt" below). |
| SessionResolver | The middleware-side port (internal/identity/authn/middleware.SessionResolver) the production composition root adapts on top of TokenSessionStore. The middleware never imports internal/transport/http/v1/auth directly. |
| Principal | The resolved caller the middleware attaches to the request context. A successful cookie resolution promotes the request to a KindUser Principal whose ID() and DomainID() mirror the stored TokenSession. |
Cookie shape
The OIDC callback handler at internal/transport/http/v1/auth/callback.go emits the cookie verbatim:
| Attribute | Value | Why |
|---|---|---|
| Name | plexsphere_session | Stable, exported as middleware.DefaultSessionCookie so tests and wiring code reference one literal. |
| Value | URL-safe base64 of 32 random bytes (crypto/rand) | Opaque, server-side-only — the cookie value is the lookup key into TokenSessionStore, never a credential the client interprets. |
| Path | /v1/ | Browsers never send the cookie alongside /static, /docs, or future non-API routes. The callback handler itself is at /v1/auth/callback so the cookie is live the moment it is set. |
| HttpOnly | true | The SPA cannot read the cookie from JavaScript — XSS cannot exfiltrate the session token. |
| Secure | derived from r.TLS != nil OR (TrustProxyHeaders AND X-Forwarded-Proto: https) | The forwarded-header path is opt-in (PLEXSPHERE_AUTH_TRUST_PROXY_HEADERS=true) because the header is spoofable by any direct-reach client. |
| SameSite | Strict | The cookie is only ever read by the issuing origin's own endpoints; Strict matches RFC 6265bis guidance. |
| Expires | now + IdleTimeout at issuance, rolled forward on every successful authn resolve | Operator-tunable via PLEXSPHERE_SESSION_IDLE_TIMEOUT and PLEXSPHERE_SESSION_MAX_LIFETIME. See "Session lifetime" below for the full rolling-idle / absolute-cap contract. |
The four cookie-path scenarios
internal/identity/authn/middleware.New collapses every cookie shape the surface might receive into one of four behaviour rows. The unit suite under internal/identity/authn/middleware/middleware_test.go pins each row; the lighter integration suite at tests/integration/auth_cookie_session_test.go exercises three of them end-to-end through a real chi router.
| Cookie shape | TokenVerifier wired? | SessionResolver wired? | Outcome |
|---|---|---|---|
Opaque random (abcdef…) | n/a | yes | SessionResolver.Get → KindUser Principal with the stored UserID/DomainID and AuthMethod=session_cookie. |
Opaque random (abcdef…) | n/a | no | 401 "session resolver not configured" — fail-closed posture so the wiring gap is visible to operators. |
psk_… plaintext | yes | n/a | TokenVerifier.Verify + TokenOwnerResolver → a KindUser or KindService Principal scoped to the owning aggregate's parent Domain, stamped with AuthMethod=bearer_api_token. The dual-credential shape lets an operator stash an API token in the cookie for browser-based scripting; the marker is what keeps the "API tokens cannot mint API tokens" guard reachable on the api-tokens handler after the principal folds onto the owner kind. |
psk_… plaintext | no | n/a | 401 "token verifier not configured". |
The bearer path consults a TokenOwnerResolver after TokenVerifier succeeds: the resolver follows the verified APIToken back to its owning User (device-flow path) or ServiceIdentity (admin-issued cross-owner path) and the middleware emits a Principal scoped to the owner's Domain. Without the resolver the middleware would emit a KindAPIToken Principal carrying a zero DomainID, and every SpiceDB CheckPermission against the bearer-authenticated caller would fail with object definition apitoken not found — the schema declares user and serviceaccount subjects only. The marker on the resulting Principal (AuthMethod=bearer_api_token) preserves the "caller authenticated via a bearer" signal that the /v1/auth/tokens issuance guard keys on; without the marker the guard would silently re-enable the token-chaining escalation path the original Kind==KindAPIToken discriminator existed to block.
A cookie value that hits the resolver but the store does not know (unknown / expired / never-issued) surfaces as 401 "session unknown or expired". The 401 body never echoes the cookie value — a client that probes the surface with random tokens cannot enumerate live sessions by error-text fingerprinting.
Session lifetime
Two windows govern how long a plexsphere_session cookie stays valid. The two are independent operator knobs and combine via a min(...) rule so an operator can tune them without one silently overriding the other.
| Window | Default | Env var | Meaning |
|---|---|---|---|
| Idle timeout | 30 min | PLEXSPHERE_SESSION_IDLE_TIMEOUT | The rolling deadline. Every successful resolve in the AuthnMiddleware extends the deadline by one idle window — a tab the user keeps interacting with stays signed in. |
| Absolute lifetime | 12 h | PLEXSPHERE_SESSION_MAX_LIFETIME | The hard upper bound regardless of activity. A long-running tab cannot keep itself alive past this horizon — once now >= IssuedAt + MaxLifetime, the next request is rejected. |
The rolling-refresh rule the middleware applies on every successful cookie-session resolve:
text
newExpires = min(now + IdleTimeout, IssuedAt + MaxLifetime)The middleware then calls TokenSessionStore.Touch so the server- side record carries the same deadline as the cookie's Expires, and re-emits the Set-Cookie header so the browser updates its copy in lockstep. Issuance and refresh both go through the middleware.BuildSessionCookie helper so the two write sites cannot drift on Path, HttpOnly, Secure, or SameSite — a drift would land two cookies under different scopes and produce a "sometimes signed-in, sometimes not" surface.
Three behavioural legs follow from the rule:
- Active tab inside the idle window — every authenticated request inside the idle gap moves the deadline forward by one idle window. The session never expires while the user keeps interacting (until the absolute cap).
- Idle tab past the idle window — the cookie's
Expiresis in the past, the lazy sweeper inside the in-memoryTokenSessionStoreevicts the entry on the nextGet, the resolver legitimately misses, and the middleware emits401 "session unknown or expired". The SPA falls back to the public sign-in page on the next interaction. - Active tab past the absolute cap — the rolling refresh pins
ExpiresatIssuedAt + MaxLifetime, so oncenow >= capthe lazy sweeper evicts the entry and the surface collapses to leg 2 (401 "session unknown or expired"). The browser drops the cookie naturally because its ownExpiresalready equals the cap. The middleware additionally carries a defensive cap-reached branch that emits401 "session lifetime exceeded"+ clear-cookie when an alternate store reports a live session whoseIssuedAtis past the cap; the in-memory store's lazy sweeper makes that branch unreachable in single- node deployments, but it ships in the production middleware so a future Redis-backed store cannot leak past the cap on a stale entry.
The middleware MUST NOT roll the expiry on a failed authn resolve. A probing client cannot extend the deadline of an arbitrary cookie value just by hammering /v1/* — only a cookie value the resolver promotes to a Principal is eligible for the roll. The unit and integration suites pin this on every auth-failure leg (psk_-prefixed cookie path, JWT path, no credential at all, resolver miss).
The IdleTimeout and MaxLifetime are validated at boot time:
- A non-positive value of either is a fail-fast error so a misconfigured operator cannot mint never-expiring sessions (
MaxLifetime = 0) or sessions that expire on the same tick they are issued (IdleTimeout = 0). IdleTimeout > MaxLifetimeis rejected because the rolling refresh would always pinExpires = MaxLifetime, which makes the idle knob meaningless. Configure them withIdleTimeout <= MaxLifetimeso both windows have an effect.
The legacy PLEXSPHERE_AUTH_SESSION_TTL knob (a single absolute cookie lifetime) is retired. An operator who keeps it in their manifest after the upgrade hits a fail-fast boot error pointing them at the two replacement vars.
Bypass list — single source of truth
middleware.DefaultBypass() returns the canonical allowlist of paths that skip the authn check entirely. Every entry is one of two shapes:
- Exact-match leaf —
"/v1/auth/sign-in","/v1/auth/callback","/v1/auth/device-code","/v1/auth/device-token","/v1/auth/service/token","/v1/health","/v1/version","/v1/openapi.json","/v1/register","/v1/docs". A request whose path is byte-for-byte equal to the entry bypasses; everything else (including adversarial extensions like/v1/healthcare,/v1/auth_bypass_attempt) does not. - Family prefix —
"/v1/docs/assets/". A request whose path begins with the entry bypasses; today only the vendored ReDoc bundle uses this shape.
The /v1/auth/* family used to be covered by a single "/v1/auth/" prefix entry. That entry was retired because it incorrectly bypassed authenticated routes (/v1/auth/whoami, /v1/auth/tokens, /v1/auth/sign-out/global, /v1/auth/device/approve) and made the AuthnMiddleware skip credential resolution on plexctl whoami. The narrowed list enumerates only the genuinely-pre-auth endpoints (sign-in, callback, device-code, device-token, service/token); every other /v1/auth/* operation now runs through the middleware and the handler observes a Principal on context.
The adversarial-path tests in internal/identity/authn/middleware/bypass_test.go and the parity gate in tests/workspace/middleware_bypass_parity_test.go together form a two-layer defence: the package tests pin the matcher behaviour, the workspace gate pins that the authn and authz middleware return the same canonical set.
The bypass list is the single source of truth for "which routes do not require a Principal" — handlers downstream of the middleware trust that any non-bypass /v1/* request carries a resolved Principal on the context.
Wiring receipt
The cookie-session contract relies on a single shared TokenSessionStore instance threading through TWO call sites:
- The OIDC callback handler writes the new session via
Putafter a successful authorization-code exchange. - The AuthnMiddleware's
SessionResolverreads viaGeton every subsequent/v1/*request.
Both sites must point at the SAME store instance — without that, the writer puts session A while the reader looks for it in store B, every /v1/* surface 401s after sign-in, and the SPA falls back to the picker. That symptom drove the wiring contract pinned below.
The production composition root in cmd/plexsphere/auth_factory_prod.go constructs the store, the OIDC callback's auth.Deps, and the middleware INSIDE one closure so the three references all bind to the same value. The wiring is pinned by:
tests/workspace/auth_factory_authn_middleware_wiring_receipt_test.go— workspace-tier source-substring gate againstcmd/plexsphere/auth_factory_prod.goandcmd/plexsphere/app.go. Fails the build if themiddleware.New(...)construction, theSessionResolver:field, theAuthnMiddleware:field onAuthWiring, or theauthnMW = authWiring.AuthnMiddlewarefallback inapp.gois removed.tests/integration/auth_cookie_session_test.go— runtime gate exercising the cookie path through a real chi router with the same adapter shape the production factory uses.web/tests/e2e/signin.spec.ts— full Dex-fronted Playwright run asserting the post-callback dashboard renders the SignedInBadge, the Domain switcher carries the seeded list, and a data panel loads without a 401-driven flicker.
Sign-out
The cookie-session surface exposes two sign-out paths. Both paths emit the canonical clearing Set-Cookie header through one shared clearSessionCookie helper so the issuance and the eviction sites cannot drift on Path, HttpOnly, Secure, or SameSite.
| Operation | Surface | Effect |
|---|---|---|
| Per-cookie sign-out | DELETE /v1/auth/whoami | Removes the single TokenSession the presented cookie addresses, clears the response cookie. Idempotent: a stale tab without a cookie or with an unknown one still returns 204 No Content with the cookie cleared. |
| Global sign-out | POST /v1/auth/sign-out/global | Authenticated KindUser only. Removes every TokenSession bound to the caller's User aggregate (regardless of which device originally issued it), clears the response cookie. An api_token principal (a cookie value carrying the psk_ prefix) is rejected with 403 Forbidden so a leaked API token cannot bulk-revoke its owner's interactive sessions. |
The clearing Set-Cookie header carries Max-Age=0 per RFC 6265 §5.2.2 — Go's net/http.Cookie renders MaxAge: -1 as Max-Age=0 on the wire, so the browser drops its copy immediately regardless of the cookie's stored Expires attribute.
Audit metadata
Each successful sign-out emits one identity.UserSignedOut outbox event whose payload carries three metadata fields downstream consumers (audit projector, alerting, dashboards) can switch on without re-parsing the URL path:
| Field | Type | Meaning |
|---|---|---|
cookie_cleared | bool | The response carried the canonical clearing Set-Cookie. Always true for the two surfaces above. |
sessions_revoked | int | Count of server-side sessions invalidated by the request. 1 for the per-cookie path, N for the global path (0 on a re-click after the first response landed; the call is idempotent). |
reason | enum | Closed taxonomy: user-initiated (the operator clicked sign-out), idp-driven (reserved for upstream back-channel logout), admin-revoked (reserved for break-glass revocation). The wire forms are stable — a typo in the constant would silently re-class events, so the events package tests pin them. |
The metadata triple is the contract between the transport-layer handler and the outbox writer: the handler builds the event with events.NewUserSignedOut(userID, domainID, cookieCleared, sessionsRevoked, reason, now) and the production composition root forwards every field onto UserRepo.RecordSignOut so the persisted row preserves them.
Behaviour matrix — POST /v1/auth/sign-out/global
| Scenario | Cookie present? | Cookie shape | Outcome |
|---|---|---|---|
| Authenticated user, one device | yes | opaque random | 204 + cookie cleared + audit row with sessions_revoked=1 |
| Authenticated user, N devices | yes | opaque random | 204 + cookie cleared + audit row with sessions_revoked=N |
| No cookie | no | n/a | 401 not-authenticated + cookie-clear (defence-in-depth: drop any stale browser-side copy) |
| Unknown / expired cookie | yes | opaque random | 401 not-authenticated + cookie-clear |
psk_-prefixed cookie value | yes | API-token | 403 forbidden-credential + cookie-clear (cross-credential boundary; revoke the API token via DELETE /v1/auth/tokens/{id} instead) |
The handler is best-effort with respect to the audit hop: if SignOutAuditor.RecordSignOut returns an error AFTER the server-side sessions have already been evicted, the response stays 204 because the operator's sessions are gone — a 5xx would lie to the client about the cookie state. The audit gap is logged at slog.Error so operators see the missing row.
CSRF posture
SameSite=Strict on the session cookie is the primary CSRF guard but never the only one. Two complementary checks back it up so a deployment that silently loses one layer is still protected by the other. Both checks live in internal/transport/http/v1/csrf and run AFTER the AuthnMiddleware in the chi middleware chain so the dual-credential model (cookie vs. Authorization: Bearer) is already observable.
| Layer | Trigger | Failure surface |
|---|---|---|
| Origin / Sec-Fetch-Site | Cookie-authenticated POST / PATCH / PUT / DELETE. The middleware accepts a request that advertises Sec-Fetch-Site: same-origin (the modern-browser fast path the header cannot be forged from) OR carries an Origin header that exactly matches the operator-supplied closed allowlist (PLEXSPHERE_CSRF_ALLOWED_ORIGINS). | 403 application/problem+json with code: "csrf-origin-mismatch" (foreign Origin), code: "csrf-origin-not-configured" (empty allowlist), or code: "csrf-origin-missing" (browser stripped both Origin and Sec-Fetch-Site). |
| Double-submit token | Same trigger. The OIDC callback writes a second cookie plexsphere_csrf alongside plexsphere_session with HttpOnly: false, Path=/, SameSite=Strict. The SPA reads the value and echoes it in the X-Plexsphere-CSRF header on every state-changing request. The cookie's Path=/ (wider than the session cookie's Path=/v1/) is load-bearing: the SPA serves from /, so a narrower path would prevent JavaScript from reading the value via document.cookie. The cookie has no authentication value on its own — the session cookie does the authentication — so the wider path is not a security loss. | 403 application/problem+json with code: "csrf-token-mismatch" when the header is absent, empty, or does not byte-match the cookie. |
The two cookies share the same lifecycle: issued together at /v1/auth/callback, cleared together by DELETE /v1/auth/whoami and POST /v1/auth/sign-out/global. A leaked CSRF token therefore never outlives the session it was minted for; a fresh sign-in mints a fresh token and restores the double-submit invariant.
Bypass list
The CSRF middleware honours the same DefaultBypass set as the authn / authz middleware (/v1/auth/, /v1/health, /v1/version, /v1/openapi.json, /v1/register, /v1/docs, /v1/docs/assets/). A workspace parity gate ensures the three middleware return the same canonical set so a future addition to one bypass list cannot drift out of sync with the others. GET and HEAD requests bypass unconditionally under the OWASP safe-method posture.
Bearer requests are exempt
Authorization: Bearer … requests skip both layers. The bearer scheme is not auto-attached by browsers — a forged cross-site request cannot present a bearer token a user-agent did not store — so CSRF does not apply. A psk_-prefixed cookie value is treated as an API token by the authn middleware, but the CSRF middleware does not need to special-case it because authn fast-paths the psk_ cookie through the bearer branch before CSRF runs.
Allowlist contract
PLEXSPHERE_CSRF_ALLOWED_ORIGINS is REQUIRED in production. The production composition root in cmd/plexsphere/auth_factory_prod.go fails fast at boot with an explicit error message when the env var is empty, every entry must be an absolute http(s):// URI, and trailing slashes are rejected so a deployment cannot pin one of https://app.example.com and https://app.example.com/ and have the middleware silently mismatch the browser's stripped-trailing- slash Origin header. The workspace drift gate at tests/workspace/csrf_wiring_receipt_test.go forces a build-time failure when the validation, the csrf.New(...) construction, the CSRFMiddleware: … assignment, or the authWiring.CSRFMiddleware fallback in app.go is removed.
The middleware fails closed: a request that survives the bypass list, presents a session cookie, but cannot satisfy BOTH layers surfaces as 403 — the deployment never silently accepts a forged mutation.
Out of scope
The following are tracked as separate follow-ups and are intentionally NOT covered by this document or the wiring it describes:
- IdP-driven and admin-revoked sign-out. The
SignOutReasontaxonomy already listsidp-drivenandadmin-revokedas reserved values, but no production caller emits them yet. A follow-up will wire an upstream back-channel logout listener and an admin break-glass revocation surface, and this section will gain the matching rows.
When any of those land, this document gains the corresponding row in the "Cookie shape" table or the "four cookie-path scenarios" matrix in lockstep with the implementation.