// Package chunking provides session chunking functionality for large tokens package chunking import ( "fmt" "strings" "sync" "sync/atomic" "time" "github.com/gorilla/sessions" ) const ( maxCookieSize = 1200 ) // TokenConfig defines validation and storage parameters for different token types. // It specifies size limits, format requirements, and security constraints to ensure // tokens can be safely stored in browser cookies while maintaining security. type TokenConfig struct { Type string MinLength int MaxLength int MaxChunks int MaxChunkSize int AllowOpaqueTokens bool RequireJWTFormat bool } // Global session tracking to prevent memory leaks across all instances var ( globalSessionCount int64 = 0 globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions ) // ResetGlobalSessionCounters resets global session tracking for testing func ResetGlobalSessionCounters() { atomic.StoreInt64(&globalSessionCount, 0) } // Predefined configurations for each token type var ( AccessTokenConfig = TokenConfig{ Type: "access", MinLength: 5, MaxLength: 100 * 1024, MaxChunks: 25, MaxChunkSize: maxCookieSize, AllowOpaqueTokens: true, RequireJWTFormat: false, } RefreshTokenConfig = TokenConfig{ Type: "refresh", MinLength: 5, MaxLength: 50 * 1024, MaxChunks: 15, MaxChunkSize: maxCookieSize, AllowOpaqueTokens: true, RequireJWTFormat: false, } IDTokenConfig = TokenConfig{ Type: "id", MinLength: 5, MaxLength: 75 * 1024, MaxChunks: 20, MaxChunkSize: maxCookieSize, AllowOpaqueTokens: false, RequireJWTFormat: true, } ) // TokenRetrievalResult represents the outcome of a token retrieval operation. // It contains either the successfully retrieved token or an error describing // what went wrong during retrieval. type TokenRetrievalResult struct { Error error Token string } // SessionEntry represents a session with expiration tracking type SessionEntry struct { Session *sessions.Session ExpiresAt time.Time LastUsed time.Time } // 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 handles the complex logic of storing and retrieving large tokens // across multiple HTTP cookies. It provides comprehensive validation, security checks, // and error handling to ensure data integrity and prevent security vulnerabilities // throughout the process. type ChunkManager struct { lastCleanup time.Time logger Logger mutex *sync.RWMutex sessionMap map[string]*SessionEntry maxSessions int sessionTTL time.Duration } // NewChunkManager creates a new ChunkManager instance with proper initialization. // It sets up logging and synchronization primitives for safe concurrent access. func NewChunkManager(logger Logger) *ChunkManager { if logger == nil { logger = NewNoOpLogger() } return &ChunkManager{ logger: logger, mutex: &sync.RWMutex{}, sessionMap: make(map[string]*SessionEntry), maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes lastCleanup: time.Now(), } } // GetToken retrieves a token from either a single cookie or multiple chunk cookies. // It handles both compressed and uncompressed tokens and performs comprehensive // validation throughout the retrieval process. func (cm *ChunkManager) GetToken( mainSession *sessions.Session, chunks map[int]*sessions.Session, config TokenConfig, compressor TokenCompressor, ) TokenRetrievalResult { // Try to get token from main session first if mainSession != nil { if tokenValue, ok := mainSession.Values[config.Type+"_token"].(string); ok && tokenValue != "" { cm.logger.Debugf("Found %s token in main session", config.Type) // Check if token is compressed decompressed := compressor.DecompressToken(tokenValue) if decompressed != tokenValue { cm.logger.Debugf("Decompressed %s token", config.Type) return cm.processSingleToken(decompressed, true, config) } return cm.processSingleToken(tokenValue, false, config) } } // If not in main session, try chunks if len(chunks) == 0 { return TokenRetrievalResult{ Error: nil, Token: "", } } cm.logger.Debugf("Found %d chunks for %s token, processing", len(chunks), config.Type) return cm.processChunkedToken(chunks, config, compressor) } // processSingleToken validates and processes a single token func (cm *ChunkManager) processSingleToken(token string, compressed bool, config TokenConfig) TokenRetrievalResult { if compressed { cm.logger.Debugf("Processing compressed %s token (length: %d)", config.Type, len(token)) } else { cm.logger.Debugf("Processing single %s token (length: %d)", config.Type, len(token)) } return cm.validateToken(token, config) } // validateToken performs comprehensive validation on a token func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult { if token == "" { return TokenRetrievalResult{Error: nil, Token: ""} } validator := NewTokenValidator() // Basic validation if err := validator.ValidateTokenSize(token, config); err != nil { cm.logger.Errorf("Token size validation failed for %s: %v", config.Type, err) return TokenRetrievalResult{Error: err, Token: ""} } // Format validation if config.RequireJWTFormat { if err := validator.ValidateJWTFormat(token, config.Type); err != nil { cm.logger.Errorf("JWT format validation failed for %s: %v", config.Type, err) return TokenRetrievalResult{Error: err, Token: ""} } } else if !config.AllowOpaqueTokens { if err := validator.ValidateJWTFormat(token, config.Type); err != nil { cm.logger.Errorf("Token format validation failed for %s: %v", config.Type, err) return TokenRetrievalResult{Error: err, Token: ""} } } // Content validation if err := validator.ValidateTokenContent(token, config); err != nil { cm.logger.Errorf("Token content validation failed for %s: %v", config.Type, err) return TokenRetrievalResult{Error: err, Token: ""} } cm.logger.Debugf("Successfully validated %s token", config.Type) return TokenRetrievalResult{Error: nil, Token: token} } // processChunkedToken reconstructs a token from multiple chunks func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig, compressor TokenCompressor) TokenRetrievalResult { if len(chunks) > config.MaxChunks { return TokenRetrievalResult{ Error: &ChunkError{ Type: config.Type, Reason: "too many chunks", Details: "chunk count exceeds maximum allowed", }, Token: "", } } // Reconstruct token from chunks reconstructedToken, err := cm.reconstructTokenFromChunks(chunks, config) if err != nil { cm.logger.Errorf("Failed to reconstruct %s token from chunks: %v", config.Type, err) return TokenRetrievalResult{Error: err, Token: ""} } // Try decompression decompressedToken := compressor.DecompressToken(reconstructedToken) if decompressedToken != reconstructedToken { cm.logger.Debugf("Decompressed reconstructed %s token", config.Type) return cm.validateToken(decompressedToken, config) } return cm.validateToken(reconstructedToken, config) } // reconstructTokenFromChunks reconstructs a token from ordered chunks func (cm *ChunkManager) reconstructTokenFromChunks(chunks map[int]*sessions.Session, config TokenConfig) (string, error) { if len(chunks) == 0 { return "", &ChunkError{ Type: config.Type, Reason: "no chunks found", Details: "no chunk sessions available for reconstruction", } } // Find the maximum chunk index to determine total chunks maxIndex := -1 for index := range chunks { if index > maxIndex { maxIndex = index } } if maxIndex < 0 { return "", &ChunkError{ Type: config.Type, Reason: "invalid chunk indices", Details: "no valid chunk indices found", } } // Reconstruct token by concatenating chunks in order var tokenBuilder strings.Builder for i := 0; i <= maxIndex; i++ { chunk, exists := chunks[i] if !exists || chunk == nil { return "", &ChunkError{ Type: config.Type, Reason: "missing chunk", Details: fmt.Sprintf("chunk %d is missing", i), } } chunkValue, ok := chunk.Values["value"].(string) if !ok || chunkValue == "" { return "", &ChunkError{ Type: config.Type, Reason: "empty chunk", Details: fmt.Sprintf("chunk %d has no value", i), } } tokenBuilder.WriteString(chunkValue) } reconstructed := tokenBuilder.String() if reconstructed == "" { return "", &ChunkError{ Type: config.Type, Reason: "empty reconstructed token", Details: "all chunks were present but resulted in empty token", } } cm.logger.Debugf("Successfully reconstructed %s token from %d chunks (length: %d)", config.Type, len(chunks), len(reconstructed)) return reconstructed, nil } // CleanupExpiredSessions removes expired sessions from the session map func (cm *ChunkManager) CleanupExpiredSessions() { cm.mutex.Lock() defer cm.mutex.Unlock() now := time.Now() // Only cleanup if enough time has passed if now.Sub(cm.lastCleanup) < time.Hour { return } cm.lastCleanup = now cleaned := 0 for key, entry := range cm.sessionMap { if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL { delete(cm.sessionMap, key) cleaned++ } } if cleaned > 0 { cm.logger.Debugf("Cleaned up %d expired sessions", cleaned) } } // StoreSession stores a session in the session map with expiration tracking func (cm *ChunkManager) StoreSession(key string, session *sessions.Session) { cm.mutex.Lock() defer cm.mutex.Unlock() // CRITICAL FIX: Aggressive session limit enforcement currentLocal := len(cm.sessionMap) currentGlobal := atomic.LoadInt64(&globalSessionCount) shouldEvict := false targetCapacity := cm.maxSessions // Check global limit first (more critical) if currentGlobal >= globalMaxSessions { shouldEvict = true targetCapacity = cm.maxSessions / 4 // Aggressive reduction to 25% } else if currentGlobal >= globalMaxSessions*8/10 { // 80% of global shouldEvict = true targetCapacity = cm.maxSessions / 2 // Reduce to 50% } else if currentLocal >= cm.maxSessions { shouldEvict = true targetCapacity = cm.maxSessions * 3 / 4 // Reduce to 75% } if shouldEvict { // Find oldest sessions to remove type sessionAge struct { lastUsed time.Time key string } sessions := make([]sessionAge, 0, currentLocal) for k, entry := range cm.sessionMap { sessions = append(sessions, sessionAge{key: k, lastUsed: entry.LastUsed}) } // Sort by last used time (oldest first) for i := 0; i < len(sessions)-1; i++ { for j := i + 1; j < len(sessions); j++ { if sessions[i].lastUsed.After(sessions[j].lastUsed) { sessions[i], sessions[j] = sessions[j], sessions[i] } } } // Remove excess sessions excessCount := currentLocal - targetCapacity if excessCount < 0 { excessCount = 0 } removedCount := int64(0) for i := 0; i < excessCount && i < len(sessions); i++ { delete(cm.sessionMap, sessions[i].key) removedCount++ } if removedCount > 0 { atomic.AddInt64(&globalSessionCount, -removedCount) } } cm.sessionMap[key] = &SessionEntry{ Session: session, ExpiresAt: time.Now().Add(cm.sessionTTL), LastUsed: time.Now(), } atomic.AddInt64(&globalSessionCount, 1) // CRITICAL FIX: Track addition } // GetSession retrieves a session from the session map func (cm *ChunkManager) GetSession(key string) *sessions.Session { cm.mutex.Lock() defer cm.mutex.Unlock() entry, exists := cm.sessionMap[key] if !exists { return nil } // Update last used time entry.LastUsed = time.Now() return entry.Session } // TokenCompressor interface for token compression operations type TokenCompressor interface { CompressToken(token string) string DecompressToken(compressed string) string } // ChunkError represents errors that occur during chunk operations type ChunkError struct { Type string Reason string Details string } // Error implements the error interface func (ce *ChunkError) Error() string { return fmt.Sprintf("%s chunk error: %s - %s", ce.Type, ce.Reason, ce.Details) } // NoOpLogger provides a no-op logger implementation type NoOpLogger struct{} // NewNoOpLogger creates a new no-op logger func NewNoOpLogger() *NoOpLogger { return &NoOpLogger{} } // Debug does nothing func (l *NoOpLogger) Debug(msg string) {} // Debugf does nothing func (l *NoOpLogger) Debugf(format string, args ...interface{}) {} // Error does nothing func (l *NoOpLogger) Error(msg string) {} // Errorf does nothing func (l *NoOpLogger) Errorf(format string, args ...interface{}) {}