package traefikoidc import ( "encoding/base64" "encoding/json" "fmt" "strings" "sync" "time" "github.com/gorilla/sessions" ) // TokenConfig defines validation rules and constraints for different token types. // It specifies size limits, chunking parameters, and format requirements to ensure // tokens can be safely stored in browser cookies while maintaining security. type TokenConfig struct { Type string MinLength int MaxLength int MaxChunks int // Maximum number of chunks allowed MaxChunkSize int // Maximum size per chunk AllowOpaqueTokens bool RequireJWTFormat bool } // Predefined configurations for each token type var ( AccessTokenConfig = TokenConfig{ Type: "access", MinLength: 5, MaxLength: 100 * 1024, // 100KB total limit MaxChunks: 25, // Maximum 25 chunks MaxChunkSize: maxCookieSize, // Use global chunk size limit AllowOpaqueTokens: true, RequireJWTFormat: false, } RefreshTokenConfig = TokenConfig{ Type: "refresh", MinLength: 5, MaxLength: 50 * 1024, // 50KB total limit (refresh tokens are typically smaller) MaxChunks: 15, // Maximum 15 chunks MaxChunkSize: maxCookieSize, AllowOpaqueTokens: true, RequireJWTFormat: false, } IDTokenConfig = TokenConfig{ Type: "id", MinLength: 5, MaxLength: 75 * 1024, // 75KB total limit MaxChunks: 20, // Maximum 20 chunks MaxChunkSize: maxCookieSize, AllowOpaqueTokens: false, RequireJWTFormat: true, } ) // TokenRetrievalResult encapsulates the result of a token retrieval operation. // It contains either a successfully retrieved token or an error describing // what went wrong during retrieval. type TokenRetrievalResult struct { Token string Error error } // ChunkManager provides thread-safe operations for splitting large tokens // into smaller chunks that fit within browser cookie size limits. It handles // the chunking and reassembly of tokens transparently, ensuring data integrity // throughout the process. type ChunkManager struct { logger *Logger mutex *sync.RWMutex } // NewChunkManager creates a new ChunkManager instance with the specified logger. // If no logger is provided, a no-op logger is used to prevent nil pointer errors. // // Parameters: // - logger: The logger instance for recording chunk operations. // // Returns: // - A new ChunkManager instance ready for use. func NewChunkManager(logger *Logger) *ChunkManager { if logger == nil { logger = newNoOpLogger() } return &ChunkManager{ logger: logger, mutex: &sync.RWMutex{}, } } // GetToken retrieves and validates a token from either single storage or chunks // GetToken retrieves and validates a token, handling both single-cookie // and chunked storage scenarios. It performs decompression if needed and // validates the token according to the provided configuration. // // Parameters: // - singleToken: The token string if stored in a single cookie. // - compressed: Whether the token is compressed. // - chunks: Map of session chunks if token is split across cookies. // - config: Token validation configuration. // // Returns: // - TokenRetrievalResult containing the token or an error. func (cm *ChunkManager) GetToken( singleToken string, compressed bool, chunks map[int]*sessions.Session, config TokenConfig, ) TokenRetrievalResult { cm.mutex.RLock() defer cm.mutex.RUnlock() // Handle single-token storage if singleToken != "" { return cm.processSingleToken(singleToken, compressed, config) } // Handle chunked storage if len(chunks) == 0 { return TokenRetrievalResult{Token: "", Error: nil} } return cm.processChunkedToken(chunks, config) } // processSingleToken processes tokens stored in a single cookie. // It handles decompression if needed and performs comprehensive validation // including corruption detection, format validation, and size checks. // // Parameters: // - token: The token string from the cookie. // - compressed: Whether the token needs decompression. // - config: Token validation configuration. // // Returns: // - TokenRetrievalResult containing the processed token or an error. func (cm *ChunkManager) processSingleToken(token string, compressed bool, config TokenConfig) TokenRetrievalResult { // Detect corruption markers if isCorruptionMarker(token) { err := fmt.Errorf("%s token contains corruption marker", config.Type) // Only log if not a known test scenario if !strings.Contains(token, "TEST_CORRUPTION") { cm.logger.Debug("Token corruption detected for %s", config.Type) } return TokenRetrievalResult{Token: "", Error: err} } var finalToken string if compressed { decompressed := decompressToken(token) if isCorruptionMarker(decompressed) { err := fmt.Errorf("decompressed %s token contains corruption marker", config.Type) cm.logger.Debug("Decompressed token corruption detected for %s", config.Type) return TokenRetrievalResult{Token: "", Error: err} } finalToken = decompressed } else { finalToken = token } return cm.validateToken(finalToken, config) } // validateToken performs comprehensive validation on a token. // It checks size limits, chunking efficiency, content validity, // expiration, freshness, and format requirements based on the token configuration. // // Parameters: // - token: The token string to validate. // - config: Token validation configuration. // // Returns: // - TokenRetrievalResult with the validated token or validation error. func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult { // Validate token size against configured limits if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil { return TokenRetrievalResult{Token: "", Error: sizeErr} } // Check if token would chunk efficiently if chunkErr := cm.validateChunkingEfficiency(token, config); chunkErr != nil { return TokenRetrievalResult{Token: "", Error: chunkErr} } // Validate token content and structure if contentErr := cm.validateTokenContent(token, config); contentErr != nil { return TokenRetrievalResult{Token: "", Error: contentErr} } // Token expiration validation if expErr := cm.validateTokenExpiration(token, config); expErr != nil { return TokenRetrievalResult{Token: "", Error: expErr} } // Token freshness validation if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil { return TokenRetrievalResult{Token: "", Error: freshnessErr} } // Validate JWT format if required if config.RequireJWTFormat && !config.AllowOpaqueTokens { if validationErr := cm.validateJWTFormat(token, config.Type); validationErr != nil { return TokenRetrievalResult{Token: "", Error: validationErr} } } else if config.RequireJWTFormat && config.AllowOpaqueTokens { // For tokens that can be either JWT or opaque, validate JWT format only if it has dots dotCount := strings.Count(token, ".") if dotCount > 0 { if validationErr := cm.validateJWTFormat(token, config.Type); validationErr != nil { return TokenRetrievalResult{Token: "", Error: validationErr} } } else { // Validate as opaque token if validationErr := cm.validateOpaqueToken(token, config.Type); validationErr != nil { return TokenRetrievalResult{Token: "", Error: validationErr} } } } return TokenRetrievalResult{Token: token, Error: nil} } // processChunkedToken handles tokens stored across multiple chunks func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult { // Validate chunk count against configured maximum if len(chunks) > config.MaxChunks { err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks) cm.logger.Info("Token chunk count exceeded for %s: %d chunks", config.Type, len(chunks)) return TokenRetrievalResult{Token: "", Error: err} } // Additional safety check for extremely large chunk counts if len(chunks) > 100 { err := fmt.Errorf("excessive %s token chunks (%d), potential security issue", config.Type, len(chunks)) cm.logger.Error("Security: Excessive token chunks detected for %s: %d", config.Type, len(chunks)) return TokenRetrievalResult{Token: "", Error: err} } // Sequential chunk validation and assembly var tokenParts []string totalSize := 0 for i := 0; i < len(chunks); i++ { session, ok := chunks[i] if !ok { err := fmt.Errorf("%s token chunk %d missing", config.Type, i) // Only log once for missing chunks, not for each missing chunk if i == 0 { cm.logger.Debug("Token chunks missing for %s starting at index %d", config.Type, i) } return TokenRetrievalResult{Token: "", Error: err} } chunk, chunkOk := session.Values["token_chunk"].(string) if !chunkOk || chunk == "" { err := fmt.Errorf("%s token chunk %d invalid", config.Type, i) return TokenRetrievalResult{Token: "", Error: err} } if isCorruptionMarker(chunk) { err := fmt.Errorf("%s token chunk %d corrupted", config.Type, i) return TokenRetrievalResult{Token: "", Error: err} } // Validate individual chunk sizes if len(chunk) > config.MaxChunkSize { err := fmt.Errorf("%s token chunk %d exceeds size limit (%d bytes, max: %d)", config.Type, i, len(chunk), config.MaxChunkSize) return TokenRetrievalResult{Token: "", Error: err} } // Additional safety check for extremely large chunks if len(chunk) > maxBrowserCookieSize { err := fmt.Errorf("%s token chunk %d exceeds browser limit (%d bytes)", config.Type, i, len(chunk)) return TokenRetrievalResult{Token: "", Error: err} } totalSize += len(chunk) if totalSize > config.MaxLength { err := fmt.Errorf("%s token total size exceeds limit", config.Type) return TokenRetrievalResult{Token: "", Error: err} } tokenParts = append(tokenParts, chunk) } // Reassemble token reassembledToken := strings.Join(tokenParts, "") // Check compression flag from first chunk compressed, _ := chunks[0].Values["compressed"].(bool) if compressed { decompressed := decompressToken(reassembledToken) if isCorruptionMarker(decompressed) { err := fmt.Errorf("decompressed chunked %s token corrupted", config.Type) return TokenRetrievalResult{Token: "", Error: err} } return cm.validateToken(decompressed, config) } return cm.validateToken(reassembledToken, config) } // validateJWTFormat performs enhanced JWT format validation func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error { // Check for exactly 2 dots dotCount := strings.Count(token, ".") if dotCount != 2 { err := fmt.Errorf("%s token invalid JWT format (dots: %d)", tokenType, dotCount) return err } // Split into parts parts := strings.Split(token, ".") if len(parts) != 3 { err := fmt.Errorf("%s token invalid JWT structure", tokenType) return err } // Validate each part is non-empty and contains valid base64url characters for i, part := range parts { if part == "" { err := fmt.Errorf("%s token has empty JWT part %d", tokenType, i) return err } // Check for valid base64url characters only (RFC 4648) // Valid characters: A-Z, a-z, 0-9, -, _, and = for padding for _, char := range part { if !((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '=') { err := fmt.Errorf("%s token contains invalid base64url character in part %d", tokenType, i) return err } } // Validate base64url padding rules if strings.Contains(part, "=") { // Padding can only be at the end paddingIndex := strings.Index(part, "=") if paddingIndex != len(part)-1 && paddingIndex != len(part)-2 { err := fmt.Errorf("%s token has invalid base64url padding in part %d", tokenType, i) return err } // Check that after padding, no other characters exist for j := paddingIndex; j < len(part); j++ { if part[j] != '=' { err := fmt.Errorf("%s token has characters after padding in part %d", tokenType, i) return err } } } } // Additional length checks for JWT parts if len(parts[0]) < 10 { // Header too short err := fmt.Errorf("%s token header too short", tokenType) return err } if len(parts[1]) < 10 { // Payload too short err := fmt.Errorf("%s token payload too short", tokenType) return err } if len(parts[2]) < 10 { // Signature too short err := fmt.Errorf("%s token signature too short", tokenType) return err } return nil } // validateOpaqueToken performs validation for opaque (non-JWT) tokens func (cm *ChunkManager) validateOpaqueToken(token string, tokenType string) error { // Check for obviously invalid characters for opaque tokens if strings.Contains(token, " ") { err := fmt.Errorf("%s opaque token contains spaces", tokenType) return err } // Check for control characters for _, char := range token { if char < 32 || char == 127 { err := fmt.Errorf("%s opaque token contains control characters", tokenType) return err } } // Ensure minimum entropy for opaque tokens (basic check) if len(token) >= 20 { uniqueChars := make(map[rune]bool) for _, char := range token { uniqueChars[char] = true } // Require at least 8 unique characters for reasonable entropy if len(uniqueChars) < 8 { err := fmt.Errorf("%s opaque token has insufficient entropy", tokenType) return err } } return nil } // validateTokenSize performs comprehensive token size validation func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) error { tokenLen := len(token) // Basic length validation if tokenLen < config.MinLength { err := fmt.Errorf("%s token below minimum length (%d bytes, min: %d)", config.Type, tokenLen, config.MinLength) return err } if tokenLen > config.MaxLength { err := fmt.Errorf("%s token exceeds maximum length (%d bytes, max: %d)", config.Type, tokenLen, config.MaxLength) return err } // JWT-specific size validation if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) { parts := strings.Split(token, ".") if len(parts) == 3 { // Validate individual JWT part sizes headerLen := len(parts[0]) payloadLen := len(parts[1]) signatureLen := len(parts[2]) // Check for unreasonably large JWT parts (potential security issue) if headerLen > 5*1024 { // 5KB header limit err := fmt.Errorf("%s token header too large (%d bytes)", config.Type, headerLen) return err } if payloadLen > config.MaxLength-10*1024 { // Leave room for header and signature err := fmt.Errorf("%s token payload too large (%d bytes)", config.Type, payloadLen) return err } if signatureLen > 2*1024 { // 2KB signature limit err := fmt.Errorf("%s token signature too large (%d bytes)", config.Type, signatureLen) return err } } } // Opaque token size validation if config.AllowOpaqueTokens && !strings.Contains(token, ".") { // For opaque tokens, check for reasonable size limits if tokenLen > 8*1024 { // 8KB limit for opaque tokens err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen) return err } } return nil } // validateChunkingEfficiency ensures that chunking is used appropriately func (cm *ChunkManager) validateChunkingEfficiency(token string, config TokenConfig) error { tokenLen := len(token) // If token is small enough to fit in a single chunk, warn about unnecessary chunking if tokenLen <= config.MaxChunkSize && tokenLen <= maxCookieSize { // This is just informational - not an error, but helps with monitoring // Token could fit in single chunk - this is fine, just informational } // Calculate expected number of chunks expectedChunks := (tokenLen + config.MaxChunkSize - 1) / config.MaxChunkSize if expectedChunks > config.MaxChunks { err := fmt.Errorf("%s token would require %d chunks (max: %d)", config.Type, expectedChunks, config.MaxChunks) return err } // Check for potential storage efficiency issues if expectedChunks > 10 && tokenLen < 50*1024 { cm.logger.Info("%s token requires many chunks (%d) for size (%d bytes) - consider token optimization", config.Type, expectedChunks, tokenLen) } return nil } // validateTokenContent performs comprehensive token content validation func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) error { // Basic content sanitization checks if err := cm.validateTokenSanitization(token, config); err != nil { return err } // JWT-specific content validation if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) { if err := cm.validateJWTContent(token, config); err != nil { return err } } // Opaque token content validation if config.AllowOpaqueTokens && !strings.Contains(token, ".") { if err := cm.validateOpaqueTokenContent(token, config); err != nil { return err } } return nil } // validateTokenSanitization checks for basic security issues in token content func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConfig) error { // Check for null bytes (potential injection attacks) if strings.Contains(token, "\x00") { err := fmt.Errorf("%s token contains null bytes", config.Type) return err } // Check for line feed/carriage return (header injection attacks) if strings.ContainsAny(token, "\r\n") { err := fmt.Errorf("%s token contains line breaks", config.Type) return err } // Check for suspicious escape sequences suspiciousPatterns := []string{ "\\x", "\\u", "\\n", "\\r", "\\t", "\\0", "= 10 { alphabetic := 0 numeric := 0 special := 0 for _, char := range token { if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') { alphabetic++ } else if char >= '0' && char <= '9' { numeric++ } else { special++ } } total := alphabetic + numeric + special if total > 0 { // Require some distribution of character types for legitimate tokens alphaRatio := float64(alphabetic) / float64(total) numericRatio := float64(numeric) / float64(total) // Opaque tokens should have reasonable character distribution if alphaRatio < 0.1 && numericRatio < 0.1 { err := fmt.Errorf("%s opaque token has suspicious character distribution", config.Type) return err } } } // Check for common token prefixes/suffixes that might indicate legitimate tokens legitimatePrefixes := []string{ "Bearer ", "bearer ", "eyJ", // JWT prefix "refresh_", "access_", "id_", "token_", "oauth_", "oidc_", } hasLegitimatePrefix := false for _, prefix := range legitimatePrefixes { if strings.HasPrefix(token, prefix) { hasLegitimatePrefix = true break } } // For longer tokens without legitimate prefixes, be more suspicious if len(token) > 50 && !hasLegitimatePrefix { // Opaque token without common prefixes - this is fine } return nil } // detectRepeatedCharacters detects potential buffer overflow attempts func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfig) error { if len(token) < 10 { return nil // Too short to analyze meaningfully } // Count consecutive repeated characters maxRepeated := 0 currentRepeated := 1 var lastChar rune for i, char := range token { if i > 0 && char == lastChar { currentRepeated++ if currentRepeated > maxRepeated { maxRepeated = currentRepeated } } else { currentRepeated = 1 } lastChar = char } // Flag tokens with excessive character repetition threshold := 20 // Allow up to 20 consecutive identical characters if maxRepeated > threshold { err := fmt.Errorf("%s token has excessive repeated characters (%d consecutive)", config.Type, maxRepeated) return err } // Check for overall character frequency (detect padding attacks) charFreq := make(map[rune]int) for _, char := range token { charFreq[char]++ } tokenLen := len(token) for char, count := range charFreq { frequency := float64(count) / float64(tokenLen) // Flag if any single character makes up more than 70% of the token if frequency > 0.7 && tokenLen > 20 { err := fmt.Errorf("%s token has suspicious character frequency (char '%c': %.1f%%)", config.Type, char, frequency*100) return err } } return nil } // validateTokenExpiration validates token expiration during storage/retrieval func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error { // Only validate expiration for JWT tokens if !strings.Contains(token, ".") { return nil // Opaque tokens don't have embedded expiration } // Parse JWT expiration claim expiration, err := cm.extractJWTExpiration(token) if err != nil { // If we can't parse expiration, log it but don't fail - the token might be valid but malformed cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err) return nil } // Check if token is expired if expiration != nil && time.Now().After(*expiration) { err := fmt.Errorf("%s token is expired (expired at: %v)", config.Type, expiration.Format(time.RFC3339)) return err } // Check if token expires too far in the future (potential security issue) if expiration != nil { maxFutureTime := time.Now().Add(10 * 365 * 24 * time.Hour) // 10 years if expiration.After(maxFutureTime) { cm.logger.Info("%s token expires very far in future (%v) - potential security issue", config.Type, expiration.Format(time.RFC3339)) } } return nil } // extractJWTExpiration extracts the expiration time from a JWT token func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT format") } // Decode the payload (second part) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse the JSON payload var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { return nil, fmt.Errorf("failed to parse JWT claims: %w", err) } // Extract expiration claim exp, exists := claims["exp"] if !exists { return nil, nil // No expiration claim } // Convert expiration to time.Time var expTime time.Time switch v := exp.(type) { case float64: expTime = time.Unix(int64(v), 0) case int64: expTime = time.Unix(v, 0) case int: expTime = time.Unix(int64(v), 0) default: return nil, fmt.Errorf("invalid expiration format: %T", exp) } return &expTime, nil } // validateTokenFreshness checks if token is fresh enough for storage func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error { // Only validate freshness for JWT tokens if !strings.Contains(token, ".") { return nil } // Extract issued at time issuedAt, err := cm.extractJWTIssuedAt(token) if err != nil { cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err) return nil } if issuedAt != nil { now := time.Now() // Check if token was issued in the future (clock skew tolerance: 5 minutes) if issuedAt.After(now.Add(5 * time.Minute)) { err := fmt.Errorf("%s token issued in future (issued at: %v)", config.Type, issuedAt.Format(time.RFC3339)) return err } // Check if token is too old (potential replay attack) maxAge := 24 * time.Hour // Tokens older than 24 hours are suspicious if now.Sub(*issuedAt) > maxAge { cm.logger.Info("%s token is quite old (issued: %v) - potential replay", config.Type, issuedAt.Format(time.RFC3339)) } } return nil } // extractJWTIssuedAt extracts the issued at time from a JWT token func (cm *ChunkManager) extractJWTIssuedAt(token string) (*time.Time, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT format") } // Decode the payload (second part) payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse the JSON payload var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { return nil, fmt.Errorf("failed to parse JWT claims: %w", err) } // Extract issued at claim iat, exists := claims["iat"] if !exists { return nil, nil // No issued at claim } // Convert issued at to time.Time var iatTime time.Time switch v := iat.(type) { case float64: iatTime = time.Unix(int64(v), 0) case int64: iatTime = time.Unix(v, 0) case int: iatTime = time.Unix(int64(v), 0) default: return nil, fmt.Errorf("invalid issued at format: %T", iat) } return &iatTime, nil }