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
+6
View File
@@ -105,6 +105,12 @@ APP_ENCRYPTION_KEY=
# Optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email) # Optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email)
# — not a shared store. RATE_LIMIT_BACKEND=redis|postgres is docs-only and forced to memory. # — not a shared store. RATE_LIMIT_BACKEND=redis|postgres is docs-only and forced to memory.
# SESSION_COOKIE_NAME=descrybe_session # SESSION_COOKIE_NAME=descrybe_session
# Session cookie Domain attribute. Empty = host-only (localhost / same-host).
# REQUIRED when web + api run on sibling subdomains (descrybe.io + api.descrybe.io):
# set the parent domain so the browser also sends the session cookie to the web
# host — SvelteKit SSR gates (/admin) forward it to /api/auth/me and otherwise
# always see 401 (login loops back to /login?next=...).
# SESSION_COOKIE_DOMAIN=descrybe.io
# CSRF_COOKIE_NAME=descrybe_csrf # CSRF_COOKIE_NAME=descrybe_csrf
# PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf # PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf
# SESSION_IDLE_HOURS=24 # SESSION_IDLE_HOURS=24
+1 -1
View File
@@ -56,7 +56,7 @@ func main() {
} }
defer pool.Close() defer pool.Close()
sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.CookieSecure(), cfg.SessionIdleHours) sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.SessionCookieDomain, cfg.CookieSecure(), cfg.SessionIdleHours)
srv := httpapi.NewServer(cfg, pool, sessions) srv := httpapi.NewServer(cfg, pool, sessions)
runCtx, runCancel := context.WithCancel(context.Background()) runCtx, runCancel := context.WithCancel(context.Background())
+10 -1
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/alexedwards/scs/pgxstore" "github.com/alexedwards/scs/pgxstore"
@@ -9,7 +10,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleHours int) *scs.SessionManager { // NewSessionManager builds the scs session manager. cookieDomain is the session
// cookie Domain attribute: empty keeps a host-only cookie (localhost / same-host
// deploys). When the web app and API live on sibling hosts of one parent domain
// (descrybe.io + api.descrybe.io), set SESSION_COOKIE_DOMAIN=descrybe.io so the
// browser also sends the session cookie to the web host — SvelteKit SSR gates
// (/admin +layout.server.ts fetchMeStaff) forward it to /api/auth/me and would
// otherwise always see 401.
func NewSessionManager(pool *pgxpool.Pool, cookieName, cookieDomain string, secure bool, idleHours int) *scs.SessionManager {
sm := scs.New() sm := scs.New()
sm.Store = pgxstore.New(pool) sm.Store = pgxstore.New(pool)
sm.Lifetime = 7 * 24 * time.Hour sm.Lifetime = 7 * 24 * time.Hour
@@ -18,6 +26,7 @@ func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleH
} }
sm.IdleTimeout = time.Duration(idleHours) * time.Hour sm.IdleTimeout = time.Duration(idleHours) * time.Hour
sm.Cookie.Name = cookieName sm.Cookie.Name = cookieName
sm.Cookie.Domain = strings.TrimPrefix(strings.TrimSpace(cookieDomain), ".")
sm.Cookie.HttpOnly = true sm.Cookie.HttpOnly = true
sm.Cookie.Secure = secure sm.Cookie.Secure = secure
sm.Cookie.SameSite = http.SameSiteLaxMode sm.Cookie.SameSite = http.SameSiteLaxMode
+12 -2
View File
@@ -9,10 +9,13 @@ import (
func TestNewSessionManagerCookieFlags(t *testing.T) { func TestNewSessionManagerCookieFlags(t *testing.T) {
t.Parallel() t.Parallel()
sm := NewSessionManager(nil, "descrybe_session", true, 12) sm := NewSessionManager(nil, "descrybe_session", "", true, 12)
if sm.Cookie.Name != "descrybe_session" { if sm.Cookie.Name != "descrybe_session" {
t.Fatalf("Name = %q", sm.Cookie.Name) 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 { if !sm.Cookie.HttpOnly {
t.Fatal("session cookie must be 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) 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 { if insecure.Cookie.Secure {
t.Fatal("secure=false must not set Secure") t.Fatal("secure=false must not set Secure")
} }
@@ -42,4 +45,11 @@ func TestNewSessionManagerCookieFlags(t *testing.T) {
if insecure.IdleTimeout != 24*time.Hour { if insecure.IdleTimeout != 24*time.Hour {
t.Fatalf("default IdleTimeout = %v, want 24h", insecure.IdleTimeout) 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)
}
} }
+5
View File
@@ -36,6 +36,7 @@ type Config struct {
// (e.g. redis/postgres) so boot can warn that memory was forced. // (e.g. redis/postgres) so boot can warn that memory was forced.
RateLimitBackendRequested string RateLimitBackendRequested string
SessionCookieName string SessionCookieName string
SessionCookieDomain string
SessionSecure bool SessionSecure bool
CSRFCookieName string CSRFCookieName string
PublicAPIURL string PublicAPIURL string
@@ -141,6 +142,10 @@ func Load() (Config, error) {
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false), RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
RateLimitBackend: "memory", RateLimitBackend: "memory",
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"), SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
// Empty = host-only cookie (localhost). Set to the parent domain
// (e.g. descrybe.io) when web + api run on sibling subdomains so
// SvelteKit SSR (descrybe.io) receives the session cookie too.
SessionCookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
// Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only // Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only
// even if SESSION_SECURE is unset; explicit false still fails closed in validate. // even if SESSION_SECURE is unset; explicit false still fails closed in validate.
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)), SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
@@ -90,7 +90,7 @@ func TestAuthSessionCoreEndpoints(t *testing.T) {
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
}) })
sessions := auth.NewSessionManager(pg, "descrybe_session", false, 24) sessions := auth.NewSessionManager(pg, "descrybe_session", "", false, 24)
s := &Server{ s := &Server{
Config: config.Config{ Config: config.Config{
CSRFCookieName: "descrybe_csrf", CSRFCookieName: "descrybe_csrf",