diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f4a97b051..3f15a7e70 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -19,14 +19,14 @@ const DefaultCookieName = pkgauth.DefaultCookieName // Re-export functions from pkg/auth var ( - UserFromContext = pkgauth.UserFromContext - ContextWithUser = pkgauth.ContextWithUser - NewPermissionCache = pkgauth.NewPermissionCache - DiscoverNamespaces = pkgauth.DiscoverNamespaces - SubjectCanI = pkgauth.SubjectCanI - FilterNamespacesForUser = pkgauth.FilterNamespacesForUser - CreateSessionCookie = pkgauth.CreateSessionCookie - NewSessionID = pkgauth.NewSessionID - ParseSessionCookie = pkgauth.ParseSessionCookie - ClearSessionCookie = pkgauth.ClearSessionCookie + UserFromContext = pkgauth.UserFromContext + ContextWithUser = pkgauth.ContextWithUser + NewPermissionCache = pkgauth.NewPermissionCache + DiscoverNamespaces = pkgauth.DiscoverNamespaces + SubjectCanI = pkgauth.SubjectCanI + FilterNamespacesForUser = pkgauth.FilterNamespacesForUser + NewSessionID = pkgauth.NewSessionID + CreateSessionCookie = pkgauth.CreateSessionCookie + ParseSessionCookie = pkgauth.ParseSessionCookie + ClearSessionCookie = pkgauth.ClearSessionCookie ) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 264dfa6c2..9ae92f072 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -60,7 +60,10 @@ func Authenticate(cfg Config) func(http.Handler) http.Handler { // Pre-upgrade cookie without sid — mint one on first sliding re-issue sid = NewSessionID() } - http.SetCookie(w, CreateSessionCookie(session.User, sid, session.IDToken, cfg.Secret, cfg.CookieTTL, secure)) + cookies := CreateSessionCookie(session.User, sid, session.IDToken, cfg.Secret, cfg.CookieTTL, secure) + for _, c := range cookies { + http.SetCookie(w, c) + } if remaining > cfg.CookieTTL { log.Printf("[auth] TTL downgrade detected for user %q: cookie remaining %s exceeds configured TTL %s, snapping", session.User.Username, remaining.Round(time.Second), cfg.CookieTTL) @@ -85,11 +88,10 @@ func Authenticate(cfg Config) func(http.Handler) http.Handler { } user := &User{Username: username, Groups: groups} - - // Set session cookie so subsequent requests don't need headers - // Header-auth creates a fresh session (new sid each time) - http.SetCookie(w, CreateSessionCookie(user, NewSessionID(), "", cfg.Secret, cfg.CookieTTL, secure)) - + cookies := CreateSessionCookie(user, NewSessionID(), "", cfg.Secret, cfg.CookieTTL, secure) + for _, c := range cookies { + http.SetCookie(w, c) + } ctx := ContextWithUser(r.Context(), user) next.ServeHTTP(w, r.WithContext(ctx)) return diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index c01973707..aaea84548 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -276,8 +276,11 @@ func (h *OIDCHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { } // Create session cookie (include raw ID token for RP-Initiated Logout) - secure := true // OIDC typically behind TLS - http.SetCookie(w, CreateSessionCookie(user, sid, rawIDToken, h.cfg.Secret, h.cfg.CookieTTL, secure)) + secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + cookies := CreateSessionCookie(user, sid, rawIDToken, h.cfg.Secret, h.cfg.CookieTTL, secure) + for _, c := range cookies { + http.SetCookie(w, c) + } log.Printf("[oidc] User %s authenticated (groups: %v)", username, groups) diff --git a/pkg/auth/cookie.go b/pkg/auth/cookie.go index 8953d1804..0145a050f 100644 --- a/pkg/auth/cookie.go +++ b/pkg/auth/cookie.go @@ -10,20 +10,15 @@ import ( "fmt" "log" "net/http" + "strconv" "strings" "time" ) -// DefaultCookieName is the default session cookie name -const DefaultCookieName = "radar_session" +const DefaultCookieName = "radar_seion" +const maxCookieSize = 3600 +const cookieChunkSuffix = "_chunk_" -// maxCookieSize is the safe limit for cookie values. RFC 6265 requires -// browsers to support at least 4096 bytes per cookie, but some proxies -// and CDNs enforce stricter limits. We use 3800 to leave headroom for -// the cookie name, attributes (Path, Secure, HttpOnly, SameSite, MaxAge). -const maxCookieSize = 3800 - -// Session represents a parsed session cookie. type Session struct { User *User SID string // stable session identifier (empty for pre-upgrade cookies) @@ -31,7 +26,6 @@ type Session struct { ExpiresAt time.Time // when the cookie expires } -// cookiePayload is the data stored in the session cookie type cookiePayload struct { Username string `json:"u"` Groups []string `json:"g,omitempty"` @@ -49,10 +43,7 @@ func NewSessionID() string { return hex.EncodeToString(b) } -// CreateSessionCookie creates a signed session cookie for the given user. -// Format: base64(json) + "." + base64(hmac-sha256). -// The sid must be non-empty — use NewSessionID() to generate one. -func CreateSessionCookie(user *User, sid, idToken, secret string, ttl time.Duration, secure bool) *http.Cookie { +func CreateSessionCookie(user *User, sid, idToken, secret string, ttl time.Duration, secure bool) []*http.Cookie { if sid == "" { panic(fmt.Sprintf("[auth] CreateSessionCookie called with empty sid for user %s", user.Username)) } @@ -67,30 +58,83 @@ func CreateSessionCookie(user *User, sid, idToken, secret string, ttl time.Durat value := buildCookieValue(payload, secret) - // Browser cookie size limit is ~4096 bytes. If the payload is too large - // (many groups + large ID token), drop the ID token first — it's only - // needed for RP-Initiated Logout's id_token_hint and falls back to - // client_id gracefully. Log so operators know. - if len(value) > maxCookieSize && payload.IDToken != "" { + if len(value) <= maxCookieSize { + return []*http.Cookie{{ + Name: DefaultCookieName, + Value: value, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }} + } + + if payload.IDToken != "" { log.Printf("[auth] Session cookie for %s exceeds %d bytes (%d), dropping ID token to fit", user.Username, maxCookieSize, len(value)) payload.IDToken = "" value = buildCookieValue(payload, secret) } - if len(value) > maxCookieSize { - log.Printf("[auth] WARNING: Session cookie for %s is %d bytes (limit ~%d) — browser may silently drop it. Reduce the number of groups in the OIDC token.", - user.Username, len(value), maxCookieSize) + + if len(value) <= maxCookieSize { + return []*http.Cookie{{ + Name: DefaultCookieName, + Value: value, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }} } - return &http.Cookie{ - Name: DefaultCookieName, - Value: value, + log.Printf("[auth] WARNING: Session cookie for %s is %d bytes, using chunked cookies", user.Username, len(value)) + return createChunkedCookies(DefaultCookieName, value, ttl, secure) +} + +func createChunkedCookies(name, value string, ttl time.Duration, secure bool) []*http.Cookie { + chunks := splitString(value, maxCookieSize-100) + cookies := make([]*http.Cookie, 0, len(chunks)+1) + + for i, chunk := range chunks { + cookies = append(cookies, &http.Cookie{ + Name: fmt.Sprintf("%s%s%d", name, cookieChunkSuffix, i), + Value: chunk, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }) + } + + cookies = append(cookies, &http.Cookie{ + Name: name + "_chunks", + Value: strconv.Itoa(len(chunks)), Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()), + }) + + return cookies +} + +func splitString(s string, chunkSize int) []string { + if len(s) <= chunkSize { + return []string{s} + } + var chunks []string + for i := 0; i < len(s); i += chunkSize { + end := i + chunkSize + if end > len(s) { + end = len(s) + } + chunks = append(chunks, s[i:end]) } + return chunks } // ParseSessionCookie validates and parses a session cookie. @@ -98,11 +142,33 @@ func CreateSessionCookie(user *User, sid, idToken, secret string, ttl time.Durat // Pre-upgrade cookies without a SID parse successfully with Session.SID == "". func ParseSessionCookie(r *http.Request, secret string) *Session { cookie, err := r.Cookie(DefaultCookieName) + if err == nil && cookie.Value != "" { + return parseCookieValue(cookie.Value, secret) + } + + chunksCookie, err := r.Cookie(DefaultCookieName + "_chunks") if err != nil { return nil } + numChunks, err := strconv.Atoi(chunksCookie.Value) + if err != nil || numChunks == 0 { + return nil + } + + var fullValue strings.Builder + for i := 0; i < numChunks; i++ { + chunkName := fmt.Sprintf("%s%s%d", DefaultCookieName, cookieChunkSuffix, i) + chunk, err := r.Cookie(chunkName) + if err != nil { + return nil + } + fullValue.WriteString(chunk.Value) + } + return parseCookieValue(fullValue.String(), secret) +} - parts := strings.SplitN(cookie.Value, ".", 2) +func parseCookieValue(cookieValue, secret string) *Session { + parts := strings.SplitN(cookieValue, ".", 2) if len(parts) != 2 { return nil } @@ -112,7 +178,7 @@ func ParseSessionCookie(r *http.Request, secret string) *Session { // Verify HMAC signature expected := signData(encoded, secret) if !hmac.Equal([]byte(sig), []byte(expected)) { - log.Printf("[auth] Session cookie HMAC verification failed — possible tampered cookie from %s", r.RemoteAddr) + log.Printf("[auth] Session cookie HMAC verification failed") return nil } @@ -129,7 +195,7 @@ func ParseSessionCookie(r *http.Request, secret string) *Session { // Check expiration if time.Now().Unix() > p.ExpiresAt { - log.Printf("[auth] Session cookie expired for user %q — prompting re-auth", p.Username) + log.Printf("[auth] Session cookie expired for user %q", p.Username) return nil } @@ -148,7 +214,7 @@ func ParseSessionCookie(r *http.Request, secret string) *Session { func buildCookieValue(p cookiePayload, secret string) string { data, err := json.Marshal(p) if err != nil { - log.Fatalf("[auth] Failed to marshal session cookie payload for user %s: %v", p.Username, err) + log.Fatalf("[auth] Failed to marshal session cookie payload: %v", err) } encoded := base64.RawURLEncoding.EncodeToString(data) return encoded + "." + signData(encoded, secret)