Ctrl K
ComunidadGuías…

pkg/auth/cookie.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package auth

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// DefaultCookieName is the default session cookie name
const DefaultCookieName = "radar_session"

// maxCookieSize is the safe limit for a single cookie value. 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), and the chunk suffix.
const maxCookieSize = 3800

// cookieChunkSuffix names the per-chunk cookies (radar_session_chunk_0, _1, …).
const cookieChunkSuffix = "_chunk_"

// cookieChunkCountSuffix names the meta-cookie holding the chunk count.
const cookieChunkCountSuffix = "_chunks"

// maxCookieChunks bounds chunk reassembly so a forged _chunks meta-cookie can't
// drive an unbounded read loop. 16 chunks ≈ 59 KB of reassembled value, well
// beyond any realistic OIDC token + group set.
const maxCookieChunks = 16

// Session represents a parsed session cookie.
type Session struct {
	User      *User
	SID       string    // stable session identifier (empty for pre-upgrade cookies)
	IDToken   string    // raw OIDC id_token for RP-Initiated Logout
	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"`
	ExpiresAt int64    `json:"e"`
	IDToken   string   `json:"t,omitempty"` // raw OIDC id_token for RP-Initiated Logout
	SID       string   `json:"s,omitempty"` // session ID for backchannel logout revocation
}

// NewSessionID generates a random 16-byte hex session ID.
func NewSessionID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(fmt.Sprintf("[auth] Failed to generate session ID: %v", err))
	}
	return hex.EncodeToString(b)
}

// CreateSessionCookie builds the signed session cookie(s) for the given user.
// Format: base64(json) + "." + base64(hmac-sha256). The sid must be non-empty —
// use NewSessionID() to generate one.
//
// Most sessions fit in a single cookie. When the signed value exceeds
// maxCookieSize (many groups + a large OIDC ID token), the ID token is dropped
// first — it's only needed for RP-Initiated Logout's id_token_hint and falls
// back to client_id gracefully. If still too large, the value is split across
// numbered chunk cookies plus a meta-cookie holding the count, so IdPs that
// emit oversized tokens (e.g. Keycloak with many claims) don't get silently
// rejected by the browser's ~4 KB per-cookie limit. Callers must SetCookie each
// returned 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))
	}

	payload := cookiePayload{
		Username:  user.Username,
		Groups:    user.Groups,
		ExpiresAt: time.Now().Add(ttl).Unix(),
		IDToken:   idToken,
		SID:       sid,
	}

	value := buildCookieValue(payload, secret)

	if len(value) > maxCookieSize && payload.IDToken != "" {
		log.Printf("[auth] Session cookie exceeds %d bytes (%d), dropping ID token to fit",
			maxCookieSize, len(value))
		payload.IDToken = ""
		value = buildCookieValue(payload, secret)
	}

	if len(value) <= maxCookieSize {
		// Single cookie. Clear the chunk meta-cookie so a previous chunked
		// representation (if any) can't be reassembled: parse only follows
		// the chunk path when the meta-cookie is present, so dropping it
		// neutralizes any stale chunks (they expire on their own TTL).
		return []*http.Cookie{
			newSessionCookie(DefaultCookieName, value, ttl, secure),
			expireCookie(DefaultCookieName+cookieChunkCountSuffix, secure),
		}
	}

	log.Printf("[auth] Session cookie is %d bytes (limit %d) — splitting into chunked cookies",
		len(value), maxCookieSize)
	return createChunkedCookies(DefaultCookieName, value, ttl, secure)
}

// newSessionCookie builds a single session cookie with the standard attributes.
func newSessionCookie(name, value string, ttl time.Duration, secure bool) *http.Cookie {
	return &http.Cookie{
		Name:     name,
		Value:    value,
		Path:     "/",
		HttpOnly: true,
		Secure:   secure,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(ttl.Seconds()),
	}
}

// createChunkedCookies splits an oversized signed value across numbered chunk
// cookies plus a meta-cookie holding the count. Reserve headroom under
// maxCookieSize for the (longer) chunk cookie names + attributes.
func createChunkedCookies(name, value string, ttl time.Duration, secure bool) []*http.Cookie {
	chunks := splitString(value, maxCookieSize-100)
	if len(chunks) > maxCookieChunks {
		// Parse rejects counts above maxCookieChunks, so a session this large
		// can't round-trip. Don't issue chunks the server will then refuse
		// (that would loop login → 401); clear any prior representation and
		// surface the misconfiguration instead.
		log.Printf("[auth] ERROR: session value needs %d chunks (max %d) — cannot issue a usable session; reduce the number of OIDC groups/claims",
			len(chunks), maxCookieChunks)
		return []*http.Cookie{
			expireCookie(name, secure),
			expireCookie(name+cookieChunkCountSuffix, secure),
		}
	}
	cookies := make([]*http.Cookie, 0, len(chunks)+2)

	// Clear any stale single main cookie so the chunked session is authoritative
	// (parse prefers a non-empty main cookie over chunks).
	cookies = append(cookies, expireCookie(name, secure))
	for i, chunk := range chunks {
		cookies = append(cookies, newSessionCookie(fmt.Sprintf("%s%s%d", name, cookieChunkSuffix, i), chunk, ttl, secure))
	}
	cookies = append(cookies, newSessionCookie(name+cookieChunkCountSuffix, strconv.Itoa(len(chunks)), ttl, secure))
	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 := min(i+chunkSize, len(s))
		chunks = append(chunks, s[i:end])
	}
	return chunks
}

