Add SESSION_COOKIE_DOMAIN so /admin SSR sees the session cookie

Production splits web (descrybe.io) and API (api.descrybe.io). The session
cookie was host-only for api.descrybe.io, so the browser never sent it to
the web host. The /admin SvelteKit SSR gate (fetchMeStaff in
+layout.server.ts) forwards the incoming cookie header to /api/auth/me —
with no cookie to forward it always got 401 and bounced every successful
login back to /login?next=/admin (login POST 200, /me 200 from the
browser, /me 401 from the web server).

New SESSION_COOKIE_DOMAIN env (default empty = host-only, local dev
unchanged) sets the session cookie Domain attribute; set it to the parent
domain (descrybe.io) in production so both hosts receive the cookie.
Leading dot is normalized away. CSRF needs no change — it already seeds
cross-origin via the X-CSRF-Token response header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:31:58 +02:00
co-authored by Claude Fable 5
parent b595398389
commit 0eef202f56
6 changed files with 35 additions and 5 deletions
+12 -2
View File
@@ -9,10 +9,13 @@ import (
func TestNewSessionManagerCookieFlags(t *testing.T) {
t.Parallel()
sm := NewSessionManager(nil, "descrybe_session", true, 12)
sm := NewSessionManager(nil, "descrybe_session", "", true, 12)
if sm.Cookie.Name != "descrybe_session" {
t.Fatalf("Name = %q", sm.Cookie.Name)
}
if sm.Cookie.Domain != "" {
t.Fatalf("Domain = %q, want host-only default", sm.Cookie.Domain)
}
if !sm.Cookie.HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
@@ -32,7 +35,7 @@ func TestNewSessionManagerCookieFlags(t *testing.T) {
t.Fatalf("Lifetime = %v, want 7d", sm.Lifetime)
}
insecure := NewSessionManager(nil, "descrybe_session", false, 0)
insecure := NewSessionManager(nil, "descrybe_session", "", false, 0)
if insecure.Cookie.Secure {
t.Fatal("secure=false must not set Secure")
}
@@ -42,4 +45,11 @@ func TestNewSessionManagerCookieFlags(t *testing.T) {
if insecure.IdleTimeout != 24*time.Hour {
t.Fatalf("default IdleTimeout = %v, want 24h", insecure.IdleTimeout)
}
// Parent-domain deploys (descrybe.io + api.descrybe.io): leading dot is
// normalized away; browsers include subdomains whenever Domain is set.
scoped := NewSessionManager(nil, "descrybe_session", ".descrybe.io", true, 12)
if scoped.Cookie.Domain != "descrybe.io" {
t.Fatalf("Domain = %q, want descrybe.io", scoped.Cookie.Domain)
}
}