diff --git a/azure_oidc_test.go b/azure_oidc_test.go index 033690b..7ef8bb7 100644 --- a/azure_oidc_test.go +++ b/azure_oidc_test.go @@ -162,19 +162,11 @@ func TestAzureOIDCRegression(t *testing.T) { session.SetAuthenticated(true) session.SetEmail("user@example.com") - // Create a valid JWT access token for testing - accessTokenClaims := map[string]interface{}{ - "iss": "https://login.microsoftonline.com/tenant-id/v2.0", - "aud": "test-client-id", - "exp": time.Now().Add(1 * time.Hour).Unix(), - "iat": time.Now().Unix(), - "sub": "user123", - "email": "user@example.com", - } - accessToken, _ := createMockJWT(accessTokenClaims) + // Use standardized test tokens with valid future expiration dates + accessToken := ValidAccessToken // This token expires in 2065 session.SetAccessToken(accessToken) - // Create an invalid/expired ID token + // Create an expired ID token using a mock JWT with past expiration idTokenClaims := map[string]interface{}{ "iss": "https://login.microsoftonline.com/tenant-id/v2.0", "aud": "test-client-id", @@ -234,28 +226,19 @@ func TestAzureOIDCRegression(t *testing.T) { req := httptest.NewRequest("GET", "/protected", nil) session, _ := tOidc.sessionManager.GetSession(req) - // Set up session with opaque access token (non-JWT) + // Set up session with JWT access token (not opaque for this test) session.SetAuthenticated(true) session.SetEmail("user@example.com") - session.SetAccessToken(ValidAccessToken) + session.SetAccessToken(ValidAccessToken) // This is actually a JWT token - // Create a valid ID token for claims extraction - idTokenClaims := map[string]interface{}{ - "iss": "https://login.microsoftonline.com/tenant-id/v2.0", - "aud": "test-client-id", - "exp": time.Now().Add(1 * time.Hour).Unix(), - "iat": time.Now().Unix(), - "sub": "user123", - "email": "user@example.com", - } - idToken, _ := createMockJWT(idTokenClaims) - session.SetIDToken(idToken) + // Use a valid ID token from test tokens + session.SetIDToken(ValidIDToken) // This token expires in 2065 // Mock the token verification originalTokenVerifier := tOidc.tokenVerifier tOidc.tokenVerifier = &mockTokenVerifier{ verifyFunc: func(token string) error { - if token == idToken { + if token == ValidIDToken { // ID token is valid - cache claims testClaims := map[string]interface{}{ "exp": float64(time.Now().Add(1 * time.Hour).Unix()), @@ -337,16 +320,17 @@ func TestAzureOIDCRegression(t *testing.T) { // createMockJWT creates a basic JWT token for testing purposes func createMockJWT(claims map[string]interface{}) (string, error) { - // Simple mock JWT - in real tests you'd use a proper JWT library - // For this test, we'll create a basic three-part token structure - header := "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0" // {"alg":"RS256","kid":"test-key-id","typ":"JWT"} + // For testing purposes, create a JWT with expired claims when needed + // Use the test tokens infrastructure for most cases, but allow expired tokens for specific tests + testTokens := NewTestTokens() - // Create a simple payload with test claims - payload := "eyJpc3MiOiJ0ZXN0LWlzc3VlciIsImF1ZCI6InRlc3QtY2xpZW50LWlkIiwiZXhwIjoxNjM4MzYwMDAwLCJpYXQiOjE2MzgzNTY0MDAsInN1YiI6InVzZXIxMjMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ" // Basic claims + // Check if this is meant to be an expired token + if exp, ok := claims["exp"].(int64); ok && exp < time.Now().Unix() { + return testTokens.CreateExpiredJWT(), nil + } - signature := "test-signature" - - return header + "." + payload + "." + signature, nil + // Otherwise return a valid token + return ValidIDToken, nil } // Mock error type for testing diff --git a/main.go b/main.go index a3f5d05..f9102e7 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "io" - "math" "net" "net/http" "net/http/cookiejar" @@ -222,6 +221,7 @@ type TraefikOidc struct { logger *Logger metadataRefreshStopChan chan struct{} cancelFunc context.CancelFunc + errorRecoveryManager *ErrorRecoveryManager clientSecret string clientID string name string @@ -702,6 +702,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h } t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger) + t.errorRecoveryManager = NewErrorRecoveryManager(t.logger) t.extractClaimsFunc = extractClaims // t.exchangeCodeForTokenFunc = t.exchangeCodeForToken // Removed, using interface now t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { @@ -754,8 +755,15 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h func (t *TraefikOidc) initializeMetadata(providerURL string) { t.logger.Debug("Starting provider metadata discovery") - // Get metadata from cache or fetch it - metadata, err := t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger) + // Get metadata from cache or fetch it with error recovery if available + var metadata *ProviderMetadata + var err error + if t.errorRecoveryManager != nil { + metadata, err = t.metadataCache.GetMetadataWithRecovery(providerURL, t.httpClient, t.logger, t.errorRecoveryManager) + } else { + // Fallback for test scenarios without error recovery manager + metadata, err = t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger) + } if err != nil { t.logger.Errorf("Failed to get provider metadata: %v", err) // Consider retrying or handling this more gracefully @@ -812,7 +820,14 @@ func (t *TraefikOidc) startMetadataRefresh(providerURL string) { select { case <-ticker.C: t.logger.Debug("Refreshing OIDC metadata") - metadata, err := t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger) + var metadata *ProviderMetadata + var err error + if t.errorRecoveryManager != nil { + metadata, err = t.metadataCache.GetMetadataWithRecovery(providerURL, t.httpClient, t.logger, t.errorRecoveryManager) + } else { + // Fallback for test scenarios without error recovery manager + metadata, err = t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger) + } if err != nil { t.logger.Errorf("Failed to refresh metadata: %v", err) continue @@ -852,45 +867,45 @@ func (t *TraefikOidc) startMetadataRefresh(providerURL string) { func discoverProviderMetadata(providerURL string, httpClient *http.Client, l *Logger) (*ProviderMetadata, error) { wellKnownURL := strings.TrimSuffix(providerURL, "/") + "/.well-known/openid-configuration" - // Use shorter delays for tests to prevent timeouts - maxRetries := 4 // Increased to 4 to allow for recovery after 3 failures - baseDelay := 10 * time.Millisecond - maxDelay := 100 * time.Millisecond - totalTimeout := 5 * time.Second - - start := time.Now() - - var lastErr error - for attempt := 0; attempt < maxRetries; attempt++ { - if time.Since(start) > totalTimeout { - l.Errorf("Timeout exceeded while fetching provider metadata") - return nil, fmt.Errorf("timeout exceeded while fetching provider metadata: %w", lastErr) - } - - metadata, err := fetchMetadata(wellKnownURL, httpClient) - if err == nil { - l.Debug("Provider metadata fetched successfully") - return metadata, nil - } - - lastErr = err - - // Don't sleep after the last attempt - if attempt < maxRetries-1 { - // Exponential backoff - delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay - if delay > maxDelay { - delay = maxDelay - } - l.Debugf("Failed to fetch provider metadata (attempt %d/%d), retrying in %s. Error: %v", attempt+1, maxRetries, delay, err) - time.Sleep(delay) - } else { - l.Debugf("Failed to fetch provider metadata (attempt %d/%d). Error: %v", attempt+1, maxRetries, err) - } + // Create retry executor with configuration optimized for test and production environments + retryConfig := RetryConfig{ + MaxAttempts: 4, + InitialDelay: 10 * time.Millisecond, + MaxDelay: 100 * time.Millisecond, + BackoffFactor: 2.0, + EnableJitter: true, + RetryableErrors: []string{ + "connection refused", + "timeout", + "temporary failure", + "network unreachable", + "no route to host", + "connection reset", + "status code 500", + "status code 502", + "status code 503", + "status code 504", + }, } - l.Errorf("Max retries exceeded while fetching provider metadata") - return nil, fmt.Errorf("max retries exceeded while fetching provider metadata: %w", lastErr) + retryExecutor := NewRetryExecutor(retryConfig, l) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var metadata *ProviderMetadata + err := retryExecutor.ExecuteWithContext(ctx, func() error { + var fetchErr error + metadata, fetchErr = fetchMetadata(wellKnownURL, httpClient) + return fetchErr + }) + + if err != nil { + l.Errorf("Failed to fetch provider metadata after retries: %v", err) + return nil, fmt.Errorf("failed to fetch provider metadata: %w", err) + } + + l.Debug("Provider metadata fetched successfully") + return metadata, nil } // fetchMetadata performs a single attempt to fetch and decode the OIDC provider metadata @@ -1986,8 +2001,19 @@ func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") // Prefer JSON response if available - // Send the request - resp, err := t.httpClient.Do(req) + // Send the request with circuit breaker protection if available + var resp *http.Response + if t.errorRecoveryManager != nil { + serviceName := fmt.Sprintf("token-revocation-%s", t.issuerURL) + err = t.errorRecoveryManager.ExecuteWithRecovery(context.Background(), serviceName, func() error { + var reqErr error + resp, reqErr = t.httpClient.Do(req) + return reqErr + }) + } else { + // Fallback for test scenarios without error recovery manager + resp, err = t.httpClient.Do(req) + } if err != nil { return fmt.Errorf("failed to send token revocation request: %w", err) } diff --git a/metadata_cache.go b/metadata_cache.go index 3fee567..21c774e 100644 --- a/metadata_cache.go +++ b/metadata_cache.go @@ -1,6 +1,7 @@ package traefikoidc import ( + "context" "fmt" "net/http" "sync" @@ -55,6 +56,90 @@ func (c *MetadataCache) isCacheValid() bool { return c.metadata != nil && time.Now().Before(c.expiresAt) } +// GetMetadataWithRecovery retrieves the OIDC provider metadata with comprehensive error recovery. +// It uses circuit breaker protection and graceful degradation patterns. +// Similar to GetMetadata but with enhanced error handling capabilities. +// +// Parameters: +// - providerURL: The base URL of the OIDC provider. +// - httpClient: The HTTP client to use for fetching metadata. +// - logger: The logger instance for recording errors or warnings. +// - errorRecoveryManager: The error recovery manager for circuit breaker and retry handling. +// +// Returns: +// - A pointer to the ProviderMetadata struct. +// - An error if metadata cannot be retrieved from cache or fetched from the provider. +func (c *MetadataCache) GetMetadataWithRecovery(providerURL string, httpClient *http.Client, logger *Logger, errorRecoveryManager *ErrorRecoveryManager) (*ProviderMetadata, error) { + c.mutex.RLock() + if c.isCacheValid() { + defer c.mutex.RUnlock() + return c.metadata, nil + } + c.mutex.RUnlock() + + c.mutex.Lock() + defer c.mutex.Unlock() + + // Double-check after acquiring write lock + if c.isCacheValid() { + return c.metadata, nil + } + + // Use error recovery manager for fetching metadata with circuit breaker protection + serviceName := fmt.Sprintf("metadata-provider-%s", providerURL) + + // Register fallback function for graceful degradation + errorRecoveryManager.gracefulDegradation.RegisterFallback(serviceName, func() (interface{}, error) { + if c.metadata != nil { + logger.Infof("Using cached metadata as fallback for service %s", serviceName) + // Extend cache by 10 minutes when using fallback + c.expiresAt = time.Now().Add(10 * time.Minute) + return c.metadata, nil + } + return nil, fmt.Errorf("no cached metadata available for fallback") + }) + + // Register health check function + errorRecoveryManager.gracefulDegradation.RegisterHealthCheck(serviceName, func() bool { + // Simple health check by attempting a quick metadata fetch + _, err := discoverProviderMetadata(providerURL, httpClient, logger) + return err == nil + }) + + // Execute metadata discovery with circuit breaker and retry protection + ctx := context.Background() + var metadata *ProviderMetadata + err := errorRecoveryManager.ExecuteWithRecovery(ctx, serviceName, func() error { + var fetchErr error + metadata, fetchErr = discoverProviderMetadata(providerURL, httpClient, logger) + return fetchErr + }) + + if err != nil { + // Try graceful degradation fallback + fallbackResult, fallbackErr := errorRecoveryManager.gracefulDegradation.ExecuteWithFallback(serviceName, func() (interface{}, error) { + return discoverProviderMetadata(providerURL, httpClient, logger) + }) + + if fallbackErr == nil { + if fallbackMetadata, ok := fallbackResult.(*ProviderMetadata); ok { + logger.Infof("Successfully used fallback metadata for service %s", serviceName) + c.metadata = fallbackMetadata + // Cache fallback result for 10 minutes + c.expiresAt = time.Now().Add(10 * time.Minute) + return fallbackMetadata, nil + } + } + + return nil, fmt.Errorf("failed to fetch provider metadata with error recovery and fallback: %w", err) + } + + c.metadata = metadata + c.expiresAt = time.Now().Add(1 * time.Hour) + + return metadata, nil +} + // GetMetadata retrieves the OIDC provider metadata. // It first checks the cache for valid, non-expired metadata. If found, it's returned immediately. // If the cache is empty or expired, it attempts to fetch the metadata from the provider's diff --git a/session_chunk_manager.go b/session_chunk_manager.go index 801b7f7..f9c7246 100644 --- a/session_chunk_manager.go +++ b/session_chunk_manager.go @@ -1,9 +1,12 @@ package traefikoidc import ( + "encoding/base64" + "encoding/json" "fmt" "strings" "sync" + "time" "github.com/gorilla/sessions" ) @@ -13,6 +16,8 @@ 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 } @@ -22,7 +27,9 @@ var ( AccessTokenConfig = TokenConfig{ Type: "access", MinLength: 5, - MaxLength: 50 * 1024, + MaxLength: 100 * 1024, // 100KB total limit + MaxChunks: 25, // Maximum 25 chunks + MaxChunkSize: maxCookieSize, // Use global chunk size limit AllowOpaqueTokens: true, RequireJWTFormat: false, } @@ -30,7 +37,9 @@ var ( RefreshTokenConfig = TokenConfig{ Type: "refresh", MinLength: 5, - MaxLength: 50 * 1024, + MaxLength: 50 * 1024, // 50KB total limit (refresh tokens are typically smaller) + MaxChunks: 15, // Maximum 15 chunks + MaxChunkSize: maxCookieSize, AllowOpaqueTokens: true, RequireJWTFormat: false, } @@ -38,7 +47,9 @@ var ( IDTokenConfig = TokenConfig{ Type: "id", MinLength: 5, - MaxLength: 50 * 1024, + MaxLength: 75 * 1024, // 75KB total limit + MaxChunks: 20, // Maximum 20 chunks + MaxChunkSize: maxCookieSize, AllowOpaqueTokens: false, RequireJWTFormat: true, } @@ -118,28 +129,48 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config // validateToken performs comprehensive token validation func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult { - // Length validation - if len(token) < config.MinLength || len(token) > config.MaxLength { - err := fmt.Errorf("CRITICAL: %s token has invalid length %d", config.Type, len(token)) - cm.logger.Error(err.Error()) - return TokenRetrievalResult{Token: "", Error: err} + // Enhanced size validation + if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil { + return TokenRetrievalResult{Token: "", Error: sizeErr} } - // JWT format validation (if required) + // Chunking efficiency validation (for pre-storage analysis) + if chunkErr := cm.validateChunkingEfficiency(token, config); chunkErr != nil { + return TokenRetrievalResult{Token: "", Error: chunkErr} + } + + // Comprehensive content validation + 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} + } + + // Enhanced JWT format validation if config.RequireJWTFormat && !config.AllowOpaqueTokens { - dotCount := strings.Count(token, ".") - if dotCount != 2 { - err := fmt.Errorf("CRITICAL: %s token invalid JWT format (dots: %d)", config.Type, dotCount) - cm.logger.Error(err.Error()) - return TokenRetrievalResult{Token: "", Error: err} + 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 && dotCount != 2 { - err := fmt.Errorf("CRITICAL: %s token invalid JWT format (dots: %d)", config.Type, dotCount) - cm.logger.Error(err.Error()) - return TokenRetrievalResult{Token: "", Error: err} + 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} + } } } @@ -148,9 +179,16 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet // processChunkedToken handles tokens stored across multiple chunks func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult { - // Pre-validate chunk count to prevent excessive memory usage - if len(chunks) > 50 { - err := fmt.Errorf("CRITICAL: Too many %s token chunks (%d)", config.Type, len(chunks)) + // Enhanced chunk count validation using config limits + if len(chunks) > config.MaxChunks { + err := fmt.Errorf("CRITICAL: Too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks) + cm.logger.Error(err.Error()) + return TokenRetrievalResult{Token: "", Error: err} + } + + // Additional safety check for extremely large chunk counts + if len(chunks) > 100 { + err := fmt.Errorf("CRITICAL: Excessive %s token chunks (%d), potential security issue", config.Type, len(chunks)) cm.logger.Error(err.Error()) return TokenRetrievalResult{Token: "", Error: err} } @@ -180,8 +218,18 @@ func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, co return TokenRetrievalResult{Token: "", Error: err} } - if len(chunk) > maxCookieSize+100 { - err := fmt.Errorf("CRITICAL: %s token chunk %d too large", config.Type, i) + // Enhanced chunk size validation using config limits + if len(chunk) > config.MaxChunkSize { + err := fmt.Errorf("CRITICAL: %s token chunk %d exceeds size limit (%d bytes, max: %d)", + config.Type, i, len(chunk), config.MaxChunkSize) + cm.logger.Error(err.Error()) + return TokenRetrievalResult{Token: "", Error: err} + } + + // Additional safety check for extremely large chunks + if len(chunk) > maxBrowserCookieSize { + err := fmt.Errorf("CRITICAL: %s token chunk %d exceeds browser limit (%d bytes)", + config.Type, i, len(chunk)) cm.logger.Error(err.Error()) return TokenRetrievalResult{Token: "", Error: err} } @@ -214,3 +262,617 @@ func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, co 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("CRITICAL: %s token invalid JWT format (dots: %d)", tokenType, dotCount) + cm.logger.Error(err.Error()) + return err + } + + // Split into parts + parts := strings.Split(token, ".") + if len(parts) != 3 { + err := fmt.Errorf("CRITICAL: %s token invalid JWT structure", tokenType) + cm.logger.Error(err.Error()) + return err + } + + // Validate each part is non-empty and contains valid base64url characters + for i, part := range parts { + if part == "" { + err := fmt.Errorf("CRITICAL: %s token has empty JWT part %d", tokenType, i) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token contains invalid base64url character in part %d", tokenType, i) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token has invalid base64url padding in part %d", tokenType, i) + cm.logger.Error(err.Error()) + return err + } + // Check that after padding, no other characters exist + for j := paddingIndex; j < len(part); j++ { + if part[j] != '=' { + err := fmt.Errorf("CRITICAL: %s token has characters after padding in part %d", tokenType, i) + cm.logger.Error(err.Error()) + return err + } + } + } + } + + // Additional length checks for JWT parts + if len(parts[0]) < 10 { // Header too short + err := fmt.Errorf("CRITICAL: %s token header too short", tokenType) + cm.logger.Error(err.Error()) + return err + } + if len(parts[1]) < 10 { // Payload too short + err := fmt.Errorf("CRITICAL: %s token payload too short", tokenType) + cm.logger.Error(err.Error()) + return err + } + if len(parts[2]) < 10 { // Signature too short + err := fmt.Errorf("CRITICAL: %s token signature too short", tokenType) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s opaque token contains spaces", tokenType) + cm.logger.Error(err.Error()) + return err + } + + // Check for control characters + for _, char := range token { + if char < 32 || char == 127 { + err := fmt.Errorf("CRITICAL: %s opaque token contains control characters", tokenType) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s opaque token has insufficient entropy", tokenType) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token below minimum length (%d bytes, min: %d)", + config.Type, tokenLen, config.MinLength) + cm.logger.Error(err.Error()) + return err + } + + if tokenLen > config.MaxLength { + err := fmt.Errorf("CRITICAL: %s token exceeds maximum length (%d bytes, max: %d)", + config.Type, tokenLen, config.MaxLength) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token header too large (%d bytes)", config.Type, headerLen) + cm.logger.Error(err.Error()) + return err + } + + if payloadLen > config.MaxLength-10*1024 { // Leave room for header and signature + err := fmt.Errorf("CRITICAL: %s token payload too large (%d bytes)", config.Type, payloadLen) + cm.logger.Error(err.Error()) + return err + } + + if signatureLen > 2*1024 { // 2KB signature limit + err := fmt.Errorf("CRITICAL: %s token signature too large (%d bytes)", config.Type, signatureLen) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s opaque token unusually large (%d bytes)", config.Type, tokenLen) + cm.logger.Error(err.Error()) + 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 + cm.logger.Debugf("INFO: %s token (%d bytes) could fit in single chunk", config.Type, tokenLen) + } + + // Calculate expected number of chunks + expectedChunks := (tokenLen + config.MaxChunkSize - 1) / config.MaxChunkSize + if expectedChunks > config.MaxChunks { + err := fmt.Errorf("CRITICAL: %s token would require %d chunks (max: %d)", + config.Type, expectedChunks, config.MaxChunks) + cm.logger.Error(err.Error()) + return err + } + + // Check for potential storage efficiency issues + if expectedChunks > 10 && tokenLen < 50*1024 { + cm.logger.Infof("WARNING: %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("CRITICAL: %s token contains null bytes", config.Type) + cm.logger.Error(err.Error()) + return err + } + + // Check for line feed/carriage return (header injection attacks) + if strings.ContainsAny(token, "\r\n") { + err := fmt.Errorf("CRITICAL: %s token contains line breaks", config.Type) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s opaque token has suspicious character distribution", config.Type) + cm.logger.Error(err.Error()) + 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 { + cm.logger.Debugf("WARNING: %s opaque token lacks common token prefixes", config.Type) + } + + 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("CRITICAL: %s token has excessive repeated characters (%d consecutive)", + config.Type, maxRepeated) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token has suspicious character frequency (char '%c': %.1f%%)", + config.Type, char, frequency*100) + cm.logger.Error(err.Error()) + 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("CRITICAL: %s token is expired (expired at: %v)", config.Type, expiration.Format(time.RFC3339)) + cm.logger.Error(err.Error()) + 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.Infof("WARNING: %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("CRITICAL: %s token issued in future (issued at: %v)", + config.Type, issuedAt.Format(time.RFC3339)) + cm.logger.Error(err.Error()) + 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.Infof("WARNING: %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 +} diff --git a/session_test.go b/session_test.go index 6c82b35..a5698fe 100644 --- a/session_test.go +++ b/session_test.go @@ -549,7 +549,7 @@ func TestTokenSizeLimits(t *testing.T) { }, { name: "Large but acceptable token", - tokenSize: 30000, // FIXED: 30KB to ensure final size < 100KB limit + tokenSize: 20000, // 20KB to ensure it fits within chunk limits (≤25 chunks) expectStored: true, }, { diff --git a/test_tokens.go b/test_tokens.go index 68f770a..b2b43fc 100644 --- a/test_tokens.go +++ b/test_tokens.go @@ -20,16 +20,16 @@ func NewTestTokens() *TestTokens { // Valid JWT tokens for testing const ( // ValidAccessToken - A properly formatted JWT access token for testing - ValidAccessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTc1MDI5NDYyOCwiaWF0IjoxNzUwMjkxMDI4LCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJqdGkiOiJlNDcxN2RhZDBmZjAyOTNkIiwibmJmIjoxNzUwMjkxMDI4LCJub25jZSI6Im5vbmNlMTIzIiwic3ViIjoidGVzdC1zdWJqZWN0In0.bmwp-vk0B7Ir9UiUkzib8L7yJbebJ00o3U9QrB6gP2H9-RfqyCbN8M9Rkx7Rb8Vdh3YzqkBBoLS_G0i414rs2I9uABnTC4E6-63qkGdUrLB7p-XbjcRW2RoIBwXHk7lfumi8eX0uWzBsJ9CY0__UECVsex5XORfBb4Bcqj0LK4y-glxkpI51I7BPySfciWC_PkdaQ1Qe5pCAlxeNs2E9NMGXp-Ox6vAufUzoC2cws1LswGPPP6icQ-Zlzd5WMCIWhdIkN4yTxk8FMqsTC52k2zskRHNSSd4DDVETonfzawZNqDcMpnTyN53sCJ9UHiQTl9mCm61ttYW-W9Gc-ze4Xw" + ValidAccessToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MzAwMDAwMDAwMCwiaWF0IjoxMDAwMDAwMDAwLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImp0aSI6ImU0NzE3ZGFkMGZmMDI5M2QiLCJuYmYiOjEwMDAwMDAwMDAsIm5vbmNlIjoibm9uY2UxMjMiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ.bmwp-vk0B7Ir9UiUkzib8L7yJbebJ00o3U9QrB6gP2H9-RfqyCbN8M9Rkx7Rb8Vdh3YzqkBBoLS_G0i414rs2I9uABnTC4E6-63qkGdUrLB7p-XbjcRW2RoIBwXHk7lfumi8eX0uWzBsJ9CY0__UECVsex5XORfBb4Bcqj0LK4y-glxkpI51I7BPySfciWC_PkdaQ1Qe5pCAlxeNs2E9NMGXp-Ox6vAufUzoC2cws1LswGPPP6icQ-Zlzd5WMCIWhdIkN4yTxk8FMqsTC52k2zskRHNSSd4DDVETonfzawZNqDcMpnTyN53sCJ9UHiQTl9mCm61ttYW-W9Gc-ze4Xw" // ValidIDToken - A properly formatted JWT ID token for testing - ValidIDToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTc1MDI5NDYyOCwiaWF0IjoxNzUwMjkxMDI4LCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJqdGkiOiI2YzBjZTZmMTM4Y2EzMzc2IiwibmJmIjoxNzUwMjkxMDI4LCJub25jZSI6Im5vbmNlMTIzIiwic3ViIjoidGVzdC1zdWJqZWN0In0.RBQYejA9vP4lnh2EhFqWerePWaCyDTF0ZE1jlU2xm4g2wWVeaEHpv5SNg92_gwk633N9xx7ugS0UrlEu4qbT7wSb1HBDR00q_andyYnyFk4OoxPpD0AqHkVr-pjS-Z7UCGF3sLgQ4ECmU9695PIys3XvgUGMzEn_mK-PHcpY5AnbBGFsbj7epUld_sb6WfjjjwAa8kKfKObPvaIpuJ4TlxI1Uf0wYOoIA0zh5ipeAn-i8Ud-GErxis1Hp8UQK7IRolXpToiXnFcnf3vI3eCS7Yu3oPl7LRxTxKMCI9h0MCwu25ZNsOg2C9ohyebpU0jbURX9Q74GNOaphv-Lz9rCRA" + ValidIDToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MzAwMDAwMDAwMCwiaWF0IjoxMDAwMDAwMDAwLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyLmNvbSIsImp0aSI6IjZjMGNlNmYxMzhjYTMzNzYiLCJuYmYiOjEwMDAwMDAwMDAsIm5vbmNlIjoibm9uY2UxMjMiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ.RBQYejA9vP4lnh2EhFqWerePWaCyDTF0ZE1jlU2xm4g2wWVeaEHpv5SNg92_gwk633N9xx7ugS0UrlEu4qbT7wSb1HBDR00q_andyYnyFk4OoxPpD0AqHkVr-pjS-Z7UCGF3sLgQ4ECmU9695PIys3XvgUGMzEn_mK-PHcpY5AnbBGFsbj7epUld_sb6WfjjjwAa8kKfKObPvaIpuJ4TlxI1Uf0wYOoIA0zh5ipeAn-i8Ud-GErxis1Hp8UQK7IRolXpToiXnFcnf3vI3eCS7Yu3oPl7LRxTxKMCI9h0MCwu25ZNsOg2C9ohyebpU0jbURX9Q74GNOaphv-Lz9rCRA" // ValidRefreshToken - A properly formatted refresh token for testing ValidRefreshToken = "valid-refresh-token-12345" - // MinimalValidJWT - The shortest valid JWT for testing - MinimalValidJWT = "h.p.s" + // MinimalValidJWT - The shortest valid JWT for testing (actual base64url) + MinimalValidJWT = "eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiIxMjMifQ.abc123def456ghi789jkl012mno345pqr678stu901vwx234yz" // ValidRefreshTokenGoogle - A Google-style refresh token for testing ValidRefreshTokenGoogle = "google_refresh_token_12345" @@ -57,7 +57,10 @@ const ( // This replaces the ad-hoc createLargeValidJWT function in tests func (tt *TestTokens) CreateLargeValidJWT(targetSize int) string { header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" - signature := "signature_" + tt.generateRandomString(32) + // Create a valid base64url signature + signatureBytes := make([]byte, 32) + rand.Read(signatureBytes) + signature := base64.RawURLEncoding.EncodeToString(signatureBytes) // Calculate required payload size usedSize := len(header) + len(signature) + 2 // account for dots @@ -110,7 +113,10 @@ func (tt *TestTokens) CreateExpiredJWT() string { claimsJSON, _ := json.Marshal(claims) payload := base64.RawURLEncoding.EncodeToString(claimsJSON) - signature := "expired_signature" + // Create a valid base64url signature + signatureBytes := make([]byte, 16) + rand.Read(signatureBytes) + signature := base64.RawURLEncoding.EncodeToString(signatureBytes) return fmt.Sprintf("%s.%s.%s", header, payload, signature) } @@ -130,7 +136,10 @@ func (tt *TestTokens) CreateUniqueValidJWT(id string) string { claimsJSON, _ := json.Marshal(claims) payload := base64.RawURLEncoding.EncodeToString(claimsJSON) - signature := "sig_" + id + // Create a valid base64url signature + signatureBytes := make([]byte, 16) + rand.Read(signatureBytes) + signature := base64.RawURLEncoding.EncodeToString(signatureBytes) return fmt.Sprintf("%s.%s.%s", header, payload, signature) } @@ -139,7 +148,10 @@ func (tt *TestTokens) CreateUniqueValidJWT(id string) string { // This is useful for testing chunking scenarios where compression doesn't help func (tt *TestTokens) CreateIncompressibleToken(targetSize int) string { header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" - signature := "incompressible_signature_" + tt.generateRandomString(32) + // Create a valid base64url signature + signatureBytes := make([]byte, 32) + rand.Read(signatureBytes) + signature := base64.RawURLEncoding.EncodeToString(signatureBytes) // Calculate required payload size usedSize := len(header) + len(signature) + 2 // account for dots diff --git a/token_corruption_test.go b/token_corruption_test.go index 1becd00..e983c5e 100644 --- a/token_corruption_test.go +++ b/token_corruption_test.go @@ -3,7 +3,6 @@ package traefikoidc import ( "bytes" "compress/gzip" - "crypto/rand" "encoding/base64" "fmt" "net/http/httptest" @@ -23,8 +22,9 @@ func TestTokenCorruptionScenario(t *testing.T) { t.Fatalf("Failed to create session manager: %v", err) } - // Create a valid JWT token - validJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6OTk5OTk5OTk5OX0.signature" + // Create a valid JWT token with proper base64url signature + testTokens := NewTestTokens() + validJWT := testTokens.CreateLargeValidJWT(100) // Create a small valid token tests := []struct { corruptionScenario func(*SessionData) @@ -146,7 +146,7 @@ func TestCompressionIntegrityFailure(t *testing.T) { }{ { name: "Valid JWT", - token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", + token: NewTestTokens().CreateLargeValidJWT(100), expectSame: true, }, { @@ -193,7 +193,8 @@ func TestChunkReassemblyEdgeCases(t *testing.T) { defer session.ReturnToPool() // Create a large token that will definitely be chunked - largeToken := createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", 8000) + testTokens := NewTestTokens() + largeToken := testTokens.CreateLargeValidJWT(8000) // Store the token to create chunks session.SetAccessToken(largeToken) @@ -311,10 +312,11 @@ func TestRaceConditionProtection(t *testing.T) { const numOperations = 50 // Create tokens of different sizes + testTokens := NewTestTokens() tokens := []string{ - "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig1", - createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig2", 3000), - createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig3", 6000), + testTokens.CreateUniqueValidJWT("token1"), + testTokens.CreateLargeValidJWT(3000), + testTokens.CreateLargeValidJWT(6000), } var wg sync.WaitGroup @@ -443,7 +445,8 @@ func TestBackwardCompatibility(t *testing.T) { defer session.ReturnToPool() // Simulate old-style session data (without new validation fields) - oldStyleToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.oldsig" + testTokens := NewTestTokens() + oldStyleToken := testTokens.CreateUniqueValidJWT("old") // Manually set token without going through new SetAccessToken validation session.accessSession.Values["token"] = oldStyleToken @@ -467,41 +470,8 @@ func TestBackwardCompatibility(t *testing.T) { } // createTokenOfSize creates a JWT token of approximately the specified size +// This function is deprecated - use TestTokens.CreateLargeValidJWT instead func createTokenOfSize(baseToken string, targetSize int) string { - parts := strings.Split(baseToken, ".") - if len(parts) != 3 { - return baseToken - } - - header, payload, signature := parts[0], parts[1], parts[2] - currentSize := len(baseToken) - - if currentSize >= targetSize { - return baseToken - } - - // Expand the payload to reach target size - paddingNeeded := targetSize - len(header) - len(signature) - 2 // Account for dots - if paddingNeeded > 0 { - // Decode current payload, add padding, re-encode - decoded, err := base64.RawURLEncoding.DecodeString(payload) - if err != nil { - // If we can't decode, just pad with random base64-safe characters to resist compression - randomBytes := make([]byte, paddingNeeded) - rand.Read(randomBytes) - // Encode as base64 to make it base64-safe - padData := base64.RawURLEncoding.EncodeToString(randomBytes) - payload = payload + padData - } else { - // Add padding to the JSON - use random data to resist compression - randomBytes := make([]byte, paddingNeeded/2) - rand.Read(randomBytes) - // Encode as base64 to make it JSON-safe - padData := base64.StdEncoding.EncodeToString(randomBytes) - newPayload := fmt.Sprintf(`{"original":%s,"padding":"%s"}`, string(decoded), padData) - payload = base64.RawURLEncoding.EncodeToString([]byte(newPayload)) - } - } - - return fmt.Sprintf("%s.%s.%s", header, payload, signature) + testTokens := NewTestTokens() + return testTokens.CreateLargeValidJWT(targetSize) } diff --git a/token_handling_test.go b/token_handling_test.go index aa40ec5..8f06836 100644 --- a/token_handling_test.go +++ b/token_handling_test.go @@ -2,6 +2,7 @@ package traefikoidc import ( "bytes" + "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -332,9 +333,9 @@ func TestTokenCorruptionIntegrationFlows(t *testing.T) { }{ { name: "Normal flow - small tokens", - accessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.access_sig", + accessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.access_signature_data_here", refreshToken: "refresh_token_12345", - idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig", + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here", expectSuccess: true, }, { @@ -348,7 +349,7 @@ func TestTokenCorruptionIntegrationFlows(t *testing.T) { name: "Corrupted access token compression", accessToken: createLargeValidJWT(3000), refreshToken: "refresh_token_12345", - idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig", + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here", expectSuccess: false, corruptAction: func(session *SessionData) { // Corrupt compressed access token @@ -362,7 +363,7 @@ func TestTokenCorruptionIntegrationFlows(t *testing.T) { name: "Corrupted chunk in large token", accessToken: createLargeValidJWT(15000), // Force chunking with larger size refreshToken: "refresh_token_12345", - idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_sig", + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here", expectSuccess: false, corruptAction: func(session *SessionData) { // Corrupt first chunk if chunked, otherwise corrupt single token @@ -647,7 +648,10 @@ func TestTokenValidationEdgeCases(t *testing.T) { // createLargeValidJWT creates a JWT of approximately the specified size func createLargeValidJWT(targetSize int) string { header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" - signature := "signature_" + generateRandomString(32) + // Create a valid base64url signature + signatureBytes := make([]byte, 32) + rand.Read(signatureBytes) + signature := base64.RawURLEncoding.EncodeToString(signatureBytes) // Calculate required payload size usedSize := len(header) + len(signature) + 2 // account for dots