// ParseSessionCookie validates and parses a session cookie.
// Returns nil if the cookie is missing, invalid, or expired.
// Pre-upgrade cookies without a SID parse successfully with Session.SID == "".
//
// The single main cookie is preferred; chunk reassembly is the fallback. A
// session that shrinks back to one cookie therefore parses correctly even if
// stale chunk cookies from a previous larger session are still in the browser
// (those expire with the TTL and are cleared on logout).
func ParseSessionCookie(r *http.Request, secret string) *Session {
	if cookie, err := r.Cookie(DefaultCookieName); err == nil && cookie.Value != "" {
		return parseCookieValue(cookie.Value, secret, r.RemoteAddr)
	}

	chunksCookie, err := r.Cookie(DefaultCookieName + cookieChunkCountSuffix)
	if err != nil {
		return nil
	}
	numChunks, err := strconv.Atoi(chunksCookie.Value)
	if err != nil || numChunks <= 0 || numChunks > maxCookieChunks {
		return nil
	}

	var fullValue strings.Builder
	for i := range numChunks {
		chunk, err := r.Cookie(fmt.Sprintf("%s%s%d", DefaultCookieName, cookieChunkSuffix, i))
		if err != nil {
			return nil
		}
		fullValue.WriteString(chunk.Value)
	}
	return parseCookieValue(fullValue.String(), secret, r.RemoteAddr)
}

// parseCookieValue verifies the HMAC over the (reassembled) value and decodes
// the payload. Because the signature covers the whole value, a chunk that is
// dropped, reordered, or forged yields a different reassembly and fails here.
func parseCookieValue(cookieValue, secret, remoteAddr string) *Session {
	parts := strings.SplitN(cookieValue, ".", 2)
	if len(parts) != 2 {
		return nil
	}

	encoded, sig := parts[0], parts[1]

	// 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 %q", remoteAddr)
		return nil
	}

	// Decode payload
	data, err := base64.RawURLEncoding.DecodeString(encoded)
	if err != nil {
		return nil
	}

	var p cookiePayload
	if err := json.Unmarshal(data, &p); err != nil {
		return nil
	}

	// Check expiration
	if time.Now().Unix() > p.ExpiresAt {
		log.Printf("[auth] Session cookie expired for user %q — prompting re-auth", p.Username)
		return nil
	}

	return &Session{
		User: &User{
			Username: p.Username,
			Groups:   p.Groups,
		},
		SID:       p.SID,
		IDToken:   p.IDToken,
		ExpiresAt: time.Unix(p.ExpiresAt, 0),
	}
}

// buildCookieValue marshals the payload and signs it: base64(json) + "." + base64(hmac).
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 %q: %v", p.Username, err)
	}
	encoded := base64.RawURLEncoding.EncodeToString(data)
	return encoded + "." + signData(encoded, secret)
}

// ClearSessionCookie returns the cookie(s) that clear the session. It always
// clears the main cookie; when the request carries the chunk meta-cookie it
// also clears each chunk and the meta-cookie, so a chunked session can't
// survive logout by being reassembled on the next request.
func ClearSessionCookie(r *http.Request) []*http.Cookie {
	// Match the Secure attribute of the session cookies being cleared: a
	// non-Secure deletion cookie may not reliably evict a Secure one in every
	// browser. OIDC issues Secure cookies, so over HTTPS we must clear Secure.
	secure := r != nil && (r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https")
	cookies := []*http.Cookie{expireCookie(DefaultCookieName, secure)}

	if r == nil {
		return cookies
	}
	// If the session was chunked, clear the meta-cookie and every possible
	// chunk index — not just the advertised count — so no chunk residue
	// survives logout even if the count is stale (after a shrink) or corrupted.
	if _, err := r.Cookie(DefaultCookieName + cookieChunkCountSuffix); err == nil {
		cookies = append(cookies, expireCookie(DefaultCookieName+cookieChunkCountSuffix, secure))
		for i := range maxCookieChunks {
			cookies = append(cookies, expireCookie(fmt.Sprintf("%s%s%d", DefaultCookieName, cookieChunkSuffix, i), secure))
		}
	}
	return cookies
}

// expireCookie returns a cookie that immediately clears the named cookie. It
// mirrors the Secure attribute of the cookie it replaces so the deletion is
// honored regardless of the original's Secure flag.
func expireCookie(name string, secure bool) *http.Cookie {
	return &http.Cookie{
		Name:     name,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   secure,
		MaxAge:   -1,
	}
}

// signData computes HMAC-SHA256 of the given data with the secret
func signData(data, secret string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprint(mac, data)
	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}