Cleanup [dec2025] (#101)

* Cleanup excessive comments.

* Remove leftovers hanging around from previous refactor

* Improve test coverage
This commit is contained in:
2025-12-09 01:38:02 +00:00
committed by GitHub
parent 9126c74723
commit c474bbafd6
172 changed files with 25136 additions and 52201 deletions
-130
View File
@@ -1,130 +0,0 @@
package core
import (
"testing"
)
// TestCookiePrefix tests that custom cookie prefixes work correctly
func TestCookiePrefix(t *testing.T) {
tests := []struct {
name string
cookiePrefix string
wantMain string
wantAccess string
wantRefresh string
wantID string
}{
{
name: "Default prefix",
cookiePrefix: "",
wantMain: "_oidc_raczylo_m",
wantAccess: "_oidc_raczylo_a",
wantRefresh: "_oidc_raczylo_r",
wantID: "_oidc_raczylo_id",
},
{
name: "Custom prefix",
cookiePrefix: "_oidc_myapp_",
wantMain: "_oidc_myapp_m",
wantAccess: "_oidc_myapp_a",
wantRefresh: "_oidc_myapp_r",
wantID: "_oidc_myapp_id",
},
{
name: "Custom prefix without underscore suffix",
cookiePrefix: "myapp",
wantMain: "myappm",
wantAccess: "myappa",
wantRefresh: "myappr",
wantID: "myappid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &MockLogger{}
chunkManager := &MockChunkManager{}
sm, err := NewSessionManager(
"0123456789abcdef0123456789abcdef0123456789abcdef",
false,
"",
tt.cookiePrefix,
0,
logger,
chunkManager,
)
if err != nil {
t.Fatalf("Failed to create session manager: %v", err)
}
// Test cookie names
if got := sm.MainCookieName(); got != tt.wantMain {
t.Errorf("MainCookieName() = %q, want %q", got, tt.wantMain)
}
if got := sm.AccessTokenCookie(); got != tt.wantAccess {
t.Errorf("AccessTokenCookie() = %q, want %q", got, tt.wantAccess)
}
if got := sm.RefreshTokenCookie(); got != tt.wantRefresh {
t.Errorf("RefreshTokenCookie() = %q, want %q", got, tt.wantRefresh)
}
if got := sm.IDTokenCookie(); got != tt.wantID {
t.Errorf("IDTokenCookie() = %q, want %q", got, tt.wantID)
}
})
}
}
// TestMultipleInstancesWithDifferentPrefixes tests that multiple session managers
// with different prefixes can coexist (addresses issue #87)
func TestMultipleInstancesWithDifferentPrefixes(t *testing.T) {
logger := &MockLogger{}
chunkManager1 := &MockChunkManager{}
chunkManager2 := &MockChunkManager{}
// Create two session managers with different prefixes
sm1, err := NewSessionManager(
"0123456789abcdef0123456789abcdef0123456789abcdef",
false,
"example.com",
"_oidc_app1_",
0,
logger,
chunkManager1,
)
if err != nil {
t.Fatalf("Failed to create session manager 1: %v", err)
}
sm2, err := NewSessionManager(
"fedcba9876543210fedcba9876543210fedcba9876543210", // Different encryption key
false,
"example.com",
"_oidc_app2_",
0,
logger,
chunkManager2,
)
if err != nil {
t.Fatalf("Failed to create session manager 2: %v", err)
}
// Verify they have different cookie names
if sm1.MainCookieName() == sm2.MainCookieName() {
t.Error("Expected different main cookie names for different instances")
}
// Verify cookie name patterns
expectedPrefix1 := "_oidc_app1_"
expectedPrefix2 := "_oidc_app2_"
if sm1.MainCookieName() != expectedPrefix1+"m" {
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix1+"m", sm1.MainCookieName())
}
if sm2.MainCookieName() != expectedPrefix2+"m" {
t.Errorf("Expected main cookie name %s, got %s", expectedPrefix2+"m", sm2.MainCookieName())
}
t.Log("✓ Session isolation verified: Different cookie prefixes prevent session sharing")
}
-357
View File
@@ -1,357 +0,0 @@
// Package core provides core session management functionality for the OIDC middleware
package core
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/sessions"
)
const (
minEncryptionKeyLength = 32
absoluteSessionTimeout = 24 * time.Hour
)
// SessionManager handles session creation, management and cleanup
type SessionManager struct {
sessionPool sync.Pool
store sessions.Store
logger Logger
chunkManager ChunkManager
cookieDomain string
cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_")
sessionMaxAge time.Duration // Maximum session age (default: 24 hours)
cleanupMutex sync.RWMutex
forceHTTPS bool
cleanupDone bool
}
// Logger interface for dependency injection
type Logger interface {
Debug(msg string)
Debugf(format string, args ...interface{})
Error(msg string)
Errorf(format string, args ...interface{})
}
// ChunkManager interface for chunk operations
type ChunkManager interface {
CleanupExpiredSessions()
}
// SessionData interface for session data operations
type SessionData interface {
Reset()
SetManager(manager *SessionManager)
SetAuthenticated(bool) error
GetAuthenticated() bool
GetAccessToken() string
GetRefreshToken() string
GetIDToken() string
GetEmail() string
GetCSRF() string
GetNonce() string
GetCodeVerifier() string
GetIncomingPath() string
GetRedirectCount() int
IncrementRedirectCount()
ResetRedirectCount()
MarkDirty()
IsDirty() bool
Save(r *http.Request, w http.ResponseWriter) error
Clear(r *http.Request, w http.ResponseWriter) error
GetRefreshTokenIssuedAt() time.Time
returnToPoolSafely()
}
// NewSessionManager creates a new SessionManager instance with secure defaults.
// It initializes the cookie store with encryption, sets up session pooling,
// and configures chunk management for large tokens.
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger Logger, chunkManager ChunkManager) (*SessionManager, error) {
if len(encryptionKey) < minEncryptionKeyLength {
return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength)
}
// Set default cookie prefix if not provided
if cookiePrefix == "" {
cookiePrefix = "_oidc_raczylo_"
}
// Set default session max age if not provided (24 hours for backward compatibility)
if sessionMaxAge == 0 {
sessionMaxAge = absoluteSessionTimeout
}
sm := &SessionManager{
store: sessions.NewCookieStore([]byte(encryptionKey)),
forceHTTPS: forceHTTPS,
cookieDomain: cookieDomain,
cookiePrefix: cookiePrefix,
sessionMaxAge: sessionMaxAge,
logger: logger,
chunkManager: chunkManager,
}
sm.sessionPool.New = func() interface{} {
return NewSessionData(sm, logger)
}
return sm, nil
}
// GetSession retrieves or creates a session for the request
func (sm *SessionManager) GetSession(r *http.Request) (SessionData, error) {
sessionDataInterface := sm.sessionPool.Get()
sessionData, ok := sessionDataInterface.(SessionData)
if !ok || sessionData == nil {
sessionData = NewSessionData(sm, sm.logger)
}
// Initialize the session data
err := sm.initializeSession(sessionData, r)
if err != nil {
sm.sessionPool.Put(sessionData)
return nil, fmt.Errorf("failed to initialize session: %w", err)
}
return sessionData, nil
}
// initializeSession initializes session data from HTTP request
func (sm *SessionManager) initializeSession(sessionData SessionData, r *http.Request) error {
// Reset session data to clean state
sessionData.Reset()
sessionData.SetManager(sm)
// Load session data from cookies
session, err := sm.store.Get(r, sm.MainCookieName())
if err != nil {
sm.logger.Debugf("Error getting main session: %v", err)
return nil // Not a fatal error, will create new session
}
// Extract and set session values
if auth, ok := session.Values["authenticated"].(bool); ok {
_ = sessionData.SetAuthenticated(auth) // Safe to ignore: session initialization error
}
return nil
}
// CleanupOldCookies removes old/expired cookies from the response
func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Request) {
sm.cleanupMutex.Lock()
defer sm.cleanupMutex.Unlock()
if sm.cleanupDone {
return
}
sm.logger.Debug("Starting cleanup of old session cookies")
oldCookieNames := []string{
"_oidc_session_old_v1",
"_oidc_session_legacy",
"_oidc_auth_state_old",
"_legacy_oidc_token",
"_old_session_chunks",
}
for _, cookieName := range oldCookieNames {
if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
sm.logger.Debugf("Expiring old cookie: %s", cookieName)
expiredCookie := &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
Domain: sm.cookieDomain,
Expires: time.Unix(0, 0),
MaxAge: -1,
Secure: sm.shouldUseSecureCookies(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, expiredCookie)
}
}
sm.cleanupDone = true
}
// PeriodicChunkCleanup performs comprehensive session maintenance and cleanup
func (sm *SessionManager) PeriodicChunkCleanup() {
if sm == nil || sm.logger == nil {
return
}
sm.logger.Debug("Starting comprehensive session cleanup cycle")
cleanupStart := time.Now()
var orphanedChunks, expiredSessions, cleanupErrors int
if sm.store != nil {
if cookieStore, ok := sm.store.(*sessions.CookieStore); ok {
sm.logger.Debug("Running session store cleanup")
_ = cookieStore
}
}
// Cleanup expired sessions in chunk manager to prevent memory leaks
if sm.chunkManager != nil {
sm.chunkManager.CleanupExpiredSessions()
}
poolCleaned := 0
for i := 0; i < 10; i++ {
if poolSession := sm.sessionPool.Get(); poolSession != nil {
if sessionData, ok := poolSession.(SessionData); ok && sessionData != nil {
sessionData.Reset()
poolCleaned++
}
sm.sessionPool.Put(poolSession)
}
}
cleanupDuration := time.Since(cleanupStart)
sm.logger.Debugf("Session cleanup completed in %v: pool_cleaned=%d, orphaned_chunks=%d, expired_sessions=%d, errors=%d",
cleanupDuration, poolCleaned, orphanedChunks, expiredSessions, cleanupErrors)
}
// ValidateSessionHealth performs comprehensive validation of session integrity
func (sm *SessionManager) ValidateSessionHealth(sessionData SessionData) error {
if sessionData == nil {
return fmt.Errorf("session data is nil")
}
// Check if user is authenticated
if !sessionData.GetAuthenticated() {
return nil // Not authenticated is not an error
}
// Validate token formats
if accessToken := sessionData.GetAccessToken(); accessToken != "" {
if err := sm.validateTokenFormat(accessToken, "access"); err != nil {
return fmt.Errorf("invalid access token format: %w", err)
}
}
if idToken := sessionData.GetIDToken(); idToken != "" {
if err := sm.validateTokenFormat(idToken, "id"); err != nil {
return fmt.Errorf("invalid ID token format: %w", err)
}
}
// Check for session tampering
if err := sm.detectSessionTampering(sessionData); err != nil {
return fmt.Errorf("session tampering detected: %w", err)
}
return nil
}
// validateTokenFormat validates the format of JWT tokens
func (sm *SessionManager) validateTokenFormat(token, tokenType string) error {
if token == "" {
return nil
}
// JWT tokens should have exactly 3 parts separated by dots
parts := strings.Split(token, ".")
if len(parts) != 3 {
return fmt.Errorf("%s token is not a valid JWT format", tokenType)
}
// Each part should be non-empty
for i, part := range parts {
if part == "" {
return fmt.Errorf("%s token part %d is empty", tokenType, i+1)
}
}
return nil
}
// detectSessionTampering detects potential tampering in session data
func (sm *SessionManager) detectSessionTampering(sessionData SessionData) error {
email := sessionData.GetEmail()
authenticated := sessionData.GetAuthenticated()
// If authenticated but no email, that's suspicious
if authenticated && email == "" {
return fmt.Errorf("authenticated session without email")
}
// If email exists but not authenticated, that's also suspicious
if !authenticated && email != "" {
sm.logger.Debugf("Warning: Email exists (%s) but session not authenticated", email)
}
return nil
}
// GetSessionMetrics returns metrics about session usage
func (sm *SessionManager) GetSessionMetrics() map[string]interface{} {
metrics := make(map[string]interface{})
metrics["store_type"] = fmt.Sprintf("%T", sm.store)
metrics["cookie_domain"] = sm.cookieDomain
metrics["force_https"] = sm.forceHTTPS
metrics["cleanup_done"] = sm.cleanupDone
return metrics
}
// shouldUseSecureCookies determines if cookies should be secure based on request
func (sm *SessionManager) shouldUseSecureCookies(r *http.Request) bool {
if sm.forceHTTPS {
return true
}
// Check if the request came over HTTPS
if r.TLS != nil {
return true
}
// Check X-Forwarded-Proto header
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
return true
}
return false
}
// getSessionOptions returns session options for the given security context
func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
return &sessions.Options{
Path: "/",
Domain: sm.cookieDomain,
MaxAge: int(sm.sessionMaxAge.Seconds()),
Secure: isSecure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// Cookie name methods - these now use the configurable prefix
func (sm *SessionManager) MainCookieName() string { return sm.cookiePrefix + "m" }
func (sm *SessionManager) AccessTokenCookie() string { return sm.cookiePrefix + "a" }
func (sm *SessionManager) RefreshTokenCookie() string { return sm.cookiePrefix + "r" }
func (sm *SessionManager) IDTokenCookie() string { return sm.cookiePrefix + "id" }
// Package-level functions for backward compatibility (use default prefix)
// These are deprecated and will be removed in a future version
func MainCookieName() string { return "_oidc_raczylo_m" }
func AccessTokenCookie() string { return "_oidc_raczylo_a" }
func RefreshTokenCookie() string { return "_oidc_raczylo_r" }
func IDTokenCookie() string { return "_oidc_raczylo_id" }
// NewSessionData creates a new session data instance
func NewSessionData(manager *SessionManager, logger Logger) SessionData {
// This function should be implemented to return a concrete SessionData implementation
// The actual implementation depends on the SessionData struct definition
return nil
}
File diff suppressed because it is too large Load Diff
-264
View File
@@ -1,264 +0,0 @@
// Package crypto provides cryptographic operations for session management
package crypto
import (
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"strings"
)
// MemoryPools interface for memory management
type MemoryPools interface {
GetCompressionBuffer() *bytes.Buffer
PutCompressionBuffer(*bytes.Buffer)
GetHTTPResponseBuffer() []byte
PutHTTPResponseBuffer([]byte)
}
// SessionCrypto provides cryptographic operations for session data
type SessionCrypto struct {
memoryPools MemoryPools
}
// NewSessionCrypto creates a new session crypto instance
func NewSessionCrypto(memoryPools MemoryPools) *SessionCrypto {
return &SessionCrypto{
memoryPools: memoryPools,
}
}
// GenerateSecureRandomString creates a cryptographically secure random string.
// It generates random bytes using crypto/rand and encodes them as hexadecimal.
// This is used for session IDs and other security-sensitive random values.
func (sc *SessionCrypto) GenerateSecureRandomString(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// CompressToken compresses a JWT token using gzip compression if beneficial.
// It validates the token format, attempts compression, and verifies the compressed
// data can be decompressed correctly. Only compresses if it reduces size.
func (sc *SessionCrypto) CompressToken(token string) string {
if token == "" {
return token
}
// Validate JWT format (should have exactly 2 dots)
dotCount := strings.Count(token, ".")
if dotCount != 2 {
return token
}
// Don't try to compress extremely large tokens
if len(token) > 50*1024 {
return token
}
b := sc.memoryPools.GetCompressionBuffer()
defer sc.memoryPools.PutCompressionBuffer(b)
gz := gzip.NewWriter(b)
written, err := gz.Write([]byte(token))
if err != nil || written != len(token) {
return token
}
if err := gz.Close(); err != nil {
return token
}
compressedBytes := b.Bytes()
if len(compressedBytes) == 0 {
return token
}
compressed := base64.StdEncoding.EncodeToString(compressedBytes)
// Only use compression if it actually reduces size
if len(compressed) >= len(token) {
return token
}
// Verify compression integrity by attempting decompression
decompressed := sc.decompressTokenInternal(compressed)
if decompressed != token {
return token
}
// Final validation of decompressed token
if strings.Count(decompressed, ".") != 2 {
return token
}
return compressed
}
// DecompressToken decompresses a previously compressed token string.
// It decodes the base64 data, validates gzip headers, and decompresses safely
// with size limits to prevent compression bombs.
func (sc *SessionCrypto) DecompressToken(compressed string) string {
return sc.decompressTokenInternal(compressed)
}
// decompressTokenInternal is the internal decompression function.
// Separated internal function for integrity verification during compression.
// It performs the actual decompression logic with proper resource management.
func (sc *SessionCrypto) decompressTokenInternal(compressed string) string {
if compressed == "" {
return compressed
}
// Prevent decompression of extremely large inputs
if len(compressed) > 100*1024 {
return compressed
}
data, err := base64.StdEncoding.DecodeString(compressed)
if err != nil {
return compressed
}
if len(data) == 0 {
return compressed
}
// Validate gzip header
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
return compressed
}
readerBuf := sc.memoryPools.GetHTTPResponseBuffer()
defer sc.memoryPools.PutHTTPResponseBuffer(readerBuf)
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return compressed
}
defer func() {
if closeErr := gz.Close(); closeErr != nil {
_ = closeErr
}
}()
// Limit decompressed size to prevent compression bombs
limitedReader := io.LimitReader(gz, 500*1024)
// Optimize for large buffer reuse
if cap(readerBuf) >= 512*1024 {
readerBuf = readerBuf[:cap(readerBuf)]
n, err := limitedReader.Read(readerBuf)
if err != nil && err != io.EOF {
return compressed
}
decompressed := readerBuf[:n]
return string(decompressed)
}
decompressed, err := io.ReadAll(limitedReader)
if err != nil {
return compressed
}
if len(decompressed) == 0 {
return compressed
}
decompressedStr := string(decompressed)
// Validate the decompressed token is a valid JWT
if decompressedStr != "" && strings.Count(decompressedStr, ".") != 2 {
return compressed
}
return decompressedStr
}
// ValidateTokenFormat validates that a token has the correct JWT format
func (sc *SessionCrypto) ValidateTokenFormat(token string) bool {
if token == "" {
return false
}
// JWT tokens should have exactly 3 parts separated by dots
parts := strings.Split(token, ".")
if len(parts) != 3 {
return false
}
// Each part should be non-empty
for _, part := range parts {
if part == "" {
return false
}
}
return true
}
// IsTokenCompressed checks if a token appears to be compressed
func (sc *SessionCrypto) IsTokenCompressed(token string) bool {
if token == "" {
return false
}
// JWT tokens have exactly 2 dots, compressed tokens don't
if strings.Count(token, ".") == 2 {
return false
}
// Try to decode as base64
data, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return false
}
// Check for gzip header
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
return true
}
return false
}
// SecureWipeBytes securely wipes sensitive data from memory
func (sc *SessionCrypto) SecureWipeBytes(data []byte) {
for i := range data {
data[i] = 0
}
}
// SecureWipeString securely wipes sensitive string data
func (sc *SessionCrypto) SecureWipeString(s *string) {
if s != nil {
*s = ""
}
}
// Utility functions that don't require instance state
// Min returns the minimum of two integers
func Min(a, b int) int {
if a < b {
return a
}
return b
}
// GenerateSecureRandomString creates a cryptographically secure random string without dependencies
func GenerateSecureRandomString(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
-900
View File
@@ -1,900 +0,0 @@
package crypto
import (
"bytes"
"crypto/rand"
"encoding/base64"
"strings"
"testing"
)
// Mock memory pools for testing
type MockMemoryPools struct{}
func (mp *MockMemoryPools) GetCompressionBuffer() *bytes.Buffer {
return &bytes.Buffer{}
}
func (mp *MockMemoryPools) PutCompressionBuffer(*bytes.Buffer) {
// Mock implementation - nothing to do
}
func (mp *MockMemoryPools) GetHTTPResponseBuffer() []byte {
return make([]byte, 32768) // 32KB buffer
}
func (mp *MockMemoryPools) PutHTTPResponseBuffer([]byte) {
// Mock implementation - nothing to do
}
// TestGenerateSecureRandomString tests secure random string generation
func TestGenerateSecureRandomString(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
length int
expectError bool
description string
}{
{
name: "Valid length",
length: 16,
expectError: false,
description: "Should generate random string of correct length",
},
{
name: "Minimum length",
length: 1,
expectError: false,
description: "Should handle minimum length",
},
{
name: "Zero length",
length: 0,
expectError: false,
description: "Should handle zero length",
},
{
name: "Large length",
length: 1024,
expectError: false,
description: "Should handle large length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := sc.GenerateSecureRandomString(tt.length)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for %s, got nil", tt.description)
}
return
}
if err != nil {
t.Errorf("Unexpected error for %s: %v", tt.description, err)
return
}
// Check length (hex encoding doubles the length)
expectedLen := tt.length * 2
if len(result) != expectedLen {
t.Errorf("Expected length %d, got %d for %s", expectedLen, len(result), tt.description)
}
// Check that result is hex
for _, char := range result {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
t.Errorf("Result contains non-hex character: %c", char)
break
}
}
})
}
}
// TestGenerateSecureRandomStringUniqueness tests that generated strings are unique
func TestGenerateSecureRandomStringUniqueness(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
// Generate multiple strings and check uniqueness
generated := make(map[string]bool)
for i := 0; i < 100; i++ {
result, err := sc.GenerateSecureRandomString(16)
if err != nil {
t.Fatalf("Failed to generate random string: %v", err)
}
if generated[result] {
t.Errorf("Generated duplicate string: %s", result)
}
generated[result] = true
}
}
// TestTokenCompressionIntegrity tests token compression and decompression
func TestTokenCompressionIntegrity(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
token string
expectValid bool
description string
}{
{
name: "Valid JWT small",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
expectValid: true,
description: "Should compress and decompress small JWT correctly",
},
{
name: "Valid JWT large",
token: createLargeJWT(2000),
expectValid: true,
description: "Should compress and decompress large JWT correctly",
},
{
name: "Invalid token - no dots",
token: "invalidtoken",
expectValid: false,
description: "Should not compress token without dots",
},
{
name: "Invalid token - wrong number of dots",
token: "header.payload",
expectValid: false,
description: "Should not compress token with wrong number of dots",
},
{
name: "Empty token",
token: "",
expectValid: false,
description: "Should handle empty token",
},
{
name: "Oversized token",
token: createOversizedToken(),
expectValid: false,
description: "Should reject oversized tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := sc.CompressToken(tt.token)
if !tt.expectValid {
// For invalid tokens, compression should return original
if compressed != tt.token {
t.Errorf("Expected compression to return original for invalid token, got different result")
}
return
}
// For valid tokens, test round-trip integrity
decompressed := sc.DecompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Token integrity lost: original length=%d, compressed length=%d, decompressed length=%d",
len(tt.token), len(compressed), len(decompressed))
}
// Test that decompression is idempotent
decompressed2 := sc.DecompressToken(decompressed)
if decompressed2 != tt.token {
t.Errorf("Decompression not idempotent: %d != %d", len(decompressed2), len(tt.token))
}
})
}
}
// TestTokenCompressionCorruptionDetection tests corruption detection
func TestTokenCompressionCorruptionDetection(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
corruptionTests := []struct {
name string
corruptedInput string
expectOriginal bool
description string
}{
{
name: "Corrupted base64",
corruptedInput: "invalid-base64!",
expectOriginal: true,
description: "Should return original for corrupted base64",
},
{
name: "Truncated compressed data",
corruptedInput: "H4sI", // Truncated gzip header
expectOriginal: true,
description: "Should return original for truncated data",
},
{
name: "Invalid gzip data",
corruptedInput: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
expectOriginal: true,
description: "Should return original for invalid gzip data",
},
{
name: "Empty compressed data",
corruptedInput: "",
expectOriginal: true,
description: "Should handle empty compressed data",
},
}
for _, tt := range corruptionTests {
t.Run(tt.name, func(t *testing.T) {
result := sc.DecompressToken(tt.corruptedInput)
if tt.expectOriginal && result != tt.corruptedInput {
t.Errorf("Expected decompression to return original corrupted input, got: %q", result)
}
})
}
// Test that valid compression still works
t.Run("Valid compression verification", func(t *testing.T) {
validJWT := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
compressed := sc.CompressToken(validJWT)
decompressed := sc.DecompressToken(compressed)
if decompressed != validJWT {
t.Errorf("Valid compression/decompression failed: %q != %q", decompressed, validJWT)
}
})
}
// TestCompressionEfficiency tests that compression only occurs when beneficial
func TestCompressionEfficiency(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
token string
shouldCompress bool
description string
}{
{
name: "Small JWT",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
shouldCompress: false, // Small tokens might not benefit from compression
description: "Small tokens should not be compressed if no benefit",
},
{
name: "Large repetitive JWT",
token: createLargeRepetitiveJWT(2000),
shouldCompress: true, // Repetitive data should compress well
description: "Large repetitive tokens should be compressed",
},
{
name: "Incompressible token",
token: createIncompressibleJWT(1000),
shouldCompress: false, // Random data won't compress well
description: "Incompressible tokens should not be compressed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compressed := sc.CompressToken(tt.token)
wasCompressed := compressed != tt.token
if tt.shouldCompress && !wasCompressed {
t.Errorf("Expected token to be compressed but it wasn't")
} else if !tt.shouldCompress && wasCompressed {
// This is okay - compression might still occur if beneficial
t.Logf("Token was compressed even though not expected (this is acceptable)")
}
// Verify decompression still works regardless
decompressed := sc.DecompressToken(compressed)
if decompressed != tt.token {
t.Errorf("Decompression failed for %s", tt.description)
}
})
}
}
// TestCompressionSizeLimits tests compression size limits
func TestCompressionSizeLimits(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
t.Run("Oversized token rejection", func(t *testing.T) {
oversizedToken := createOversizedToken()
compressed := sc.CompressToken(oversizedToken)
// Oversized tokens should not be compressed
if compressed != oversizedToken {
t.Error("Oversized token should not be compressed")
}
})
t.Run("Oversized compressed data rejection", func(t *testing.T) {
oversizedCompressed := strings.Repeat("a", 150*1024) // >100KB
decompressed := sc.DecompressToken(oversizedCompressed)
// Should return original when input is too large
if decompressed != oversizedCompressed {
t.Error("Oversized compressed data should be returned as-is")
}
})
}
// Helper functions for creating test tokens
func createLargeJWT(size int) string {
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
// Create payload that will result in desired total size
payloadSize := size - len(header) - len(signature) - 2 // -2 for dots
if payloadSize < 10 {
payloadSize = 10
}
payload := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", payloadSize*3/4)))
return header + "." + payload + "." + signature
}
func createLargeRepetitiveJWT(size int) string {
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
// Create repetitive payload that compresses well
payloadSize := size - len(header) - len(signature) - 2
if payloadSize < 10 {
payloadSize = 10
}
repetitiveData := strings.Repeat("repetitive_data_", payloadSize/16)
payload := base64.StdEncoding.EncodeToString([]byte(repetitiveData))
return header + "." + payload + "." + signature
}
func createIncompressibleJWT(size int) string {
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
// Create random payload that won't compress well
payloadSize := size - len(header) - len(signature) - 2
if payloadSize < 10 {
payloadSize = 10
}
randomBytes := make([]byte, payloadSize*3/4)
rand.Read(randomBytes)
payload := base64.StdEncoding.EncodeToString(randomBytes)
return header + "." + payload + "." + signature
}
func createOversizedToken() string {
// Create a token larger than 50KB (the limit in CompressToken)
size := 55 * 1024 // 55KB
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
payloadSize := size - len(header) - len(signature) - 2
payload := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", payloadSize*3/4)))
return header + "." + payload + "." + signature
}
// BenchmarkCompression benchmarks compression operations
func BenchmarkCompression(b *testing.B) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
b.Run("CompressLargeJWT", func(b *testing.B) {
largeToken := createLargeJWT(5000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = sc.CompressToken(largeToken)
}
})
b.Run("DecompressLargeJWT", func(b *testing.B) {
largeToken := createLargeJWT(5000)
compressed := sc.CompressToken(largeToken)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = sc.DecompressToken(compressed)
}
})
b.Run("RoundTripCompression", func(b *testing.B) {
largeToken := createLargeJWT(5000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
compressed := sc.CompressToken(largeToken)
_ = sc.DecompressToken(compressed)
}
})
}
// TestValidateTokenFormat tests JWT token format validation
func TestValidateTokenFormat(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
token string
expected bool
}{
{
name: "Valid JWT token",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
expected: true,
},
{
name: "Valid JWT with different content",
token: "header.payload.signature",
expected: true,
},
{
name: "Empty token",
token: "",
expected: false,
},
{
name: "Token with no dots",
token: "nodots",
expected: false,
},
{
name: "Token with one dot",
token: "header.payload",
expected: false,
},
{
name: "Token with four dots",
token: "header.payload.signature.extra",
expected: false,
},
{
name: "Token with empty header",
token: ".payload.signature",
expected: false,
},
{
name: "Token with empty payload",
token: "header..signature",
expected: false,
},
{
name: "Token with empty signature",
token: "header.payload.",
expected: false,
},
{
name: "Token with all empty parts",
token: "..",
expected: false,
},
{
name: "Opaque token",
token: "opaque_token_without_dots",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sc.ValidateTokenFormat(tt.token)
if result != tt.expected {
t.Errorf("ValidateTokenFormat(%q) = %v, expected %v", tt.token, result, tt.expected)
}
})
}
}
// TestIsTokenCompressed tests token compression detection
func TestIsTokenCompressed(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
token string
expected bool
}{
{
name: "Empty token",
token: "",
expected: false,
},
{
name: "Valid JWT token (uncompressed)",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
expected: false,
},
{
name: "Invalid base64",
token: "invalid!base64",
expected: false,
},
{
name: "Valid base64 but not gzip",
token: base64.StdEncoding.EncodeToString([]byte("not gzip data")),
expected: false,
},
{
name: "Valid gzip header",
token: base64.StdEncoding.EncodeToString([]byte{0x1f, 0x8b, 0x08, 0x00}), // gzip magic bytes
expected: true,
},
{
name: "Partial gzip header",
token: base64.StdEncoding.EncodeToString([]byte{0x1f}), // only first byte
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sc.IsTokenCompressed(tt.token)
if result != tt.expected {
t.Errorf("IsTokenCompressed(%q) = %v, expected %v", tt.token, result, tt.expected)
}
})
}
// Test with actual compressed token
t.Run("Real compressed token", func(t *testing.T) {
originalToken := createLargeJWT(2000)
compressedToken := sc.CompressToken(originalToken)
// If compression occurred (token changed), it should be detected as compressed
if compressedToken != originalToken {
if !sc.IsTokenCompressed(compressedToken) {
t.Error("Failed to detect actual compressed token")
}
}
// Original token should not be detected as compressed
if sc.IsTokenCompressed(originalToken) {
t.Error("Original JWT detected as compressed")
}
})
}
// TestSecureWipeBytes tests secure byte wiping
func TestSecureWipeBytes(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
data []byte
}{
{
name: "Normal byte slice",
data: []byte("sensitive data"),
},
{
name: "Empty slice",
data: []byte{},
},
{
name: "Single byte",
data: []byte{0xFF},
},
{
name: "Large data",
data: bytes.Repeat([]byte("secret"), 1000),
},
{
name: "Nil slice",
data: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a copy to verify original content
original := make([]byte, len(tt.data))
copy(original, tt.data)
// Wipe the data
sc.SecureWipeBytes(tt.data)
// Verify all bytes are zero (except for nil slice)
if tt.data != nil {
for i, b := range tt.data {
if b != 0 {
t.Errorf("Byte at index %d not wiped: got %d, expected 0", i, b)
}
}
}
// Verify we had actual data to wipe (except for empty/nil cases)
if len(original) > 0 {
hasNonZero := false
for _, b := range original {
if b != 0 {
hasNonZero = true
break
}
}
if !hasNonZero {
t.Log("Test data was already all zeros")
}
}
})
}
}
// TestSecureWipeString tests secure string wiping
func TestSecureWipeString(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
tests := []struct {
name string
input *string
expect string
}{
{
name: "Normal string",
input: func() *string { s := "sensitive data"; return &s }(),
expect: "",
},
{
name: "Empty string",
input: func() *string { s := ""; return &s }(),
expect: "",
},
{
name: "Long string",
input: func() *string { s := strings.Repeat("secret", 1000); return &s }(),
expect: "",
},
{
name: "Nil string pointer",
input: nil,
expect: "", // This test verifies no panic occurs
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Store original value for verification
var original string
if tt.input != nil {
original = *tt.input
}
// Wipe the string
sc.SecureWipeString(tt.input)
// Verify result
if tt.input != nil {
if *tt.input != tt.expect {
t.Errorf("String not wiped properly: got %q, expected %q", *tt.input, tt.expect)
}
}
// Verify we had actual data to wipe (except for nil case)
if tt.input != nil && original != "" {
t.Logf("Successfully wiped string of length %d", len(original))
}
})
}
}
// TestMin tests the minimum utility function
func TestMin(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{
name: "a smaller than b",
a: 5,
b: 10,
expected: 5,
},
{
name: "b smaller than a",
a: 15,
b: 7,
expected: 7,
},
{
name: "equal values",
a: 42,
b: 42,
expected: 42,
},
{
name: "negative values",
a: -10,
b: -5,
expected: -10,
},
{
name: "zero values",
a: 0,
b: 0,
expected: 0,
},
{
name: "mixed positive and negative",
a: -3,
b: 2,
expected: -3,
},
{
name: "large numbers",
a: 1000000,
b: 999999,
expected: 999999,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Min(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Min(%d, %d) = %d, expected %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
// TestGenerateSecureRandomStringStandalone tests the standalone random string function
func TestGenerateSecureRandomStringStandalone(t *testing.T) {
tests := []struct {
name string
length int
expectError bool
}{
{
name: "Valid length",
length: 16,
expectError: false,
},
{
name: "Zero length",
length: 0,
expectError: false,
},
{
name: "Large length",
length: 1024,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := GenerateSecureRandomString(tt.length)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
// Check length (hex encoding doubles the length)
expectedLen := tt.length * 2
if len(result) != expectedLen {
t.Errorf("Expected length %d, got %d", expectedLen, len(result))
}
// Check that result is hex
for _, char := range result {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
t.Errorf("Result contains non-hex character: %c", char)
break
}
}
})
}
// Test uniqueness
t.Run("Uniqueness test", func(t *testing.T) {
generated := make(map[string]bool)
for i := 0; i < 100; i++ {
result, err := GenerateSecureRandomString(16)
if err != nil {
t.Fatalf("Failed to generate random string: %v", err)
}
if generated[result] {
t.Errorf("Generated duplicate string: %s", result)
}
generated[result] = true
}
})
}
// TestCompressionEdgeCases tests edge cases for compression
func TestCompressionEdgeCases(t *testing.T) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
t.Run("Token with exact size limit", func(t *testing.T) {
// Create token at exactly 50KB
token := createTokenWithExactSize(50 * 1024)
compressed := sc.CompressToken(token)
// Should still attempt compression at the limit
decompressed := sc.DecompressToken(compressed)
if decompressed != token {
t.Error("Failed to handle token at size limit")
}
})
t.Run("Compressed token with exact decompression limit", func(t *testing.T) {
// Create data that decompresses to exactly 100KB
largeData := strings.Repeat("a", 100*1024)
encoded := base64.StdEncoding.EncodeToString([]byte(largeData))
result := sc.DecompressToken(encoded)
// Should return original since it's not valid gzip
if result != encoded {
t.Error("Failed to handle large non-gzip data")
}
})
}
// Helper function to create token with exact size
func createTokenWithExactSize(targetSize int) string {
header := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
// Calculate needed payload size
dotsSize := 2 // two dots
otherSize := len(header) + len(signature) + dotsSize
payloadSize := targetSize - otherSize
if payloadSize <= 0 {
payloadSize = 10 // minimum payload
}
// Create payload of exact size
payload := strings.Repeat("x", payloadSize)
return header + "." + payload + "." + signature
}
// BenchmarkRandomGeneration benchmarks random string generation
func BenchmarkRandomGeneration(b *testing.B) {
memoryPools := &MockMemoryPools{}
sc := NewSessionCrypto(memoryPools)
b.Run("Generate16Bytes", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = sc.GenerateSecureRandomString(16)
}
})
b.Run("Generate32Bytes", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = sc.GenerateSecureRandomString(32)
}
})
}
-329
View File
@@ -1,329 +0,0 @@
// Package storage provides session storage operations for the OIDC middleware
package storage
import (
"fmt"
"net/http"
"sync"
"github.com/gorilla/sessions"
)
// SessionData represents a user's authentication session with comprehensive token management.
// It handles main session data and supports large tokens that need to be
// split across multiple cookies due to browser size limitations.
type SessionData struct {
manager SessionManager
request *http.Request
mainSession *sessions.Session
accessSession *sessions.Session
refreshSession *sessions.Session
idTokenSession *sessions.Session
accessTokenChunks map[int]*sessions.Session
refreshTokenChunks map[int]*sessions.Session
idTokenChunks map[int]*sessions.Session
refreshMutex sync.Mutex
sessionMutex sync.RWMutex
dirty bool
inUse bool
}
// ChunkCleaner interface for chunk cleanup operations
type ChunkCleaner interface {
CleanupChunks(chunks map[int]*sessions.Session, w http.ResponseWriter)
}
// SessionManager interface for session management operations
type SessionManager interface {
GetSessionOptions(isSecure bool) *sessions.Options
EnhanceSessionSecurity(options *sessions.Options, r *http.Request) *sessions.Options
GetLogger() Logger
}
// Logger interface for dependency injection
type Logger interface {
Error(msg string)
Errorf(format string, args ...interface{})
}
// NewSessionData creates a new session data instance
func NewSessionData(manager SessionManager) *SessionData {
return &SessionData{
manager: manager,
accessTokenChunks: make(map[int]*sessions.Session),
refreshTokenChunks: make(map[int]*sessions.Session),
idTokenChunks: make(map[int]*sessions.Session),
refreshMutex: sync.Mutex{},
sessionMutex: sync.RWMutex{},
dirty: false,
inUse: false,
}
}
// IsDirty returns true if the session data has been modified since it was last loaded or saved.
// This is used to optimize session saves by only writing when necessary.
func (sd *SessionData) IsDirty() bool {
sd.sessionMutex.RLock()
defer sd.sessionMutex.RUnlock()
return sd.dirty
}
// MarkDirty marks the session as having pending changes that need to be saved.
// This is used when session data hasn't changed in content but should still
// trigger a session save (e.g., to ensure the cookie is re-issued).
func (sd *SessionData) MarkDirty() {
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
sd.dirty = true
}
// Save persists all session data including main session and token chunks.
// It applies security options, saves all session components, and handles
// errors gracefully by continuing to save other components even if one fails.
func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error {
isSecure := r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil
if forceHTTPS := sd.manager.GetLogger(); forceHTTPS != nil {
// Add force HTTPS check if needed
}
options := sd.manager.GetSessionOptions(isSecure)
options = sd.manager.EnhanceSessionSecurity(options, r)
if sd.mainSession != nil {
sd.mainSession.Options = options
}
if sd.accessSession != nil {
sd.accessSession.Options = options
}
if sd.refreshSession != nil {
sd.refreshSession.Options = options
}
if sd.idTokenSession != nil {
sd.idTokenSession.Options = options
}
var firstErr error
saveOrLogError := func(s *sessions.Session, name string) {
if s == nil {
logger := sd.manager.GetLogger()
if logger != nil {
logger.Errorf("Attempted to save nil session: %s", name)
}
if firstErr == nil {
firstErr = fmt.Errorf("attempted to save nil session: %s", name)
}
return
}
if err := s.Save(r, w); err != nil {
errMsg := fmt.Errorf("failed to save %s session: %w", name, err)
logger := sd.manager.GetLogger()
if logger != nil {
logger.Error(errMsg.Error())
}
if firstErr == nil {
firstErr = errMsg
}
}
}
saveOrLogError(sd.mainSession, "main")
saveOrLogError(sd.accessSession, "access token")
saveOrLogError(sd.refreshSession, "refresh token")
saveOrLogError(sd.idTokenSession, "ID token")
for i, sessionChunk := range sd.accessTokenChunks {
if sessionChunk != nil {
sessionChunk.Options = options
saveOrLogError(sessionChunk, fmt.Sprintf("access token chunk %d", i))
}
}
for i, sessionChunk := range sd.refreshTokenChunks {
if sessionChunk != nil {
sessionChunk.Options = options
saveOrLogError(sessionChunk, fmt.Sprintf("refresh token chunk %d", i))
}
}
for i, sessionChunk := range sd.idTokenChunks {
if sessionChunk != nil {
sessionChunk.Options = options
saveOrLogError(sessionChunk, fmt.Sprintf("ID token chunk %d", i))
}
}
if firstErr == nil {
sd.dirty = false
}
return firstErr
}
// Clear completely clears all session data and safely returns the session to the pool.
// It removes all authentication data, expires cookies, and handles panic recovery.
func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error {
defer func() {
sd.returnToPoolSafely()
}()
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
sd.clearAllSessionData(r, true)
// This is primarily for testing - in production w will often be nil
var err error
if w != nil {
if r != nil && r.Header.Get("X-Test-Error") == "true" {
if sd.mainSession != nil {
sd.mainSession.Values["error_trigger"] = func() {}
}
}
err = sd.Save(r, w)
}
sd.request = nil
return err
}
// clearAllSessionData clears all session data including main session and token chunks.
// It removes all session values and optionally expires all associated cookies.
func (sd *SessionData) clearAllSessionData(r *http.Request, expire bool) {
clearSessionValues(sd.mainSession, expire)
clearSessionValues(sd.accessSession, expire)
clearSessionValues(sd.refreshSession, expire)
clearSessionValues(sd.idTokenSession, expire)
if expire && r != nil {
sd.clearTokenChunks(r, sd.accessTokenChunks)
sd.clearTokenChunks(r, sd.refreshTokenChunks)
sd.clearTokenChunks(r, sd.idTokenChunks)
} else {
for k := range sd.accessTokenChunks {
delete(sd.accessTokenChunks, k)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
for k := range sd.idTokenChunks {
delete(sd.idTokenChunks, k)
}
}
if expire {
sd.dirty = true
}
}
// clearSessionValues removes all values from a session and optionally expires it.
// This is used during session cleanup and logout operations.
func clearSessionValues(session *sessions.Session, expire bool) {
if session == nil {
return
}
for k := range session.Values {
delete(session.Values, k)
}
if expire {
session.Options.MaxAge = -1
}
}
// clearTokenChunks clears token chunks from the session
func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) {
for i, chunk := range chunks {
if chunk != nil {
clearSessionValues(chunk, true)
}
delete(chunks, i)
}
}
// returnToPoolSafely safely returns the session to the object pool
func (sd *SessionData) returnToPoolSafely() {
defer func() {
if r := recover(); r != nil {
logger := sd.manager.GetLogger()
if logger != nil {
logger.Errorf("Panic during session pool return: %v", r)
}
}
}()
sd.sessionMutex.Lock()
defer sd.sessionMutex.Unlock()
if sd.inUse {
sd.inUse = false
sd.Reset()
// Pool return should be handled by calling code
}
}
// Reset resets the session data to a clean state
func (sd *SessionData) Reset() {
sd.mainSession = nil
sd.accessSession = nil
sd.refreshSession = nil
sd.idTokenSession = nil
// Clear maps without recreating them
for k := range sd.accessTokenChunks {
delete(sd.accessTokenChunks, k)
}
for k := range sd.refreshTokenChunks {
delete(sd.refreshTokenChunks, k)
}
for k := range sd.idTokenChunks {
delete(sd.idTokenChunks, k)
}
sd.dirty = false
sd.inUse = false
sd.request = nil
}
// SetSessions sets the session objects
func (sd *SessionData) SetSessions(main, access, refresh, idToken *sessions.Session) {
sd.mainSession = main
sd.accessSession = access
sd.refreshSession = refresh
sd.idTokenSession = idToken
}
// GetMainSession returns the main session
func (sd *SessionData) GetMainSession() *sessions.Session {
return sd.mainSession
}
// GetAccessSession returns the access token session
func (sd *SessionData) GetAccessSession() *sessions.Session {
return sd.accessSession
}
// GetRefreshSession returns the refresh token session
func (sd *SessionData) GetRefreshSession() *sessions.Session {
return sd.refreshSession
}
// GetIDTokenSession returns the ID token session
func (sd *SessionData) GetIDTokenSession() *sessions.Session {
return sd.idTokenSession
}
// GetTokenChunks returns the token chunk maps
func (sd *SessionData) GetTokenChunks() (map[int]*sessions.Session, map[int]*sessions.Session, map[int]*sessions.Session) {
return sd.accessTokenChunks, sd.refreshTokenChunks, sd.idTokenChunks
}
// SetInUse marks the session as in use
func (sd *SessionData) SetInUse(inUse bool) {
sd.inUse = inUse
}
// IsInUse returns whether the session is in use
func (sd *SessionData) IsInUse() bool {
return sd.inUse
}
File diff suppressed because it is too large Load Diff
-300
View File
@@ -1,300 +0,0 @@
// Package validators provides validation functionality for session data
package validators
import (
"strings"
"time"
)
const (
maxBrowserCookieSize = 3500
maxCookieSize = 1200
)
// SessionValidator provides validation operations for session data
type SessionValidator struct{}
// NewSessionValidator creates a new session validator
func NewSessionValidator() *SessionValidator {
return &SessionValidator{}
}
// ValidateChunkSize checks if a chunk will fit within browser cookie limits.
// It estimates the encoded size including cookie overhead and headers
// to ensure the chunk won't exceed browser-imposed cookie size limits.
func (sv *SessionValidator) ValidateChunkSize(chunkData string) bool {
estimatedEncodedSize := len(chunkData) + (len(chunkData)*50)/100
return estimatedEncodedSize <= maxBrowserCookieSize
}
// IsCorruptionMarker detects if data contains known corruption indicators.
// It checks for specific corruption markers and invalid characters
// that indicate the data has been tampered with or corrupted.
func (sv *SessionValidator) IsCorruptionMarker(data string) bool {
if data == "" {
return false
}
corruptionMarkers := []string{
"__CORRUPTION_MARKER_TEST__",
"__INVALID_BASE64_DATA__",
"__CORRUPTED_CHUNK_DATA__",
"!@#$%^&*()",
"<<<CORRUPTED>>>",
}
for _, marker := range corruptionMarkers {
if data == marker {
return true
}
}
if len(data) > 10 {
invalidChars := "!@#$%^&*(){}[]|\\:;\"'<>?,`~"
for _, char := range invalidChars {
if strings.ContainsRune(data, char) {
return true
}
}
}
return false
}
// ValidateTokenFormat validates that a token has the correct JWT format
func (sv *SessionValidator) ValidateTokenFormat(token, tokenType string) error {
if token == "" {
return nil // Empty token is not an error
}
// JWT tokens should have exactly 3 parts separated by dots
parts := strings.Split(token, ".")
if len(parts) != 3 {
return &ValidationError{
Type: tokenType,
Reason: "invalid JWT format",
Details: "token must have exactly 3 parts separated by dots",
}
}
// Each part should be non-empty
for i, part := range parts {
if part == "" {
return &ValidationError{
Type: tokenType,
Reason: "empty token part",
Details: strings.Join([]string{"token part", string(rune(i + 1)), "is empty"}, " "),
}
}
}
return nil
}
// ValidateSessionIntegrity performs comprehensive validation of session data integrity
func (sv *SessionValidator) ValidateSessionIntegrity(sessionData SessionData) error {
if sessionData == nil {
return &ValidationError{
Type: "session",
Reason: "nil session data",
Details: "session data cannot be nil",
}
}
// Check authentication state consistency
authenticated := sessionData.GetAuthenticated()
email := sessionData.GetEmail()
if authenticated && email == "" {
return &ValidationError{
Type: "session",
Reason: "authentication inconsistency",
Details: "session is authenticated but has no email",
}
}
// Validate token formats if present
if accessToken := sessionData.GetAccessToken(); accessToken != "" {
if err := sv.ValidateTokenFormat(accessToken, "access"); err != nil {
return err
}
}
if idToken := sessionData.GetIDToken(); idToken != "" {
if err := sv.ValidateTokenFormat(idToken, "id"); err != nil {
return err
}
}
if refreshToken := sessionData.GetRefreshToken(); refreshToken != "" {
// Refresh tokens don't have to be JWTs, so we do basic validation
if len(refreshToken) == 0 {
return &ValidationError{
Type: "refresh",
Reason: "empty refresh token",
Details: "refresh token cannot be empty if set",
}
}
}
return nil
}
// ValidateSessionTiming validates session timing and expiration
func (sv *SessionValidator) ValidateSessionTiming(sessionData SessionData, maxAge time.Duration) error {
if sessionData == nil {
return &ValidationError{
Type: "session",
Reason: "nil session data",
Details: "session data cannot be nil",
}
}
// Check refresh token timing
refreshTokenIssuedAt := sessionData.GetRefreshTokenIssuedAt()
if !refreshTokenIssuedAt.IsZero() {
age := time.Since(refreshTokenIssuedAt)
if age > maxAge {
return &ValidationError{
Type: "timing",
Reason: "refresh token expired",
Details: strings.Join([]string{"refresh token age", age.String(), "exceeds max age", maxAge.String()}, " "),
}
}
}
return nil
}
// ValidateEmailDomain validates that an email belongs to an allowed domain
func (sv *SessionValidator) ValidateEmailDomain(email string, allowedDomains map[string]struct{}) error {
if email == "" {
return &ValidationError{
Type: "email",
Reason: "empty email",
Details: "email cannot be empty",
}
}
if len(allowedDomains) == 0 {
return nil // No domain restrictions
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return &ValidationError{
Type: "email",
Reason: "invalid email format",
Details: "email must contain exactly one @ symbol",
}
}
domain := parts[1]
if _, allowed := allowedDomains[domain]; !allowed {
return &ValidationError{
Type: "email",
Reason: "domain not allowed",
Details: strings.Join([]string{"domain", domain, "is not in allowed domains list"}, " "),
}
}
return nil
}
// SplitIntoChunks splits a string into chunks that fit within cookie size limits
func (sv *SessionValidator) SplitIntoChunks(s string, chunkSize int) []string {
effectiveChunkSize := min(chunkSize, maxCookieSize)
var chunks []string
for len(s) > 0 {
if len(s) > effectiveChunkSize {
chunks = append(chunks, s[:effectiveChunkSize])
s = s[effectiveChunkSize:]
} else {
chunks = append(chunks, s)
break
}
}
return chunks
}
// ValidateChunks validates all chunks in a chunk set
func (sv *SessionValidator) ValidateChunks(chunks []string) error {
for i, chunk := range chunks {
if chunk == "" {
return &ValidationError{
Type: "chunk",
Reason: "empty chunk",
Details: strings.Join([]string{"chunk", string(rune(i)), "is empty"}, " "),
}
}
if !sv.ValidateChunkSize(chunk) {
return &ValidationError{
Type: "chunk",
Reason: "chunk too large",
Details: strings.Join([]string{"chunk", string(rune(i)), "exceeds size limit"}, " "),
}
}
if sv.IsCorruptionMarker(chunk) {
return &ValidationError{
Type: "chunk",
Reason: "corrupted chunk",
Details: strings.Join([]string{"chunk", string(rune(i)), "contains corruption markers"}, " "),
}
}
}
return nil
}
// ValidationError represents a validation error with context
type ValidationError struct {
Type string
Reason string
Details string
}
// Error implements the error interface
func (ve *ValidationError) Error() string {
return strings.Join([]string{ve.Type, "validation error:", ve.Reason, "-", ve.Details}, " ")
}
// SessionData interface for validation operations
type SessionData interface {
GetAuthenticated() bool
GetEmail() string
GetAccessToken() string
GetIDToken() string
GetRefreshToken() string
GetRefreshTokenIssuedAt() time.Time
}
// Utility functions
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ValidateChunkSize is a package-level function for backward compatibility
func ValidateChunkSize(chunkData string) bool {
sv := &SessionValidator{}
return sv.ValidateChunkSize(chunkData)
}
// IsCorruptionMarker is a package-level function for backward compatibility
func IsCorruptionMarker(data string) bool {
sv := &SessionValidator{}
return sv.IsCorruptionMarker(data)
}
// SplitIntoChunks is a package-level function for backward compatibility
func SplitIntoChunks(s string, chunkSize int) []string {
sv := &SessionValidator{}
return sv.SplitIntoChunks(s, chunkSize)
}
File diff suppressed because it is too large Load Diff