diff --git a/.env.example b/.env.example index d57ccbf..bb5cb57 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,12 @@ APP_ENCRYPTION_KEY= # 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. # 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 # PUBLIC_CSRF_COOKIE_NAME=descrybe_csrf # SESSION_IDLE_HOURS=24 diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index e00c7fb..c5a0bc2 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -56,7 +56,7 @@ func main() { } 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) runCtx, runCancel := context.WithCancel(context.Background()) diff --git a/apps/api/internal/auth/session.go b/apps/api/internal/auth/session.go index 9bc0cdb..8fce5e1 100644 --- a/apps/api/internal/auth/session.go +++ b/apps/api/internal/auth/session.go @@ -2,6 +2,7 @@ package auth import ( "net/http" + "strings" "time" "github.com/alexedwards/scs/pgxstore" @@ -9,7 +10,14 @@ import ( "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.Store = pgxstore.New(pool) 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.Cookie.Name = cookieName + sm.Cookie.Domain = strings.TrimPrefix(strings.TrimSpace(cookieDomain), ".") sm.Cookie.HttpOnly = true sm.Cookie.Secure = secure sm.Cookie.SameSite = http.SameSiteLaxMode diff --git a/apps/api/internal/auth/session_test.go b/apps/api/internal/auth/session_test.go index 5be6a0e..b2f0eb2 100644 --- a/apps/api/internal/auth/session_test.go +++ b/apps/api/internal/auth/session_test.go @@ -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) + } } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 06cf2e9..0403c7d 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -36,6 +36,7 @@ type Config struct { // (e.g. redis/postgres) so boot can warn that memory was forced. RateLimitBackendRequested string SessionCookieName string + SessionCookieDomain string SessionSecure bool CSRFCookieName string PublicAPIURL string @@ -141,6 +142,10 @@ func Load() (Config, error) { RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false), RateLimitBackend: "memory", 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 // even if SESSION_SECURE is unset; explicit false still fails closed in validate. SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)), diff --git a/apps/api/internal/httpapi/auth_session_integration_test.go b/apps/api/internal/httpapi/auth_session_integration_test.go index 1ebba4f..0d06f98 100644 --- a/apps/api/internal/httpapi/auth_session_integration_test.go +++ b/apps/api/internal/httpapi/auth_session_integration_test.go @@ -90,7 +90,7 @@ func TestAuthSessionCoreEndpoints(t *testing.T) { _, _ = 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{ Config: config.Config{ CSRFCookieName: "descrybe_csrf",