pkg/auth/cookie_test.go
Rama main · réplica de lectura del repositorio original.
package auth
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mainCookie returns the primary radar_session cookie from a returned set,
// ignoring housekeeping cookies (e.g. a chunk meta-cookie cleared on the
// single-cookie path).
func mainCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
t.Helper()
for _, c := range cookies {
if c.Name == DefaultCookieName {
return c
}
}
t.Fatalf("no %s cookie in returned set of %d", DefaultCookieName, len(cookies))
return nil
}
// addCookies attaches every cookie to the request, mimicking a browser sending
// back all cookies the server previously set.
func addCookies(req *http.Request, cookies []*http.Cookie) {
for _, c := range cookies {
req.AddCookie(c)
}
}
func TestCreateAndParseSessionCookie(t *testing.T) {
secret := "test-secret-key"
user := &User{Username: "alice", Groups: []string{"devs", "admins"}}
sid := NewSessionID()
ttl := 1 * time.Hour
cookie := mainCookie(t, CreateSessionCookie(user, sid, "", secret, ttl, false))
// Verify cookie properties
if cookie.Name != DefaultCookieName {
t.Errorf("cookie name = %q, want %q", cookie.Name, DefaultCookieName)
}
if !cookie.HttpOnly {
t.Error("cookie should be HttpOnly")
}
if cookie.Secure {
t.Error("cookie should not be Secure when secure=false")
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Errorf("cookie SameSite = %v, want Lax", cookie.SameSite)
}
if cookie.MaxAge != 3600 {
t.Errorf("cookie MaxAge = %d, want 3600", cookie.MaxAge)
}
// Parse it back
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil for valid cookie")
}
if parsed.User.Username != "alice" {
t.Errorf("username = %q, want %q", parsed.User.Username, "alice")
}
if len(parsed.User.Groups) != 2 || parsed.User.Groups[0] != "devs" || parsed.User.Groups[1] != "admins" {
t.Errorf("groups = %v, want [devs admins]", parsed.User.Groups)
}
if parsed.SID != sid {
t.Errorf("SID = %q, want %q", parsed.SID, sid)
}
}
func TestParseSessionCookie_WrongSecret(t *testing.T) {
user := &User{Username: "alice"}
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", "secret-1", 1*time.Hour, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, "secret-2")
if parsed != nil {
t.Error("ParseSessionCookie should return nil for wrong secret")
}
}
func TestParseSessionCookie_Expired(t *testing.T) {
user := &User{Username: "alice"}
// TTL of -1 second = already expired
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", "secret", -1*time.Second, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, "secret")
if parsed != nil {
t.Error("ParseSessionCookie should return nil for expired cookie")
}
}
func TestParseSessionCookie_NoCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
parsed := ParseSessionCookie(req, "secret")
if parsed != nil {
t.Error("ParseSessionCookie should return nil when no cookie present")
}
}
func TestParseSessionCookie_TamperedPayload(t *testing.T) {
user := &User{Username: "alice"}
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", "secret", 1*time.Hour, false))
// Tamper with the payload (change first char)
val := cookie.Value
if val[0] == 'a' {
cookie.Value = "b" + val[1:]
} else {
cookie.Value = "a" + val[1:]
}
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, "secret")
if parsed != nil {
t.Error("ParseSessionCookie should return nil for tampered cookie")
}
}
func TestParseSessionCookie_MalformedValue(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "not-a-valid-cookie"})
parsed := ParseSessionCookie(req, "secret")
if parsed != nil {
t.Error("ParseSessionCookie should return nil for malformed cookie (no dot)")
}
}
func TestCreateSessionCookie_Secure(t *testing.T) {
user := &User{Username: "alice"}
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", "secret", 1*time.Hour, true))
if !cookie.Secure {
t.Error("cookie should be Secure when secure=true")
}
}
func TestCreateSessionCookie_NoGroups(t *testing.T) {
user := &User{Username: "bob"}
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", "secret", 1*time.Hour, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, "secret")
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil")
}
if parsed.User.Username != "bob" {
t.Errorf("username = %q, want %q", parsed.User.Username, "bob")
}
if len(parsed.User.Groups) != 0 {
t.Errorf("groups = %v, want empty", parsed.User.Groups)
}
}
func TestClearSessionCookie(t *testing.T) {
cookie := mainCookie(t, ClearSessionCookie(nil))
if cookie.Name != DefaultCookieName {
t.Errorf("cookie name = %q, want %q", cookie.Name, DefaultCookieName)
}
if cookie.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", cookie.MaxAge)
}
}
func TestSignData_Deterministic(t *testing.T) {
sig1 := signData("hello", "secret")
sig2 := signData("hello", "secret")
if sig1 != sig2 {
t.Error("signData should be deterministic")
}
}
func TestSignData_DifferentInputs(t *testing.T) {
sig1 := signData("hello", "secret")
sig2 := signData("world", "secret")
if sig1 == sig2 {
t.Error("signData should produce different signatures for different inputs")
}
}
func TestSignData_DifferentSecrets(t *testing.T) {
sig1 := signData("hello", "secret1")
sig2 := signData("hello", "secret2")
if sig1 == sig2 {
t.Error("signData should produce different signatures for different secrets")
}
}
func TestCreateSessionCookie_WithIDToken(t *testing.T) {
secret := "test-secret"
user := &User{Username: "alice", Groups: []string{"devs"}}
sid := NewSessionID()
idToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-payload.test-sig"
cookie := mainCookie(t, CreateSessionCookie(user, sid, idToken, secret, 1*time.Hour, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil for cookie with ID token")
}
if parsed.User.Username != "alice" {
t.Errorf("username = %q, want %q", parsed.User.Username, "alice")
}
if parsed.IDToken != idToken {
t.Errorf("IDToken = %q, want %q", parsed.IDToken, idToken)
}
if parsed.SID != sid {
t.Errorf("SID = %q, want %q", parsed.SID, sid)
}
}
func TestSessionIDToken_NoIDToken(t *testing.T) {
secret := "test-secret"
user := &User{Username: "alice"}
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", secret, 1*time.Hour, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil")
}
if parsed.IDToken != "" {
t.Errorf("IDToken = %q, want empty string", parsed.IDToken)
}
}
func TestCreateSessionCookie_WithSID(t *testing.T) {
secret := "test-secret"
user := &User{Username: "alice"}
sid := "abcdef0123456789abcdef0123456789"
cookie := mainCookie(t, CreateSessionCookie(user, sid, "", secret, 1*time.Hour, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil")
}
if parsed.SID != sid {
t.Errorf("SID = %q, want %q", parsed.SID, sid)
}
}
func TestParseSessionCookie_LegacyCookieWithoutSID(t *testing.T) {
// Simulate a pre-upgrade cookie that doesn't have the "s" field
secret := "test-secret"
payload := struct {
Username string `json:"u"`
Groups []string `json:"g,omitempty"`
ExpiresAt int64 `json:"e"`
IDToken string `json:"t,omitempty"`
}{
Username: "alice",
Groups: []string{"devs"},
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
encoded := base64.RawURLEncoding.EncodeToString(data)
sig := signData(encoded, secret)
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: DefaultCookieName,
Value: encoded + "." + sig,
})
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie should handle legacy cookies without sid")
}
if parsed.User.Username != "alice" {
t.Errorf("username = %q, want %q", parsed.User.Username, "alice")
}
if parsed.SID != "" {
t.Errorf("SID = %q, want empty string for legacy cookie", parsed.SID)
}
}
func TestNewSessionID_Unique(t *testing.T) {
id1 := NewSessionID()
id2 := NewSessionID()
if id1 == id2 {
t.Error("NewSessionID should produce unique values")
}
if len(id1) != 32 {
t.Errorf("NewSessionID length = %d, want 32 (16 bytes hex)", len(id1))
}
if len(id2) != 32 {
t.Errorf("NewSessionID length = %d, want 32 (16 bytes hex)", len(id2))
}
}
func TestParseSessionCookie_ExpiresAt(t *testing.T) {
secret := "test-secret"
user := &User{Username: "alice"}
ttl := 2 * time.Hour
cookie := mainCookie(t, CreateSessionCookie(user, NewSessionID(), "", secret, ttl, false))
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil")
}
// ExpiresAt should be approximately now + ttl (within a few seconds)
expected := time.Now().Add(ttl)
diff := parsed.ExpiresAt.Sub(expected)
if diff < -5*time.Second || diff > 5*time.Second {
t.Errorf("ExpiresAt off by %v, want within 5s of now+2h", diff)
}
}
func TestCreateSessionCookie_DropsIDTokenWhenTooLarge(t *testing.T) {
secret := "test-secret"
// Build a cookie that's over maxCookieSize bytes with the ID token, but under without it.
groups := make([]string, 40)
for i := range groups {
groups[i] = "org:engineering:team-" + strings.Repeat("x", 10)
}
user := &User{Username: "alice@example.com", Groups: groups}
sid := NewSessionID()
largeIDToken := strings.Repeat("x", 2000)
// First verify the cookie WITHOUT ID token fits in a single cookie
smallCookie := mainCookie(t, CreateSessionCookie(user, sid, "", secret, 1*time.Hour, false))
if len(smallCookie.Value) > maxCookieSize {
t.Skipf("groups alone exceed %d bytes (%d) — can't test ID token drop", maxCookieSize, len(smallCookie.Value))
}
// Now create with the large ID token — should trigger the drop and still fit one cookie
cookie := mainCookie(t, CreateSessionCookie(user, sid, largeIDToken, secret, 1*time.Hour, false))
// Parse and verify the cookie is still valid
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil for size-capped cookie")
}
if parsed.User.Username != "alice@example.com" {
t.Errorf("username = %q, want alice@example.com", parsed.User.Username)
}
if parsed.SID != sid {
t.Errorf("SID lost after size cap")
}
if parsed.IDToken == largeIDToken {
t.Error("ID token should have been dropped to fit cookie size limit")
}
if len(cookie.Value) > maxCookieSize {
t.Errorf("cookie still %d bytes after dropping ID token (limit %d)", len(cookie.Value), maxCookieSize)
}
}
// hugeUser builds a user whose signed cookie can't fit in one cookie even after
// the ID token is dropped, forcing the chunked path. Many large groups do it.
func hugeUser() *User {
groups := make([]string, 300)
for i := range groups {
groups[i] = "org:platform:engineering:team-" + strings.Repeat("x", 20)
}
return &User{Username: "alice@example.com", Groups: groups}
}
func TestCreateSessionCookie_ChunksWhenTooLarge(t *testing.T) {
secret := "test-secret"
sid := NewSessionID()
cookies := CreateSessionCookie(hugeUser(), sid, "", secret, 1*time.Hour, true)
if len(cookies) < 3 {
t.Fatalf("expected chunked cookies (chunks + meta), got %d", len(cookies))
}
var sawMeta bool
for _, c := range cookies {
if c.MaxAge < 0 {
continue // housekeeping deletion cookie (stale main)
}
if len(c.Value) > maxCookieSize {
t.Errorf("chunk %q is %d bytes, exceeds limit %d", c.Name, len(c.Value), maxCookieSize)
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
t.Errorf("chunk %q missing standard attributes (HttpOnly/Secure/SameSite)", c.Name)
}
if c.Name == DefaultCookieName+"_chunks" {
sawMeta = true
}
}
if !sawMeta {
t.Error("chunked cookie set is missing the _chunks meta-cookie")
}
// Reassembly round-trips back to the original identity.
req := httptest.NewRequest("GET", "/", nil)
addCookies(req, cookies)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil for chunked cookies")
}
if parsed.User.Username != "alice@example.com" {
t.Errorf("username = %q, want alice@example.com", parsed.User.Username)
}
if len(parsed.User.Groups) != 300 {
t.Errorf("groups = %d, want 300", len(parsed.User.Groups))
}
if parsed.SID != sid {
t.Errorf("SID = %q, want %q", parsed.SID, sid)
}
}
func TestParseSessionCookie_DroppedChunkFails(t *testing.T) {
secret := "test-secret"
cookies := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, 1*time.Hour, false)
// Drop the first chunk but keep the meta-cookie claiming the full count.
req := httptest.NewRequest("GET", "/", nil)
for _, c := range cookies {
if c.Name == DefaultCookieName+"_chunk_0" {
continue
}
req.AddCookie(c)
}
if ParseSessionCookie(req, secret) != nil {
t.Error("ParseSessionCookie should return nil when a chunk is missing")
}
}
func TestParseSessionCookie_TamperedChunkFails(t *testing.T) {
secret := "test-secret"
cookies := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, 1*time.Hour, false)
req := httptest.NewRequest("GET", "/", nil)
for _, c := range cookies {
if c.Name == DefaultCookieName+"_chunk_1" && len(c.Value) > 0 {
// Flip a byte in a middle chunk; the HMAC over the reassembled value must fail.
b := []byte(c.Value)
if b[0] == 'A' {
b[0] = 'B'
} else {
b[0] = 'A'
}
c.Value = string(b)
}
req.AddCookie(c)
}
if ParseSessionCookie(req, secret) != nil {
t.Error("ParseSessionCookie should return nil when a chunk is tampered")
}
}
func TestParseSessionCookie_ChunkCountCapped(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: DefaultCookieName + "_chunks", Value: "100000"})
if ParseSessionCookie(req, "secret") != nil {
t.Error("ParseSessionCookie should reject an out-of-range chunk count")
}
}
func TestParseSessionCookie_PrefersMainCookieOverStaleChunks(t *testing.T) {
secret := "test-secret"
// A valid single cookie for a shrunk session...
main := mainCookie(t, CreateSessionCookie(&User{Username: "bob"}, NewSessionID(), "", secret, 1*time.Hour, false))
// ...with stale chunk cookies from a previous larger session still in the browser.
stale := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, 1*time.Hour, false)
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(main)
addCookies(req, stale)
parsed := ParseSessionCookie(req, secret)
if parsed == nil {
t.Fatal("ParseSessionCookie returned nil despite a valid main cookie")
}
if parsed.User.Username != "bob" {
t.Errorf("username = %q, want bob (main cookie should win over stale chunks)", parsed.User.Username)
}
}
func TestClearSessionCookie_ClearsChunks(t *testing.T) {
secret := "test-secret"
cookies := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, 1*time.Hour, false)
// Simulate the browser sending the chunked session back on logout.
req := httptest.NewRequest("POST", "/auth/logout", nil)
addCookies(req, cookies)
cleared := ClearSessionCookie(req)
// Every set cookie (chunks + meta + main) must get a clearing counterpart.
clearedNames := map[string]bool{}
for _, c := range cleared {
if c.MaxAge != -1 || c.Value != "" {
t.Errorf("clearing cookie %q should have MaxAge=-1 and empty value, got MaxAge=%d value=%q", c.Name, c.MaxAge, c.Value)
}
clearedNames[c.Name] = true
}
if !clearedNames[DefaultCookieName] {
t.Error("logout did not clear the main cookie")
}
if !clearedNames[DefaultCookieName+"_chunks"] {
t.Error("logout did not clear the _chunks meta-cookie")
}
for _, c := range cookies {
if strings.Contains(c.Name, "_chunk_") && !clearedNames[c.Name] {
t.Errorf("logout did not clear chunk cookie %q — session would survive logout", c.Name)
}
}
// After applying the clears, the (now empty) chunk cookies must not reassemble.
logoutReq := httptest.NewRequest("GET", "/", nil)
addCookies(logoutReq, cleared)
if ParseSessionCookie(logoutReq, secret) != nil {
t.Error("session still parses after logout cleared the chunk cookies")
}
}
func TestCreateSessionCookie_ChunkedExpiresStaleMain(t *testing.T) {
secret := "test-secret"
cookies := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, time.Hour, false)
var clearedMain bool
for _, c := range cookies {
if c.Name == DefaultCookieName {
if c.MaxAge != -1 || c.Value != "" {
t.Errorf("chunked set should expire the main cookie, got MaxAge=%d valueLen=%d", c.MaxAge, len(c.Value))
}
clearedMain = true
}
}
if !clearedMain {
t.Error("chunked set does not expire the stale main cookie — an old single session would win on parse")
}
}
func TestCreateSessionCookie_SingleClearsStaleChunkMeta(t *testing.T) {
secret := "test-secret"
cookies := CreateSessionCookie(&User{Username: "bob"}, NewSessionID(), "", secret, time.Hour, false)
var clearedMeta bool
for _, c := range cookies {
if c.Name == DefaultCookieName+"_chunks" {
if c.MaxAge != -1 {
t.Errorf("single-cookie set should expire the _chunks meta-cookie, got MaxAge=%d", c.MaxAge)
}
clearedMeta = true
}
}
if !clearedMeta {
t.Error("single-cookie set does not clear the _chunks meta-cookie — stale chunks could resurrect")
}
}
// TestRepresentationSwitchDoesNotResurrectStaleSession models a browser cookie
// jar across a chunked→single transition: applying the fresh single-cookie set
// must clear the chunk representation so an older chunked session can't be
// reassembled even if the new main cookie is later removed.
func TestRepresentationSwitchDoesNotResurrectStaleSession(t *testing.T) {
secret := "test-secret"
stale := CreateSessionCookie(hugeUser(), NewSessionID(), "", secret, time.Hour, false) // chunked
fresh := CreateSessionCookie(&User{Username: "bob"}, NewSessionID(), "", secret, time.Hour, false) // single
jar := map[string]string{}
apply := func(cs []*http.Cookie) {
for _, c := range cs {
if c.MaxAge < 0 {
delete(jar, c.Name)
} else {
jar[c.Name] = c.Value
}
}
}
reqFromJar := func() *http.Request {
req := httptest.NewRequest("GET", "/", nil)
for name, val := range jar {
req.AddCookie(&http.Cookie{Name: name, Value: val})
}
return req
}
apply(stale)
apply(fresh)
parsed := ParseSessionCookie(reqFromJar(), secret)
if parsed == nil || parsed.User.Username != "bob" {
t.Fatalf("expected fresh single session for bob, got %+v", parsed)
}
// Main cookie removed out-of-band; stale chunks must not resurrect a session.
delete(jar, DefaultCookieName)
if ParseSessionCookie(reqFromJar(), secret) != nil {
t.Error("stale chunks resurrected a session after the main cookie was removed")
}
}
// TestCreateSessionCookie_RefusesOversizedSession verifies that a session too
// large to round-trip (more than maxCookieChunks pieces) is not issued as a
// chunk set the parser would reject; only clearing cookies are returned.
func TestCreateSessionCookie_RefusesOversizedSession(t *testing.T) {
secret := "test-secret"
groups := make([]string, 2000)
for i := range groups {
groups[i] = "org:engineering:platform:team-" + strings.Repeat("x", 16)
}
cookies := CreateSessionCookie(&User{Username: "alice", Groups: groups}, NewSessionID(), "", secret, time.Hour, true)
for _, c := range cookies {
if c.Value != "" {
t.Errorf("oversized session should issue only clearing cookies, but %q carries a value (len %d)", c.Name, len(c.Value))
}
if c.MaxAge != -1 {
t.Errorf("clearing cookie %q should have MaxAge=-1, got %d", c.Name, c.MaxAge)
}
}
req := httptest.NewRequest("GET", "/", nil)
addCookies(req, cookies)
if ParseSessionCookie(req, secret) != nil {
t.Error("oversized session unexpectedly parsed to a valid session")
}
}