diff --git a/auth_flow.go b/auth_flow.go index 56f9d0c..db489d6 100644 --- a/auth_flow.go +++ b/auth_flow.go @@ -168,7 +168,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, return } - if state != csrfToken { + if !constantTimeStringCompare(state, csrfToken) { t.logger.Error("State parameter does not match CSRF token in session during callback") t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest) return @@ -191,6 +191,17 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier) if err != nil { t.logger.Errorf("Failed to exchange code for token during callback: %v", err) + // Invalidate the one-time auth state and persist it so a + // partially-failed flow cannot be continued: the authorization code + // may still be unconsumed at the provider, so a replayed callback + // must re-authenticate rather than revalidate a stale + // state/nonce/code_verifier. Mirrors the cleanup on success. + session.SetCSRF("") + session.SetNonce("") + session.SetCodeVerifier("") + if saveErr := session.Save(req, rw); saveErr != nil { + t.logger.Errorf("Failed to save session after callback exchange failure: %v", saveErr) + } t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError) return } diff --git a/autocleanup.go b/autocleanup.go index d064eaa..ff25da4 100644 --- a/autocleanup.go +++ b/autocleanup.go @@ -146,6 +146,22 @@ func (bt *BackgroundTask) Stop() { }) } +// safeRunTask executes the task function, recovering from any panic so that a +// single panicking run does not permanently kill the background goroutine and its +// periodic loop. Under yaegi (where this plugin runs) unexpected panics are more +// likely, and a silently-dead background task (metadata refresh, autocleanup, +// memory monitor) would otherwise stop forever with no log or restart. +func (bt *BackgroundTask) safeRunTask() { + defer func() { + if r := recover(); r != nil { + if bt.logger != nil { + bt.logger.Errorf("Background task %s panicked (recovered): %v", bt.name, r) + } + } + }() + bt.taskFunc() +} + // run is the main loop for the background task. // It executes the task function immediately, then periodically // until the stop signal is received. @@ -187,7 +203,7 @@ func (bt *BackgroundTask) run() { } return default: - bt.taskFunc() + bt.safeRunTask() } for { @@ -206,7 +222,7 @@ func (bt *BackgroundTask) run() { } return default: - bt.taskFunc() + bt.safeRunTask() } case <-bt.stopChan: if bt.logger != nil { @@ -463,10 +479,13 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error { tr.mu.Lock() defer tr.mu.Unlock() - // Task execution tracking is now handled in the run() method - tr.tasks[name] = task - tr.cb.OnTaskSuccess(name) + // Execution tracking (the circuit-breaker concurrent count) is handled by + // Start()/run(): Start() reserves a slot via OnTaskStart and run() releases + // it via OnTaskComplete. Do NOT also OnTaskSuccess here — that would + // double-count every registered task (+1 on register, +1 on Start, -1 on + // stop), leaking one slot per task and eventually blocking all background + // task creation at the concurrency limit. if tr.logger != nil { tr.logger.Debug("Registered background task: %s", name) diff --git a/bearer_auth_fuzz_test.go b/bearer_auth_fuzz_test.go new file mode 100644 index 0000000..d9b7e76 --- /dev/null +++ b/bearer_auth_fuzz_test.go @@ -0,0 +1,46 @@ +package traefikoidc + +import "testing" + +// FuzzParseBearerJOSEHeader verifies the attacker-controlled JWT header parser +// never panics on arbitrary input (no slice OOB, no unbounded alloc, no +// JSON-engine surprise) regardless of token contents. +func FuzzParseBearerJOSEHeader(f *testing.F) { + seeds := []string{ + "", + "...", + "abc.def.ghi", + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEifQ.sig.sig", // alg=RS256 kid=kid1 + "::", + "snapshot=", + "a,b.zzz", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, token string) { + parseBearerJOSEHeader(token) // must not panic + }) +} + +// FuzzSanitizeBearerIdentifier verifies the identifier sanitizer never panics on +// arbitrary strings and always returns either a clean value or a *bearerError. +func FuzzSanitizeBearerIdentifier(f *testing.F) { + seeds := []string{ + "", + "alice", + "user,group=admin", + "a\u202eb\u202c", + "\t \n", + "verylongidentifier-padding-padding-padding-padding", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, raw string) { + id, berr := sanitizeBearerIdentifier(raw, 256) + if berr == nil && id == "" { + t.Fatalf("sanitize ok but empty identifier for %q", raw) + } + }) +} diff --git a/cache_test.go b/cache_test.go index dbaab1f..d9e2957 100644 --- a/cache_test.go +++ b/cache_test.go @@ -178,6 +178,11 @@ func TestCacheInterfaceWrapper_Clear(t *testing.T) { cm := getTestCacheManager(t) cache := cm.GetSharedTokenBlacklist() + // The shared token blacklist is process-global across tests; under shuffled + // execution a prior test may have left items behind. Clear first so the + // size assertions are stable regardless of test order. + cache.Clear() + cache.Set("key1", "value1", time.Hour) cache.Set("key2", "value2", time.Hour) diff --git a/concurrent_servehttp_test.go b/concurrent_servehttp_test.go new file mode 100644 index 0000000..01596be --- /dev/null +++ b/concurrent_servehttp_test.go @@ -0,0 +1,95 @@ +package traefikoidc + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +// TestConcurrentServeHTTP_SharedInstance drives the full request path +// (bootstraps, auth-bypass evaluation, session lookup, redirect) across many +// goroutines against ONE shared plugin instance. Traefik runs the middleware +// concurrently for every request on a router, so this is the production +// concurrency shape. Holds under -race only if the request path touches +// shared mutable state via atomics/locks, never plain field writes. +func TestConcurrentServeHTTP_SharedInstance(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + oidc := &TraefikOidc{ + next: next, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), + sessionManager: createTestSessionManager(t), + issuerURL: "https://provider.example.com", + redirURLPath: "/callback", + logoutURLPath: "/logout", + clientID: "test-client", + audience: "test-client", + authURL: "https://provider.example.com/auth", + firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS + metadataRefreshStartedAtomic: 0, + } + close(oidc.initComplete) + + const goroutines = 40 + const perGoroutine = 5 + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + req := httptest.NewRequest("GET", fmt.Sprintf("/api/g%d/", g), nil) + w := httptest.NewRecorder() + oidc.ServeHTTP(w, req) + // Unauthenticated request must bounce to the IdP (3xx), not + // forward to the backend (200) or error. + if w.Code < 300 || w.Code >= 400 { + t.Errorf("expected auth redirect (3xx), got %d", w.Code) + } + } + }() + } + wg.Wait() +} + +// TestConcurrentServeHTTP_ExcludedBypass drives the auth-bypass path +// concurrently (shouldBypassAuth read-path) on a shared instance. +func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + oidc := &TraefikOidc{ + next: next, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), + sessionManager: createTestSessionManager(t), + firstRequestStarted: 1, + metadataRefreshStartedAtomic: 1, + authURL: "https://provider.example.com/auth", + } + close(oidc.initComplete) + + const goroutines = 40 + const perGoroutine = 5 + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + oidc.ServeHTTP(w, req) + } + }() + } + wg.Wait() +} diff --git a/error_recovery_test.go b/error_recovery_test.go index 393416b..e9cd1e8 100644 --- a/error_recovery_test.go +++ b/error_recovery_test.go @@ -1271,9 +1271,12 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) { defer gd.Close() t.Run("register health check", func(t *testing.T) { - healthy := true + var healthy atomic.Bool + healthy.Store(true) + // The health check runs on a background goroutine (performHealthChecks at + // the configured interval); a plain captured bool would be a data race. healthCheck := func() bool { - return healthy + return healthy.Load() } gd.RegisterHealthCheck("service1", healthCheck) @@ -1281,7 +1284,7 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) { gd.markServiceDegraded("service1") assert.True(t, gd.isServiceDegraded("service1")) - healthy = true + healthy.Store(true) time.Sleep(100 * time.Millisecond) }) } @@ -1609,9 +1612,12 @@ func TestGracefulDegradationFullScenario(t *testing.T) { return "fallback data", nil }) - serviceHealthy := false + var serviceHealthy atomic.Bool + serviceHealthy.Store(false) + // Health checks run on a background goroutine (performHealthChecks at the + // configured interval); a plain captured bool is a data race. gd.RegisterHealthCheck("critical-service", func() bool { - return serviceHealthy + return serviceHealthy.Load() }) result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) { @@ -1634,7 +1640,7 @@ func TestGracefulDegradationFullScenario(t *testing.T) { assert.NoError(t, err3) assert.Equal(t, "fallback data", result3) - serviceHealthy = true + serviceHealthy.Store(true) time.Sleep(250 * time.Millisecond) metrics := gd.GetMetrics() diff --git a/input_validation.go b/input_validation.go deleted file mode 100644 index 215db86..0000000 --- a/input_validation.go +++ /dev/null @@ -1,727 +0,0 @@ -package traefikoidc - -import ( - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - "unicode" - "unicode/utf8" -) - -// Pre-compiled regex patterns for validation (const patterns should use MustCompile) -var ( - emailRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - urlRegexPattern = regexp.MustCompile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`) - tokenRegexPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - usernameRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) -) - -// InputValidator provides comprehensive input validation and sanitization -// to protect against common security vulnerabilities including SQL injection, -// XSS, path traversal, and other injection attacks. It validates and sanitizes -// various input types used in OIDC authentication flows. -type InputValidator struct { - usernameRegex *regexp.Regexp - tokenRegex *regexp.Regexp - logger *Logger - urlRegex *regexp.Regexp - emailRegex *regexp.Regexp - sqlInjectionPatterns []string - pathTraversalPatterns []string - xssPatterns []string - maxUsernameLength int - maxURLLength int - maxTokenLength int - maxEmailLength int - maxClaimLength int - maxHeaderLength int - allowPrivateIPAddresses bool // Allow private IP addresses in URL validation -} - -// ValidationResult encapsulates the outcome of input validation. -// It includes the sanitized value, detected security risks, validation -// errors and warnings, and an overall validity status. -type ValidationResult struct { - SanitizedValue string `json:"sanitized_value,omitempty"` - SecurityRisk string `json:"security_risk,omitempty"` - Errors []string `json:"errors,omitempty"` - Warnings []string `json:"warnings,omitempty"` - IsValid bool `json:"is_valid"` -} - -// InputValidationConfig defines the configuration parameters for input validation. -// It specifies maximum lengths for various input types and controls whether -// strict validation mode is enabled. -type InputValidationConfig struct { - MaxTokenLength int `json:"max_token_length"` - MaxURLLength int `json:"max_url_length"` - MaxHeaderLength int `json:"max_header_length"` - MaxClaimLength int `json:"max_claim_length"` - MaxEmailLength int `json:"max_email_length"` - MaxUsernameLength int `json:"max_username_length"` - StrictMode bool `json:"strict_mode"` - AllowPrivateIPAddresses bool `json:"allow_private_ip_addresses"` // Allow private IP addresses in URL validation -} - -// DefaultInputValidationConfig returns a secure default configuration -// for input validation with reasonable limits based on industry standards -// and security best practices. -func DefaultInputValidationConfig() InputValidationConfig { - return InputValidationConfig{ - MaxTokenLength: 50000, // 50KB for tokens - MaxURLLength: 2048, // Standard URL length limit - MaxHeaderLength: 8192, // 8KB for headers - MaxClaimLength: 1024, // 1KB for individual claims - MaxEmailLength: 254, // RFC 5321 limit - MaxUsernameLength: 64, // Reasonable username limit - StrictMode: true, // Enable strict validation by default - } -} - -// NewInputValidator creates a new input validator with the specified configuration. -// It uses pre-compiled regex patterns and initializes security pattern lists. -// -// Parameters: -// - config: Validation configuration with size limits and mode settings. -// - logger: Logger instance for recording validation events. -// -// Returns: -// - A configured InputValidator instance. -// - An error (always nil, kept for API compatibility). -func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) { - return &InputValidator{ - maxTokenLength: config.MaxTokenLength, - maxURLLength: config.MaxURLLength, - maxHeaderLength: config.MaxHeaderLength, - maxClaimLength: config.MaxClaimLength, - maxEmailLength: config.MaxEmailLength, - maxUsernameLength: config.MaxUsernameLength, - allowPrivateIPAddresses: config.AllowPrivateIPAddresses, - emailRegex: emailRegexPattern, - urlRegex: urlRegexPattern, - tokenRegex: tokenRegexPattern, - usernameRegex: usernameRegexPattern, - sqlInjectionPatterns: []string{ - "'", "\"", ";", "--", "/*", "*/", "xp_", "sp_", - "union", "select", "insert", "update", "delete", "drop", - "create", "alter", "exec", "execute", "script", - }, - xssPatterns: []string{ - "", "javascript:", "vbscript:", - "onload=", "onerror=", "onclick=", "onmouseover=", - " iv.maxTokenLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength)) - return result - } - - // Check for minimum reasonable length - if len(token) < 10 { - result.IsValid = false - result.Errors = append(result.Errors, "token is too short to be valid") - return result - } - - // Check for valid JWT structure (3 parts separated by dots) - parts := strings.Split(token, ".") - if len(parts) != 3 { - result.IsValid = false - result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)") - return result - } - - // Validate each part is base64url encoded - for i, part := range parts { - if !iv.isValidBase64URL(part) { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1)) - return result - } - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(token); risk != "" { - result.SecurityRisk = risk - result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk)) - } - - // Check for null bytes and control characters - if iv.containsNullBytes(token) { - result.IsValid = false - result.Errors = append(result.Errors, "token contains null bytes") - return result - } - - if iv.containsControlCharacters(token) { - result.IsValid = false - result.Errors = append(result.Errors, "token contains control characters") - return result - } - - // Validate UTF-8 encoding - if !utf8.ValidString(token) { - result.IsValid = false - result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences") - return result - } - - result.SanitizedValue = token - return result -} - -// ValidateEmail validates email addresses -func (iv *InputValidator) ValidateEmail(email string) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - // Check for empty email - if email == "" { - result.IsValid = false - result.Errors = append(result.Errors, "email cannot be empty") - return result - } - - // Check length limits - if len(email) > iv.maxEmailLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength)) - return result - } - - // Sanitize email (trim whitespace, convert to lowercase) - sanitized := strings.TrimSpace(strings.ToLower(email)) - - // Check regex pattern - if !iv.emailRegex.MatchString(sanitized) { - result.IsValid = false - result.Errors = append(result.Errors, "email format is invalid") - return result - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(sanitized); risk != "" { - result.SecurityRisk = risk - result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk)) - } - - // Additional email-specific validations - parts := strings.Split(sanitized, "@") - if len(parts) != 2 { - result.IsValid = false - result.Errors = append(result.Errors, "email must contain exactly one @ symbol") - return result - } - - localPart, domain := parts[0], parts[1] - - // Validate local part - if len(localPart) == 0 || len(localPart) > 64 { - result.IsValid = false - result.Errors = append(result.Errors, "email local part length is invalid") - return result - } - - // Validate domain - if len(domain) == 0 || len(domain) > 253 { - result.IsValid = false - result.Errors = append(result.Errors, "email domain length is invalid") - return result - } - - // Check for consecutive dots - if strings.Contains(sanitized, "..") { - result.IsValid = false - result.Errors = append(result.Errors, "email contains consecutive dots") - return result - } - - result.SanitizedValue = sanitized - return result -} - -// ValidateURL validates URLs -func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - // Check for empty URL - if urlStr == "" { - result.IsValid = false - result.Errors = append(result.Errors, "URL cannot be empty") - return result - } - - // Check length limits - if len(urlStr) > iv.maxURLLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength)) - return result - } - - // Sanitize URL (trim whitespace) - sanitized := strings.TrimSpace(urlStr) - - // Parse URL - parsedURL, err := url.Parse(sanitized) - if err != nil { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err)) - return result - } - - // Check scheme - if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" { - result.IsValid = false - result.Errors = append(result.Errors, "URL scheme must be http or https") - return result - } - - // Prefer HTTPS - if parsedURL.Scheme == "http" { - result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS") - } - - // Check host - if parsedURL.Host == "" { - result.IsValid = false - result.Errors = append(result.Errors, "URL must have a valid host") - return result - } - - // Check for localhost or private IPs for security - // Allow localhost for HTTPS (development/testing) but warn about it - hostname := strings.ToLower(parsedURL.Hostname()) - if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { - if parsedURL.Scheme == "https" { - // Allow HTTPS localhost for development but warn - result.Warnings = append(result.Warnings, "localhost URLs should only be used for development/testing") - } else { - // Reject non-HTTPS localhost for security - result.IsValid = false - result.Errors = append(result.Errors, "non-HTTPS localhost URLs are not allowed for security") - return result - } - } - - // Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled - if !iv.allowPrivateIPAddresses { - if strings.HasPrefix(hostname, "10.") || - strings.HasPrefix(hostname, "192.168.") || - strings.HasPrefix(hostname, "172.") { - // For 172.x check if it's in the 172.16.0.0/12 range - if strings.HasPrefix(hostname, "172.") { - parts := strings.Split(hostname, ".") - if len(parts) >= 2 { - if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 { - result.IsValid = false - result.Errors = append(result.Errors, "private IP URLs are not allowed for security") - return result - } - } - } else { - result.IsValid = false - result.Errors = append(result.Errors, "private IP URLs are not allowed for security") - return result - } - } - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(sanitized); risk != "" { - result.SecurityRisk = risk - result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk)) - } - - // Check for path traversal attempts - if iv.containsPathTraversal(sanitized) { - result.IsValid = false - result.Errors = append(result.Errors, "URL contains path traversal patterns") - return result - } - - result.SanitizedValue = sanitized - return result -} - -// ValidateUsername validates usernames -func (iv *InputValidator) ValidateUsername(username string) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - // Check for empty username - if username == "" { - result.IsValid = false - result.Errors = append(result.Errors, "username cannot be empty") - return result - } - - // Check length limits - if len(username) > iv.maxUsernameLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength)) - return result - } - - // Check minimum length - if len(username) < 2 { - result.IsValid = false - result.Errors = append(result.Errors, "username must be at least 2 characters long") - return result - } - - // Sanitize username (trim whitespace) - sanitized := strings.TrimSpace(username) - - // Check regex pattern - if !iv.usernameRegex.MatchString(sanitized) { - result.IsValid = false - result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)") - return result - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(sanitized); risk != "" { - result.SecurityRisk = risk - result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk)) - } - - result.SanitizedValue = sanitized - return result -} - -// ValidateClaim validates individual JWT claims -func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - // Check claim name - if claimName == "" { - result.IsValid = false - result.Errors = append(result.Errors, "claim name cannot be empty") - return result - } - - // Check claim value length - if len(claimValue) > iv.maxClaimLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength)) - return result - } - - // Check for null bytes and control characters - if iv.containsNullBytes(claimValue) { - result.IsValid = false - result.Errors = append(result.Errors, "claim value contains null bytes") - return result - } - - if iv.containsControlCharacters(claimValue) { - result.IsValid = false - result.Errors = append(result.Errors, "claim value contains control characters") - return result - } - - // Validate UTF-8 encoding - if !utf8.ValidString(claimValue) { - result.IsValid = false - result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences") - return result - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(claimValue); risk != "" { - result.SecurityRisk = risk - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk)) - return result - } - - // Check for excessive unicode (emojis and special characters) - unicodeCount := 0 - runeCount := 0 - for _, r := range claimValue { - runeCount++ - if r > 127 { // Non-ASCII character - unicodeCount++ - } - } - // If more than 50% of the characters are unicode, consider it suspicious - if runeCount > 0 && unicodeCount > runeCount/2 { - result.IsValid = false - result.Errors = append(result.Errors, "claim value contains excessive unicode characters") - return result - } - - // Specific validations based on claim name - switch claimName { - case "email": - emailResult := iv.ValidateEmail(claimValue) - if !emailResult.IsValid { - result.IsValid = false - result.Errors = append(result.Errors, emailResult.Errors...) - } - result.Warnings = append(result.Warnings, emailResult.Warnings...) - result.SanitizedValue = emailResult.SanitizedValue - - case "iss", "aud": - urlResult := iv.ValidateURL(claimValue) - if !urlResult.IsValid { - // For issuer/audience, we're more lenient - just warn - result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors)) - } - result.SanitizedValue = claimValue - - case "preferred_username", "username": - usernameResult := iv.ValidateUsername(claimValue) - if !usernameResult.IsValid { - result.IsValid = false - result.Errors = append(result.Errors, usernameResult.Errors...) - } - result.Warnings = append(result.Warnings, usernameResult.Warnings...) - result.SanitizedValue = usernameResult.SanitizedValue - - default: - // Generic string validation - result.SanitizedValue = strings.TrimSpace(claimValue) - } - - return result -} - -// ValidateHeader validates HTTP header values -func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - // Check header name - if headerName == "" { - result.IsValid = false - result.Errors = append(result.Errors, "header name cannot be empty") - return result - } - - // Check for control characters in header name (including CRLF) - if iv.containsControlCharacters(headerName) { - result.IsValid = false - result.Errors = append(result.Errors, "header name contains control characters") - return result - } - - // Check for CRLF injection in header name - if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") { - result.IsValid = false - result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)") - return result - } - - // Check header value length - if len(headerValue) > iv.maxHeaderLength { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength)) - return result - } - - // Check for null bytes and control characters (except allowed ones) - if iv.containsNullBytes(headerValue) { - result.IsValid = false - result.Errors = append(result.Errors, "header value contains null bytes") - return result - } - - // Check for CRLF injection - if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") { - result.IsValid = false - result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)") - return result - } - - // Check for control characters in header value - if iv.containsControlCharacters(headerValue) { - result.IsValid = false - result.Errors = append(result.Errors, "header value contains control characters") - return result - } - - // Validate UTF-8 encoding - if !utf8.ValidString(headerValue) { - result.IsValid = false - result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences") - return result - } - - // Check for suspicious patterns - if risk := iv.detectSecurityRisk(headerValue); risk != "" { - result.SecurityRisk = risk - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk)) - return result - } - - result.SanitizedValue = strings.TrimSpace(headerValue) - return result -} - -// isValidBase64URL checks if a string is valid base64url encoding -func (iv *InputValidator) isValidBase64URL(s string) bool { - // Base64url uses A-Z, a-z, 0-9, -, _ and no padding - for _, r := range s { - if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || - (r >= '0' && r <= '9') || r == '-' || r == '_') { - return false - } - } - return true -} - -// containsNullBytes checks if a string contains null bytes -func (iv *InputValidator) containsNullBytes(s string) bool { - return strings.Contains(s, "\x00") -} - -// containsControlCharacters checks if a string contains control characters -func (iv *InputValidator) containsControlCharacters(s string) bool { - for _, r := range s { - if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' { - return true - } - } - return false -} - -// containsPathTraversal checks for path traversal patterns -func (iv *InputValidator) containsPathTraversal(s string) bool { - lowerS := strings.ToLower(s) - for _, pattern := range iv.pathTraversalPatterns { - if strings.Contains(lowerS, pattern) { - return true - } - } - return false -} - -// detectSecurityRisk detects potential security risks in input -func (iv *InputValidator) detectSecurityRisk(input string) string { - lowerInput := strings.ToLower(input) - - // Check for SQL injection patterns - for _, pattern := range iv.sqlInjectionPatterns { - if strings.Contains(lowerInput, pattern) { - return "sql_injection" - } - } - - // Check for XSS patterns - for _, pattern := range iv.xssPatterns { - if strings.Contains(lowerInput, pattern) { - return "xss" - } - } - - // Check for path traversal - if iv.containsPathTraversal(input) { - return "path_traversal" - } - - // Check for excessive length (potential DoS) - if len(input) > 10000 { - return "excessive_length" - } - - // Check for suspicious character patterns - if iv.containsNullBytes(input) { - return "null_bytes" - } - - // Check for binary data patterns - nonPrintableCount := 0 - for _, r := range input { - if !unicode.IsPrint(r) && !unicode.IsSpace(r) { - nonPrintableCount++ - } - } - if nonPrintableCount > len(input)/10 { // More than 10% non-printable - return "binary_data" - } - - return "" -} - -// SanitizeInput provides general input sanitization -func (iv *InputValidator) SanitizeInput(input string, maxLength int) string { - // Trim whitespace - sanitized := strings.TrimSpace(input) - - // Truncate if too long - if len(sanitized) > maxLength { - sanitized = sanitized[:maxLength] - } - - // Remove null bytes - sanitized = strings.ReplaceAll(sanitized, "\x00", "") - - // Remove other control characters except tab, newline, carriage return - var result strings.Builder - for _, r := range sanitized { - if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' { - result.WriteRune(r) - } - } - - return result.String() -} - -// ValidateBoundaryValues validates numeric boundary values -func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult { - result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}} - - var numValue int64 - - switch v := value.(type) { - case int: - numValue = int64(v) - case int32: - numValue = int64(v) - case int64: - numValue = v - case float64: - numValue = int64(v) - if float64(numValue) != v { - result.Warnings = append(result.Warnings, "floating point value truncated to integer") - } - default: - result.IsValid = false - result.Errors = append(result.Errors, "value is not a numeric type") - return result - } - - if numValue < min { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min)) - } - - if numValue > max { - result.IsValid = false - result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max)) - } - - return result -} diff --git a/input_validation_test.go b/input_validation_test.go deleted file mode 100644 index 94e5e05..0000000 --- a/input_validation_test.go +++ /dev/null @@ -1,895 +0,0 @@ -package traefikoidc - -import ( - "strings" - "testing" -) - -func TestInputValidator(t *testing.T) { - config := DefaultInputValidationConfig() - logger := NewLogger("debug") - validator, err := NewInputValidator(config, logger) - if err != nil { - t.Fatalf("Failed to create validator: %v", err) - } - - t.Run("Valid token validation", func(t *testing.T) { - validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc" // trufflehog:ignore - - result := validator.ValidateToken(validToken) - if !result.IsValid { - t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors) - } - }) - - t.Run("Invalid token validation", func(t *testing.T) { - invalidTokens := []string{ - "", // Empty token - "invalid.token", // Invalid format - "a.b", // Too few parts - "a.b.c.d", // Too many parts - } - - for _, token := range invalidTokens { - result := validator.ValidateToken(token) - if result.IsValid { - t.Errorf("Expected invalid token '%s' to fail validation", token) - } - } - }) - - t.Run("Valid email validation", func(t *testing.T) { - validEmails := []string{ - "user@example.com", - "test.email@domain.co.uk", - "user123@test-domain.org", - } - - for _, email := range validEmails { - result := validator.ValidateEmail(email) - if !result.IsValid { - t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors) - } - } - }) - - t.Run("Invalid email validation", func(t *testing.T) { - invalidEmails := []string{ - "", // Empty - "invalid", // No @ symbol - "@domain.com", // No local part - "user@", // No domain - "user@domain", // No TLD - "user..double@domain.com", // Double dots - } - - for _, email := range invalidEmails { - result := validator.ValidateEmail(email) - if result.IsValid { - t.Errorf("Expected invalid email '%s' to fail validation", email) - } - } - }) - - t.Run("Valid URL validation", func(t *testing.T) { - validURLs := []string{ - "https://example.com", - "https://sub.domain.com/path", - "https://localhost:8080/callback", - } - - for _, url := range validURLs { - result := validator.ValidateURL(url) - if !result.IsValid { - t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors) - } - } - }) - - t.Run("Invalid URL validation", func(t *testing.T) { - invalidURLs := []string{ - "", // Empty - "not-a-url", // Invalid format - "ftp://example.com", // Wrong scheme - "https://", // No host - } - - for _, url := range invalidURLs { - result := validator.ValidateURL(url) - if result.IsValid { - t.Errorf("Expected invalid URL '%s' to fail validation", url) - } - } - }) - - t.Run("Valid username validation", func(t *testing.T) { - validUsernames := []string{ - "user123", - "test_user", - "user-name", - } - - for _, username := range validUsernames { - result := validator.ValidateUsername(username) - if !result.IsValid { - t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors) - } - } - }) - - t.Run("Invalid username validation", func(t *testing.T) { - invalidUsernames := []string{ - "", // Empty - "a", // Too short - strings.Repeat("a", 100), // Too long - "user name", // Spaces - } - - for _, username := range invalidUsernames { - result := validator.ValidateUsername(username) - if result.IsValid { - t.Errorf("Expected invalid username '%s' to fail validation", username) - } - } - }) - - t.Run("Valid claim validation", func(t *testing.T) { - validClaims := map[string]string{ - "sub": "user123", - "email": "user@example.com", - "name": "John Doe", - } - - for key, value := range validClaims { - result := validator.ValidateClaim(key, value) - if !result.IsValid { - t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors) - } - } - }) - - t.Run("Invalid claim validation", func(t *testing.T) { - invalidClaims := map[string]string{ - "": "value", // Empty key - "long_key": strings.Repeat("a", 10000), // Too long value - } - - for key, value := range invalidClaims { - result := validator.ValidateClaim(key, value) - if result.IsValid { - t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value) - } - } - }) - - t.Run("Valid header validation", func(t *testing.T) { - validHeaders := map[string]string{ - "Authorization": "Bearer token123", - "Content-Type": "application/json", - "X-Custom": "custom-value", - } - - for key, value := range validHeaders { - result := validator.ValidateHeader(key, value) - if !result.IsValid { - t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors) - } - } - }) - - t.Run("Invalid header validation", func(t *testing.T) { - invalidHeaders := map[string]string{ - "": "value", // Empty key - "Invalid\nKey": "value", // Control characters in key - "key": "value\r\n", // Control characters in value - } - - for key, value := range invalidHeaders { - result := validator.ValidateHeader(key, value) - if result.IsValid { - t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value) - } - } - }) -} - -func TestSanitizeInput(t *testing.T) { - config := DefaultInputValidationConfig() - logger := NewLogger("debug") - validator, err := NewInputValidator(config, logger) - if err != nil { - t.Fatalf("Failed to create validator: %v", err) - } - - tests := []struct { - name string - input string - expected string - maxLen int - }{ - { - name: "Normal text", - input: "Hello World", - maxLen: 100, - expected: "Hello World", - }, - { - name: "Control characters", - input: "text\x00with\x01control\x02chars", - maxLen: 100, - expected: "textwithcontrolchars", - }, - { - name: "Truncation", - input: "very long text", - maxLen: 5, - expected: "very ", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validator.SanitizeInput(tt.input, tt.maxLen) - if result != tt.expected { - t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result) - } - }) - } -} - -func TestValidateBoundaryValues(t *testing.T) { - config := DefaultInputValidationConfig() - logger := NewLogger("debug") - validator, err := NewInputValidator(config, logger) - if err != nil { - t.Fatalf("Failed to create validator: %v", err) - } - - t.Run("Valid boundary values", func(t *testing.T) { - validValues := []interface{}{ - int(50), - int64(100), - float64(75.5), - } - - for _, value := range validValues { - result := validator.ValidateBoundaryValues(value, 1, 1000) - if !result.IsValid { - t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors) - } - } - }) - - t.Run("Invalid boundary values", func(t *testing.T) { - invalidValues := []interface{}{ - int(-1), - int64(2000), - "not a number", - } - - for _, value := range invalidValues { - result := validator.ValidateBoundaryValues(value, 1, 1000) - if result.IsValid { - t.Errorf("Expected invalid boundary value %v to fail validation", value) - } - } - }) -} - -func TestDefaultInputValidationConfig(t *testing.T) { - config := DefaultInputValidationConfig() - - if config.MaxTokenLength <= 0 { - t.Error("Expected positive MaxTokenLength") - } - if config.MaxEmailLength <= 0 { - t.Error("Expected positive MaxEmailLength") - } - if config.MaxUsernameLength <= 0 { - t.Error("Expected positive MaxUsernameLength") - } - if config.MaxClaimLength <= 0 { - t.Error("Expected positive MaxClaimLength") - } - if config.MaxHeaderLength <= 0 { - t.Error("Expected positive MaxHeaderLength") - } - if !config.StrictMode { - t.Error("Expected StrictMode to be true by default") - } -} - -func TestInputValidationHelpers(t *testing.T) { - config := DefaultInputValidationConfig() - logger := NewLogger("debug") - validator, err := NewInputValidator(config, logger) - if err != nil { - t.Fatalf("Failed to create validator: %v", err) - } - - t.Run("isValidBase64URL", func(t *testing.T) { - validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" - if !validator.isValidBase64URL(validBase64URL) { - t.Error("Expected valid base64url to be recognized") - } - - invalidBase64URL := "invalid+base64/with+padding=" - if validator.isValidBase64URL(invalidBase64URL) { - t.Error("Expected invalid base64url to be rejected") - } - }) - - t.Run("containsNullBytes", func(t *testing.T) { - withNull := "text\x00with\x00null" - if !validator.containsNullBytes(withNull) { - t.Error("Expected string with null bytes to be detected") - } - - withoutNull := "normal text" - if validator.containsNullBytes(withoutNull) { - t.Error("Expected string without null bytes to pass") - } - }) - - t.Run("containsControlCharacters", func(t *testing.T) { - withControl := "text\x01with\x02control" - if !validator.containsControlCharacters(withControl) { - t.Error("Expected string with control characters to be detected") - } - - withoutControl := "normal text" - if validator.containsControlCharacters(withoutControl) { - t.Error("Expected string without control characters to pass") - } - }) - - t.Run("containsPathTraversal", func(t *testing.T) { - withTraversal := "../../../etc/passwd" - if !validator.containsPathTraversal(withTraversal) { - t.Error("Expected path traversal to be detected") - } - - normalPath := "/normal/path" - if validator.containsPathTraversal(normalPath) { - t.Error("Expected normal path to pass") - } - }) - - t.Run("detectSecurityRisk", func(t *testing.T) { - riskyInputs := []string{ - "", - "'; DROP TABLE users; --", - "javascript:alert('xss')", - } - - for _, input := range riskyInputs { - if validator.detectSecurityRisk(input) == "" { - t.Errorf("Expected security risk to be detected in: %s", input) - } - } - - safeInput := "normal safe text" - if validator.detectSecurityRisk(safeInput) != "" { - t.Error("Expected safe input to pass security check") - } - }) -} - -func TestInputValidationEdgeCases(t *testing.T) { - config := DefaultInputValidationConfig() - logger := NewLogger("debug") - validator, err := NewInputValidator(config, logger) - if err != nil { - t.Fatalf("Failed to create validator: %v", err) - } - - t.Run("Empty inputs", func(t *testing.T) { - // Most validations should reject empty inputs - if result := validator.ValidateToken(""); result.IsValid { - t.Error("Expected empty token to be rejected") - } - if result := validator.ValidateEmail(""); result.IsValid { - t.Error("Expected empty email to be rejected") - } - if result := validator.ValidateURL(""); result.IsValid { - t.Error("Expected empty URL to be rejected") - } - if result := validator.ValidateUsername(""); result.IsValid { - t.Error("Expected empty username to be rejected") - } - }) - - t.Run("Very long inputs", func(t *testing.T) { - longString := strings.Repeat("a", 10000) - - if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid { - t.Error("Expected very long email to be rejected") - } - if result := validator.ValidateUsername(longString); result.IsValid { - t.Error("Expected very long username to be rejected") - } - }) - - t.Run("Unicode handling", func(t *testing.T) { - unicodeEmail := "用户@example.com" - // Should handle unicode gracefully - validator.ValidateEmail(unicodeEmail) // Don't fail on unicode - - unicodeUsername := "用户名" - validator.ValidateUsername(unicodeUsername) // Don't fail on unicode - }) -} - -// TestInputValidatorValidateToken tests comprehensive token validation -func TestInputValidatorValidateToken(t *testing.T) { - config := DefaultInputValidationConfig() - validator, _ := NewInputValidator(config, newNoOpLogger()) - - tests := []struct { - name string - token string - description string - expectValid bool - }{ - { - name: "ValidJWTToken", - token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjJ9.signature", // trufflehog:ignore - expectValid: true, - description: "Valid JWT token should pass validation", - }, - { - name: "InvalidOpaqueToken", - token: "opaque_access_token_that_is_long_enough_to_pass", - expectValid: false, - description: "Opaque token (non-JWT) should fail validation", - }, - { - name: "EmptyToken", - token: "", - expectValid: false, - description: "Empty token should fail validation", - }, - { - name: "TokenWithNullBytes", - token: "token_with_null\x00byte", - expectValid: false, - description: "Token with null bytes should fail validation", - }, - { - name: "TokenTooLong", - token: strings.Repeat("a", config.MaxTokenLength+1), - expectValid: false, - description: "Token exceeding max length should fail validation", - }, - { - name: "TokenWithControlCharacters", - token: "token_with_control\x01character", - expectValid: false, - description: "Token with control characters should fail validation", - }, - { - name: "TokenWithHighUnicode", - token: "token_with_unicode_\uffff", - expectValid: false, - description: "Token with high unicode characters should fail validation", - }, - { - name: "MaliciousJWTWithExtraData", - token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig.malicious_extra", // trufflehog:ignore - expectValid: false, - description: "JWT with extra malicious data should fail validation", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validator.ValidateToken(tt.token) - - if result.IsValid != tt.expectValid { - t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description) - } - }) - } -} - -// TestInputValidatorValidateEmail tests email validation edge cases -func TestInputValidatorValidateEmail(t *testing.T) { - config := DefaultInputValidationConfig() - validator, _ := NewInputValidator(config, newNoOpLogger()) - - tests := []struct { - name string - email string - description string - expectValid bool - }{ - { - name: "ValidEmail", - email: "user@example.com", - expectValid: true, - description: "Valid email should pass validation", - }, - { - name: "ValidEmailWithSubdomain", - email: "user@mail.example.com", - expectValid: true, - description: "Valid email with subdomain should pass validation", - }, - { - name: "EmptyEmail", - email: "", - expectValid: false, - description: "Empty email should fail validation", - }, - { - name: "EmailWithoutAtSign", - email: "userexample.com", - expectValid: false, - description: "Email without @ sign should fail validation", - }, - { - name: "EmailWithNullBytes", - email: "user@example\x00.com", - expectValid: false, - description: "Email with null bytes should fail validation", - }, - { - name: "EmailTooLong", - email: strings.Repeat("a", config.MaxEmailLength-10) + "@example.com", - expectValid: false, - description: "Email exceeding max length should fail validation", - }, - { - name: "EmailWithControlCharacters", - email: "user\x01@example.com", - expectValid: false, - description: "Email with control characters should fail validation", - }, - { - name: "MaliciousEmailWithScriptTag", - email: "user", - expectValid: false, - description: "Claim with HTML/script should fail validation", - }, - { - name: "ClaimWithExcessiveUnicode", - claimName: "test", - claimValue: strings.Repeat("🚀", 100), // Many unicode chars - expectValid: false, - description: "Claim with excessive unicode should fail validation", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validator.ValidateClaim(tt.claimName, tt.claimValue) - - if result.IsValid != tt.expectValid { - t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description) - } - }) - } -} - -// TestInputValidatorValidateHeader tests HTTP header validation -func TestInputValidatorValidateHeader(t *testing.T) { - config := DefaultInputValidationConfig() - validator, _ := NewInputValidator(config, newNoOpLogger()) - - tests := []struct { - name string - headerName string - headerValue string - description string - expectValid bool - }{ - { - name: "ValidHeader", - headerName: "Authorization", - headerValue: "Bearer token123", - expectValid: true, - description: "Valid header should pass validation", - }, - { - name: "ValidContentType", - headerName: "Content-Type", - headerValue: "application/json", - expectValid: true, - description: "Valid content type header should pass validation", - }, - { - name: "EmptyHeaderName", - headerName: "", - headerValue: "value", - expectValid: false, - description: "Empty header name should fail validation", - }, - { - name: "HeaderWithNullBytes", - headerName: "test", - headerValue: "value\x00with_null", - expectValid: false, - description: "Header with null bytes should fail validation", - }, - { - name: "HeaderValueTooLong", - headerName: "test", - headerValue: strings.Repeat("a", config.MaxHeaderLength+1), - expectValid: false, - description: "Header value exceeding max length should fail validation", - }, - { - name: "HeaderWithCRLF", - headerName: "test", - headerValue: "value\r\nMalicious: header", - expectValid: false, - description: "Header with CRLF should fail validation to prevent injection", - }, - { - name: "HeaderWithControlCharacters", - headerName: "test", - headerValue: "value\x01with_control", - expectValid: false, - description: "Header with control characters should fail validation", - }, - { - name: "MaliciousHeaderWithHTML", - headerName: "test", - headerValue: "", - expectValid: false, - description: "Header with HTML/script should fail validation", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validator.ValidateHeader(tt.headerName, tt.headerValue) - - if result.IsValid != tt.expectValid { - t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description) - } - }) - } -} - -// TestInputValidatorValidateUsername tests username validation -func TestInputValidatorValidateUsername(t *testing.T) { - config := DefaultInputValidationConfig() - validator, _ := NewInputValidator(config, newNoOpLogger()) - - tests := []struct { - name string - username string - description string - expectValid bool - }{ - { - name: "ValidUsername", - username: "john_doe", - expectValid: true, - description: "Valid username should pass validation", - }, - { - name: "ValidUsernameWithNumbers", - username: "user123", - expectValid: true, - description: "Valid username with numbers should pass validation", - }, - { - name: "EmptyUsername", - username: "", - expectValid: false, - description: "Empty username should fail validation", - }, - { - name: "UsernameWithNullBytes", - username: "user\x00name", - expectValid: false, - description: "Username with null bytes should fail validation", - }, - { - name: "UsernameTooLong", - username: strings.Repeat("a", config.MaxUsernameLength+1), - expectValid: false, - description: "Username exceeding max length should fail validation", - }, - { - name: "UsernameWithSpecialChars", - username: "user@name", - expectValid: false, - description: "Username with special characters should fail validation", - }, - { - name: "UsernameWithSpaces", - username: "user name", - expectValid: false, - description: "Username with spaces should fail validation", - }, - { - name: "UsernameWithControlCharacters", - username: "user\x01name", - expectValid: false, - description: "Username with control characters should fail validation", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validator.ValidateUsername(tt.username) - - if result.IsValid != tt.expectValid { - t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description) - } - }) - } -} diff --git a/internal/cache/backends/redis_pool.go b/internal/cache/backends/redis_pool.go index 994b1ef..0178e1a 100644 --- a/internal/cache/backends/redis_pool.go +++ b/internal/cache/backends/redis_pool.go @@ -83,6 +83,11 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) { select { case conn = <-p.connections: + if conn == nil { + // Pool channel was closed concurrently (Close) - receiving from a + // closed channel yields the zero value. + return nil, ErrBackendClosed + } // Reuse existing connection - validate if health check enabled if p.config.EnableHealthCheck && !p.isConnectionHealthy(conn) { // Connection is stale, close it and try again @@ -118,6 +123,11 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) { // Pool exhausted, wait for a connection with timeout select { case conn = <-p.connections: + if conn == nil { + // Pool channel was closed concurrently (Close) - receiving from a + // closed channel yields the zero value. + return nil, ErrBackendClosed + } // Validate connection if health check enabled if p.config.EnableHealthCheck && !p.isConnectionHealthy(conn) { _ = conn.Close() diff --git a/internal/cache/backends/redis_pool_test.go b/internal/cache/backends/redis_pool_test.go index bf8431c..2cb0945 100644 --- a/internal/cache/backends/redis_pool_test.go +++ b/internal/cache/backends/redis_pool_test.go @@ -367,7 +367,47 @@ func TestConnectionPool_Close(t *testing.T) { require.NoError(t, err) } -// TestConnectionPool_Timeouts tests various timeout scenarios +// TestConnectionPool_GetDuringClose_NoNilRegression ensures a Get racing with +// Close never yields a nil *RedisConn with a nil error (which previously +// happened when the receive from the closed p.connections channel returned +// the zero value), and never panics by calling Close on a nil receiver. +func TestConnectionPool_GetDuringClose_NoNilRegression(t *testing.T) { + mr := NewMiniredisServer(t) + + config := &PoolConfig{ + Address: mr.GetAddr(), + MaxConnections: 1, + ConnectTimeout: 1 * time.Second, + } + + pool, err := NewConnectionPool(config) + require.NoError(t, err) + + // Fill the pool so a concurrent Get blocks waiting for a connection. + held, err := pool.Get(context.Background()) + require.NoError(t, err) + defer pool.Put(held) + + type result struct { + conn *RedisConn + err error + } + resCh := make(chan result, 1) + go func() { + c, e := pool.Get(context.Background()) + resCh <- result{c, e} + }() + + // Give the goroutine time to reach the blocking receive on p.connections, + // then close the pool. Closing the channel unblocks the receive with the + // zero value; Get must surface ErrBackendClosed rather than (nil, nil). + time.Sleep(50 * time.Millisecond) + require.NoError(t, pool.Close()) + + res := <-resCh + require.Error(t, res.err, "Get after Close must return an error, not (nil, nil)") + assert.True(t, errors.Is(res.err, ErrBackendClosed), "want ErrBackendClosed, got %v", res.err) +} func TestConnectionPool_Timeouts(t *testing.T) { mr := NewMiniredisServer(t) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 7a66b5e..3e1fcb8 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -1223,6 +1223,10 @@ func TestCacheManagerCompat(t *testing.T) { if err != nil { t.Errorf("CacheManagerCompat Close should not error: %v", err) } + // Close() shuts down the process-global manager. Recreate it so later + // tests using the compat wrappers see an open cache (order-independent; + // the singleton is otherwise pinned closed for the process lifetime). + resetGlobalManagerForTest() } // TestUniversalCacheManagerCompat tests UniversalCacheManagerCompat methods @@ -1262,6 +1266,9 @@ func TestUniversalCacheManagerCompat(t *testing.T) { if err != nil && err.Error() != "cache already closed" { t.Errorf("UniversalCacheManagerCompat Close should not error (unless already closed): %v", err) } + // Close() shuts down the process-global manager. Recreate it so later + // tests using the compat wrappers see an open cache (order-independent). + resetGlobalManagerForTest() } // TestTypedCacheWrapper tests TypedCache methods diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 588ecce..bc73d65 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -33,6 +33,15 @@ func GetGlobalManager(logger Logger) *Manager { return globalManager } +// resetGlobalManagerForTest clears the process-global manager so the next call to +// GetGlobalManager returns a fresh instance. The singleton is otherwise pinned +// for the process lifetime; tests that Close() the global manager must reset it, +// or any later test using the compat wrappers fails with "cache already closed". +func resetGlobalManagerForTest() { + globalManagerOnce = sync.Once{} + globalManager = nil +} + // NewManager creates a new cache manager func NewManager(logger Logger) *Manager { if logger == nil { diff --git a/internal/compat/compatibility.go b/internal/compat/compatibility.go deleted file mode 100644 index ecb5f94..0000000 --- a/internal/compat/compatibility.go +++ /dev/null @@ -1,320 +0,0 @@ -// Package compat provides backward compatibility layer during refactoring -package compat - -import ( - "fmt" - "reflect" - "sync" -) - -// CompatibilityLayer provides backward compatibility during the migration -type CompatibilityLayer struct { - mappings map[string]string // old path -> new path - converters map[string]Converter - deprecations map[string]string // deprecated field -> warning message - mu sync.RWMutex -} - -// Converter is a function that converts old value format to new format -type Converter func(oldValue interface{}) (newValue interface{}, err error) - -// Global compatibility layer instance -var ( - layer *CompatibilityLayer - layerOnce sync.Once -) - -// GetLayer returns the global compatibility layer instance -func GetLayer() *CompatibilityLayer { - layerOnce.Do(func() { - layer = &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - layer.initialize() - }) - return layer -} - -// initialize sets up default compatibility mappings -func (c *CompatibilityLayer) initialize() { - // Configuration path mappings (old -> new) - c.RegisterMapping("ProviderURL", "Provider.IssuerURL") - c.RegisterMapping("ClientID", "Provider.ClientID") - c.RegisterMapping("ClientSecret", "Provider.ClientSecret") - c.RegisterMapping("CallbackURL", "Provider.RedirectURL") - c.RegisterMapping("LogoutURL", "Provider.LogoutURL") - c.RegisterMapping("SessionEncryptionKey", "Session.EncryptionKey") - c.RegisterMapping("Scopes", "Provider.Scopes") - c.RegisterMapping("RateLimit", "Middleware.RateLimit") - c.RegisterMapping("RefreshGracePeriodSeconds", "Token.RefreshGracePeriod") - - // Redis configuration mappings - c.RegisterMapping("RedisAddr", "Redis.Addresses[0]") - c.RegisterMapping("RedisPassword", "Redis.Password") - c.RegisterMapping("RedisDB", "Redis.DB") - - // Session configuration mappings - c.RegisterMapping("SessionName", "Session.Name") - c.RegisterMapping("SessionMaxAge", "Session.MaxAge") - c.RegisterMapping("SessionSecret", "Session.Secret") - c.RegisterMapping("SessionChunkSize", "Session.ChunkSize") - - // Security configuration mappings - c.RegisterMapping("ForceHTTPS", "Security.ForceHTTPS") - c.RegisterMapping("EnablePKCE", "Security.EnablePKCE") - c.RegisterMapping("AllowedUsers", "Security.AllowedUsers") - c.RegisterMapping("AllowedUserDomains", "Security.AllowedUserDomains") - c.RegisterMapping("AllowedRolesAndGroups", "Security.AllowedRolesAndGroups") - c.RegisterMapping("ExcludedURLs", "Security.ExcludedURLs") - - // Register converters for complex transformations - c.RegisterConverter("RefreshGracePeriodSeconds", func(oldValue interface{}) (interface{}, error) { - // Convert seconds (int) to duration string - if seconds, ok := oldValue.(int); ok { - return fmt.Sprintf("%ds", seconds), nil - } - return oldValue, nil - }) - - // Register deprecations - c.RegisterDeprecation("LogLevel", "LogLevel is deprecated, use Logging.Level instead") - c.RegisterDeprecation("HTTPClient", "HTTPClient is deprecated, configure via Transport settings") -} - -// RegisterMapping registers a field mapping from old to new path -func (c *CompatibilityLayer) RegisterMapping(oldPath, newPath string) { - c.mu.Lock() - defer c.mu.Unlock() - c.mappings[oldPath] = newPath -} - -// RegisterConverter registers a value converter for a field -func (c *CompatibilityLayer) RegisterConverter(field string, converter Converter) { - c.mu.Lock() - defer c.mu.Unlock() - c.converters[field] = converter -} - -// RegisterDeprecation registers a deprecation warning for a field -func (c *CompatibilityLayer) RegisterDeprecation(field, message string) { - c.mu.Lock() - defer c.mu.Unlock() - c.deprecations[field] = message -} - -// GetMapping returns the new path for an old configuration path -func (c *CompatibilityLayer) GetMapping(oldPath string) (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - newPath, exists := c.mappings[oldPath] - return newPath, exists -} - -// Convert applies conversion logic to a value -func (c *CompatibilityLayer) Convert(field string, value interface{}) (interface{}, error) { - c.mu.RLock() - converter, exists := c.converters[field] - c.mu.RUnlock() - - if !exists { - return value, nil - } - - return converter(value) -} - -// CheckDeprecation checks if a field is deprecated and returns warning message -func (c *CompatibilityLayer) CheckDeprecation(field string) (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - message, deprecated := c.deprecations[field] - return message, deprecated -} - -// MigrateMap migrates an old configuration map to new structure -func (c *CompatibilityLayer) MigrateMap(oldConfig map[string]interface{}) (map[string]interface{}, []string) { - newConfig := make(map[string]interface{}) - warnings := []string{} - - for key, value := range oldConfig { - // Check for deprecation - if warning, deprecated := c.CheckDeprecation(key); deprecated { - warnings = append(warnings, warning) - } - - // Get new path - newPath, hasMappming := c.GetMapping(key) - if !hasMappming { - // No mapping, use as-is - newConfig[key] = value - continue - } - - // Apply converter if exists - convertedValue, err := c.Convert(key, value) - if err != nil { - warnings = append(warnings, fmt.Sprintf("Failed to convert %s: %v", key, err)) - convertedValue = value - } - - // Set value at new path - setNestedValue(newConfig, newPath, convertedValue) - } - - return newConfig, warnings -} - -// setNestedValue sets a value in a nested map structure using dot notation -func setNestedValue(m map[string]interface{}, path string, value interface{}) { - keys := splitPath(path) - if len(keys) == 0 { - return - } - - current := m - for i := 0; i < len(keys)-1; i++ { - key := keys[i] - - // Check if this key has array notation - if isArrayPath(key) { - // Handle array notation (e.g., "Addresses[0]") - continue // Skip array handling for now, will be handled in actual migration - } - - if _, exists := current[key]; !exists { - current[key] = make(map[string]interface{}) - } - - // Ensure it's a map - if next, ok := current[key].(map[string]interface{}); ok { - current = next - } else { - // Can't traverse further, create new map - newMap := make(map[string]interface{}) - current[key] = newMap - current = newMap - } - } - - // Set the final value - finalKey := keys[len(keys)-1] - current[finalKey] = value -} - -// splitPath splits a configuration path into segments -func splitPath(path string) []string { - segments := []string{} - current := "" - - for i := 0; i < len(path); i++ { - if path[i] == '.' { - if current != "" { - segments = append(segments, current) - current = "" - } - } else { - current += string(path[i]) - } - } - - if current != "" { - segments = append(segments, current) - } - - return segments -} - -// isArrayPath checks if a path segment contains array notation -func isArrayPath(segment string) bool { - for _, char := range segment { - if char == '[' { - return true - } - } - return false -} - -// ConfigAdapter provides an adapter interface for old code to work with new config -type ConfigAdapter struct { - newConfig interface{} - oldPaths map[string]func() interface{} - mu sync.RWMutex -} - -// NewConfigAdapter creates a new configuration adapter -func NewConfigAdapter(newConfig interface{}) *ConfigAdapter { - adapter := &ConfigAdapter{ - newConfig: newConfig, - oldPaths: make(map[string]func() interface{}), - } - return adapter -} - -// RegisterGetter registers a getter function for an old path -func (a *ConfigAdapter) RegisterGetter(oldPath string, getter func() interface{}) { - a.mu.Lock() - defer a.mu.Unlock() - a.oldPaths[oldPath] = getter -} - -// Get retrieves a value using old path notation -func (a *ConfigAdapter) Get(oldPath string) (interface{}, bool) { - a.mu.RLock() - getter, exists := a.oldPaths[oldPath] - a.mu.RUnlock() - - if !exists { - // Try to get from new config using reflection - return a.getFromNewConfig(oldPath) - } - - return getter(), true -} - -// getFromNewConfig attempts to retrieve value from new config using reflection -func (a *ConfigAdapter) getFromNewConfig(path string) (interface{}, bool) { - // Check if there's a mapping for this path - compat := GetLayer() - if newPath, hasMappming := compat.GetMapping(path); hasMappming { - return a.getNestedField(newPath) - } - - // Try direct access - return a.getNestedField(path) -} - -// getNestedField retrieves a nested field value using reflection -func (a *ConfigAdapter) getNestedField(path string) (interface{}, bool) { - segments := splitPath(path) - if len(segments) == 0 { - return nil, false - } - - v := reflect.ValueOf(a.newConfig) - - // Dereference pointer if needed - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - - for _, segment := range segments { - if v.Kind() != reflect.Struct { - return nil, false - } - - field := v.FieldByName(segment) - if !field.IsValid() { - return nil, false - } - - v = field - } - - if v.IsValid() && v.CanInterface() { - return v.Interface(), true - } - - return nil, false -} diff --git a/internal/compat/compatibility_test.go b/internal/compat/compatibility_test.go deleted file mode 100644 index ae9f224..0000000 --- a/internal/compat/compatibility_test.go +++ /dev/null @@ -1,495 +0,0 @@ -//go:build !yaegi - -package compat - -import ( - "sync" - "testing" -) - -func TestGetLayer_Singleton(t *testing.T) { - // Reset global state - layerOnce = sync.Once{} - layer = nil - - layer1 := GetLayer() - layer2 := GetLayer() - - if layer1 != layer2 { - t.Error("Expected GetLayer to return same instance") - } -} - -func TestGetLayer_Initialize(t *testing.T) { - // Reset global state - layerOnce = sync.Once{} - layer = nil - - l := GetLayer() - - // Check default mappings exist - if _, exists := l.GetMapping("ProviderURL"); !exists { - t.Error("Expected ProviderURL mapping to exist") - } - - if _, exists := l.GetMapping("ClientID"); !exists { - t.Error("Expected ClientID mapping to exist") - } - - // Check deprecations exist - if _, deprecated := l.CheckDeprecation("LogLevel"); !deprecated { - t.Error("Expected LogLevel to be marked deprecated") - } -} - -func TestRegisterMapping(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterMapping("OldField", "New.Field") - - newPath, exists := l.GetMapping("OldField") - if !exists { - t.Error("Expected mapping to exist") - } - - if newPath != "New.Field" { - t.Errorf("Expected 'New.Field', got '%s'", newPath) - } -} - -func TestRegisterConverter(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - converter := func(oldValue interface{}) (interface{}, error) { - if str, ok := oldValue.(string); ok { - return str + "_converted", nil - } - return oldValue, nil - } - - l.RegisterConverter("TestField", converter) - - result, err := l.Convert("TestField", "test") - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result != "test_converted" { - t.Errorf("Expected 'test_converted', got '%v'", result) - } -} - -func TestConvert_NoConverter(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - // No converter registered - result, err := l.Convert("UnknownField", "value") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if result != "value" { - t.Error("Expected original value when no converter exists") - } -} - -func TestRegisterDeprecation(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterDeprecation("OldField", "This field is deprecated") - - message, deprecated := l.CheckDeprecation("OldField") - if !deprecated { - t.Error("Expected field to be deprecated") - } - - if message != "This field is deprecated" { - t.Errorf("Expected deprecation message, got '%s'", message) - } -} - -func TestCheckDeprecation_NotDeprecated(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - _, deprecated := l.CheckDeprecation("NewField") - if deprecated { - t.Error("Expected field not to be deprecated") - } -} - -func TestMigrateMap_BasicMapping(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterMapping("OldField", "New.Field") - - oldConfig := map[string]interface{}{ - "OldField": "value123", - } - - newConfig, warnings := l.MigrateMap(oldConfig) - - if len(warnings) != 0 { - t.Errorf("Expected no warnings, got %d", len(warnings)) - } - - // Check nested structure - if newMap, ok := newConfig["New"].(map[string]interface{}); ok { - if val, exists := newMap["Field"]; !exists || val != "value123" { - t.Errorf("Expected nested field value 'value123', got %v", val) - } - } else { - t.Error("Expected nested map structure") - } -} - -func TestMigrateMap_WithDeprecation(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterMapping("DeprecatedField", "New.Field") - l.RegisterDeprecation("DeprecatedField", "Field is deprecated") - - oldConfig := map[string]interface{}{ - "DeprecatedField": "value", - } - - _, warnings := l.MigrateMap(oldConfig) - - if len(warnings) != 1 { - t.Errorf("Expected 1 warning, got %d", len(warnings)) - } - - if warnings[0] != "Field is deprecated" { - t.Errorf("Expected deprecation warning, got '%s'", warnings[0]) - } -} - -func TestMigrateMap_WithConverter(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterMapping("Seconds", "Duration") - l.RegisterConverter("Seconds", func(oldValue interface{}) (interface{}, error) { - if seconds, ok := oldValue.(int); ok { - return seconds * 1000, nil // Convert to milliseconds - } - return oldValue, nil - }) - - oldConfig := map[string]interface{}{ - "Seconds": 60, - } - - newConfig, _ := l.MigrateMap(oldConfig) - - if val, ok := newConfig["Duration"]; !ok || val != 60000 { - t.Errorf("Expected Duration to be 60000, got %v", val) - } -} - -func TestMigrateMap_NoMapping(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - oldConfig := map[string]interface{}{ - "UnmappedField": "value", - } - - newConfig, _ := l.MigrateMap(oldConfig) - - if val, ok := newConfig["UnmappedField"]; !ok || val != "value" { - t.Error("Expected unmapped field to be copied as-is") - } -} - -func TestSplitPath(t *testing.T) { - tests := []struct { - path string - expected []string - }{ - {"Simple", []string{"Simple"}}, - {"Nested.Path", []string{"Nested", "Path"}}, - {"Deep.Nested.Path", []string{"Deep", "Nested", "Path"}}, - {"", []string{}}, - {"Single", []string{"Single"}}, - } - - for _, tt := range tests { - result := splitPath(tt.path) - if len(result) != len(tt.expected) { - t.Errorf("Path '%s': expected %d segments, got %d", tt.path, len(tt.expected), len(result)) - continue - } - - for i, segment := range result { - if segment != tt.expected[i] { - t.Errorf("Path '%s': segment %d expected '%s', got '%s'", tt.path, i, tt.expected[i], segment) - } - } - } -} - -func TestIsArrayPath(t *testing.T) { - tests := []struct { - segment string - expected bool - }{ - {"Addresses[0]", true}, - {"Items[5]", true}, - {"Simple", false}, - {"NoArray", false}, - {"[start", true}, - } - - for _, tt := range tests { - result := isArrayPath(tt.segment) - if result != tt.expected { - t.Errorf("Segment '%s': expected %v, got %v", tt.segment, tt.expected, result) - } - } -} - -func TestSetNestedValue_SingleLevel(t *testing.T) { - m := make(map[string]interface{}) - setNestedValue(m, "Field", "value") - - if val, ok := m["Field"]; !ok || val != "value" { - t.Error("Expected single level field to be set") - } -} - -func TestSetNestedValue_MultiLevel(t *testing.T) { - m := make(map[string]interface{}) - setNestedValue(m, "Parent.Child", "value") - - parent, ok := m["Parent"].(map[string]interface{}) - if !ok { - t.Fatal("Expected Parent to be a map") - } - - if val, ok := parent["Child"]; !ok || val != "value" { - t.Error("Expected nested field to be set") - } -} - -func TestSetNestedValue_DeepNesting(t *testing.T) { - m := make(map[string]interface{}) - setNestedValue(m, "Level1.Level2.Level3", "deep_value") - - level1, ok := m["Level1"].(map[string]interface{}) - if !ok { - t.Fatal("Expected Level1 to be a map") - } - - level2, ok := level1["Level2"].(map[string]interface{}) - if !ok { - t.Fatal("Expected Level2 to be a map") - } - - if val, ok := level2["Level3"]; !ok || val != "deep_value" { - t.Error("Expected deeply nested field to be set") - } -} - -// ConfigAdapter tests - -func TestNewConfigAdapter(t *testing.T) { - config := map[string]interface{}{"key": "value"} - adapter := NewConfigAdapter(config) - - if adapter == nil { - t.Fatal("Expected adapter to be created") - } - - if adapter.newConfig == nil { - t.Error("Expected config to be stored") - } -} - -func TestConfigAdapter_RegisterGetter(t *testing.T) { - adapter := NewConfigAdapter(nil) - - called := false - adapter.RegisterGetter("TestPath", func() interface{} { - called = true - return "test_value" - }) - - val, exists := adapter.Get("TestPath") - if !exists { - t.Error("Expected getter to exist") - } - - if val != "test_value" { - t.Errorf("Expected 'test_value', got %v", val) - } - - if !called { - t.Error("Expected getter function to be called") - } -} - -type TestConfig struct { - Provider struct { - IssuerURL string - ClientID string - } - Session struct { - EncryptionKey string - } -} - -func TestConfigAdapter_GetNestedField(t *testing.T) { - config := &TestConfig{} - config.Provider.IssuerURL = "https://test.com" - config.Provider.ClientID = "test-client" - config.Session.EncryptionKey = "secret123" - - adapter := NewConfigAdapter(config) - - // Test nested field access - val, exists := adapter.getNestedField("Provider.IssuerURL") - if !exists { - t.Error("Expected field to exist") - } - - if val != "https://test.com" { - t.Errorf("Expected 'https://test.com', got %v", val) - } - - // Test another nested field - val2, exists2 := adapter.getNestedField("Provider.ClientID") - if !exists2 || val2 != "test-client" { - t.Error("Expected ClientID to be accessible") - } - - // Test non-existent field - _, exists3 := adapter.getNestedField("NonExistent.Field") - if exists3 { - t.Error("Expected non-existent field to return false") - } -} - -// Race condition tests - -func TestCompatibilityLayer_ConcurrentAccess(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - var wg sync.WaitGroup - - // Concurrent registrations - for i := 0; i < 100; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - l.RegisterMapping(string(rune('A'+idx%26)), "New.Field") - }(i) - } - - // Concurrent reads - for i := 0; i < 100; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - _, _ = l.GetMapping(string(rune('A' + idx%26))) - }(i) - } - - wg.Wait() -} - -func TestCompatibilityLayer_ConcurrentMigrate(t *testing.T) { - l := &CompatibilityLayer{ - mappings: make(map[string]string), - converters: make(map[string]Converter), - deprecations: make(map[string]string), - } - - l.RegisterMapping("OldField", "New.Field") - - var wg sync.WaitGroup - - // Concurrent migrations - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - oldConfig := map[string]interface{}{ - "OldField": "value", - } - _, _ = l.MigrateMap(oldConfig) - }() - } - - wg.Wait() -} - -func TestConfigAdapter_ConcurrentAccess(t *testing.T) { - config := &TestConfig{} - config.Provider.IssuerURL = "https://test.com" - - adapter := NewConfigAdapter(config) - - var wg sync.WaitGroup - - // Concurrent getter registrations - for i := 0; i < 50; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - path := string(rune('A' + idx%26)) - adapter.RegisterGetter(path, func() interface{} { - return "value" - }) - }(i) - } - - // Concurrent gets - for i := 0; i < 50; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - path := string(rune('A' + idx%26)) - _, _ = adapter.Get(path) - }(i) - } - - wg.Wait() -} diff --git a/internal/dcrstorage/file.go b/internal/dcrstorage/file.go index 8fc630b..61e46af 100644 --- a/internal/dcrstorage/file.go +++ b/internal/dcrstorage/file.go @@ -83,10 +83,18 @@ func (s *FileStore) Save(ctx context.Context, providerURL string, creds *ClientR return fmt.Errorf("failed to marshal credentials: %w", err) } - // Write with restrictive permissions (owner read/write only) - if err := os.WriteFile(filePath, data, 0600); err != nil { + // Write with restrictive permissions (owner read/write only). Write to a + // temp file and atomically rename so a crash mid-write can never leave a + // truncated credentials file (which would force an avoidable client + // re-registration on the next start). + tmpPath := filePath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { return fmt.Errorf("failed to write credentials file: %w", err) } + if err := os.Rename(tmpPath, filePath); err != nil { + _ = os.Remove(tmpPath) // best-effort cleanup + return fmt.Errorf("failed to commit credentials file: %w", err) + } s.logger.Debugf("Saved client credentials to %s", filePath) return nil diff --git a/internal/dcrstorage/storage_test.go b/internal/dcrstorage/storage_test.go index 7920846..0294f5d 100644 --- a/internal/dcrstorage/storage_test.go +++ b/internal/dcrstorage/storage_test.go @@ -2,6 +2,7 @@ package dcrstorage import ( "context" + "encoding/json" "os" "path/filepath" "sync" @@ -462,3 +463,69 @@ func TestFileStore_DirectoryCreation(t *testing.T) { t.Error("Failed to load credentials from nested directory") } } + +// TestFileStore_AtomicWrite guards the temp-file + rename implementation: +// after a successful Save the committed file is a valid JSON document, no +// ".tmp" scratch file is left behind, and the file mode is 0600. +func TestFileStore_AtomicWrite(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + store := NewFileStore(basePath, nil) + + ctx := context.Background() + providerURL := "https://auth.example.com" + creds := &ClientRegistrationResponse{ + ClientID: "atomic-client", + ClientSecret: "s3cret", + } + + if err := store.Save(ctx, providerURL, creds); err != nil { + t.Fatalf("Save failed: %v", err) + } + + filePath := store.GetFilePath(providerURL) + + // No scratch file should remain. + if _, err := os.Stat(filePath + ".tmp"); !os.IsNotExist(err) { + t.Errorf("expected no %q scratch file after Save, stat err=%v", filePath+".tmp", err) + } + + // Committed file must be valid JSON. + raw, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read committed file: %v", err) + } + var parsed ClientRegistrationResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("committed file is not valid JSON: %v", err) + } + if parsed.ClientID != creds.ClientID || parsed.ClientSecret != creds.ClientSecret { + t.Errorf("committed content mismatch: got %q/%q", parsed.ClientID, parsed.ClientSecret) + } + + // Mode should be owner read/write. + info, err := os.Stat(filePath) + if err != nil { + t.Fatalf("failed to stat committed file: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("expected mode 0600, got %v", info.Mode().Perm()) + } + + // Overwriting an existing file must also be atomic and leave no residue. + if err := store.Save(ctx, providerURL, &ClientRegistrationResponse{ClientID: "updated"}); err != nil { + t.Fatalf("second Save failed: %v", err) + } + if _, err := os.Stat(filePath + ".tmp"); !os.IsNotExist(err) { + t.Errorf("expected no %q scratch file after overwrite", filePath+".tmp") + } + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Load after overwrite failed: %v", err) + } + if loaded == nil || loaded.ClientID != "updated" { + t.Errorf("overwrite not reflected: %+v", loaded) + } +} diff --git a/internal/features/flags.go b/internal/features/flags.go deleted file mode 100644 index f2eb7b0..0000000 --- a/internal/features/flags.go +++ /dev/null @@ -1,235 +0,0 @@ -// Package features provides feature flag management for safe rollback during refactoring -package features - -import ( - "os" - "strings" - "sync" - "sync/atomic" -) - -// FeatureFlag represents a feature flag for controlling new functionality -type FeatureFlag struct { - name string - description string - callbacks []func(bool) - mu sync.RWMutex - enabled atomic.Bool -} - -// FeatureManager manages all feature flags in the application -type FeatureManager struct { - flags map[string]*FeatureFlag - mu sync.RWMutex -} - -var ( - // Global feature manager instance - manager *FeatureManager - managerOnce sync.Once -) - -// Feature flag names -const ( - // UseUnifiedConfig enables the new unified configuration system - UseUnifiedConfig = "USE_UNIFIED_CONFIG" - - // UseNewFileStructure enables the new modularized file structure - UseNewFileStructure = "USE_NEW_FILE_STRUCTURE" - - // UseStandardErrors enables the standardized error package - UseStandardErrors = "USE_STANDARD_ERRORS" - - // UseEnhancedLogging enables the enhanced logging system - UseEnhancedLogging = "USE_ENHANCED_LOGGING" - - // UseOptimizedTests enables the consolidated test suite - UseOptimizedTests = "USE_OPTIMIZED_TESTS" - - // UseRedisRESP enables the custom Redis RESP implementation - UseRedisRESP = "USE_REDIS_RESP" -) - -// GetManager returns the global feature manager instance -func GetManager() *FeatureManager { - managerOnce.Do(func() { - manager = &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - manager.initialize() - }) - return manager -} - -// initialize sets up default feature flags -func (m *FeatureManager) initialize() { - // Phase 0: Feature flags setup - m.Register(UseUnifiedConfig, "Enable unified configuration package", false) - m.Register(UseNewFileStructure, "Enable modularized file structure", false) - m.Register(UseStandardErrors, "Enable standardized error handling", false) - m.Register(UseEnhancedLogging, "Enable enhanced logging system", false) - m.Register(UseOptimizedTests, "Enable optimized test suite", false) - m.Register(UseRedisRESP, "Enable custom Redis RESP implementation", false) - - // Load from environment variables - m.LoadFromEnv() -} - -// Register creates a new feature flag -func (m *FeatureManager) Register(name, description string, defaultValue bool) { - m.mu.Lock() - defer m.mu.Unlock() - - flag := &FeatureFlag{ - name: name, - description: description, - callbacks: make([]func(bool), 0), - } - flag.enabled.Store(defaultValue) - m.flags[name] = flag -} - -// IsEnabled checks if a feature flag is enabled -func (m *FeatureManager) IsEnabled(name string) bool { - m.mu.RLock() - flag, exists := m.flags[name] - m.mu.RUnlock() - - if !exists { - return false - } - - return flag.enabled.Load() -} - -// Enable turns on a feature flag -func (m *FeatureManager) Enable(name string) { - m.setFlag(name, true) -} - -// Disable turns off a feature flag -func (m *FeatureManager) Disable(name string) { - m.setFlag(name, false) -} - -// Toggle switches a feature flag state -func (m *FeatureManager) Toggle(name string) { - m.mu.RLock() - flag, exists := m.flags[name] - m.mu.RUnlock() - - if exists { - newValue := !flag.enabled.Load() - m.setFlag(name, newValue) - } -} - -// setFlag updates a feature flag value and triggers callbacks -func (m *FeatureManager) setFlag(name string, value bool) { - m.mu.RLock() - flag, exists := m.flags[name] - m.mu.RUnlock() - - if !exists { - return - } - - oldValue := flag.enabled.Swap(value) - - // Only trigger callbacks if value actually changed - if oldValue != value { - flag.mu.RLock() - callbacks := flag.callbacks - flag.mu.RUnlock() - - for _, callback := range callbacks { - callback(value) - } - } -} - -// OnChange registers a callback to be called when a feature flag changes -func (m *FeatureManager) OnChange(name string, callback func(bool)) { - m.mu.RLock() - flag, exists := m.flags[name] - m.mu.RUnlock() - - if exists { - flag.mu.Lock() - flag.callbacks = append(flag.callbacks, callback) - flag.mu.Unlock() - } -} - -// LoadFromEnv loads feature flag values from environment variables -func (m *FeatureManager) LoadFromEnv() { - m.mu.RLock() - flags := make(map[string]*FeatureFlag) - for name, flag := range m.flags { - flags[name] = flag - } - m.mu.RUnlock() - - for name, flag := range flags { - envVar := "FEATURE_" + name - if value := os.Getenv(envVar); value != "" { - enabled := strings.EqualFold(value, "true") || value == "1" - flag.enabled.Store(enabled) - } - } -} - -// GetAll returns all feature flags and their states -func (m *FeatureManager) GetAll() map[string]bool { - m.mu.RLock() - defer m.mu.RUnlock() - - result := make(map[string]bool) - for name, flag := range m.flags { - result[name] = flag.enabled.Load() - } - return result -} - -// Reset resets all feature flags to their default values -func (m *FeatureManager) Reset() { - m.mu.Lock() - defer m.mu.Unlock() - - for _, flag := range m.flags { - flag.enabled.Store(false) - flag.callbacks = make([]func(bool), 0) - } -} - -// Helper functions for common checks - -// IsUnifiedConfigEnabled checks if unified config is enabled -func IsUnifiedConfigEnabled() bool { - return GetManager().IsEnabled(UseUnifiedConfig) -} - -// IsNewFileStructureEnabled checks if new file structure is enabled -func IsNewFileStructureEnabled() bool { - return GetManager().IsEnabled(UseNewFileStructure) -} - -// IsStandardErrorsEnabled checks if standard errors are enabled -func IsStandardErrorsEnabled() bool { - return GetManager().IsEnabled(UseStandardErrors) -} - -// IsEnhancedLoggingEnabled checks if enhanced logging is enabled -func IsEnhancedLoggingEnabled() bool { - return GetManager().IsEnabled(UseEnhancedLogging) -} - -// IsOptimizedTestsEnabled checks if optimized tests are enabled -func IsOptimizedTestsEnabled() bool { - return GetManager().IsEnabled(UseOptimizedTests) -} - -// IsRedisRESPEnabled checks if custom Redis RESP is enabled -func IsRedisRESPEnabled() bool { - return GetManager().IsEnabled(UseRedisRESP) -} diff --git a/internal/features/flags_test.go b/internal/features/flags_test.go deleted file mode 100644 index 494ed5d..0000000 --- a/internal/features/flags_test.go +++ /dev/null @@ -1,483 +0,0 @@ -//go:build !yaegi - -package features - -import ( - "os" - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestFeatureManager_Register(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - if !m.flags["TEST_FEATURE"].enabled.Load() == false { - t.Error("Expected feature to be disabled by default") - } - - m.Register("TEST_ENABLED", "Test enabled feature", true) - if m.flags["TEST_ENABLED"].enabled.Load() != true { - t.Error("Expected feature to be enabled") - } -} - -func TestFeatureManager_IsEnabled(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", true) - - if !m.IsEnabled("TEST_FEATURE") { - t.Error("Expected feature to be enabled") - } - - if m.IsEnabled("NON_EXISTENT") { - t.Error("Expected non-existent feature to return false") - } -} - -func TestFeatureManager_EnableDisable(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - // Enable the feature - m.Enable("TEST_FEATURE") - if !m.IsEnabled("TEST_FEATURE") { - t.Error("Expected feature to be enabled") - } - - // Disable the feature - m.Disable("TEST_FEATURE") - if m.IsEnabled("TEST_FEATURE") { - t.Error("Expected feature to be disabled") - } - - // Enable/Disable non-existent feature should not panic - m.Enable("NON_EXISTENT") - m.Disable("NON_EXISTENT") -} - -func TestFeatureManager_Toggle(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - // Toggle from false to true - m.Toggle("TEST_FEATURE") - if !m.IsEnabled("TEST_FEATURE") { - t.Error("Expected feature to be enabled after toggle") - } - - // Toggle from true to false - m.Toggle("TEST_FEATURE") - if m.IsEnabled("TEST_FEATURE") { - t.Error("Expected feature to be disabled after toggle") - } - - // Toggle non-existent feature should not panic - m.Toggle("NON_EXISTENT") -} - -func TestFeatureManager_OnChange(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - var callbackCalled atomic.Bool - var callbackValue atomic.Bool - - m.OnChange("TEST_FEATURE", func(enabled bool) { - callbackCalled.Store(true) - callbackValue.Store(enabled) - }) - - // Enable should trigger callback - m.Enable("TEST_FEATURE") - - // Wait briefly for callback - time.Sleep(10 * time.Millisecond) - - if !callbackCalled.Load() { - t.Error("Expected callback to be called") - } - - if !callbackValue.Load() { - t.Error("Expected callback value to be true") - } - - // Setting to same value should NOT trigger callback again - callbackCalled.Store(false) - m.Enable("TEST_FEATURE") - time.Sleep(10 * time.Millisecond) - - if callbackCalled.Load() { - t.Error("Expected callback NOT to be called when value doesn't change") - } -} - -func TestFeatureManager_LoadFromEnv(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - m.Register("TEST_FEATURE_2", "Test feature 2", false) - - // Set environment variables - os.Setenv("FEATURE_TEST_FEATURE", "true") - os.Setenv("FEATURE_TEST_FEATURE_2", "1") - defer func() { - os.Unsetenv("FEATURE_TEST_FEATURE") - os.Unsetenv("FEATURE_TEST_FEATURE_2") - }() - - m.LoadFromEnv() - - if !m.IsEnabled("TEST_FEATURE") { - t.Error("Expected TEST_FEATURE to be enabled from env") - } - - if !m.IsEnabled("TEST_FEATURE_2") { - t.Error("Expected TEST_FEATURE_2 to be enabled from env (value=1)") - } -} - -func TestFeatureManager_LoadFromEnv_FalseValues(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", true) // Default true - - // Set to false - os.Setenv("FEATURE_TEST_FEATURE", "false") - defer os.Unsetenv("FEATURE_TEST_FEATURE") - - m.LoadFromEnv() - - if m.IsEnabled("TEST_FEATURE") { - t.Error("Expected TEST_FEATURE to be disabled from env") - } -} - -func TestFeatureManager_GetAll(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("FEATURE_1", "Feature 1", true) - m.Register("FEATURE_2", "Feature 2", false) - - all := m.GetAll() - - if len(all) != 2 { - t.Errorf("Expected 2 features, got %d", len(all)) - } - - if !all["FEATURE_1"] { - t.Error("Expected FEATURE_1 to be enabled") - } - - if all["FEATURE_2"] { - t.Error("Expected FEATURE_2 to be disabled") - } -} - -func TestFeatureManager_Reset(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("FEATURE_1", "Feature 1", true) - m.Register("FEATURE_2", "Feature 2", true) - - var callbackCalled atomic.Int32 - m.OnChange("FEATURE_1", func(enabled bool) { - callbackCalled.Add(1) - }) - - m.Reset() - - // All features should be disabled - if m.IsEnabled("FEATURE_1") { - t.Error("Expected FEATURE_1 to be disabled after reset") - } - - if m.IsEnabled("FEATURE_2") { - t.Error("Expected FEATURE_2 to be disabled after reset") - } - - // Callbacks should be cleared - m.Enable("FEATURE_1") - time.Sleep(10 * time.Millisecond) - - if callbackCalled.Load() != 0 { - t.Error("Expected callbacks to be cleared after reset") - } -} - -func TestGetManager_Singleton(t *testing.T) { - // Reset global state for clean test - managerOnce = sync.Once{} - manager = nil - - m1 := GetManager() - m2 := GetManager() - - if m1 != m2 { - t.Error("Expected GetManager to return same instance") - } -} - -func TestGetManager_Initialize(t *testing.T) { - // Reset global state for clean test - managerOnce = sync.Once{} - manager = nil - - m := GetManager() - - // Should have default feature flags - all := m.GetAll() - if len(all) < 6 { - t.Errorf("Expected at least 6 default feature flags, got %d", len(all)) - } - - // Check specific flags exist - flags := []string{ - UseUnifiedConfig, - UseNewFileStructure, - UseStandardErrors, - UseEnhancedLogging, - UseOptimizedTests, - UseRedisRESP, - } - - for _, flag := range flags { - if _, exists := m.flags[flag]; !exists { - t.Errorf("Expected default flag %s to exist", flag) - } - } -} - -func TestHelperFunctions(t *testing.T) { - // Reset global state - managerOnce = sync.Once{} - manager = nil - - // Test IsUnifiedConfigEnabled - if IsUnifiedConfigEnabled() { - t.Error("Expected unified config to be disabled by default") - } - - GetManager().Enable(UseUnifiedConfig) - if !IsUnifiedConfigEnabled() { - t.Error("Expected unified config to be enabled") - } - - // Reset for next test - GetManager().Reset() - - // Test IsNewFileStructureEnabled - if IsNewFileStructureEnabled() { - t.Error("Expected new file structure to be disabled by default") - } - - GetManager().Enable(UseNewFileStructure) - if !IsNewFileStructureEnabled() { - t.Error("Expected new file structure to be enabled") - } - - // Test IsStandardErrorsEnabled - GetManager().Reset() - GetManager().Enable(UseStandardErrors) - if !IsStandardErrorsEnabled() { - t.Error("Expected standard errors to be enabled") - } - - // Test IsEnhancedLoggingEnabled - GetManager().Reset() - GetManager().Enable(UseEnhancedLogging) - if !IsEnhancedLoggingEnabled() { - t.Error("Expected enhanced logging to be enabled") - } - - // Test IsOptimizedTestsEnabled - GetManager().Reset() - GetManager().Enable(UseOptimizedTests) - if !IsOptimizedTestsEnabled() { - t.Error("Expected optimized tests to be enabled") - } - - // Test IsRedisRESPEnabled - GetManager().Reset() - GetManager().Enable(UseRedisRESP) - if !IsRedisRESPEnabled() { - t.Error("Expected Redis RESP to be enabled") - } -} - -// Race condition tests -func TestFeatureManager_ConcurrentAccess(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - var wg sync.WaitGroup - iterations := 100 - - // Concurrent enables - for i := 0; i < iterations; i++ { - wg.Add(1) - go func() { - defer wg.Done() - m.Enable("TEST_FEATURE") - }() - } - - // Concurrent disables - for i := 0; i < iterations; i++ { - wg.Add(1) - go func() { - defer wg.Done() - m.Disable("TEST_FEATURE") - }() - } - - // Concurrent reads - for i := 0; i < iterations; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = m.IsEnabled("TEST_FEATURE") - }() - } - - wg.Wait() - - // Should not panic - final state is not deterministic but that's ok -} - -func TestFeatureManager_ConcurrentCallbacks(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("TEST_FEATURE", "Test feature", false) - - var callbackCount atomic.Int32 - var wg sync.WaitGroup - - // Register multiple callbacks concurrently - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - m.OnChange("TEST_FEATURE", func(enabled bool) { - callbackCount.Add(1) - }) - }() - } - - wg.Wait() - - // Toggle the feature - m.Toggle("TEST_FEATURE") - - // Wait for callbacks - time.Sleep(50 * time.Millisecond) - - // All 10 callbacks should have been called - if callbackCount.Load() != 10 { - t.Errorf("Expected 10 callbacks, got %d", callbackCount.Load()) - } -} - -func TestFeatureManager_ConcurrentGetAll(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - for i := 0; i < 5; i++ { - m.Register(string(rune('A'+i)), "Feature", false) - } - - var wg sync.WaitGroup - - // Concurrent GetAll calls - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - all := m.GetAll() - if len(all) != 5 { - t.Errorf("Expected 5 flags, got %d", len(all)) - } - }() - } - - // Concurrent modifications - for i := 0; i < 100; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - flag := string(rune('A' + (idx % 5))) - if idx%2 == 0 { - m.Enable(flag) - } else { - m.Disable(flag) - } - }(i) - } - - wg.Wait() -} - -func TestFeatureManager_LoadFromEnv_Concurrent(t *testing.T) { - m := &FeatureManager{ - flags: make(map[string]*FeatureFlag), - } - - m.Register("FEATURE_1", "Feature 1", false) - m.Register("FEATURE_2", "Feature 2", false) - - os.Setenv("FEATURE_FEATURE_1", "true") - os.Setenv("FEATURE_FEATURE_2", "true") - defer func() { - os.Unsetenv("FEATURE_FEATURE_1") - os.Unsetenv("FEATURE_FEATURE_2") - }() - - var wg sync.WaitGroup - - // Load from env concurrently - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - m.LoadFromEnv() - }() - } - - wg.Wait() - - // Both should be enabled - if !m.IsEnabled("FEATURE_1") || !m.IsEnabled("FEATURE_2") { - t.Error("Expected features to be enabled from env") - } -} diff --git a/internal/pool/transport.go b/internal/pool/transport.go index d3ec823..68cfea7 100644 --- a/internal/pool/transport.go +++ b/internal/pool/transport.go @@ -60,21 +60,37 @@ var ( // GetTransportPool returns the global transport pool instance func GetTransportPool() *TransportPool { - transportPoolOnce.Do(func() { - ctx, cancel := context.WithCancel(context.Background()) - globalTransportPool = &TransportPool{ - transports: make(map[string]*sharedTransport), - maxConns: 20, - ctx: ctx, - cancel: cancel, - clientCount: 0, - maxClients: 5, - } - go globalTransportPool.cleanupRoutine(ctx) - }) + if globalTransportPool == nil { + transportPoolOnce.Do(func() { + ctx, cancel := context.WithCancel(context.Background()) + globalTransportPool = &TransportPool{ + transports: make(map[string]*sharedTransport), + maxConns: 20, + ctx: ctx, + cancel: cancel, + clientCount: 0, + maxClients: 5, + } + go globalTransportPool.cleanupRoutine(ctx) + }) + } return globalTransportPool } +// resetGlobalTransportPoolForTest clears the process-global transport pool so the +// next call to GetTransportPool returns a fresh instance, and cancels the +// current pool's cleanup goroutine. Required for order-independent tests that +// replace the global pool: the singleton is sync.Once-guarded, so without a +// reset a consumed Once leaves GetTransportPool returning nil and later callers +// panic on a nil receiver (see TestCreateHTTPClient_Fallback). +func resetGlobalTransportPoolForTest() { + if globalTransportPool != nil && globalTransportPool.cancel != nil { + globalTransportPool.cancel() + } + transportPoolOnce = sync.Once{} + globalTransportPool = nil +} + // DefaultTransportConfig returns a secure default configuration func DefaultTransportConfig() TransportConfig { return TransportConfig{ diff --git a/internal/pool/transport_test.go b/internal/pool/transport_test.go index 756fa6b..4ac0ce6 100644 --- a/internal/pool/transport_test.go +++ b/internal/pool/transport_test.go @@ -476,11 +476,13 @@ func TestCreateHTTPClient(t *testing.T) { // TestCreateHTTPClient_Fallback tests fallback when pool is exhausted func TestCreateHTTPClient_Fallback(t *testing.T) { - // Override global pool with limited one - originalPool := globalTransportPool - defer func() { - globalTransportPool = originalPool - }() + // Replace the global pool with a full one. resetGlobalTransportPoolForTest + // is required: the global is sync.Once-guarded, so overriding the var + // alone would be clobbered by GetTransportPool's once-body, and restoring + // a nil original after the Once was consumed would leave later callers + // panicking on a nil receiver. + resetGlobalTransportPoolForTest() + defer resetGlobalTransportPoolForTest() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -504,9 +506,17 @@ func TestCreateHTTPClient_Fallback(t *testing.T) { return } + // When the pool is exhausted, GetTransport returns nil and CreateHTTPClient + // falls back to a basic client: timeout set, no transport, no redirect policy. if client.Timeout != timeout { t.Errorf("Client timeout should be %v, got %v", timeout, client.Timeout) } + if client.Transport != nil { + t.Error("Fallback client should have no transport") + } + if client.CheckRedirect != nil { + t.Error("Fallback client should have no redirect policy") + } } // TestTransportPool_ConcurrentAccess tests concurrent access to transport pool diff --git a/internal/providers/adapter.go b/internal/providers/adapter.go deleted file mode 100644 index 3e9b264..0000000 --- a/internal/providers/adapter.go +++ /dev/null @@ -1,115 +0,0 @@ -package providers - -import ( - "net/url" - "strings" - "time" -) - -// Adapter facilitates communication between the legacy TraefikOIDC struct and the new provider system. -type Adapter struct { - provider OIDCProvider - legacySettings LegacySettings - tokenVerifier TokenVerifier - tokenCache TokenCache -} - -// LegacySettings provides the adapter with access to the original configuration values. -type LegacySettings interface { - GetIssuerURL() string - GetAuthURL() string - GetScopes() []string - IsPKCEEnabled() bool - GetClientID() string - GetRefreshGracePeriod() time.Duration - IsOverrideScopes() bool -} - -// NewAdapter creates a new adapter for a given provider and legacy settings. -func NewAdapter(provider OIDCProvider, settings LegacySettings, tokenVerifier TokenVerifier, tokenCache TokenCache) *Adapter { - return &Adapter{ - provider: provider, - legacySettings: settings, - tokenVerifier: tokenVerifier, - tokenCache: tokenCache, - } -} - -// BuildAuthURL constructs the authentication URL using the adapted provider. -func (a *Adapter) BuildAuthURL(redirectURL, state, nonce, codeChallenge string) string { - params := url.Values{} - params.Set("client_id", a.legacySettings.GetClientID()) - params.Set("response_type", "code") - params.Set("redirect_uri", redirectURL) - params.Set("state", state) - params.Set("nonce", nonce) - - if a.legacySettings.IsPKCEEnabled() && codeChallenge != "" { - params.Set("code_challenge", codeChallenge) - params.Set("code_challenge_method", "S256") - } - - scopes := a.legacySettings.GetScopes() - - if a.legacySettings.IsOverrideScopes() { - finalParams := params - finalParams.Set("scope", strings.Join(scopes, " ")) - - switch a.provider.GetType() { - case ProviderTypeGoogle: - finalParams.Set("access_type", "offline") - finalParams.Set("prompt", "consent") - case ProviderTypeAzure: - finalParams.Set("response_mode", "query") - } - - return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams) - } - - authParams, err := a.provider.BuildAuthParams(params, scopes) - if err != nil { - return "" - } - - finalParams := authParams.URLValues - finalParams.Set("scope", strings.Join(authParams.Scopes, " ")) - - return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams) -} - -// from the configured issuerURL. -func (a *Adapter) buildURLWithParams(baseURL string, params url.Values) string { - if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { - issuerURLParsed, err := url.Parse(a.legacySettings.GetIssuerURL()) - if err != nil { - return "" - } - - baseURLParsed, err := url.Parse(baseURL) - if err != nil { - return "" - } - - resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed) - resolvedURL.RawQuery = params.Encode() - return resolvedURL.String() - } - - u, err := url.Parse(baseURL) - if err != nil { - return "" - } - - u.RawQuery = params.Encode() - return u.String() -} - -// ValidateTokens validates tokens using the adapted provider. -func (a *Adapter) ValidateTokens(session Session) (*ValidationResult, error) { - return a.provider.ValidateTokens(session, a.tokenVerifier, a.tokenCache, a.legacySettings.GetRefreshGracePeriod()) -} - -// GetType returns the underlying provider's type. -func (a *Adapter) GetType() ProviderType { - return a.provider.GetType() -} diff --git a/internal/providers/auth0.go b/internal/providers/auth0.go deleted file mode 100644 index a49f56d..0000000 --- a/internal/providers/auth0.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "net/url" -) - -// Auth0Provider encapsulates Auth0-specific OIDC logic. -type Auth0Provider struct { - *BaseProvider -} - -// NewAuth0Provider creates a new instance of the Auth0Provider. -func NewAuth0Provider() *Auth0Provider { - return &Auth0Provider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *Auth0Provider) GetType() ProviderType { - return ProviderTypeAuth0 -} - -// GetCapabilities returns the specific capabilities of the Auth0 provider. -func (p *Auth0Provider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: true, - RequiresPromptConsent: false, - PreferredTokenValidation: "id", // Auth0 typically uses ID tokens - } -} - -// BuildAuthParams configures Auth0-specific authentication parameters. -func (p *Auth0Provider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // Auth0 supports various response types and connection parameters - baseParams.Set("response_type", "code") - - // Ensure offline_access scope is present for refresh tokens - hasOfflineAccess := false - for _, scope := range scopes { - if scope == ScopeOfflineAccess { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, ScopeOfflineAccess) - } - - // Ensure openid scope is present - hasOpenID := false - for _, scope := range scopes { - if scope == ScopeOpenID { - hasOpenID = true - break - } - } - if !hasOpenID { - scopes = append(scopes, ScopeOpenID) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(scopes), - }, nil -} - -// Auth0 requires specific tenant configuration and connection handling. -func (p *Auth0Provider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/auth0_test.go b/internal/providers/auth0_test.go deleted file mode 100644 index 01f16b8..0000000 --- a/internal/providers/auth0_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestAuth0Provider_NewAuth0Provider tests the constructor -func TestAuth0Provider_NewAuth0Provider(t *testing.T) { - provider := NewAuth0Provider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestAuth0Provider_GetType tests provider type -func TestAuth0Provider_GetType(t *testing.T) { - provider := NewAuth0Provider() - - if provider.GetType() != ProviderTypeAuth0 { - t.Errorf("Expected ProviderTypeAuth0, got %v", provider.GetType()) - } -} - -// TestAuth0Provider_GetCapabilities tests Auth0-specific capabilities -func TestAuth0Provider_GetCapabilities(t *testing.T) { - provider := NewAuth0Provider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true for Auth0") - } - - if !capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be true for Auth0") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for Auth0") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestAuth0Provider_BuildAuthParams tests Auth0-specific auth params -func TestAuth0Provider_BuildAuthParams(t *testing.T) { - provider := NewAuth0Provider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Add offline_access and openid scopes", - scopes: []string{"profile", "email"}, - expectedScopes: []string{"profile", "email", "offline_access", "openid"}, - }, - { - name: "Keep existing offline_access and openid", - scopes: []string{"openid", "profile", "offline_access", "email"}, - expectedScopes: []string{"openid", "profile", "offline_access", "email"}, - }, - { - name: "Add both scopes when none provided", - scopes: []string{}, - expectedScopes: []string{"offline_access", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Check that response_type is set - if authParams.URLValues.Get("response_type") != "code" { - t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type")) - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v", - len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes) - return - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestAuth0Provider_ValidateConfig tests config validation -func TestAuth0Provider_ValidateConfig(t *testing.T) { - provider := NewAuth0Provider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} diff --git a/internal/providers/aws_cognito.go b/internal/providers/aws_cognito.go deleted file mode 100644 index a573bbe..0000000 --- a/internal/providers/aws_cognito.go +++ /dev/null @@ -1,74 +0,0 @@ -package providers - -import ( - "net/url" - "strings" -) - -// AWSCognitoProvider encapsulates AWS Cognito-specific OIDC logic. -type AWSCognitoProvider struct { - *BaseProvider -} - -// NewAWSCognitoProvider creates a new instance of the AWSCognitoProvider. -func NewAWSCognitoProvider() *AWSCognitoProvider { - return &AWSCognitoProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *AWSCognitoProvider) GetType() ProviderType { - return ProviderTypeAWSCognito -} - -// GetCapabilities returns the specific capabilities of the AWS Cognito provider. -func (p *AWSCognitoProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: false, // Cognito doesn't use offline_access scope - RequiresPromptConsent: false, - PreferredTokenValidation: "id", // Cognito typically uses ID tokens - } -} - -// BuildAuthParams configures AWS Cognito-specific authentication parameters. -func (p *AWSCognitoProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // AWS Cognito supports standard OIDC parameters - baseParams.Set("response_type", "code") - - // Remove offline_access scope as Cognito doesn't use it (case-insensitive) - var filteredScopes []string - for _, scope := range scopes { - if !strings.EqualFold(scope, ScopeOfflineAccess) { - filteredScopes = append(filteredScopes, scope) - } - } - - // Ensure openid scope is present - hasOpenID := false - for _, scope := range filteredScopes { - if scope == ScopeOpenID { - hasOpenID = true - break - } - } - if !hasOpenID { - filteredScopes = append(filteredScopes, ScopeOpenID) - } - - // Default Cognito scopes if none specified - if len(filteredScopes) == 1 && filteredScopes[0] == ScopeOpenID { - filteredScopes = append(filteredScopes, ScopeEmail, ScopeProfile) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(filteredScopes), - }, nil -} - -// AWS Cognito requires user pool and domain configuration. -func (p *AWSCognitoProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/aws_cognito_test.go b/internal/providers/aws_cognito_test.go deleted file mode 100644 index 779be42..0000000 --- a/internal/providers/aws_cognito_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestAWSCognitoProvider_NewAWSCognitoProvider tests the constructor -func TestAWSCognitoProvider_NewAWSCognitoProvider(t *testing.T) { - provider := NewAWSCognitoProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestAWSCognitoProvider_GetType tests provider type -func TestAWSCognitoProvider_GetType(t *testing.T) { - provider := NewAWSCognitoProvider() - - if provider.GetType() != ProviderTypeAWSCognito { - t.Errorf("Expected ProviderTypeAWSCognito, got %v", provider.GetType()) - } -} - -// TestAWSCognitoProvider_GetCapabilities tests AWS Cognito-specific capabilities -func TestAWSCognitoProvider_GetCapabilities(t *testing.T) { - provider := NewAWSCognitoProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true for AWS Cognito") - } - - if capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be false for AWS Cognito") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for AWS Cognito") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestAWSCognitoProvider_BuildAuthParams tests AWS Cognito-specific auth params -func TestAWSCognitoProvider_BuildAuthParams(t *testing.T) { - provider := NewAWSCognitoProvider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Remove offline_access scope and ensure openid", - scopes: []string{"email", "profile", "offline_access"}, - expectedScopes: []string{"email", "profile", "openid"}, - }, - { - name: "Keep existing openid, remove offline_access", - scopes: []string{"openid", "email", "offline_access", "profile"}, - expectedScopes: []string{"openid", "email", "profile"}, - }, - { - name: "Add default scopes when only openid", - scopes: []string{"openid"}, - expectedScopes: []string{"openid", "email", "profile"}, - }, - { - name: "Add openid and defaults when empty", - scopes: []string{}, - expectedScopes: []string{"openid", "email", "profile"}, - }, - { - name: "Cognito-specific scopes", - scopes: []string{"aws.cognito.signin.user.admin", "phone"}, - expectedScopes: []string{"aws.cognito.signin.user.admin", "phone", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Check that response_type is set - if authParams.URLValues.Get("response_type") != "code" { - t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type")) - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v", - len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes) - return - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - - // Ensure offline_access is NOT present - for _, actualScope := range authParams.Scopes { - if actualScope == "offline_access" { - t.Error("offline_access scope should be filtered out for AWS Cognito") - } - } - }) - } -} - -// TestAWSCognitoProvider_ValidateConfig tests config validation -func TestAWSCognitoProvider_ValidateConfig(t *testing.T) { - provider := NewAWSCognitoProvider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} - -// TestAWSCognitoProvider_InterfaceCompliance tests that AWS Cognito provider implements the OIDCProvider interface -func TestAWSCognitoProvider_InterfaceCompliance(t *testing.T) { - var _ OIDCProvider = NewAWSCognitoProvider() -} - -// TestAWSCognitoProvider_BaseProviderInheritance tests that AWS Cognito provider inherits from BaseProvider correctly -func TestAWSCognitoProvider_BaseProviderInheritance(t *testing.T) { - provider := NewAWSCognitoProvider() - - // Test that it has access to BaseProvider methods - if provider.BaseProvider == nil { - t.Error("Expected BaseProvider to be initialized") - } - - // Test HandleTokenRefresh (inherited from BaseProvider) - err := provider.HandleTokenRefresh(&TokenResult{ - IDToken: "test-id-token", - AccessToken: "test-access-token", - RefreshToken: "test-refresh-token", - }) - if err != nil { - t.Errorf("HandleTokenRefresh failed: %v", err) - } -} - -// TestAWSCognitoProvider_OfflineAccessFiltering tests that offline_access scope is always filtered out -func TestAWSCognitoProvider_OfflineAccessFiltering(t *testing.T) { - provider := NewAWSCognitoProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - }{ - { - name: "Single offline_access", - scopes: []string{"offline_access"}, - }, - { - name: "Multiple offline_access occurrences", - scopes: []string{"offline_access", "email", "offline_access", "profile"}, - }, - { - name: "Mixed case", - scopes: []string{"OFFLINE_ACCESS", "email"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Ensure offline_access is NOT present in any form - for _, actualScope := range authParams.Scopes { - if actualScope == "offline_access" || actualScope == "OFFLINE_ACCESS" { - t.Errorf("offline_access scope should be filtered out, but found: %s", actualScope) - } - } - }) - } -} - -// TestAWSCognitoProvider_CognitoSpecificScopes tests AWS Cognito-specific scopes -func TestAWSCognitoProvider_CognitoSpecificScopes(t *testing.T) { - provider := NewAWSCognitoProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - checkFor []string - }{ - { - name: "Cognito admin scope", - scopes: []string{"aws.cognito.signin.user.admin"}, - checkFor: []string{"aws.cognito.signin.user.admin", "openid"}, - }, - { - name: "Phone scope", - scopes: []string{"phone"}, - checkFor: []string{"phone", "openid"}, - }, - { - name: "Address scope", - scopes: []string{"address"}, - checkFor: []string{"address", "openid"}, - }, - { - name: "Multiple Cognito scopes", - scopes: []string{"aws.cognito.signin.user.admin", "phone", "address"}, - checkFor: []string{"aws.cognito.signin.user.admin", "phone", "address", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - for _, expectedScope := range tt.checkFor { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestAWSCognitoProvider_DefaultScopeHandling tests default scope behavior -func TestAWSCognitoProvider_DefaultScopeHandling(t *testing.T) { - provider := NewAWSCognitoProvider() - baseParams := url.Values{} - - // Test with only openid scope - should add defaults - authParams, err := provider.BuildAuthParams(baseParams, []string{"openid"}) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - expectedScopes := []string{"openid", "email", "profile"} - if len(authParams.Scopes) != len(expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(expectedScopes), len(authParams.Scopes)) - return - } - - for _, expectedScope := range expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected default scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } -} diff --git a/internal/providers/azure.go b/internal/providers/azure.go deleted file mode 100644 index 39e84ee..0000000 --- a/internal/providers/azure.go +++ /dev/null @@ -1,106 +0,0 @@ -package providers - -import ( - "net/url" - "strings" - "time" -) - -// AzureProvider encapsulates Azure AD-specific OIDC logic. -type AzureProvider struct { - *BaseProvider -} - -// NewAzureProvider creates a new instance of the AzureProvider. -func NewAzureProvider() *AzureProvider { - return &AzureProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *AzureProvider) GetType() ProviderType { - return ProviderTypeAzure -} - -// GetCapabilities returns the specific capabilities of the Azure provider. -func (p *AzureProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: true, - PreferredTokenValidation: "access", - } -} - -// BuildAuthParams configures Azure-specific authentication parameters. -func (p *AzureProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - baseParams.Set("response_mode", "query") - - hasOfflineAccess := false - for _, scope := range scopes { - if scope == ScopeOfflineAccess { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, ScopeOfflineAccess) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(scopes), - }, nil -} - -// Azure may use access tokens for validation, and this method ensures that behavior is preserved. -func (p *AzureProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) { - if !session.GetAuthenticated() { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - - accessToken := session.GetAccessToken() - idToken := session.GetIDToken() - - if accessToken != "" { - if strings.Count(accessToken, ".") == 2 { - if err := verifier.VerifyToken(accessToken); err != nil { - if idToken != "" { - return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod) - } - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - return p.ValidateTokenExpiry(session, accessToken, tokenCache, refreshGracePeriod) - } - if idToken != "" { - return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod) - } - return &ValidationResult{Authenticated: true}, nil - } - - if idToken != "" { - if err := verifier.VerifyToken(idToken); err != nil { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod) - } - - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil -} - -// Azure requires specific tenant configuration and scope handling. -func (p *AzureProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/azure_test.go b/internal/providers/azure_test.go deleted file mode 100644 index bcda575..0000000 --- a/internal/providers/azure_test.go +++ /dev/null @@ -1,584 +0,0 @@ -package providers - -import ( - "errors" - "net/url" - "strings" - "testing" - "time" -) - -// TestAzureProvider_NewAzureProvider tests the constructor -func TestAzureProvider_NewAzureProvider(t *testing.T) { - provider := NewAzureProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestAzureProvider_GetType tests provider type -func TestAzureProvider_GetType(t *testing.T) { - provider := NewAzureProvider() - - if provider.GetType() != ProviderTypeAzure { - t.Errorf("Expected ProviderTypeAzure, got %v", provider.GetType()) - } -} - -// TestAzureProvider_GetCapabilities tests Azure-specific capabilities -func TestAzureProvider_GetCapabilities(t *testing.T) { - provider := NewAzureProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true") - } - - if !capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be true for Azure") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for Azure") - } - - if capabilities.PreferredTokenValidation != "access" { - t.Errorf("Expected PreferredTokenValidation 'access', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestAzureProvider_BuildAuthParams tests Azure-specific auth parameters -func TestAzureProvider_BuildAuthParams(t *testing.T) { - provider := NewAzureProvider() - - tests := []struct { - name string - inputScopes []string - expectedScopes []string - shouldHaveResponseMode bool - shouldAddOfflineAccess bool - }{ - { - name: "Basic scopes without offline_access", - inputScopes: []string{"openid", "profile", "email"}, - expectedScopes: []string{"openid", "profile", "email", "offline_access"}, - shouldHaveResponseMode: true, - shouldAddOfflineAccess: true, - }, - { - name: "Scopes with offline_access already present", - inputScopes: []string{"openid", "profile", "offline_access", "email"}, - expectedScopes: []string{"openid", "profile", "offline_access", "email"}, - shouldHaveResponseMode: true, - shouldAddOfflineAccess: false, - }, - { - name: "Only offline_access scope", - inputScopes: []string{"offline_access"}, - expectedScopes: []string{"offline_access"}, - shouldHaveResponseMode: true, - shouldAddOfflineAccess: false, - }, - { - name: "Empty scopes (should add offline_access)", - inputScopes: []string{}, - expectedScopes: []string{"offline_access"}, - shouldHaveResponseMode: true, - shouldAddOfflineAccess: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - - result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Check Azure-specific parameters - if tt.shouldHaveResponseMode { - if result.URLValues.Get("response_mode") != "query" { - t.Errorf("Expected response_mode 'query', got '%s'", result.URLValues.Get("response_mode")) - } - } - - // Check scopes - if len(result.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes)) - } - - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range result.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in result", expectedScope) - } - } - - // Verify offline_access is present - hasOfflineAccess := false - for _, scope := range result.Scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - t.Error("Azure provider should always include offline_access scope") - } - - // Verify original base parameters are preserved - if result.URLValues.Get("client_id") != "test-client" { - t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id")) - } - }) - } -} - -// TestAzureProvider_ValidateTokens tests Azure-specific token validation logic -func TestAzureProvider_ValidateTokens(t *testing.T) { - provider := NewAzureProvider() - - tests := []struct { - verifierError error - session *mockSession - cacheData map[string]interface{} - name string - expectedResult ValidationResult - }{ - { - name: "Unauthenticated with refresh token", - session: &mockSession{ - authenticated: false, - refreshToken: "refresh-token", - }, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Unauthenticated without refresh token", - session: &mockSession{ - authenticated: false, - }, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - { - name: "JWT access token valid", - session: &mockSession{ - authenticated: true, - accessToken: "valid.jwt.token", - refreshToken: "refresh-token", - }, - verifierError: nil, - cacheData: map[string]interface{}{ - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "JWT access token invalid, valid ID token", - session: &mockSession{ - authenticated: true, - accessToken: "invalid.jwt.token", - idToken: "valid.id.token", - refreshToken: "refresh-token", - }, - verifierError: errors.New("invalid token"), - cacheData: map[string]interface{}{ - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "Opaque access token with valid ID token", - session: &mockSession{ - authenticated: true, - accessToken: "opaque-token-no-dots", - idToken: "valid.id.token", - refreshToken: "refresh-token", - }, - cacheData: map[string]interface{}{ - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "Opaque access token without ID token", - session: &mockSession{ - authenticated: true, - accessToken: "opaque-token-no-dots", - refreshToken: "refresh-token", - }, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "No access token, valid ID token", - session: &mockSession{ - authenticated: true, - idToken: "valid.id.token", - refreshToken: "refresh-token", - }, - verifierError: nil, - cacheData: map[string]interface{}{ - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "No access token, invalid ID token, with refresh token", - session: &mockSession{ - authenticated: true, - idToken: "invalid.id.token", - refreshToken: "refresh-token", - }, - verifierError: errors.New("invalid token"), - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "No tokens, with refresh token", - session: &mockSession{ - authenticated: true, - refreshToken: "refresh-token", - }, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "No tokens, no refresh token", - session: &mockSession{ - authenticated: true, - }, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier := &mockTokenVerifier{error: tt.verifierError} - cache := &mockTokenCache{claims: make(map[string]map[string]interface{})} - - // Set up cache data - if tt.cacheData != nil { - if tt.session.accessToken != "" && strings.Count(tt.session.accessToken, ".") == 2 { - cache.claims[tt.session.accessToken] = tt.cacheData - } - if tt.session.idToken != "" { - cache.claims[tt.session.idToken] = tt.cacheData - } - } - - result, err := provider.ValidateTokens(tt.session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestAzureProvider_ValidateConfig tests configuration validation -func TestAzureProvider_ValidateConfig(t *testing.T) { - provider := NewAzureProvider() - - err := provider.ValidateConfig() - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } -} - -// TestAzureProvider_InterfaceCompliance tests that Azure provider implements OIDCProvider -func TestAzureProvider_InterfaceCompliance(t *testing.T) { - provider := NewAzureProvider() - - // Verify it implements the OIDCProvider interface - var _ OIDCProvider = provider -} - -// TestAzureProvider_OfflineAccessHandling tests comprehensive offline_access handling -func TestAzureProvider_OfflineAccessHandling(t *testing.T) { - provider := NewAzureProvider() - - tests := []struct { - name string - description string - inputScopes []string - expectedCount int - }{ - { - name: "No offline_access - should add one", - inputScopes: []string{"openid", "profile", "email"}, - expectedCount: 1, - description: "Should add offline_access when not present", - }, - { - name: "One offline_access - should preserve", - inputScopes: []string{"openid", "offline_access", "profile"}, - expectedCount: 1, - description: "Should preserve existing offline_access", - }, - { - name: "Multiple offline_access - should deduplicate", - inputScopes: []string{"openid", "offline_access", "profile", "offline_access"}, - expectedCount: 1, - description: "Should deduplicate multiple offline_access scopes", - }, - { - name: "Only offline_access", - inputScopes: []string{"offline_access"}, - expectedCount: 1, - description: "Should preserve when only offline_access is present", - }, - { - name: "Empty scopes - should add offline_access", - inputScopes: []string{}, - expectedCount: 1, - description: "Should add offline_access when no scopes provided", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseParams := make(url.Values) - result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Count offline_access occurrences in result - offlineAccessCount := 0 - for _, scope := range result.Scopes { - if scope == "offline_access" { - offlineAccessCount++ - } - } - - if offlineAccessCount != tt.expectedCount { - t.Errorf("Expected %d offline_access scopes in result, got %d", tt.expectedCount, offlineAccessCount) - } - - // Ensure at least one offline_access is always present - if offlineAccessCount == 0 { - t.Error("Azure provider should always have at least one offline_access scope") - } - - // Verify other scopes are preserved (except for the empty case) - if len(tt.inputScopes) > 0 { - for _, originalScope := range tt.inputScopes { - found := false - for _, resultScope := range result.Scopes { - if resultScope == originalScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' to be preserved", originalScope) - } - } - } - }) - } -} - -// TestAzureProvider_TokenValidationPriority tests access token vs ID token priority -func TestAzureProvider_TokenValidationPriority(t *testing.T) { - provider := NewAzureProvider() - - // Test that Azure prefers access tokens over ID tokens when both are JWT - session := &mockSession{ - authenticated: true, - accessToken: "valid.access.token", - idToken: "valid.id.token", - refreshToken: "refresh-token", - } - - verifier := &mockTokenVerifier{} // Valid tokens - cache := &mockTokenCache{ - claims: map[string]map[string]interface{}{ - "valid.access.token": { - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - "valid.id.token": { - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - }, - } - - result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if !result.Authenticated { - t.Error("Should be authenticated with valid access token") - } - - if result.NeedsRefresh { - t.Error("Should not need refresh with valid access token") - } -} - -// TestAzureProvider_AuthParamsPreservation tests that base parameters are not overwritten -func TestAzureProvider_AuthParamsPreservation(t *testing.T) { - provider := NewAzureProvider() - - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - baseParams.Set("redirect_uri", "https://example.com/callback") - baseParams.Set("response_type", "code") - baseParams.Set("state", "test-state") - baseParams.Set("nonce", "test-nonce") - - scopes := []string{"openid", "profile"} - - result, err := provider.BuildAuthParams(baseParams, scopes) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Verify all original parameters are preserved - expectedParams := map[string]string{ - "client_id": "test-client", - "redirect_uri": "https://example.com/callback", - "response_type": "code", - "state": "test-state", - "nonce": "test-nonce", - "response_mode": "query", // Added by Azure provider - } - - for key, expectedValue := range expectedParams { - actualValue := result.URLValues.Get(key) - if actualValue != expectedValue { - t.Errorf("Expected %s '%s', got '%s'", key, expectedValue, actualValue) - } - } - - // Verify scopes (should include offline_access) - if len(result.Scopes) != 3 { - t.Errorf("Expected 3 scopes (including offline_access), got %d", len(result.Scopes)) - } - - expectedScopes := []string{"openid", "profile", "offline_access"} - for _, expectedScope := range expectedScopes { - found := false - for _, actualScope := range result.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found", expectedScope) - } - } -} - -// Benchmark tests -func BenchmarkAzureProvider_BuildAuthParams(b *testing.B) { - provider := NewAzureProvider() - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - scopes := []string{"openid", "profile", "email"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.BuildAuthParams(baseParams, scopes) - } -} - -func BenchmarkAzureProvider_ValidateTokens(b *testing.B) { - provider := NewAzureProvider() - session := &mockSession{ - authenticated: true, - accessToken: "valid.access.token", - idToken: "valid.id.token", - refreshToken: "refresh-token", - } - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{ - claims: map[string]map[string]interface{}{ - "valid.access.token": { - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - }, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.ValidateTokens(session, verifier, cache, time.Minute) - } -} diff --git a/internal/providers/base.go b/internal/providers/base.go deleted file mode 100644 index 627a2d5..0000000 --- a/internal/providers/base.go +++ /dev/null @@ -1,155 +0,0 @@ -package providers - -import ( - "net/url" - "strings" - "time" -) - -// BaseProvider provides common functionality for all OIDC provider implementations. -// It defines default behaviors that can be overridden by specific providers. -// It can be embedded in specific provider structs to share common logic. -type BaseProvider struct { -} - -// GetType returns the default provider type (generic). -// This should be overridden by specific provider implementations. -func (p *BaseProvider) GetType() ProviderType { - return ProviderTypeGeneric -} - -// GetCapabilities returns default provider capabilities. -// This can be overridden by specific providers to declare their unique features. -func (p *BaseProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: true, - PreferredTokenValidation: "id", - } -} - -// ValidateTokens performs basic token validation logic common to all providers. -// It checks authentication state, token presence, and determines if refresh is needed. -// This method can be extended or replaced by specific providers. -func (p *BaseProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) { - if !session.GetAuthenticated() { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{}, nil - } - - accessToken := session.GetAccessToken() - if accessToken == "" { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - - idToken := session.GetIDToken() - if idToken == "" { - if session.GetRefreshToken() != "" { - return &ValidationResult{Authenticated: true, NeedsRefresh: true}, nil - } - return &ValidationResult{Authenticated: true}, nil - } - - if err := verifier.VerifyToken(idToken); err != nil { - if strings.Contains(err.Error(), "token has expired") { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - - return p.ValidateTokenExpiry(session, idToken, tokenCache, refreshGracePeriod) -} - -// ValidateTokenExpiry checks if a token is expired or needs refresh based on cached claims. -// This method is now exported so provider implementations can reuse this logic without duplication. -func (p *BaseProvider) ValidateTokenExpiry(session Session, token string, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) { - cachedClaims, found := tokenCache.Get(token) - if !found { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - - expClaim, ok := cachedClaims["exp"].(float64) - if !ok { - if session.GetRefreshToken() != "" { - return &ValidationResult{NeedsRefresh: true}, nil - } - return &ValidationResult{IsExpired: true}, nil - } - - expTime := time.Unix(int64(expClaim), 0) - if expTime.Before(time.Now().Add(refreshGracePeriod)) { - if session.GetRefreshToken() != "" { - return &ValidationResult{Authenticated: true, NeedsRefresh: true}, nil - } - return &ValidationResult{Authenticated: true}, nil - } - - return &ValidationResult{Authenticated: true}, nil -} - -// BuildAuthParams constructs authorization parameters for the provider. -// It includes the offline_access scope by default for refresh token support. -func (p *BaseProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - hasOfflineAccess := false - for _, scope := range scopes { - if scope == ScopeOfflineAccess { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, ScopeOfflineAccess) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(scopes), - }, nil -} - -// HandleTokenRefresh processes provider-specific token refresh logic. -// By default, it does nothing and assumes the standard token response is sufficient. -func (p *BaseProvider) HandleTokenRefresh(tokenData *TokenResult) error { - return nil -} - -// deduplicateScopes removes duplicate scopes from a slice while preserving order. -func deduplicateScopes(scopes []string) []string { - seen := make(map[string]bool) - result := make([]string, 0, len(scopes)) - - for _, scope := range scopes { - if !seen[scope] { - seen[scope] = true - result = append(result, scope) - } - } - - return result -} - -// ValidateConfig checks provider-specific configuration requirements. -// By default, it assumes the configuration is valid. -func (p *BaseProvider) ValidateConfig() error { - return nil -} - -// NewBaseProvider creates a new BaseProvider instance. -// This can be used when a generic OIDC provider is sufficient. -func NewBaseProvider() *BaseProvider { - return &BaseProvider{} -} diff --git a/internal/providers/base_test.go b/internal/providers/base_test.go deleted file mode 100644 index 188df74..0000000 --- a/internal/providers/base_test.go +++ /dev/null @@ -1,652 +0,0 @@ -package providers - -import ( - "errors" - "testing" - "time" -) - -// Mock implementations for testing -type mockSession struct { - idToken string - accessToken string - refreshToken string - authenticated bool -} - -func (s *mockSession) GetIDToken() string { return s.idToken } -func (s *mockSession) GetAccessToken() string { return s.accessToken } -func (s *mockSession) GetRefreshToken() string { return s.refreshToken } -func (s *mockSession) GetAuthenticated() bool { return s.authenticated } - -type mockTokenVerifier struct { - error error -} - -func (v *mockTokenVerifier) VerifyToken(token string) error { - return v.error -} - -type mockTokenCache struct { - claims map[string]map[string]interface{} -} - -func (c *mockTokenCache) Get(key string) (map[string]interface{}, bool) { - claims, exists := c.claims[key] - return claims, exists -} - -// TestBaseProvider_GetType tests the default provider type -func TestBaseProvider_GetType(t *testing.T) { - provider := NewBaseProvider() - - if provider.GetType() != ProviderTypeGeneric { - t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType()) - } -} - -// TestBaseProvider_GetCapabilities tests the default capabilities -func TestBaseProvider_GetCapabilities(t *testing.T) { - provider := NewBaseProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true") - } - - if !capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be true") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false") - } -} - -// TestBaseProvider_ValidateTokens_Unauthenticated tests validation when not authenticated -func TestBaseProvider_ValidateTokens_Unauthenticated(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{authenticated: false} - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{} - - tests := []struct { - name string - refreshToken string - expectedResult ValidationResult - }{ - { - name: "No refresh token", - refreshToken: "", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "Has refresh token", - refreshToken: "refresh-token", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - session.refreshToken = tt.refreshToken - - result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_ValidateTokens_AuthenticatedNoAccessToken tests authenticated session without access token -func TestBaseProvider_ValidateTokens_AuthenticatedNoAccessToken(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{ - authenticated: true, - accessToken: "", // No access token - } - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{} - - tests := []struct { - name string - refreshToken string - expectedResult ValidationResult - }{ - { - name: "No access token, no refresh token", - refreshToken: "", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - { - name: "No access token, has refresh token", - refreshToken: "refresh-token", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - session.refreshToken = tt.refreshToken - - result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_ValidateTokens_AuthenticatedNoIDToken tests authenticated session without ID token -func TestBaseProvider_ValidateTokens_AuthenticatedNoIDToken(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{ - authenticated: true, - accessToken: "access-token", - idToken: "", // No ID token - } - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{} - - tests := []struct { - name string - refreshToken string - expectedResult ValidationResult - }{ - { - name: "No ID token, no refresh token", - refreshToken: "", - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "No ID token, has refresh token", - refreshToken: "refresh-token", - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: true, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - session.refreshToken = tt.refreshToken - - result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_ValidateTokens_TokenVerificationFailure tests token verification failures -func TestBaseProvider_ValidateTokens_TokenVerificationFailure(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{ - authenticated: true, - accessToken: "access-token", - idToken: "id-token", - } - cache := &mockTokenCache{} - - tests := []struct { - name string - verifierError error - refreshToken string - expectedResult ValidationResult - }{ - { - name: "Token expired, has refresh token", - verifierError: errors.New("token has expired"), - refreshToken: "refresh-token", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Token expired, no refresh token", - verifierError: errors.New("token has expired"), - refreshToken: "", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - { - name: "Other verification error, has refresh token", - verifierError: errors.New("invalid signature"), - refreshToken: "refresh-token", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Other verification error, no refresh token", - verifierError: errors.New("invalid signature"), - refreshToken: "", - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier := &mockTokenVerifier{error: tt.verifierError} - session.refreshToken = tt.refreshToken - - result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_ValidateTokenExpiry tests token expiry validation logic -func TestBaseProvider_ValidateTokenExpiry(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{refreshToken: "refresh-token"} - - now := time.Now() - gracePeriod := 5 * time.Minute - - tests := []struct { - claims map[string]interface{} - name string - expectedResult ValidationResult - cacheFound bool - }{ - { - name: "Token not found in cache, has refresh token", - claims: nil, - cacheFound: false, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Claims without exp, has refresh token", - claims: map[string]interface{}{"sub": "user123"}, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Token expired (beyond grace period), has refresh token", - claims: map[string]interface{}{ - "exp": float64(now.Add(-10 * time.Minute).Unix()), - }, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Token expires within grace period, has refresh token", - claims: map[string]interface{}{ - "exp": float64(now.Add(2 * time.Minute).Unix()), - }, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Token valid (beyond grace period)", - claims: map[string]interface{}{ - "exp": float64(now.Add(10 * time.Minute).Unix()), - }, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cache := &mockTokenCache{claims: make(map[string]map[string]interface{})} - if tt.cacheFound { - cache.claims["test-token"] = tt.claims - } - - result, err := provider.ValidateTokenExpiry(session, "test-token", cache, gracePeriod) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_ValidateTokenExpiry_NoRefreshToken tests expiry validation without refresh token -func TestBaseProvider_ValidateTokenExpiry_NoRefreshToken(t *testing.T) { - provider := NewBaseProvider() - session := &mockSession{refreshToken: ""} // No refresh token - - now := time.Now() - gracePeriod := 5 * time.Minute - - tests := []struct { - claims map[string]interface{} - name string - expectedResult ValidationResult - cacheFound bool - }{ - { - name: "Token not found in cache, no refresh token", - claims: nil, - cacheFound: false, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - { - name: "Claims without exp, no refresh token", - claims: map[string]interface{}{"sub": "user123"}, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: false, - IsExpired: true, - }, - }, - { - name: "Token expires within grace period, no refresh token", - claims: map[string]interface{}{ - "exp": float64(now.Add(2 * time.Minute).Unix()), - }, - cacheFound: true, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cache := &mockTokenCache{claims: make(map[string]map[string]interface{})} - if tt.cacheFound { - cache.claims["test-token"] = tt.claims - } - - result, err := provider.ValidateTokenExpiry(session, "test-token", cache, gracePeriod) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// TestBaseProvider_BuildAuthParams tests authorization parameter building -func TestBaseProvider_BuildAuthParams(t *testing.T) { - provider := NewBaseProvider() - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "No existing offline_access scope", - scopes: []string{"openid", "profile", "email"}, - expectedScopes: []string{"openid", "profile", "email", "offline_access"}, - }, - { - name: "Existing offline_access scope", - scopes: []string{"openid", "profile", "offline_access", "email"}, - expectedScopes: []string{"openid", "profile", "offline_access", "email"}, - }, - { - name: "Empty scopes", - scopes: []string{}, - expectedScopes: []string{"offline_access"}, - }, - { - name: "Only offline_access", - scopes: []string{"offline_access"}, - expectedScopes: []string{"offline_access"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseParams := make(map[string][]string) - baseParams["client_id"] = []string{"test-client"} - - result, err := provider.BuildAuthParams(baseParams, tt.scopes) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if len(result.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes)) - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range result.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in result", expectedScope) - } - } - - // Verify base parameters are preserved - if result.URLValues.Get("client_id") != "test-client" { - t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id")) - } - }) - } -} - -// TestBaseProvider_HandleTokenRefresh tests token refresh handling -func TestBaseProvider_HandleTokenRefresh(t *testing.T) { - provider := NewBaseProvider() - tokenData := &TokenResult{ - IDToken: "new-id-token", - AccessToken: "new-access-token", - RefreshToken: "new-refresh-token", - } - - // Base provider should do nothing and return no error - err := provider.HandleTokenRefresh(tokenData) - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } -} - -// TestBaseProvider_ValidateConfig tests configuration validation -func TestBaseProvider_ValidateConfig(t *testing.T) { - provider := NewBaseProvider() - - // Base provider should always return valid configuration - err := provider.ValidateConfig() - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } -} - -// TestNewBaseProvider tests the constructor -func TestNewBaseProvider(t *testing.T) { - provider := NewBaseProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - // Verify it implements the OIDCProvider interface - var _ OIDCProvider = provider -} - -// Benchmark tests -func BenchmarkBaseProvider_ValidateTokens(b *testing.B) { - provider := NewBaseProvider() - session := &mockSession{ - authenticated: true, - idToken: "test-token", - accessToken: "access-token", - refreshToken: "refresh-token", - } - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{ - claims: map[string]map[string]interface{}{ - "test-token": { - "exp": float64(time.Now().Add(10 * time.Minute).Unix()), - "sub": "user123", - }, - }, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.ValidateTokens(session, verifier, cache, time.Minute) - } -} - -func BenchmarkBaseProvider_BuildAuthParams(b *testing.B) { - provider := NewBaseProvider() - baseParams := make(map[string][]string) - baseParams["client_id"] = []string{"test-client"} - scopes := []string{"openid", "profile", "email"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.BuildAuthParams(baseParams, scopes) - } -} diff --git a/internal/providers/factory.go b/internal/providers/factory.go deleted file mode 100644 index 23964f4..0000000 --- a/internal/providers/factory.go +++ /dev/null @@ -1,150 +0,0 @@ -package providers - -import ( - "fmt" - "net/url" - "strings" -) - -// ProviderFactory encapsulates the logic for creating and configuring OIDC providers. -type ProviderFactory struct { - registry *ProviderRegistry -} - -// NewProviderFactory creates a new factory with a pre-configured registry. -func NewProviderFactory() *ProviderFactory { - registry := NewProviderRegistry() - - registry.RegisterProvider(NewGenericProvider()) - registry.RegisterProvider(NewGoogleProvider()) - registry.RegisterProvider(NewAzureProvider()) - registry.RegisterProvider(NewGitHubProvider()) - registry.RegisterProvider(NewAuth0Provider()) - registry.RegisterProvider(NewOktaProvider()) - registry.RegisterProvider(NewKeycloakProvider()) - registry.RegisterProvider(NewAWSCognitoProvider()) - registry.RegisterProvider(NewGitLabProvider()) - - return &ProviderFactory{ - registry: registry, - } -} - -// CreateProvider creates an OIDC provider based on the issuer URL. -// It automatically detects the provider type and returns a configured instance. -func (f *ProviderFactory) CreateProvider(issuerURL string) (OIDCProvider, error) { - if issuerURL == "" { - return nil, fmt.Errorf("issuer URL cannot be empty") - } - - parsedURL, err := url.Parse(issuerURL) - if err != nil { - return nil, fmt.Errorf("invalid issuer URL format: %w", err) - } - - // Check if the URL has a valid scheme and host - if parsedURL.Scheme == "" || parsedURL.Host == "" { - return nil, fmt.Errorf("invalid issuer URL format: URL must have a valid scheme and host") - } - - provider := f.registry.DetectProvider(issuerURL) - if provider == nil { - return nil, fmt.Errorf("unable to detect provider for issuer URL: %s", issuerURL) - } - - if err := provider.ValidateConfig(); err != nil { - return nil, fmt.Errorf("provider configuration validation failed: %w", err) - } - - return provider, nil -} - -// CreateProviderByType creates a provider instance of the specified type. -// This is useful when you want to force a specific provider type regardless of URL. -func (f *ProviderFactory) CreateProviderByType(providerType ProviderType) (OIDCProvider, error) { - var provider OIDCProvider - - switch providerType { - case ProviderTypeGeneric: - provider = NewGenericProvider() - case ProviderTypeGoogle: - provider = NewGoogleProvider() - case ProviderTypeAzure: - provider = NewAzureProvider() - case ProviderTypeGitHub: - provider = NewGitHubProvider() - case ProviderTypeAuth0: - provider = NewAuth0Provider() - case ProviderTypeOkta: - provider = NewOktaProvider() - case ProviderTypeKeycloak: - provider = NewKeycloakProvider() - case ProviderTypeAWSCognito: - provider = NewAWSCognitoProvider() - case ProviderTypeGitLab: - provider = NewGitLabProvider() - default: - return nil, fmt.Errorf("unsupported provider type: %d", providerType) - } - - if err := provider.ValidateConfig(); err != nil { - return nil, fmt.Errorf("provider configuration validation failed: %w", err) - } - - return provider, nil -} - -// GetSupportedProviders returns a list of all supported provider types and their detection patterns. -func (f *ProviderFactory) GetSupportedProviders() map[ProviderType][]string { - return map[ProviderType][]string{ - ProviderTypeGeneric: {"*"}, - ProviderTypeGoogle: {"accounts.google.com"}, - ProviderTypeAzure: {"login.microsoftonline.com", "sts.windows.net"}, - ProviderTypeGitHub: {"github.com"}, - ProviderTypeAuth0: {".auth0.com"}, - ProviderTypeOkta: {".okta.com", ".oktapreview.com", ".okta-emea.com"}, - ProviderTypeKeycloak: {"keycloak"}, - ProviderTypeAWSCognito: {"cognito-idp", ".amazonaws.com"}, - ProviderTypeGitLab: {"gitlab.com"}, - } -} - -// DetectProviderType determines the provider type for a given issuer URL. -// This is useful for diagnostic purposes or UI display. -func (f *ProviderFactory) DetectProviderType(issuerURL string) (ProviderType, error) { - provider, err := f.CreateProvider(issuerURL) - if err != nil { - return ProviderTypeGeneric, err - } - return provider.GetType(), nil -} - -// IsProviderSupported checks if a given issuer URL is supported by any registered provider. -func (f *ProviderFactory) IsProviderSupported(issuerURL string) bool { - if issuerURL == "" { - return false - } - - normalizedURL, err := url.Parse(issuerURL) - if err != nil { - return false - } - - // Check if the URL has a valid scheme and host - if normalizedURL.Scheme == "" || normalizedURL.Host == "" { - return false - } - - host := strings.ToLower(normalizedURL.Host) - supportedProviders := f.GetSupportedProviders() - - for _, patterns := range supportedProviders { - for _, pattern := range patterns { - if pattern == "*" || strings.Contains(host, strings.ToLower(pattern)) { - return true - } - } - } - - return false -} diff --git a/internal/providers/factory_test.go b/internal/providers/factory_test.go deleted file mode 100644 index 3dee5c4..0000000 --- a/internal/providers/factory_test.go +++ /dev/null @@ -1,624 +0,0 @@ -package providers - -import ( - "strings" - "testing" -) - -// TestProviderFactory_NewProviderFactory tests the factory constructor -func TestProviderFactory_NewProviderFactory(t *testing.T) { - factory := NewProviderFactory() - - if factory == nil { - t.Fatal("Expected factory to be created, got nil") - } - - if factory.registry == nil { - t.Error("Expected registry to be initialized") - } -} - -// TestProviderFactory_CreateProvider tests provider creation by issuer URL -func TestProviderFactory_CreateProvider(t *testing.T) { - factory := NewProviderFactory() - - tests := []struct { - name string - issuerURL string - errMsg string - expectedType ProviderType - wantErr bool - }{ - { - name: "Google provider", - issuerURL: "https://accounts.google.com", - expectedType: ProviderTypeGoogle, - wantErr: false, - }, - { - name: "Google provider with path", - issuerURL: "https://accounts.google.com/oauth2", - expectedType: ProviderTypeGoogle, - wantErr: false, - }, - { - name: "Azure provider - login.microsoftonline.com", - issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0", - expectedType: ProviderTypeAzure, - wantErr: false, - }, - { - name: "Azure provider - sts.windows.net", - issuerURL: "https://sts.windows.net/tenant-id", - expectedType: ProviderTypeAzure, - wantErr: false, - }, - { - name: "GitHub provider", - issuerURL: "https://github.com/login/oauth", - expectedType: ProviderTypeGitHub, - wantErr: false, - }, - { - name: "Auth0 provider", - issuerURL: "https://tenant.auth0.com", - expectedType: ProviderTypeAuth0, - wantErr: false, - }, - { - name: "Okta provider", - issuerURL: "https://tenant.okta.com", - expectedType: ProviderTypeOkta, - wantErr: false, - }, - { - name: "Okta preview provider", - issuerURL: "https://tenant.oktapreview.com", - expectedType: ProviderTypeOkta, - wantErr: false, - }, - { - name: "Keycloak provider", - issuerURL: "https://auth.example.com/auth/realms/master", - expectedType: ProviderTypeKeycloak, - wantErr: false, - }, - { - name: "AWS Cognito provider", - issuerURL: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example", - expectedType: ProviderTypeAWSCognito, - wantErr: false, - }, - { - name: "GitLab provider", - issuerURL: "https://gitlab.com/oauth", - expectedType: ProviderTypeGitLab, - wantErr: false, - }, - { - name: "Generic provider", - issuerURL: "https://auth.example.com", - expectedType: ProviderTypeGeneric, - wantErr: false, - }, - { - name: "Empty issuer URL", - issuerURL: "", - wantErr: true, - errMsg: "issuer URL cannot be empty", - }, - { - name: "Invalid URL format", - issuerURL: "not-a-url", - wantErr: true, - errMsg: "invalid issuer URL format", - }, - { - name: "URL without scheme", - issuerURL: "example.com", - wantErr: true, - errMsg: "invalid issuer URL format", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := factory.CreateProvider(tt.issuerURL) - - if tt.wantErr { - if err == nil { - t.Error("Expected error but got none") - return - } - if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errMsg, err.Error()) - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.GetType() != tt.expectedType { - t.Errorf("Expected provider type %v, got %v", tt.expectedType, provider.GetType()) - } - }) - } -} - -// TestProviderFactory_CreateProviderByType tests provider creation by type -func TestProviderFactory_CreateProviderByType(t *testing.T) { - factory := NewProviderFactory() - - tests := []struct { - name string - errMsg string - providerType ProviderType - expectedType ProviderType - wantErr bool - }{ - { - name: "Generic provider", - providerType: ProviderTypeGeneric, - expectedType: ProviderTypeGeneric, - wantErr: false, - }, - { - name: "Google provider", - providerType: ProviderTypeGoogle, - expectedType: ProviderTypeGoogle, - wantErr: false, - }, - { - name: "Azure provider", - providerType: ProviderTypeAzure, - expectedType: ProviderTypeAzure, - wantErr: false, - }, - { - name: "GitHub provider", - providerType: ProviderTypeGitHub, - expectedType: ProviderTypeGitHub, - wantErr: false, - }, - { - name: "Auth0 provider", - providerType: ProviderTypeAuth0, - expectedType: ProviderTypeAuth0, - wantErr: false, - }, - { - name: "Okta provider", - providerType: ProviderTypeOkta, - expectedType: ProviderTypeOkta, - wantErr: false, - }, - { - name: "Keycloak provider", - providerType: ProviderTypeKeycloak, - expectedType: ProviderTypeKeycloak, - wantErr: false, - }, - { - name: "AWS Cognito provider", - providerType: ProviderTypeAWSCognito, - expectedType: ProviderTypeAWSCognito, - wantErr: false, - }, - { - name: "GitLab provider", - providerType: ProviderTypeGitLab, - expectedType: ProviderTypeGitLab, - wantErr: false, - }, - { - name: "Invalid provider type", - providerType: ProviderType(999), - wantErr: true, - errMsg: "unsupported provider type", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider, err := factory.CreateProviderByType(tt.providerType) - - if tt.wantErr { - if err == nil { - t.Error("Expected error but got none") - return - } - if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errMsg, err.Error()) - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.GetType() != tt.expectedType { - t.Errorf("Expected provider type %v, got %v", tt.expectedType, provider.GetType()) - } - }) - } -} - -// TestProviderFactory_GetSupportedProviders tests listing supported providers -func TestProviderFactory_GetSupportedProviders(t *testing.T) { - factory := NewProviderFactory() - supported := factory.GetSupportedProviders() - - // Verify expected provider types are present - expectedTypes := []ProviderType{ - ProviderTypeGeneric, - ProviderTypeGoogle, - ProviderTypeAzure, - } - - for _, expectedType := range expectedTypes { - if _, exists := supported[expectedType]; !exists { - t.Errorf("Expected provider type %v to be supported", expectedType) - } - } - - // Verify Google patterns - googlePatterns := supported[ProviderTypeGoogle] - if len(googlePatterns) != 1 || googlePatterns[0] != "accounts.google.com" { - t.Errorf("Expected Google patterns ['accounts.google.com'], got %v", googlePatterns) - } - - // Verify Azure patterns - azurePatterns := supported[ProviderTypeAzure] - expectedAzurePatterns := []string{"login.microsoftonline.com", "sts.windows.net"} - if len(azurePatterns) != len(expectedAzurePatterns) { - t.Errorf("Expected %d Azure patterns, got %d", len(expectedAzurePatterns), len(azurePatterns)) - } - - for _, expectedPattern := range expectedAzurePatterns { - found := false - for _, pattern := range azurePatterns { - if pattern == expectedPattern { - found = true - break - } - } - if !found { - t.Errorf("Expected Azure pattern '%s' not found", expectedPattern) - } - } - - // Verify Generic patterns - genericPatterns := supported[ProviderTypeGeneric] - if len(genericPatterns) != 1 || genericPatterns[0] != "*" { - t.Errorf("Expected Generic patterns ['*'], got %v", genericPatterns) - } -} - -// TestProviderFactory_DetectProviderType tests provider type detection -func TestProviderFactory_DetectProviderType(t *testing.T) { - factory := NewProviderFactory() - - tests := []struct { - name string - issuerURL string - expectedType ProviderType - wantErr bool - }{ - { - name: "Google provider detection", - issuerURL: "https://accounts.google.com", - expectedType: ProviderTypeGoogle, - wantErr: false, - }, - { - name: "Azure provider detection", - issuerURL: "https://login.microsoftonline.com/tenant/v2.0", - expectedType: ProviderTypeAzure, - wantErr: false, - }, - { - name: "Generic provider detection", - issuerURL: "https://auth.example.com", - expectedType: ProviderTypeGeneric, - wantErr: false, - }, - { - name: "Invalid URL", - issuerURL: "not-a-url", - wantErr: true, - }, - { - name: "Empty URL", - issuerURL: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - providerType, err := factory.DetectProviderType(tt.issuerURL) - - if tt.wantErr { - if err == nil { - t.Error("Expected error but got none") - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if providerType != tt.expectedType { - t.Errorf("Expected provider type %v, got %v", tt.expectedType, providerType) - } - }) - } -} - -// TestProviderFactory_IsProviderSupported tests provider support checking -func TestProviderFactory_IsProviderSupported(t *testing.T) { - factory := NewProviderFactory() - - tests := []struct { - name string - issuerURL string - expected bool - }{ - { - name: "Google provider supported", - issuerURL: "https://accounts.google.com", - expected: true, - }, - { - name: "Google provider with subdomain supported", - issuerURL: "https://accounts.google.com/oauth2", - expected: true, - }, - { - name: "Azure login.microsoftonline.com supported", - issuerURL: "https://login.microsoftonline.com/tenant/v2.0", - expected: true, - }, - { - name: "Azure sts.windows.net supported", - issuerURL: "https://sts.windows.net/tenant", - expected: true, - }, - { - name: "Generic provider supported (wildcard)", - issuerURL: "https://auth.example.com", - expected: true, - }, - { - name: "Any valid URL supported (wildcard)", - issuerURL: "https://custom-auth.company.org", - expected: true, - }, - { - name: "Empty URL not supported", - issuerURL: "", - expected: false, - }, - { - name: "Invalid URL format not supported", - issuerURL: "not-a-url", - expected: false, - }, - { - name: "URL without scheme not supported", - issuerURL: "example.com", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := factory.IsProviderSupported(tt.issuerURL) - - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -// TestProviderFactory_IntegrationTest tests the full flow -func TestProviderFactory_IntegrationTest(t *testing.T) { - factory := NewProviderFactory() - - // Test Google provider flow - t.Run("Google Provider Flow", func(t *testing.T) { - // Check if supported - if !factory.IsProviderSupported("https://accounts.google.com") { - t.Error("Google provider should be supported") - } - - // Detect type - providerType, err := factory.DetectProviderType("https://accounts.google.com") - if err != nil { - t.Errorf("Unexpected error detecting Google provider: %v", err) - } - if providerType != ProviderTypeGoogle { - t.Errorf("Expected ProviderTypeGoogle, got %v", providerType) - } - - // Create provider by URL - provider, err := factory.CreateProvider("https://accounts.google.com") - if err != nil { - t.Errorf("Unexpected error creating Google provider: %v", err) - } - if provider.GetType() != ProviderTypeGoogle { - t.Errorf("Expected ProviderTypeGoogle, got %v", provider.GetType()) - } - - // Create provider by type - provider2, err := factory.CreateProviderByType(ProviderTypeGoogle) - if err != nil { - t.Errorf("Unexpected error creating Google provider by type: %v", err) - } - if provider2.GetType() != ProviderTypeGoogle { - t.Errorf("Expected ProviderTypeGoogle, got %v", provider2.GetType()) - } - }) - - // Test Azure provider flow - t.Run("Azure Provider Flow", func(t *testing.T) { - azureURL := "https://login.microsoftonline.com/tenant/v2.0" - - // Check if supported - if !factory.IsProviderSupported(azureURL) { - t.Error("Azure provider should be supported") - } - - // Detect type - providerType, err := factory.DetectProviderType(azureURL) - if err != nil { - t.Errorf("Unexpected error detecting Azure provider: %v", err) - } - if providerType != ProviderTypeAzure { - t.Errorf("Expected ProviderTypeAzure, got %v", providerType) - } - - // Create provider - provider, err := factory.CreateProvider(azureURL) - if err != nil { - t.Errorf("Unexpected error creating Azure provider: %v", err) - } - if provider.GetType() != ProviderTypeAzure { - t.Errorf("Expected ProviderTypeAzure, got %v", provider.GetType()) - } - }) - - // Test Generic provider flow - t.Run("Generic Provider Flow", func(t *testing.T) { - genericURL := "https://auth.custom-provider.com" - - // Check if supported - if !factory.IsProviderSupported(genericURL) { - t.Error("Generic provider should be supported") - } - - // Detect type - providerType, err := factory.DetectProviderType(genericURL) - if err != nil { - t.Errorf("Unexpected error detecting generic provider: %v", err) - } - if providerType != ProviderTypeGeneric { - t.Errorf("Expected ProviderTypeGeneric, got %v", providerType) - } - - // Create provider - provider, err := factory.CreateProvider(genericURL) - if err != nil { - t.Errorf("Unexpected error creating generic provider: %v", err) - } - if provider.GetType() != ProviderTypeGeneric { - t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType()) - } - }) -} - -// TestProviderFactory_CaseInsensitiveHostMatching tests case insensitive host matching -func TestProviderFactory_CaseInsensitiveHostMatching(t *testing.T) { - factory := NewProviderFactory() - - tests := []struct { - name string - issuerURL string - expectedType ProviderType - }{ - { - name: "Google with uppercase", - issuerURL: "https://ACCOUNTS.GOOGLE.COM", - expectedType: ProviderTypeGoogle, - }, - { - name: "Google with mixed case", - issuerURL: "https://Accounts.Google.Com", - expectedType: ProviderTypeGoogle, - }, - { - name: "Azure with uppercase", - issuerURL: "https://LOGIN.MICROSOFTONLINE.COM/tenant", - expectedType: ProviderTypeAzure, - }, - { - name: "Azure STS with mixed case", - issuerURL: "https://Sts.Windows.Net/tenant", - expectedType: ProviderTypeAzure, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Should be supported - if !factory.IsProviderSupported(tt.issuerURL) { - t.Errorf("URL %s should be supported", tt.issuerURL) - } - - // Should detect correct type - providerType, err := factory.DetectProviderType(tt.issuerURL) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - if providerType != tt.expectedType { - t.Errorf("Expected %v, got %v", tt.expectedType, providerType) - } - - // Should create correct provider - provider, err := factory.CreateProvider(tt.issuerURL) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - if provider.GetType() != tt.expectedType { - t.Errorf("Expected %v, got %v", tt.expectedType, provider.GetType()) - } - }) - } -} - -// Benchmark tests -func BenchmarkProviderFactory_CreateProvider(b *testing.B) { - factory := NewProviderFactory() - issuerURL := "https://accounts.google.com" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - factory.CreateProvider(issuerURL) - } -} - -func BenchmarkProviderFactory_IsProviderSupported(b *testing.B) { - factory := NewProviderFactory() - issuerURL := "https://auth.example.com" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - factory.IsProviderSupported(issuerURL) - } -} - -func BenchmarkProviderFactory_DetectProviderType(b *testing.B) { - factory := NewProviderFactory() - issuerURL := "https://login.microsoftonline.com/tenant" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - factory.DetectProviderType(issuerURL) - } -} diff --git a/internal/providers/generic.go b/internal/providers/generic.go deleted file mode 100644 index 8ec3b37..0000000 --- a/internal/providers/generic.go +++ /dev/null @@ -1,18 +0,0 @@ -package providers - -// GenericProvider encapsulates standard OIDC logic for any compliant provider. -type GenericProvider struct { - *BaseProvider -} - -// NewGenericProvider creates a new instance of the GenericProvider. -func NewGenericProvider() *GenericProvider { - return &GenericProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *GenericProvider) GetType() ProviderType { - return ProviderTypeGeneric -} diff --git a/internal/providers/generic_test.go b/internal/providers/generic_test.go deleted file mode 100644 index c3d0560..0000000 --- a/internal/providers/generic_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package providers - -import ( - "testing" -) - -// TestGenericProvider_NewGenericProvider tests the constructor -func TestGenericProvider_NewGenericProvider(t *testing.T) { - provider := NewGenericProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestGenericProvider_GetType tests provider type -func TestGenericProvider_GetType(t *testing.T) { - provider := NewGenericProvider() - - if provider.GetType() != ProviderTypeGeneric { - t.Errorf("Expected ProviderTypeGeneric, got %v", provider.GetType()) - } -} - -// TestGenericProvider_GetCapabilities tests that it inherits BaseProvider capabilities -func TestGenericProvider_GetCapabilities(t *testing.T) { - provider := NewGenericProvider() - capabilities := provider.GetCapabilities() - - // Should have the same capabilities as BaseProvider - baseProvider := NewBaseProvider() - baseCapabilities := baseProvider.GetCapabilities() - - if capabilities.SupportsRefreshTokens != baseCapabilities.SupportsRefreshTokens { - t.Errorf("Expected SupportsRefreshTokens %v, got %v", - baseCapabilities.SupportsRefreshTokens, capabilities.SupportsRefreshTokens) - } - - if capabilities.RequiresOfflineAccessScope != baseCapabilities.RequiresOfflineAccessScope { - t.Errorf("Expected RequiresOfflineAccessScope %v, got %v", - baseCapabilities.RequiresOfflineAccessScope, capabilities.RequiresOfflineAccessScope) - } - - if capabilities.PreferredTokenValidation != baseCapabilities.PreferredTokenValidation { - t.Errorf("Expected PreferredTokenValidation %v, got %v", - baseCapabilities.PreferredTokenValidation, capabilities.PreferredTokenValidation) - } - - if capabilities.RequiresPromptConsent != baseCapabilities.RequiresPromptConsent { - t.Errorf("Expected RequiresPromptConsent %v, got %v", - baseCapabilities.RequiresPromptConsent, capabilities.RequiresPromptConsent) - } -} - -// TestGenericProvider_InterfaceCompliance tests that Generic provider implements OIDCProvider -func TestGenericProvider_InterfaceCompliance(t *testing.T) { - provider := NewGenericProvider() - - // Verify it implements the OIDCProvider interface - var _ OIDCProvider = provider -} - -// TestGenericProvider_InheritsBaseProviderBehavior tests inherited functionality -func TestGenericProvider_InheritsBaseProviderBehavior(t *testing.T) { - provider := NewGenericProvider() - baseProvider := NewBaseProvider() - - // Test BuildAuthParams behavior is the same - scopes := []string{"openid", "profile", "email"} - baseParams := make(map[string][]string) - baseParams["client_id"] = []string{"test-client"} - - genericResult, genericErr := provider.BuildAuthParams(baseParams, scopes) - baseResult, baseErr := baseProvider.BuildAuthParams(baseParams, scopes) - - if (genericErr == nil) != (baseErr == nil) { - t.Errorf("BuildAuthParams error mismatch: generic=%v, base=%v", genericErr, baseErr) - } - - if genericErr == nil && baseErr == nil { - // Compare scopes length (offline_access should be added) - if len(genericResult.Scopes) != len(baseResult.Scopes) { - t.Errorf("BuildAuthParams scope count mismatch: generic=%d, base=%d", - len(genericResult.Scopes), len(baseResult.Scopes)) - } - - // Verify offline_access is added in both cases - genericHasOffline := false - baseHasOffline := false - - for _, scope := range genericResult.Scopes { - if scope == "offline_access" { - genericHasOffline = true - break - } - } - - for _, scope := range baseResult.Scopes { - if scope == "offline_access" { - baseHasOffline = true - break - } - } - - if genericHasOffline != baseHasOffline { - t.Errorf("offline_access scope handling mismatch: generic=%v, base=%v", - genericHasOffline, baseHasOffline) - } - } - - // Test ValidateConfig behavior is the same - genericConfigErr := provider.ValidateConfig() - baseConfigErr := baseProvider.ValidateConfig() - - if (genericConfigErr == nil) != (baseConfigErr == nil) { - t.Errorf("ValidateConfig error mismatch: generic=%v, base=%v", genericConfigErr, baseConfigErr) - } - - // Test HandleTokenRefresh behavior is the same - tokenData := &TokenResult{IDToken: "test-token"} - genericRefreshErr := provider.HandleTokenRefresh(tokenData) - baseRefreshErr := baseProvider.HandleTokenRefresh(tokenData) - - if (genericRefreshErr == nil) != (baseRefreshErr == nil) { - t.Errorf("HandleTokenRefresh error mismatch: generic=%v, base=%v", - genericRefreshErr, baseRefreshErr) - } -} - -// TestGenericProvider_ValidateTokens tests token validation inheritance -func TestGenericProvider_ValidateTokens(t *testing.T) { - provider := NewGenericProvider() - - tests := []struct { - verifierError error - session *mockSession - name string - expectedResult ValidationResult - }{ - { - name: "Unauthenticated with refresh token", - session: &mockSession{ - authenticated: false, - refreshToken: "refresh-token", - }, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - { - name: "Authenticated with valid tokens", - session: &mockSession{ - authenticated: true, - idToken: "valid-token", - accessToken: "access-token", - refreshToken: "refresh-token", - }, - verifierError: nil, - expectedResult: ValidationResult{ - Authenticated: true, - NeedsRefresh: false, - IsExpired: false, - }, - }, - { - name: "Authenticated with invalid token, has refresh", - session: &mockSession{ - authenticated: true, - idToken: "invalid-token", - refreshToken: "refresh-token", - }, - verifierError: &testError{"token expired"}, - expectedResult: ValidationResult{ - Authenticated: false, - NeedsRefresh: true, - IsExpired: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier := &mockTokenVerifier{error: tt.verifierError} - cache := &mockTokenCache{ - claims: map[string]map[string]interface{}{ - "valid-token": { - "exp": float64(9999999999), // Far future - "sub": "user123", - }, - }, - } - - result, err := provider.ValidateTokens(tt.session, verifier, cache, 0) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if result.Authenticated != tt.expectedResult.Authenticated { - t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) - } - - if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { - t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) - } - - if result.IsExpired != tt.expectedResult.IsExpired { - t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) - } - }) - } -} - -// Benchmark tests -func BenchmarkGenericProvider_GetType(b *testing.B) { - provider := NewGenericProvider() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.GetType() - } -} - -func BenchmarkGenericProvider_GetCapabilities(b *testing.B) { - provider := NewGenericProvider() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.GetCapabilities() - } -} - -// Test error type for testing -type testError struct { - message string -} - -func (e *testError) Error() string { - return e.message -} diff --git a/internal/providers/github.go b/internal/providers/github.go deleted file mode 100644 index 49f2151..0000000 --- a/internal/providers/github.go +++ /dev/null @@ -1,61 +0,0 @@ -package providers - -import ( - "net/url" -) - -// GitHubProvider encapsulates GitHub-specific OIDC logic. -type GitHubProvider struct { - *BaseProvider -} - -// NewGitHubProvider creates a new instance of the GitHubProvider. -func NewGitHubProvider() *GitHubProvider { - return &GitHubProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *GitHubProvider) GetType() ProviderType { - return ProviderTypeGitHub -} - -// GetCapabilities returns the specific capabilities of the GitHub provider. -// WARNING: GitHub does NOT support OpenID Connect - it's OAuth 2.0 only. -// This provider should only be used for OAuth flows, not OIDC authentication. -func (p *GitHubProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: false, // GitHub OAuth apps don't support refresh tokens - RequiresOfflineAccessScope: false, // GitHub doesn't use offline_access - RequiresPromptConsent: false, - PreferredTokenValidation: "access", // GitHub only provides access tokens, no ID tokens - } -} - -// BuildAuthParams configures GitHub-specific authentication parameters. -func (p *GitHubProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // GitHub doesn't use offline_access scope, so remove it if present - var filteredScopes []string - for _, scope := range scopes { - if scope != ScopeOfflineAccess { - filteredScopes = append(filteredScopes, scope) - } - } - - // If no scopes specified, use default GitHub scopes for OAuth - // Note: GitHub doesn't support 'openid' scope as it's not an OIDC provider - if len(filteredScopes) == 0 { - filteredScopes = []string{"user:email", "read:user"} - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(filteredScopes), - }, nil -} - -// GitHub requires specific configuration for proper operation. -func (p *GitHubProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/github_test.go b/internal/providers/github_test.go deleted file mode 100644 index 385cee3..0000000 --- a/internal/providers/github_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestGitHubProvider_NewGitHubProvider tests the constructor -func TestGitHubProvider_NewGitHubProvider(t *testing.T) { - provider := NewGitHubProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestGitHubProvider_GetType tests provider type -func TestGitHubProvider_GetType(t *testing.T) { - provider := NewGitHubProvider() - - if provider.GetType() != ProviderTypeGitHub { - t.Errorf("Expected ProviderTypeGitHub, got %v", provider.GetType()) - } -} - -// TestGitHubProvider_GetCapabilities tests GitHub-specific capabilities -func TestGitHubProvider_GetCapabilities(t *testing.T) { - provider := NewGitHubProvider() - capabilities := provider.GetCapabilities() - - if capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be false for GitHub") - } - - if capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be false for GitHub") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for GitHub") - } - - if capabilities.PreferredTokenValidation != "access" { - t.Errorf("Expected PreferredTokenValidation 'access', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestGitHubProvider_BuildAuthParams tests GitHub-specific auth params -func TestGitHubProvider_BuildAuthParams(t *testing.T) { - provider := NewGitHubProvider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Remove offline_access scope", - scopes: []string{"user:email", "offline_access", "read:user"}, - expectedScopes: []string{"user:email", "read:user"}, - }, - { - name: "Default scopes when none provided", - scopes: []string{}, - expectedScopes: []string{"user:email", "read:user"}, - }, - { - name: "Keep other scopes", - scopes: []string{"user", "repo"}, - expectedScopes: []string{"user", "repo"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(authParams.Scopes)) - return - } - - for i, scope := range tt.expectedScopes { - if authParams.Scopes[i] != scope { - t.Errorf("Expected scope '%s', got '%s'", scope, authParams.Scopes[i]) - } - } - }) - } -} - -// TestGitHubProvider_ValidateConfig tests config validation -func TestGitHubProvider_ValidateConfig(t *testing.T) { - provider := NewGitHubProvider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} diff --git a/internal/providers/gitlab.go b/internal/providers/gitlab.go deleted file mode 100644 index d59c348..0000000 --- a/internal/providers/gitlab.go +++ /dev/null @@ -1,73 +0,0 @@ -package providers - -import ( - "net/url" -) - -// GitLabProvider encapsulates GitLab-specific OIDC logic. -type GitLabProvider struct { - *BaseProvider -} - -// NewGitLabProvider creates a new instance of the GitLabProvider. -func NewGitLabProvider() *GitLabProvider { - return &GitLabProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *GitLabProvider) GetType() ProviderType { - return ProviderTypeGitLab -} - -// GetCapabilities returns the specific capabilities of the GitLab provider. -func (p *GitLabProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: false, // GitLab doesn't use offline_access scope - RequiresPromptConsent: false, - PreferredTokenValidation: "id", // GitLab typically uses ID tokens - } -} - -// BuildAuthParams configures GitLab-specific authentication parameters. -func (p *GitLabProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // GitLab supports standard OAuth 2.0 parameters - baseParams.Set("response_type", "code") - - // Remove offline_access scope as GitLab doesn't use it - var filteredScopes []string - for _, scope := range scopes { - if scope != ScopeOfflineAccess { - filteredScopes = append(filteredScopes, scope) - } - } - - // Ensure openid scope is present for OIDC - hasOpenID := false - for _, scope := range filteredScopes { - if scope == ScopeOpenID { - hasOpenID = true - break - } - } - if !hasOpenID { - filteredScopes = append(filteredScopes, ScopeOpenID) - } - - // Default GitLab scopes if none specified - if len(filteredScopes) == 1 && filteredScopes[0] == ScopeOpenID { - filteredScopes = append(filteredScopes, ScopeProfile, ScopeEmail) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(filteredScopes), - }, nil -} - -// GitLab requires application configuration and proper redirect URIs. -func (p *GitLabProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/gitlab_test.go b/internal/providers/gitlab_test.go deleted file mode 100644 index 233a39f..0000000 --- a/internal/providers/gitlab_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestGitLabProvider_NewGitLabProvider tests the constructor -func TestGitLabProvider_NewGitLabProvider(t *testing.T) { - provider := NewGitLabProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestGitLabProvider_GetType tests provider type -func TestGitLabProvider_GetType(t *testing.T) { - provider := NewGitLabProvider() - - if provider.GetType() != ProviderTypeGitLab { - t.Errorf("Expected ProviderTypeGitLab, got %v", provider.GetType()) - } -} - -// TestGitLabProvider_GetCapabilities tests GitLab-specific capabilities -func TestGitLabProvider_GetCapabilities(t *testing.T) { - provider := NewGitLabProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true for GitLab") - } - - if capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be false for GitLab") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for GitLab") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestGitLabProvider_BuildAuthParams tests GitLab-specific auth params -func TestGitLabProvider_BuildAuthParams(t *testing.T) { - provider := NewGitLabProvider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Remove offline_access scope and ensure openid", - scopes: []string{"read_user", "read_api", "offline_access"}, - expectedScopes: []string{"read_user", "read_api", "openid"}, - }, - { - name: "Keep existing openid, remove offline_access", - scopes: []string{"openid", "read_user", "offline_access", "profile"}, - expectedScopes: []string{"openid", "read_user", "profile"}, - }, - { - name: "Add default scopes when only openid", - scopes: []string{"openid"}, - expectedScopes: []string{"openid", "profile", "email"}, - }, - { - name: "Add openid and defaults when empty", - scopes: []string{}, - expectedScopes: []string{"openid", "profile", "email"}, - }, - { - name: "GitLab-specific scopes", - scopes: []string{"read_user", "read_api", "read_repository"}, - expectedScopes: []string{"read_user", "read_api", "read_repository", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Check that response_type is set - if authParams.URLValues.Get("response_type") != "code" { - t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type")) - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v", - len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes) - return - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - - // Ensure offline_access is NOT present - for _, actualScope := range authParams.Scopes { - if actualScope == "offline_access" { - t.Error("offline_access scope should be filtered out for GitLab") - } - } - }) - } -} - -// TestGitLabProvider_ValidateConfig tests config validation -func TestGitLabProvider_ValidateConfig(t *testing.T) { - provider := NewGitLabProvider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} - -// TestGitLabProvider_InterfaceCompliance tests that GitLab provider implements the OIDCProvider interface -func TestGitLabProvider_InterfaceCompliance(t *testing.T) { - var _ OIDCProvider = NewGitLabProvider() -} - -// TestGitLabProvider_BaseProviderInheritance tests that GitLab provider inherits from BaseProvider correctly -func TestGitLabProvider_BaseProviderInheritance(t *testing.T) { - provider := NewGitLabProvider() - - // Test that it has access to BaseProvider methods - if provider.BaseProvider == nil { - t.Error("Expected BaseProvider to be initialized") - } - - // Test HandleTokenRefresh (inherited from BaseProvider) - err := provider.HandleTokenRefresh(&TokenResult{ - IDToken: "test-id-token", - AccessToken: "test-access-token", - RefreshToken: "test-refresh-token", - }) - if err != nil { - t.Errorf("HandleTokenRefresh failed: %v", err) - } -} - -// TestGitLabProvider_OfflineAccessFiltering tests that offline_access scope is always filtered out -func TestGitLabProvider_OfflineAccessFiltering(t *testing.T) { - provider := NewGitLabProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - }{ - { - name: "Single offline_access", - scopes: []string{"offline_access"}, - }, - { - name: "Multiple offline_access occurrences", - scopes: []string{"offline_access", "read_user", "offline_access", "profile"}, - }, - { - name: "Mixed with other scopes", - scopes: []string{"read_api", "offline_access", "read_user"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Ensure offline_access is NOT present - for _, actualScope := range authParams.Scopes { - if actualScope == "offline_access" { - t.Error("offline_access scope should be filtered out for GitLab") - } - } - }) - } -} - -// TestGitLabProvider_GitLabSpecificScopes tests GitLab-specific scopes -func TestGitLabProvider_GitLabSpecificScopes(t *testing.T) { - provider := NewGitLabProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - checkFor []string - }{ - { - name: "GitLab API scopes", - scopes: []string{"read_api", "read_user"}, - checkFor: []string{"read_api", "read_user", "openid"}, - }, - { - name: "GitLab repository scopes", - scopes: []string{"read_repository", "write_repository"}, - checkFor: []string{"read_repository", "write_repository", "openid"}, - }, - { - name: "GitLab admin scopes", - scopes: []string{"api", "sudo"}, - checkFor: []string{"api", "sudo", "openid"}, - }, - { - name: "GitLab registry scopes", - scopes: []string{"read_registry", "write_registry"}, - checkFor: []string{"read_registry", "write_registry", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - for _, expectedScope := range tt.checkFor { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestGitLabProvider_DefaultScopeHandling tests default scope behavior -func TestGitLabProvider_DefaultScopeHandling(t *testing.T) { - provider := NewGitLabProvider() - baseParams := url.Values{} - - // Test with only openid scope - should add defaults - authParams, err := provider.BuildAuthParams(baseParams, []string{"openid"}) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - expectedScopes := []string{"openid", "profile", "email"} - if len(authParams.Scopes) != len(expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(expectedScopes), len(authParams.Scopes)) - return - } - - for _, expectedScope := range expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected default scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } -} - -// TestGitLabProvider_ScopeDeduplication tests that duplicate scopes are handled correctly -func TestGitLabProvider_ScopeDeduplication(t *testing.T) { - provider := NewGitLabProvider() - baseParams := url.Values{} - - // Test with duplicate scopes - scopes := []string{"openid", "read_user", "openid", "profile", "read_user"} - authParams, err := provider.BuildAuthParams(baseParams, scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Count occurrences of each scope - scopeCounts := make(map[string]int) - for _, scope := range authParams.Scopes { - scopeCounts[scope]++ - } - - // Check that no scope appears more than once - for scope, count := range scopeCounts { - if count > 1 { - t.Errorf("Scope '%s' appears %d times, expected 1", scope, count) - } - } -} diff --git a/internal/providers/google.go b/internal/providers/google.go deleted file mode 100644 index 2395439..0000000 --- a/internal/providers/google.go +++ /dev/null @@ -1,56 +0,0 @@ -package providers - -import ( - "net/url" -) - -// GoogleProvider encapsulates Google-specific OIDC logic. -type GoogleProvider struct { - *BaseProvider -} - -// NewGoogleProvider creates a new instance of the GoogleProvider. -func NewGoogleProvider() *GoogleProvider { - return &GoogleProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *GoogleProvider) GetType() ProviderType { - return ProviderTypeGoogle -} - -// GetCapabilities returns the specific capabilities of the Google provider. -func (p *GoogleProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, // Google DOES support refresh tokens - RequiresOfflineAccessScope: false, // Google uses access_type=offline instead - RequiresPromptConsent: true, - PreferredTokenValidation: "id", - } -} - -// BuildAuthParams configures Google-specific authentication parameters. -func (p *GoogleProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - baseParams.Set("access_type", "offline") - baseParams.Set("prompt", "consent") - - // Google does not use the ScopeOfflineAccess scope, so we remove it if present. - var filteredScopes []string - for _, scope := range scopes { - if scope != ScopeOfflineAccess { - filteredScopes = append(filteredScopes, scope) - } - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(filteredScopes), - }, nil -} - -// Google requires specific scopes and client configuration for proper operation. -func (p *GoogleProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/google_test.go b/internal/providers/google_test.go deleted file mode 100644 index e8a5967..0000000 --- a/internal/providers/google_test.go +++ /dev/null @@ -1,350 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestGoogleProvider_NewGoogleProvider tests the constructor -func TestGoogleProvider_NewGoogleProvider(t *testing.T) { - provider := NewGoogleProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestGoogleProvider_GetType tests provider type -func TestGoogleProvider_GetType(t *testing.T) { - provider := NewGoogleProvider() - - if provider.GetType() != ProviderTypeGoogle { - t.Errorf("Expected ProviderTypeGoogle, got %v", provider.GetType()) - } -} - -// TestGoogleProvider_GetCapabilities tests Google-specific capabilities -func TestGoogleProvider_GetCapabilities(t *testing.T) { - provider := NewGoogleProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true") - } - - if capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be false for Google") - } - - if !capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be true for Google") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestGoogleProvider_BuildAuthParams tests Google-specific auth parameters -func TestGoogleProvider_BuildAuthParams(t *testing.T) { - provider := NewGoogleProvider() - - tests := []struct { - name string - inputScopes []string - expectedScopes []string - shouldHaveAccessType bool - shouldHavePrompt bool - }{ - { - name: "Basic scopes without offline_access", - inputScopes: []string{"openid", "profile", "email"}, - expectedScopes: []string{"openid", "profile", "email"}, - shouldHaveAccessType: true, - shouldHavePrompt: true, - }, - { - name: "Scopes with offline_access (should be filtered out)", - inputScopes: []string{"openid", "profile", "offline_access", "email"}, - expectedScopes: []string{"openid", "profile", "email"}, - shouldHaveAccessType: true, - shouldHavePrompt: true, - }, - { - name: "Only offline_access scope (should be filtered out)", - inputScopes: []string{"offline_access"}, - expectedScopes: []string{}, - shouldHaveAccessType: true, - shouldHavePrompt: true, - }, - { - name: "Empty scopes", - inputScopes: []string{}, - expectedScopes: []string{}, - shouldHaveAccessType: true, - shouldHavePrompt: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - - result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Check Google-specific parameters - if tt.shouldHaveAccessType { - if result.URLValues.Get("access_type") != "offline" { - t.Errorf("Expected access_type 'offline', got '%s'", result.URLValues.Get("access_type")) - } - } - - if tt.shouldHavePrompt { - if result.URLValues.Get("prompt") != "consent" { - t.Errorf("Expected prompt 'consent', got '%s'", result.URLValues.Get("prompt")) - } - } - - // Check filtered scopes - if len(result.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes)) - } - - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range result.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in result", expectedScope) - } - } - - // Ensure offline_access is not in the result scopes - for _, scope := range result.Scopes { - if scope == "offline_access" { - t.Error("offline_access scope should be filtered out for Google") - } - } - - // Verify original base parameters are preserved - if result.URLValues.Get("client_id") != "test-client" { - t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id")) - } - }) - } -} - -// TestGoogleProvider_ValidateConfig tests configuration validation -func TestGoogleProvider_ValidateConfig(t *testing.T) { - provider := NewGoogleProvider() - - err := provider.ValidateConfig() - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } -} - -// TestGoogleProvider_InterfaceCompliance tests that Google provider implements OIDCProvider -func TestGoogleProvider_InterfaceCompliance(t *testing.T) { - provider := NewGoogleProvider() - - // Verify it implements the OIDCProvider interface - var _ OIDCProvider = provider -} - -// TestGoogleProvider_OfflineAccessFiltering tests comprehensive offline_access filtering -func TestGoogleProvider_OfflineAccessFiltering(t *testing.T) { - provider := NewGoogleProvider() - - tests := []struct { - name string - description string - inputScopes []string - }{ - { - name: "Multiple offline_access occurrences", - inputScopes: []string{"openid", "offline_access", "profile", "offline_access", "email"}, - description: "Should remove all instances of offline_access", - }, - { - name: "Case sensitive filtering", - inputScopes: []string{"openid", "OFFLINE_ACCESS", "profile", "offline_access"}, - description: "Should only remove exact case matches", - }, - { - name: "Similar but different scopes", - inputScopes: []string{"openid", "offline_access_extended", "profile", "offline_access"}, - description: "Should only remove exact offline_access matches", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - baseParams := make(url.Values) - result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) - - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Count offline_access occurrences in result - offlineAccessCount := 0 - for _, scope := range result.Scopes { - if scope == "offline_access" { - offlineAccessCount++ - } - } - - if offlineAccessCount != 0 { - t.Errorf("Expected 0 offline_access scopes in result, got %d", offlineAccessCount) - } - - // Verify other scopes are preserved - for _, originalScope := range tt.inputScopes { - if originalScope == "offline_access" { - continue // Skip the filtered scope - } - - found := false - for _, resultScope := range result.Scopes { - if resultScope == originalScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' to be preserved", originalScope) - } - } - }) - } -} - -// TestGoogleProvider_BaseProviderInheritance tests inherited functionality from BaseProvider -func TestGoogleProvider_BaseProviderInheritance(t *testing.T) { - provider := NewGoogleProvider() - - // Test ValidateTokens (inherited from BaseProvider) - session := &mockSession{ - authenticated: true, - idToken: "test-token", - accessToken: "access-token", // Add access token for proper validation - } - verifier := &mockTokenVerifier{} - cache := &mockTokenCache{ - claims: map[string]map[string]interface{}{ - "test-token": { - "exp": float64(9999999999), // Far future - "sub": "user123", - }, - }, - } - - result, err := provider.ValidateTokens(session, verifier, cache, 0) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if !result.Authenticated { - t.Error("Expected result to be authenticated") - } - - // Test HandleTokenRefresh (inherited from BaseProvider) - tokenData := &TokenResult{IDToken: "new-token"} - err = provider.HandleTokenRefresh(tokenData) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } -} - -// TestGoogleProvider_AuthParamsPreservation tests that base parameters are not overwritten -func TestGoogleProvider_AuthParamsPreservation(t *testing.T) { - provider := NewGoogleProvider() - - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - baseParams.Set("redirect_uri", "https://example.com/callback") - baseParams.Set("response_type", "code") - baseParams.Set("state", "test-state") - baseParams.Set("nonce", "test-nonce") - - scopes := []string{"openid", "profile"} - - result, err := provider.BuildAuthParams(baseParams, scopes) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - // Verify all original parameters are preserved - expectedParams := map[string]string{ - "client_id": "test-client", - "redirect_uri": "https://example.com/callback", - "response_type": "code", - "state": "test-state", - "nonce": "test-nonce", - "access_type": "offline", // Added by Google provider - "prompt": "consent", // Added by Google provider - } - - for key, expectedValue := range expectedParams { - actualValue := result.URLValues.Get(key) - if actualValue != expectedValue { - t.Errorf("Expected %s '%s', got '%s'", key, expectedValue, actualValue) - } - } - - // Verify scopes - if len(result.Scopes) != 2 { - t.Errorf("Expected 2 scopes, got %d", len(result.Scopes)) - } - - expectedScopes := []string{"openid", "profile"} - for _, expectedScope := range expectedScopes { - found := false - for _, actualScope := range result.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found", expectedScope) - } - } -} - -// Benchmark tests -func BenchmarkGoogleProvider_BuildAuthParams(b *testing.B) { - provider := NewGoogleProvider() - baseParams := make(url.Values) - baseParams.Set("client_id", "test-client") - scopes := []string{"openid", "profile", "email", "offline_access"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.BuildAuthParams(baseParams, scopes) - } -} - -func BenchmarkGoogleProvider_GetCapabilities(b *testing.B) { - provider := NewGoogleProvider() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider.GetCapabilities() - } -} diff --git a/internal/providers/interfaces.go b/internal/providers/interfaces.go deleted file mode 100644 index af36f95..0000000 --- a/internal/providers/interfaces.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package providers implements a universal OIDC provider abstraction system. -// It provides a clean interface for different OIDC providers (Google, Azure, Generic) -// with provider-specific logic encapsulated in separate implementations. -package providers - -import ( - "net/url" - "time" -) - -// TokenVerifier defines the interface for token verification. -type TokenVerifier interface { - VerifyToken(token string) error -} - -// TokenCache defines the interface for a token cache. -type TokenCache interface { - Get(key string) (map[string]interface{}, bool) -} - -// ProviderType is an enumeration for identifying different OIDC providers. -type ProviderType int - -const ( - ProviderTypeGeneric ProviderType = iota - ProviderTypeGoogle - ProviderTypeAzure - ProviderTypeGitHub - ProviderTypeAuth0 - ProviderTypeOkta - ProviderTypeKeycloak - ProviderTypeAWSCognito - ProviderTypeGitLab -) - -// Standard OAuth2/OIDC scope constants -const ( - ScopeOfflineAccess = "offline_access" - ScopeOpenID = "openid" - ScopeProfile = "profile" - ScopeEmail = "email" -) - -// ProviderCapabilities defines the specific features and behaviors of an OIDC provider. -type ProviderCapabilities struct { - PreferredTokenValidation string - SupportsRefreshTokens bool - RequiresOfflineAccessScope bool - RequiresPromptConsent bool -} - -// ValidationResult holds the outcome of a token validation check. -type ValidationResult struct { - Authenticated bool - NeedsRefresh bool - IsExpired bool -} - -// AuthParams contains the provider-specific parameters for building the authorization URL. -type AuthParams struct { - URLValues url.Values - Scopes []string -} - -// TokenResult holds the tokens returned by the provider. -type TokenResult struct { - IDToken string - AccessToken string - RefreshToken string -} - -// This abstraction allows for provider-specific logic to be encapsulated. -type OIDCProvider interface { - GetType() ProviderType - - GetCapabilities() ProviderCapabilities - - ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) - - BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) - - HandleTokenRefresh(tokenData *TokenResult) error - - ValidateConfig() error -} - -// This interface decouples the providers from the main session management implementation. -type Session interface { - GetIDToken() string - GetAccessToken() string - GetRefreshToken() string - GetAuthenticated() bool -} diff --git a/internal/providers/keycloak.go b/internal/providers/keycloak.go deleted file mode 100644 index ee483d8..0000000 --- a/internal/providers/keycloak.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "net/url" -) - -// KeycloakProvider encapsulates Keycloak-specific OIDC logic. -type KeycloakProvider struct { - *BaseProvider -} - -// NewKeycloakProvider creates a new instance of the KeycloakProvider. -func NewKeycloakProvider() *KeycloakProvider { - return &KeycloakProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *KeycloakProvider) GetType() ProviderType { - return ProviderTypeKeycloak -} - -// GetCapabilities returns the specific capabilities of the Keycloak provider. -func (p *KeycloakProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: true, - RequiresPromptConsent: false, - PreferredTokenValidation: "id", // Keycloak typically uses ID tokens - } -} - -// BuildAuthParams configures Keycloak-specific authentication parameters. -func (p *KeycloakProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // Keycloak supports standard OIDC parameters - baseParams.Set("response_type", "code") - - // Ensure offline_access scope is present for refresh tokens - hasOfflineAccess := false - for _, scope := range scopes { - if scope == ScopeOfflineAccess { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, ScopeOfflineAccess) - } - - // Ensure openid scope is present - hasOpenID := false - for _, scope := range scopes { - if scope == ScopeOpenID { - hasOpenID = true - break - } - } - if !hasOpenID { - scopes = append(scopes, ScopeOpenID) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(scopes), - }, nil -} - -// Keycloak requires realm and server configuration. -func (p *KeycloakProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/keycloak_test.go b/internal/providers/keycloak_test.go deleted file mode 100644 index be72e73..0000000 --- a/internal/providers/keycloak_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestKeycloakProvider_NewKeycloakProvider tests the constructor -func TestKeycloakProvider_NewKeycloakProvider(t *testing.T) { - provider := NewKeycloakProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestKeycloakProvider_GetType tests provider type -func TestKeycloakProvider_GetType(t *testing.T) { - provider := NewKeycloakProvider() - - if provider.GetType() != ProviderTypeKeycloak { - t.Errorf("Expected ProviderTypeKeycloak, got %v", provider.GetType()) - } -} - -// TestKeycloakProvider_GetCapabilities tests Keycloak-specific capabilities -func TestKeycloakProvider_GetCapabilities(t *testing.T) { - provider := NewKeycloakProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true for Keycloak") - } - - if !capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be true for Keycloak") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for Keycloak") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestKeycloakProvider_BuildAuthParams tests Keycloak-specific auth params -func TestKeycloakProvider_BuildAuthParams(t *testing.T) { - provider := NewKeycloakProvider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Add offline_access and openid scopes", - scopes: []string{"roles", "groups"}, - expectedScopes: []string{"roles", "groups", "offline_access", "openid"}, - }, - { - name: "Keep existing offline_access and openid", - scopes: []string{"openid", "roles", "offline_access", "groups"}, - expectedScopes: []string{"openid", "roles", "offline_access", "groups"}, - }, - { - name: "Add both scopes when none provided", - scopes: []string{}, - expectedScopes: []string{"offline_access", "openid"}, - }, - { - name: "Keycloak custom scopes", - scopes: []string{"realm-roles", "account"}, - expectedScopes: []string{"realm-roles", "account", "offline_access", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Check that response_type is set - if authParams.URLValues.Get("response_type") != "code" { - t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type")) - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v", - len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes) - return - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestKeycloakProvider_ValidateConfig tests config validation -func TestKeycloakProvider_ValidateConfig(t *testing.T) { - provider := NewKeycloakProvider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} - -// TestKeycloakProvider_InterfaceCompliance tests that Keycloak provider implements the OIDCProvider interface -func TestKeycloakProvider_InterfaceCompliance(t *testing.T) { - var _ OIDCProvider = NewKeycloakProvider() -} - -// TestKeycloakProvider_BaseProviderInheritance tests that Keycloak provider inherits from BaseProvider correctly -func TestKeycloakProvider_BaseProviderInheritance(t *testing.T) { - provider := NewKeycloakProvider() - - // Test that it has access to BaseProvider methods - if provider.BaseProvider == nil { - t.Error("Expected BaseProvider to be initialized") - } - - // Test HandleTokenRefresh (inherited from BaseProvider) - err := provider.HandleTokenRefresh(&TokenResult{ - IDToken: "test-id-token", - AccessToken: "test-access-token", - RefreshToken: "test-refresh-token", - }) - if err != nil { - t.Errorf("HandleTokenRefresh failed: %v", err) - } -} - -// TestKeycloakProvider_RealmSpecificScopes tests Keycloak realm-specific scopes -func TestKeycloakProvider_RealmSpecificScopes(t *testing.T) { - provider := NewKeycloakProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - checkFor []string - }{ - { - name: "Keycloak standard scopes", - scopes: []string{"roles", "groups", "profile", "email"}, - checkFor: []string{"roles", "groups", "profile", "email", "offline_access", "openid"}, - }, - { - name: "Keycloak realm roles", - scopes: []string{"realm-roles", "client-roles"}, - checkFor: []string{"realm-roles", "client-roles", "offline_access", "openid"}, - }, - { - name: "Keycloak account service", - scopes: []string{"account"}, - checkFor: []string{"account", "offline_access", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - for _, expectedScope := range tt.checkFor { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestKeycloakProvider_ScopeDeduplication tests that duplicate scopes are handled correctly -func TestKeycloakProvider_ScopeDeduplication(t *testing.T) { - provider := NewKeycloakProvider() - baseParams := url.Values{} - - // Test with duplicate scopes - scopes := []string{"openid", "profile", "offline_access", "roles", "openid", "profile"} - authParams, err := provider.BuildAuthParams(baseParams, scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Count occurrences of each scope - scopeCounts := make(map[string]int) - for _, scope := range authParams.Scopes { - scopeCounts[scope]++ - } - - // Check that no scope appears more than once - for scope, count := range scopeCounts { - if count > 1 { - t.Errorf("Scope '%s' appears %d times, expected 1", scope, count) - } - } -} diff --git a/internal/providers/okta.go b/internal/providers/okta.go deleted file mode 100644 index 6bd41c8..0000000 --- a/internal/providers/okta.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "net/url" -) - -// OktaProvider encapsulates Okta-specific OIDC logic. -type OktaProvider struct { - *BaseProvider -} - -// NewOktaProvider creates a new instance of the OktaProvider. -func NewOktaProvider() *OktaProvider { - return &OktaProvider{ - BaseProvider: NewBaseProvider(), - } -} - -// GetType returns the provider's type. -func (p *OktaProvider) GetType() ProviderType { - return ProviderTypeOkta -} - -// GetCapabilities returns the specific capabilities of the Okta provider. -func (p *OktaProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{ - SupportsRefreshTokens: true, - RequiresOfflineAccessScope: true, - RequiresPromptConsent: false, - PreferredTokenValidation: "id", // Okta primarily uses ID tokens - } -} - -// BuildAuthParams configures Okta-specific authentication parameters. -func (p *OktaProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - // Okta supports various response types - baseParams.Set("response_type", "code") - - // Ensure offline_access scope is present for refresh tokens - hasOfflineAccess := false - for _, scope := range scopes { - if scope == ScopeOfflineAccess { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, ScopeOfflineAccess) - } - - // Ensure openid scope is present - hasOpenID := false - for _, scope := range scopes { - if scope == ScopeOpenID { - hasOpenID = true - break - } - } - if !hasOpenID { - scopes = append(scopes, ScopeOpenID) - } - - return &AuthParams{ - URLValues: baseParams, - Scopes: deduplicateScopes(scopes), - }, nil -} - -// Okta requires specific domain configuration and application setup. -func (p *OktaProvider) ValidateConfig() error { - return p.BaseProvider.ValidateConfig() -} diff --git a/internal/providers/okta_test.go b/internal/providers/okta_test.go deleted file mode 100644 index 8cf7f9b..0000000 --- a/internal/providers/okta_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package providers - -import ( - "net/url" - "testing" -) - -// TestOktaProvider_NewOktaProvider tests the constructor -func TestOktaProvider_NewOktaProvider(t *testing.T) { - provider := NewOktaProvider() - - if provider == nil { - t.Fatal("Expected provider to be created, got nil") - } - - if provider.BaseProvider == nil { - t.Error("BaseProvider should be initialized") - } -} - -// TestOktaProvider_GetType tests provider type -func TestOktaProvider_GetType(t *testing.T) { - provider := NewOktaProvider() - - if provider.GetType() != ProviderTypeOkta { - t.Errorf("Expected ProviderTypeOkta, got %v", provider.GetType()) - } -} - -// TestOktaProvider_GetCapabilities tests Okta-specific capabilities -func TestOktaProvider_GetCapabilities(t *testing.T) { - provider := NewOktaProvider() - capabilities := provider.GetCapabilities() - - if !capabilities.SupportsRefreshTokens { - t.Error("Expected SupportsRefreshTokens to be true for Okta") - } - - if !capabilities.RequiresOfflineAccessScope { - t.Error("Expected RequiresOfflineAccessScope to be true for Okta") - } - - if capabilities.RequiresPromptConsent { - t.Error("Expected RequiresPromptConsent to be false for Okta") - } - - if capabilities.PreferredTokenValidation != "id" { - t.Errorf("Expected PreferredTokenValidation 'id', got '%s'", capabilities.PreferredTokenValidation) - } -} - -// TestOktaProvider_BuildAuthParams tests Okta-specific auth params -func TestOktaProvider_BuildAuthParams(t *testing.T) { - provider := NewOktaProvider() - baseParams := url.Values{} - baseParams.Set("client_id", "test_client") - - tests := []struct { - name string - scopes []string - expectedScopes []string - }{ - { - name: "Add offline_access and openid scopes", - scopes: []string{"groups", "profile"}, - expectedScopes: []string{"groups", "profile", "offline_access", "openid"}, - }, - { - name: "Keep existing offline_access and openid", - scopes: []string{"openid", "groups", "offline_access", "profile"}, - expectedScopes: []string{"openid", "groups", "offline_access", "profile"}, - }, - { - name: "Add both scopes when none provided", - scopes: []string{}, - expectedScopes: []string{"offline_access", "openid"}, - }, - { - name: "Add openid when only offline_access present", - scopes: []string{"offline_access"}, - expectedScopes: []string{"offline_access", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - // Check that response_type is set - if authParams.URLValues.Get("response_type") != "code" { - t.Errorf("Expected response_type 'code', got '%s'", authParams.URLValues.Get("response_type")) - } - - if len(authParams.Scopes) != len(tt.expectedScopes) { - t.Errorf("Expected %d scopes, got %d. Expected: %v, Got: %v", - len(tt.expectedScopes), len(authParams.Scopes), tt.expectedScopes, authParams.Scopes) - return - } - - // Check that all expected scopes are present - for _, expectedScope := range tt.expectedScopes { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} - -// TestOktaProvider_ValidateConfig tests config validation -func TestOktaProvider_ValidateConfig(t *testing.T) { - provider := NewOktaProvider() - - err := provider.ValidateConfig() - if err != nil { - t.Errorf("ValidateConfig failed: %v", err) - } -} - -// TestOktaProvider_InterfaceCompliance tests that Okta provider implements the OIDCProvider interface -func TestOktaProvider_InterfaceCompliance(t *testing.T) { - var _ OIDCProvider = NewOktaProvider() -} - -// TestOktaProvider_BaseProviderInheritance tests that Okta provider inherits from BaseProvider correctly -func TestOktaProvider_BaseProviderInheritance(t *testing.T) { - provider := NewOktaProvider() - - // Test that it has access to BaseProvider methods - if provider.BaseProvider == nil { - t.Error("Expected BaseProvider to be initialized") - } - - // Test HandleTokenRefresh (inherited from BaseProvider) - err := provider.HandleTokenRefresh(&TokenResult{ - IDToken: "test-id-token", - AccessToken: "test-access-token", - RefreshToken: "test-refresh-token", - }) - if err != nil { - t.Errorf("HandleTokenRefresh failed: %v", err) - } -} - -// TestOktaProvider_ScopeHandling tests Okta-specific scope handling -func TestOktaProvider_ScopeHandling(t *testing.T) { - provider := NewOktaProvider() - baseParams := url.Values{} - - tests := []struct { - name string - scopes []string - checkFor []string - }{ - { - name: "Groups scope handling", - scopes: []string{"groups", "profile"}, - checkFor: []string{"groups", "profile", "offline_access", "openid"}, - }, - { - name: "Custom Okta scopes", - scopes: []string{"okta.users.read", "okta.groups.read"}, - checkFor: []string{"okta.users.read", "okta.groups.read", "offline_access", "openid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - authParams, err := provider.BuildAuthParams(baseParams, tt.scopes) - if err != nil { - t.Errorf("BuildAuthParams failed: %v", err) - return - } - - for _, expectedScope := range tt.checkFor { - found := false - for _, actualScope := range authParams.Scopes { - if actualScope == expectedScope { - found = true - break - } - } - if !found { - t.Errorf("Expected scope '%s' not found in %v", expectedScope, authParams.Scopes) - } - } - }) - } -} diff --git a/internal/providers/registry.go b/internal/providers/registry.go deleted file mode 100644 index fed57db..0000000 --- a/internal/providers/registry.go +++ /dev/null @@ -1,174 +0,0 @@ -package providers - -import ( - "net/url" - "strings" - "sync" -) - -// ProviderRegistry manages a collection of OIDC provider implementations. -// It provides thread-safe access to provider instances and caches detection results. -type ProviderRegistry struct { - cache map[string]OIDCProvider - typeMap map[ProviderType]OIDCProvider - providers []OIDCProvider - mu sync.RWMutex - // Bounded cache configuration to prevent memory leaks - maxCacheSize int - cacheCount int -} - -// NewProviderRegistry creates and initializes a new ProviderRegistry. -func NewProviderRegistry() *ProviderRegistry { - return &ProviderRegistry{ - providers: make([]OIDCProvider, 0), - cache: make(map[string]OIDCProvider), - typeMap: make(map[ProviderType]OIDCProvider), - maxCacheSize: 1000, // Prevent unbounded cache growth - cacheCount: 0, - } -} - -// RegisterProvider adds a new provider to the registry. -// It maintains both a list of providers and a type-to-provider mapping for efficient lookups. -func (r *ProviderRegistry) RegisterProvider(provider OIDCProvider) { - r.mu.Lock() - defer r.mu.Unlock() - r.providers = append(r.providers, provider) - r.typeMap[provider.GetType()] = provider -} - -// GetProviderByType retrieves a provider instance by its type. -// Returns nil if the provider type is not registered. -func (r *ProviderRegistry) GetProviderByType(providerType ProviderType) OIDCProvider { - r.mu.RLock() - defer r.mu.RUnlock() - return r.typeMap[providerType] -} - -// GetRegisteredProviders returns a slice of all registered provider types. -func (r *ProviderRegistry) GetRegisteredProviders() []ProviderType { - r.mu.RLock() - defer r.mu.RUnlock() - - types := make([]ProviderType, 0, len(r.typeMap)) - for providerType := range r.typeMap { - types = append(types, providerType) - } - return types -} - -// ClearCache removes all cached provider detection results. -// This can be useful for testing or when provider configuration changes. -func (r *ProviderRegistry) ClearCache() { - r.mu.Lock() - defer r.mu.Unlock() - r.cache = make(map[string]OIDCProvider) - r.cacheCount = 0 -} - -// evictOldestCacheEntry removes the first cache entry when cache is full -// This is a simple eviction strategy - in production, LRU might be preferred -func (r *ProviderRegistry) evictOldestCacheEntry() { - // Simple eviction: remove first entry found - for key := range r.cache { - delete(r.cache, key) - r.cacheCount-- - break - } -} - -// DetectProvider identifies the appropriate OIDC provider for an issuer URL. -// Uses double-checked locking pattern to avoid race conditions while caching results. -func (r *ProviderRegistry) DetectProvider(issuerURL string) OIDCProvider { - r.mu.RLock() - if provider, found := r.cache[issuerURL]; found { - r.mu.RUnlock() - return provider - } - r.mu.RUnlock() - - r.mu.Lock() - defer r.mu.Unlock() - - if provider, found := r.cache[issuerURL]; found { - return provider - } - - detectedProvider := r.detectProviderUnsafe(issuerURL) - - // Check if cache is full and evict if necessary - if r.cacheCount >= r.maxCacheSize { - r.evictOldestCacheEntry() - } - - r.cache[issuerURL] = detectedProvider - r.cacheCount++ - - return detectedProvider -} - -// detectProviderUnsafe performs the actual provider detection logic. -// This method assumes the caller holds the appropriate lock and should not be called directly. -func (r *ProviderRegistry) detectProviderUnsafe(issuerURL string) OIDCProvider { - normalizedURL, err := url.Parse(issuerURL) - if err != nil { - return nil - } - - // Check if the URL has a valid scheme and host - if normalizedURL.Scheme == "" || normalizedURL.Host == "" { - return nil - } - - // Convert host to lowercase for case-insensitive matching - host := strings.ToLower(normalizedURL.Host) - - for _, p := range r.providers { - switch p.GetType() { - case ProviderTypeGoogle: - if strings.Contains(host, "accounts.google.com") { - return p - } - case ProviderTypeAzure: - if strings.Contains(host, "login.microsoftonline.com") || strings.Contains(host, "sts.windows.net") { - return p - } - case ProviderTypeGitHub: - if strings.Contains(host, "github.com") { - return p - } - case ProviderTypeAuth0: - if strings.Contains(host, ".auth0.com") { - return p - } - case ProviderTypeOkta: - if strings.Contains(host, ".okta.com") || strings.Contains(host, ".oktapreview.com") || strings.Contains(host, ".okta-emea.com") { - return p - } - case ProviderTypeKeycloak: - // Match both Keycloak <17 (`/auth/realms/`) and 17+ (`/realms/`). - if strings.Contains(host, "keycloak") || strings.Contains(normalizedURL.Path, "/realms/") { - return p - } - case ProviderTypeAWSCognito: - if strings.Contains(host, "cognito-idp") && strings.Contains(host, ".amazonaws.com") { - return p - } - case ProviderTypeGitLab: - // Match gitlab.com, self-hosted (gitlab.*), and instances with gitlab in subdomain - if strings.Contains(host, "gitlab.com") || - strings.Contains(host, "gitlab") { - return p - } - } - } - - for _, p := range r.providers { - if p.GetType() == ProviderTypeGeneric { - return p - } - } - - return nil -} diff --git a/internal/providers/registry_test.go b/internal/providers/registry_test.go deleted file mode 100644 index 6a354a2..0000000 --- a/internal/providers/registry_test.go +++ /dev/null @@ -1,747 +0,0 @@ -package providers - -import ( - "fmt" - "sync" - "testing" -) - -// TestProviderRegistry_NewProviderRegistry tests registry constructor -func TestProviderRegistry_NewProviderRegistry(t *testing.T) { - registry := NewProviderRegistry() - - if registry == nil { - t.Fatal("Expected registry to be created, got nil") - } - - if registry.providers == nil { - t.Error("Providers slice should be initialized") - } - - if registry.cache == nil { - t.Error("Cache map should be initialized") - } - - if registry.typeMap == nil { - t.Error("TypeMap should be initialized") - } - - if registry.maxCacheSize != 1000 { - t.Errorf("Expected maxCacheSize 1000, got %d", registry.maxCacheSize) - } - - if registry.cacheCount != 0 { - t.Errorf("Expected initial cacheCount 0, got %d", registry.cacheCount) - } -} - -// TestProviderRegistry_RegisterProvider tests provider registration -func TestProviderRegistry_RegisterProvider(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - googleProvider := NewGoogleProvider() - azureProvider := NewAzureProvider() - - // Register providers - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(googleProvider) - registry.RegisterProvider(azureProvider) - - // Verify providers are registered - if len(registry.providers) != 3 { - t.Errorf("Expected 3 providers, got %d", len(registry.providers)) - } - - if len(registry.typeMap) != 3 { - t.Errorf("Expected 3 type mappings, got %d", len(registry.typeMap)) - } - - // Verify type mappings - if registry.typeMap[ProviderTypeGeneric] != genericProvider { - t.Error("Generic provider not mapped correctly") - } - - if registry.typeMap[ProviderTypeGoogle] != googleProvider { - t.Error("Google provider not mapped correctly") - } - - if registry.typeMap[ProviderTypeAzure] != azureProvider { - t.Error("Azure provider not mapped correctly") - } -} - -// TestProviderRegistry_GetProviderByType tests provider retrieval by type -func TestProviderRegistry_GetProviderByType(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - googleProvider := NewGoogleProvider() - - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(googleProvider) - - tests := []struct { - expected OIDCProvider - name string - providerType ProviderType - }{ - { - name: "Get Generic provider", - providerType: ProviderTypeGeneric, - expected: genericProvider, - }, - { - name: "Get Google provider", - providerType: ProviderTypeGoogle, - expected: googleProvider, - }, - { - name: "Get unregistered provider", - providerType: ProviderTypeAzure, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := registry.GetProviderByType(tt.providerType) - - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -// TestProviderRegistry_GetRegisteredProviders tests listing registered provider types -func TestProviderRegistry_GetRegisteredProviders(t *testing.T) { - registry := NewProviderRegistry() - - // Initially empty - types := registry.GetRegisteredProviders() - if len(types) != 0 { - t.Errorf("Expected 0 registered providers, got %d", len(types)) - } - - // Register some providers - registry.RegisterProvider(NewGenericProvider()) - registry.RegisterProvider(NewGoogleProvider()) - - types = registry.GetRegisteredProviders() - if len(types) != 2 { - t.Errorf("Expected 2 registered providers, got %d", len(types)) - } - - // Verify types are correct - expectedTypes := map[ProviderType]bool{ - ProviderTypeGeneric: false, - ProviderTypeGoogle: false, - } - - for _, providerType := range types { - if _, exists := expectedTypes[providerType]; exists { - expectedTypes[providerType] = true - } else { - t.Errorf("Unexpected provider type: %v", providerType) - } - } - - for providerType, found := range expectedTypes { - if !found { - t.Errorf("Provider type %v not found in results", providerType) - } - } -} - -// TestProviderRegistry_DetectProvider tests provider detection -func TestProviderRegistry_DetectProvider(t *testing.T) { - registry := NewProviderRegistry() - - // Register providers - genericProvider := NewGenericProvider() - googleProvider := NewGoogleProvider() - azureProvider := NewAzureProvider() - githubProvider := NewGitHubProvider() - auth0Provider := NewAuth0Provider() - oktaProvider := NewOktaProvider() - keycloakProvider := NewKeycloakProvider() - cognitoProvider := NewAWSCognitoProvider() - gitlabProvider := NewGitLabProvider() - - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(googleProvider) - registry.RegisterProvider(azureProvider) - registry.RegisterProvider(githubProvider) - registry.RegisterProvider(auth0Provider) - registry.RegisterProvider(oktaProvider) - registry.RegisterProvider(keycloakProvider) - registry.RegisterProvider(cognitoProvider) - registry.RegisterProvider(gitlabProvider) - - tests := []struct { - expected OIDCProvider - name string - issuerURL string - }{ - { - name: "Google provider detection", - issuerURL: "https://accounts.google.com", - expected: googleProvider, - }, - { - name: "Google provider with path", - issuerURL: "https://accounts.google.com/oauth2", - expected: googleProvider, - }, - { - name: "Azure provider detection - login.microsoftonline.com", - issuerURL: "https://login.microsoftonline.com/tenant/v2.0", - expected: azureProvider, - }, - { - name: "Azure provider detection - sts.windows.net", - issuerURL: "https://sts.windows.net/tenant", - expected: azureProvider, - }, - { - name: "GitHub provider detection", - issuerURL: "https://github.com/login/oauth", - expected: githubProvider, - }, - { - name: "Auth0 provider detection", - issuerURL: "https://tenant.auth0.com", - expected: auth0Provider, - }, - { - name: "Okta provider detection", - issuerURL: "https://tenant.okta.com", - expected: oktaProvider, - }, - { - name: "Okta preview provider detection", - issuerURL: "https://tenant.oktapreview.com", - expected: oktaProvider, - }, - { - name: "Keycloak provider detection (legacy /auth/realms/)", - issuerURL: "https://auth.example.com/auth/realms/master", - expected: keycloakProvider, - }, - { - name: "Keycloak provider detection (modern /realms/, KC 17+)", - issuerURL: "https://auth.example.com/realms/master", - expected: keycloakProvider, - }, - { - name: "AWS Cognito provider detection", - issuerURL: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example", - expected: cognitoProvider, - }, - { - name: "GitLab provider detection", - issuerURL: "https://gitlab.com/oauth", - expected: gitlabProvider, - }, - { - name: "GitLab self-hosted detection - gitlab subdomain", - issuerURL: "https://gitlab.example.com", - expected: gitlabProvider, - }, - { - name: "GitLab self-hosted detection - gitlab in domain", - issuerURL: "https://my-gitlab.company.io", - expected: gitlabProvider, - }, - { - name: "GitLab self-hosted detection - gitlab prefix", - issuerURL: "https://gitlab-prod.internal.net", - expected: gitlabProvider, - }, - { - name: "GitLab self-hosted detection - gitlab suffix", - issuerURL: "https://company-gitlab.net", - expected: gitlabProvider, - }, - { - name: "Generic provider fallback", - issuerURL: "https://auth.example.com", - expected: genericProvider, - }, - { - name: "Invalid URL", - issuerURL: "not-a-url", - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := registry.DetectProvider(tt.issuerURL) - - if result != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, result) - } - }) - } -} - -// TestProviderRegistry_DetectProvider_Caching tests cache behavior -func TestProviderRegistry_DetectProvider_Caching(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - issuerURL := "https://auth.example.com" - - // First call should detect and cache - result1 := registry.DetectProvider(issuerURL) - if result1 != genericProvider { - t.Errorf("Expected generic provider, got %v", result1) - } - - // Verify it's cached - registry.mu.RLock() - cachedResult, found := registry.cache[issuerURL] - registry.mu.RUnlock() - - if !found { - t.Error("Expected result to be cached") - } - - if cachedResult != genericProvider { - t.Errorf("Expected cached generic provider, got %v", cachedResult) - } - - // Second call should return cached result - result2 := registry.DetectProvider(issuerURL) - if result2 != genericProvider { - t.Errorf("Expected cached generic provider, got %v", result2) - } - - // Should be same instance (from cache) - if result1 != result2 { - t.Error("Expected same instance from cache") - } -} - -// TestProviderRegistry_ClearCache tests cache clearing -func TestProviderRegistry_ClearCache(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - // Populate cache - registry.DetectProvider("https://auth1.example.com") - registry.DetectProvider("https://auth2.example.com") - - // Verify cache has entries - registry.mu.RLock() - cacheSize := len(registry.cache) - registry.mu.RUnlock() - - if cacheSize != 2 { - t.Errorf("Expected 2 cache entries, got %d", cacheSize) - } - - // Clear cache - registry.ClearCache() - - // Verify cache is empty - registry.mu.RLock() - cacheSize = len(registry.cache) - cacheCount := registry.cacheCount - registry.mu.RUnlock() - - if cacheSize != 0 { - t.Errorf("Expected 0 cache entries after clear, got %d", cacheSize) - } - - if cacheCount != 0 { - t.Errorf("Expected 0 cache count after clear, got %d", cacheCount) - } -} - -// TestProviderRegistry_CacheEviction tests cache size limits and eviction -func TestProviderRegistry_CacheEviction(t *testing.T) { - registry := NewProviderRegistry() - registry.maxCacheSize = 2 // Set small cache size for testing - - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - // Fill cache to capacity - registry.DetectProvider("https://auth1.example.com") - registry.DetectProvider("https://auth2.example.com") - - // Verify cache is at capacity - registry.mu.RLock() - cacheSize := len(registry.cache) - registry.mu.RUnlock() - - if cacheSize != 2 { - t.Errorf("Expected 2 cache entries, got %d", cacheSize) - } - - // Add one more entry (should trigger eviction) - registry.DetectProvider("https://auth3.example.com") - - // Cache size should still be at max - registry.mu.RLock() - cacheSize = len(registry.cache) - registry.mu.RUnlock() - - if cacheSize != 2 { - t.Errorf("Expected 2 cache entries after eviction, got %d", cacheSize) - } - - // Verify the new entry is cached - registry.mu.RLock() - _, found := registry.cache["https://auth3.example.com"] - registry.mu.RUnlock() - - if !found { - t.Error("Expected new entry to be cached") - } -} - -// TestProviderRegistry_ConcurrentAccess tests thread safety -func TestProviderRegistry_ConcurrentAccess(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - googleProvider := NewGoogleProvider() - azureProvider := NewAzureProvider() - - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(googleProvider) - registry.RegisterProvider(azureProvider) - - var wg sync.WaitGroup - goroutines := 10 - iterations := 100 - - // Test concurrent detection - for i := 0; i < goroutines; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - for j := 0; j < iterations; j++ { - issuerURL := "https://accounts.google.com" - if id%2 == 0 { - issuerURL = "https://login.microsoftonline.com/tenant" - } else if id%3 == 0 { - issuerURL = "https://auth.example.com" - } - - result := registry.DetectProvider(issuerURL) - if result == nil { - t.Errorf("Expected provider for URL %s", issuerURL) - } - } - }(i) - } - - // Test concurrent registration - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < 10; i++ { - newProvider := NewGenericProvider() - registry.RegisterProvider(newProvider) - } - }() - - // Test concurrent cache clearing - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < 10; i++ { - registry.ClearCache() - } - }() - - wg.Wait() - - // Verify final state is consistent - types := registry.GetRegisteredProviders() - if len(types) < 3 { // Should have at least the original 3 - t.Errorf("Expected at least 3 provider types, got %d", len(types)) - } -} - -// TestProviderRegistry_DoubleCheckedLocking tests the double-checked locking pattern -func TestProviderRegistry_DoubleCheckedLocking(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - var wg sync.WaitGroup - goroutines := 100 - issuerURL := "https://auth.example.com" - - // Multiple goroutines trying to detect the same provider simultaneously - for i := 0; i < goroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() - result := registry.DetectProvider(issuerURL) - if result != genericProvider { - t.Errorf("Expected generic provider, got %v", result) - } - }() - } - - wg.Wait() - - // Verify only one cache entry was created - registry.mu.RLock() - cacheSize := len(registry.cache) - registry.mu.RUnlock() - - if cacheSize != 1 { - t.Errorf("Expected 1 cache entry, got %d", cacheSize) - } -} - -// TestProviderRegistry_DetectGitLabSelfHosted tests improved GitLab detection for issue #61 -func TestProviderRegistry_DetectGitLabSelfHosted(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - gitlabProvider := NewGitLabProvider() - githubProvider := NewGitHubProvider() - - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(gitlabProvider) - registry.RegisterProvider(githubProvider) - - tests := []struct { - name string - issuerURL string - expected OIDCProvider - description string - }{ - { - name: "GitLab.com official", - issuerURL: "https://gitlab.com", - expected: gitlabProvider, - description: "Should detect official GitLab.com", - }, - { - name: "GitLab.com with path", - issuerURL: "https://gitlab.com/oauth/authorize", - expected: gitlabProvider, - description: "Should detect GitLab.com with path", - }, - { - name: "Self-hosted gitlab.example.com", - issuerURL: "https://gitlab.example.com", - expected: gitlabProvider, - description: "Should detect gitlab as subdomain", - }, - { - name: "Self-hosted my.gitlab.io", - issuerURL: "https://my.gitlab.io", - expected: gitlabProvider, - description: "Should detect gitlab in domain", - }, - { - name: "Self-hosted example-gitlab.com", - issuerURL: "https://example-gitlab.com", - expected: gitlabProvider, - description: "Should detect gitlab as suffix", - }, - { - name: "Self-hosted gitlab-prod.company.net", - issuerURL: "https://gitlab-prod.company.net", - expected: gitlabProvider, - description: "Should detect gitlab as prefix", - }, - { - name: "Self-hosted my-gitlab.internal", - issuerURL: "https://my-gitlab.internal", - expected: gitlabProvider, - description: "Should detect gitlab in middle of host", - }, - { - name: "Self-hosted company.gitlab.services", - issuerURL: "https://company.gitlab.services", - expected: gitlabProvider, - description: "Should detect gitlab in middle of domain", - }, - { - name: "Self-hosted with port", - issuerURL: "https://gitlab.example.com:8443", - expected: gitlabProvider, - description: "Should detect GitLab with custom port", - }, - { - name: "Self-hosted with path and query", - issuerURL: "https://gitlab.example.com/oauth?param=value", - expected: gitlabProvider, - description: "Should detect GitLab with complex URL", - }, - { - name: "Case insensitive - GITLAB", - issuerURL: "https://GITLAB.example.com", - expected: gitlabProvider, - description: "Should detect GitLab case-insensitively", - }, - { - name: "Case insensitive - GitLab", - issuerURL: "https://GitLab.example.com", - expected: gitlabProvider, - description: "Should detect GitLab with mixed case", - }, - { - name: "Not GitLab - git prefix only", - issuerURL: "https://github.com", - expected: githubProvider, // Should match GitHub provider, not GitLab - description: "Should not match github.com as GitLab", - }, - { - name: "Not GitLab - lab suffix only", - issuerURL: "https://mylab.example.com", - expected: genericProvider, - description: "Should not match partial gitlab string", - }, - { - name: "Not GitLab - git and lab separate", - issuerURL: "https://git.mylab.example.com", - expected: genericProvider, - description: "Should not match git and lab when not together", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Clear cache to ensure fresh detection - registry.ClearCache() - - result := registry.DetectProvider(tt.issuerURL) - - if result != tt.expected { - t.Errorf("%s: Expected %v, got %v", tt.description, tt.expected, result) - } - }) - } -} - -// TestProviderRegistry_GitLabDetection_RealWorldURLs tests real-world GitLab URLs -func TestProviderRegistry_GitLabDetection_RealWorldURLs(t *testing.T) { - registry := NewProviderRegistry() - - genericProvider := NewGenericProvider() - gitlabProvider := NewGitLabProvider() - githubProvider := NewGitHubProvider() - - registry.RegisterProvider(genericProvider) - registry.RegisterProvider(gitlabProvider) - registry.RegisterProvider(githubProvider) - - realWorldTests := []struct { - expected OIDCProvider - name string - issuerURL string - }{ - // Actual self-hosted GitLab examples from issue #61 - { - name: "Company self-hosted GitLab", - issuerURL: "https://gitlab.company.com", - expected: gitlabProvider, - }, - { - name: "Organization GitLab instance with gitlab in subdomain", - issuerURL: "https://gitlab.organization.org", - expected: gitlabProvider, - }, - { - name: "Internal GitLab server", - issuerURL: "https://gitlab.internal.corp", - expected: gitlabProvider, - }, - { - name: "GitLab with custom subdomain", - issuerURL: "https://code.gitlab.mycompany.com", - expected: gitlabProvider, - }, - // Negative cases to ensure we don't over-match - { - name: "GitHub should not match GitLab", - issuerURL: "https://github.com", - expected: githubProvider, - }, - { - name: "Generic git server", - issuerURL: "https://git.example.com", - expected: genericProvider, - }, - } - - for _, tt := range realWorldTests { - t.Run(tt.name, func(t *testing.T) { - registry.ClearCache() - result := registry.DetectProvider(tt.issuerURL) - - if result != tt.expected { - var expectedType, resultType string - if tt.expected != nil { - expectedType = fmt.Sprintf("%v", tt.expected.GetType()) - } else { - expectedType = "nil" - } - if result != nil { - resultType = fmt.Sprintf("%v", result.GetType()) - } else { - resultType = "nil" - } - - t.Errorf("Expected provider type %s, got %s for URL %s", - expectedType, resultType, tt.issuerURL) - } - }) - } -} - -// Benchmark tests -func BenchmarkProviderRegistry_DetectProvider_Cached(b *testing.B) { - registry := NewProviderRegistry() - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - issuerURL := "https://auth.example.com" - // Warm up cache - registry.DetectProvider(issuerURL) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - registry.DetectProvider(issuerURL) - } -} - -func BenchmarkProviderRegistry_DetectProvider_Uncached(b *testing.B) { - registry := NewProviderRegistry() - genericProvider := NewGenericProvider() - registry.RegisterProvider(genericProvider) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - registry.ClearCache() // Clear cache for each iteration - registry.DetectProvider("https://auth.example.com") - } -} - -func BenchmarkProviderRegistry_RegisterProvider(b *testing.B) { - registry := NewProviderRegistry() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - provider := NewGenericProvider() - registry.RegisterProvider(provider) - } -} diff --git a/internal/providers/validation.go b/internal/providers/validation.go deleted file mode 100644 index e7a9cf9..0000000 --- a/internal/providers/validation.go +++ /dev/null @@ -1,151 +0,0 @@ -package providers - -import ( - "fmt" - "net/url" - "strings" -) - -// ConfigValidator provides common configuration validation utilities for providers. -type ConfigValidator struct{} - -// NewConfigValidator creates a new configuration validator. -func NewConfigValidator() *ConfigValidator { - return &ConfigValidator{} -} - -// ValidateIssuerURL validates that an issuer URL is properly formatted and accessible. -func (v *ConfigValidator) ValidateIssuerURL(issuerURL string) error { - if issuerURL == "" { - return fmt.Errorf("issuer URL cannot be empty") - } - - parsedURL, err := url.Parse(issuerURL) - if err != nil { - return fmt.Errorf("invalid issuer URL format: %w", err) - } - - if parsedURL.Scheme == "" { - return fmt.Errorf("issuer URL must include scheme (http/https)") - } - - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return fmt.Errorf("issuer URL scheme must be http or https") - } - - if parsedURL.Host == "" { - return fmt.Errorf("issuer URL must include host") - } - - return nil -} - -// ValidateClientID validates that a client ID is properly formatted. -func (v *ConfigValidator) ValidateClientID(clientID string) error { - if clientID == "" { - return fmt.Errorf("client ID cannot be empty") - } - - if len(clientID) < 3 { - return fmt.Errorf("client ID appears to be too short") - } - - return nil -} - -// ValidateScopes validates that the provided scopes are reasonable. -func (v *ConfigValidator) ValidateScopes(scopes []string) error { - if len(scopes) == 0 { - return fmt.Errorf("at least one scope must be provided") - } - - hasOpenIDScope := false - for _, scope := range scopes { - if strings.TrimSpace(scope) == ScopeOpenID { - hasOpenIDScope = true - break - } - } - - if !hasOpenIDScope { - return fmt.Errorf("'openid' scope is required for OIDC authentication") - } - - return nil -} - -// ValidateRedirectURL validates that a redirect URL is properly formatted. -func (v *ConfigValidator) ValidateRedirectURL(redirectURL string) error { - if redirectURL == "" { - return fmt.Errorf("redirect URL cannot be empty") - } - - parsedURL, err := url.Parse(redirectURL) - if err != nil { - return fmt.Errorf("invalid redirect URL format: %w", err) - } - - if parsedURL.Scheme == "" { - return fmt.Errorf("redirect URL must include scheme (http/https)") - } - - return nil -} - -// ValidateProviderSpecificConfig performs provider-specific validation. -func (v *ConfigValidator) ValidateProviderSpecificConfig(provider OIDCProvider, config map[string]interface{}) error { - switch provider.GetType() { - case ProviderTypeGoogle: - return v.validateGoogleConfig(config) - case ProviderTypeAzure: - return v.validateAzureConfig(config) - case ProviderTypeGeneric: - return v.validateGenericConfig(config) - default: - return fmt.Errorf("unknown provider type: %d", provider.GetType()) - } -} - -// validateGoogleConfig validates Google-specific configuration. -func (v *ConfigValidator) validateGoogleConfig(config map[string]interface{}) error { - if issuerURL, ok := config["issuer_url"].(string); ok { - if !strings.Contains(issuerURL, "accounts.google.com") { - return fmt.Errorf("google provider requires issuer URL to contain accounts.google.com") - } - } - - return nil -} - -// validateAzureConfig validates Azure-specific configuration. -func (v *ConfigValidator) validateAzureConfig(config map[string]interface{}) error { - if issuerURL, ok := config["issuer_url"].(string); ok { - if !strings.Contains(issuerURL, "login.microsoftonline.com") && !strings.Contains(issuerURL, "sts.windows.net") { - return fmt.Errorf("azure provider requires issuer URL to contain login.microsoftonline.com or sts.windows.net") - } - } - - if issuerURL, ok := config["issuer_url"].(string); ok { - parsedURL, err := url.Parse(issuerURL) - if err == nil { - pathParts := strings.Split(parsedURL.Path, "/") - hasTenantID := false - for _, part := range pathParts { - if len(part) == 36 && strings.Count(part, "-") == 4 { - hasTenantID = true - break - } - } - if !hasTenantID { - return fmt.Errorf("azure issuer URL should include tenant ID") - } - } - } - - return nil -} - -// validateGenericConfig validates generic OIDC provider configuration. -func (v *ConfigValidator) validateGenericConfig(config map[string]interface{}) error { - return nil -} diff --git a/internal/providers/validation_test.go b/internal/providers/validation_test.go deleted file mode 100644 index 50de80f..0000000 --- a/internal/providers/validation_test.go +++ /dev/null @@ -1,563 +0,0 @@ -package providers - -import ( - "net/url" - "strings" - "testing" - "time" -) - -// TestNewConfigValidator tests the creation of a ConfigValidator -func TestNewConfigValidator(t *testing.T) { - validator := NewConfigValidator() - if validator == nil { - t.Error("expected non-nil validator") - } -} - -// TestValidateIssuerURL tests the ValidateIssuerURL function -func TestValidateIssuerURL(t *testing.T) { - tests := []struct { - name string - issuerURL string - errMsg string - wantErr bool - }{ - { - name: "valid https URL", - issuerURL: "https://accounts.google.com", - wantErr: false, - }, - { - name: "valid http URL", - issuerURL: "http://localhost:8080", - wantErr: false, - }, - { - name: "valid URL with path", - issuerURL: "https://login.microsoftonline.com/tenant-id/v2.0", - wantErr: false, - }, - { - name: "empty URL", - issuerURL: "", - wantErr: true, - errMsg: "issuer URL cannot be empty", - }, - { - name: "URL without scheme", - issuerURL: "accounts.google.com", - wantErr: true, - errMsg: "issuer URL must include scheme", - }, - { - name: "URL with invalid scheme", - issuerURL: "ftp://example.com", - wantErr: true, - errMsg: "issuer URL scheme must be http or https", - }, - { - name: "URL without host", - issuerURL: "https://", - wantErr: true, - errMsg: "issuer URL must include host", - }, - { - name: "malformed URL", - issuerURL: "ht!tp://[invalid", - wantErr: true, - errMsg: "invalid issuer URL format", - }, - { - name: "URL with port", - issuerURL: "https://auth.example.com:443/oauth", - wantErr: false, - }, - { - name: "URL with query parameters", - issuerURL: "https://auth.example.com?tenant=123", - wantErr: false, - }, - } - - validator := NewConfigValidator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateIssuerURL(tt.issuerURL) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -// TestValidateClientID tests the ValidateClientID function -func TestValidateClientID(t *testing.T) { - tests := []struct { - name string - clientID string - errMsg string - wantErr bool - }{ - { - name: "valid client ID", - clientID: "my-application-client", - wantErr: false, - }, - { - name: "valid UUID client ID", - clientID: "123e4567-e89b-12d3-a456-426614174000", - wantErr: false, - }, - { - name: "empty client ID", - clientID: "", - wantErr: true, - errMsg: "client ID cannot be empty", - }, - { - name: "too short client ID", - clientID: "ab", - wantErr: true, - errMsg: "client ID appears to be too short", - }, - { - name: "minimum length client ID", - clientID: "abc", - wantErr: false, - }, - { - name: "client ID with special characters", - clientID: "client-id_123.app", - wantErr: false, - }, - { - name: "long client ID", - clientID: strings.Repeat("a", 255), - wantErr: false, - }, - } - - validator := NewConfigValidator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateClientID(tt.clientID) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -// TestValidateScopes tests the ValidateScopes function -func TestValidateScopes(t *testing.T) { - tests := []struct { - name string - errMsg string - scopes []string - wantErr bool - }{ - { - name: "valid scopes with openid", - scopes: []string{"openid", "email", "profile"}, - wantErr: false, - }, - { - name: "only openid scope", - scopes: []string{"openid"}, - wantErr: false, - }, - { - name: "openid with whitespace", - scopes: []string{" openid ", "email"}, - wantErr: false, - }, - { - name: "empty scopes", - scopes: []string{}, - wantErr: true, - errMsg: "at least one scope must be provided", - }, - { - name: "nil scopes", - scopes: nil, - wantErr: true, - errMsg: "at least one scope must be provided", - }, - { - name: "missing openid scope", - scopes: []string{"email", "profile"}, - wantErr: true, - errMsg: "'openid' scope is required", - }, - { - name: "duplicate openid scope", - scopes: []string{"openid", "openid", "email"}, - wantErr: false, - }, - { - name: "custom scopes with openid", - scopes: []string{"openid", "api:read", "api:write"}, - wantErr: false, - }, - } - - validator := NewConfigValidator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateScopes(tt.scopes) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -// TestValidateRedirectURL tests the ValidateRedirectURL function -func TestValidateRedirectURL(t *testing.T) { - tests := []struct { - name string - redirectURL string - errMsg string - wantErr bool - }{ - { - name: "valid https redirect URL", - redirectURL: "https://example.com/callback", - wantErr: false, - }, - { - name: "valid http redirect URL", - redirectURL: "http://localhost:3000/auth/callback", - wantErr: false, - }, - { - name: "empty redirect URL", - redirectURL: "", - wantErr: true, - errMsg: "redirect URL cannot be empty", - }, - { - name: "redirect URL without scheme", - redirectURL: "example.com/callback", - wantErr: true, - errMsg: "redirect URL must include scheme", - }, - { - name: "malformed redirect URL", - redirectURL: "ht!tp://[invalid", - wantErr: true, - errMsg: "invalid redirect URL format", - }, - { - name: "redirect URL with query parameters", - redirectURL: "https://example.com/callback?state=abc", - wantErr: false, - }, - { - name: "redirect URL with fragment", - redirectURL: "https://example.com/callback#section", - wantErr: false, - }, - } - - validator := NewConfigValidator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateRedirectURL(tt.redirectURL) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -// TestValidateProviderSpecificConfig tests provider-specific configuration validation -func TestValidateProviderSpecificConfig(t *testing.T) { - tests := []struct { - provider OIDCProvider - config map[string]interface{} - name string - errMsg string - wantErr bool - }{ - { - name: "valid Google config", - provider: NewGoogleProvider(), - config: map[string]interface{}{ - "issuer_url": "https://accounts.google.com", - }, - wantErr: false, - }, - { - name: "invalid Google config - wrong issuer", - provider: NewGoogleProvider(), - config: map[string]interface{}{ - "issuer_url": "https://example.com", - }, - wantErr: true, - errMsg: "google provider requires issuer URL to contain accounts.google.com", - }, - { - name: "valid Azure config with tenant ID", - provider: NewAzureProvider(), - config: map[string]interface{}{ - "issuer_url": "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0", - }, - wantErr: false, - }, - { - name: "invalid Azure config - wrong domain", - provider: NewAzureProvider(), - config: map[string]interface{}{ - "issuer_url": "https://example.com/tenant", - }, - wantErr: true, - errMsg: "azure provider requires issuer URL to contain login.microsoftonline.com", - }, - { - name: "Azure config with sts.windows.net", - provider: NewAzureProvider(), - config: map[string]interface{}{ - "issuer_url": "https://sts.windows.net/12345678-1234-1234-1234-123456789012", - }, - wantErr: false, - }, - { - name: "Azure config without tenant ID", - provider: NewAzureProvider(), - config: map[string]interface{}{ - "issuer_url": "https://login.microsoftonline.com/common", - }, - wantErr: true, - errMsg: "azure issuer URL should include tenant ID", - }, - { - name: "valid generic provider config", - provider: NewGenericProvider(), - config: map[string]interface{}{ - "issuer_url": "https://auth.example.com", - }, - wantErr: false, - }, - { - name: "empty config for generic provider", - provider: NewGenericProvider(), - config: map[string]interface{}{}, - wantErr: false, - }, - } - - validator := NewConfigValidator() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateProviderSpecificConfig(tt.provider, tt.config) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} - -// TestValidateProviderSpecificConfig_UnknownProvider tests handling of unknown provider types -func TestValidateProviderSpecificConfig_UnknownProvider(t *testing.T) { - validator := NewConfigValidator() - - // Create a mock provider with invalid type - mockProvider := &mockUnknownProvider{} - - err := validator.ValidateProviderSpecificConfig(mockProvider, map[string]interface{}{}) - if err == nil { - t.Error("expected error for unknown provider type") - } - if !strings.Contains(err.Error(), "unknown provider type") { - t.Errorf("expected 'unknown provider type' error, got: %v", err) - } -} - -// mockUnknownProvider is a test provider with an invalid type -type mockUnknownProvider struct{} - -func (m *mockUnknownProvider) GetType() ProviderType { - return ProviderType(999) // Invalid type -} - -func (m *mockUnknownProvider) GetCapabilities() ProviderCapabilities { - return ProviderCapabilities{} -} - -func (m *mockUnknownProvider) ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) { - return &ValidationResult{}, nil -} - -func (m *mockUnknownProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { - return &AuthParams{}, nil -} - -func (m *mockUnknownProvider) HandleTokenRefresh(tokenData *TokenResult) error { - return nil -} - -func (m *mockUnknownProvider) ValidateConfig() error { - return nil -} - -// TestValidateGoogleConfig_EdgeCases tests edge cases for Google config validation -func TestValidateGoogleConfig_EdgeCases(t *testing.T) { - validator := NewConfigValidator() - googleProvider := NewGoogleProvider() - - tests := []struct { - config map[string]interface{} - name string - wantErr bool - }{ - { - name: "config without issuer_url", - config: map[string]interface{}{}, - wantErr: false, // Should pass as issuer_url is not present - }, - { - name: "config with non-string issuer_url", - config: map[string]interface{}{ - "issuer_url": 123, - }, - wantErr: false, // Should pass as type assertion fails - }, - { - name: "config with accounts.google.com in path", - config: map[string]interface{}{ - "issuer_url": "https://example.com/accounts.google.com", - }, - wantErr: false, // Should pass as it contains the required string - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateProviderSpecificConfig(googleProvider, tt.config) - - if tt.wantErr && err == nil { - t.Error("expected error, got nil") - } else if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} - -// TestValidateAzureConfig_EdgeCases tests edge cases for Azure config validation -func TestValidateAzureConfig_EdgeCases(t *testing.T) { - validator := NewConfigValidator() - azureProvider := NewAzureProvider() - - tests := []struct { - config map[string]interface{} - name string - errMsg string - wantErr bool - }{ - { - name: "valid tenant ID format", - config: map[string]interface{}{ - "issuer_url": "https://login.microsoftonline.com/a1b2c3d4-e5f6-7890-abcd-ef1234567890/v2.0", - }, - wantErr: false, - }, - { - name: "tenant ID in different position", - config: map[string]interface{}{ - "issuer_url": "https://login.microsoftonline.com/v2.0/a1b2c3d4-e5f6-7890-abcd-ef1234567890/oauth", - }, - wantErr: false, - }, - { - name: "malformed URL for parsing", - config: map[string]interface{}{ - "issuer_url": "https://login.microsoftonline.com/[invalid", - }, - wantErr: true, - errMsg: "azure issuer URL should include tenant ID", - }, - { - name: "config without issuer_url", - config: map[string]interface{}{}, - wantErr: false, - }, - { - name: "config with non-string issuer_url", - config: map[string]interface{}{ - "issuer_url": []string{"https://login.microsoftonline.com"}, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateProviderSpecificConfig(azureProvider, tt.config) - - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - } - }) - } -} diff --git a/internal/providers/warnings.go b/internal/providers/warnings.go deleted file mode 100644 index 661ba30..0000000 --- a/internal/providers/warnings.go +++ /dev/null @@ -1,152 +0,0 @@ -package providers - -import ( - "fmt" - "strings" -) - -// ProviderWarning represents a warning about provider limitations or requirements. -type ProviderWarning struct { - Level string - Message string - ProviderType ProviderType -} - -// GetProviderWarnings returns warnings about provider-specific limitations. -func GetProviderWarnings(providerType ProviderType) []ProviderWarning { - var warnings []ProviderWarning - - switch providerType { - case ProviderTypeGitHub: - warnings = append(warnings, - ProviderWarning{ - ProviderType: ProviderTypeGitHub, - Level: "warning", - Message: "GitHub uses OAuth 2.0, not OpenID Connect. ID tokens are not available. Use access tokens for API calls only.", - }, - ProviderWarning{ - ProviderType: ProviderTypeGitHub, - Level: "info", - Message: "GitHub OAuth apps do not support refresh tokens. Users will need to re-authenticate when tokens expire.", - }) - - case ProviderTypeAuth0: - warnings = append(warnings, ProviderWarning{ - ProviderType: ProviderTypeAuth0, - Level: "info", - Message: "Auth0 requires 'offline_access' scope for refresh tokens. This will be automatically added.", - }) - - case ProviderTypeOkta: - warnings = append(warnings, ProviderWarning{ - ProviderType: ProviderTypeOkta, - Level: "info", - Message: "Okta requires proper application configuration in your Okta admin console for OIDC to work.", - }) - - case ProviderTypeKeycloak: - warnings = append(warnings, ProviderWarning{ - ProviderType: ProviderTypeKeycloak, - Level: "info", - Message: "Keycloak detection is based on URL path '/auth/realms/'. Ensure your issuer URL follows this pattern.", - }) - - case ProviderTypeAWSCognito: - warnings = append(warnings, ProviderWarning{ - ProviderType: ProviderTypeAWSCognito, - Level: "info", - Message: "AWS Cognito uses regional endpoints. Ensure your issuer URL includes the correct region (e.g., cognito-idp.us-east-1.amazonaws.com).", - }) - - case ProviderTypeGitLab: - warnings = append(warnings, ProviderWarning{ - ProviderType: ProviderTypeGitLab, - Level: "info", - Message: "GitLab supports OIDC but requires application registration in GitLab admin settings.", - }) - } - - return warnings -} - -// ValidateProviderCompatibility checks if a provider is suitable for OIDC authentication. -func ValidateProviderCompatibility(providerType ProviderType, requiresOIDC bool) error { - switch providerType { - case ProviderTypeGitHub: - if requiresOIDC { - return fmt.Errorf("GitHub does not support OpenID Connect. It only supports OAuth 2.0. Consider using a different provider for OIDC authentication") - } - return nil - default: - return nil - } -} - -// GetProviderRecommendations returns setup recommendations for each provider. -func GetProviderRecommendations(providerType ProviderType) []string { - switch providerType { - case ProviderTypeGitHub: - return []string{ - "Register an OAuth App in GitHub Settings > Developer settings > OAuth Apps", - "Use scopes: 'user:email', 'read:user' for basic profile access", - "GitHub tokens expire, plan for re-authentication flow", - } - - case ProviderTypeAuth0: - return []string{ - "Create an Application in Auth0 Dashboard", - "Set Application Type to 'Regular Web Application'", - "Configure Allowed Callback URLs with your redirect URI", - "Enable 'offline_access' scope for refresh tokens", - } - - case ProviderTypeOkta: - return []string{ - "Create an App Integration in Okta Admin Console", - "Choose 'OIDC - OpenID Connect' as sign-in method", - "Select 'Web Application' as application type", - "Configure redirect URIs and assign users/groups", - } - - case ProviderTypeKeycloak: - return []string{ - "Create a Client in your Keycloak realm", - "Set Client Protocol to 'openid-connect'", - "Configure Valid Redirect URIs", - "Ensure issuer URL format: https://your-keycloak/auth/realms/your-realm", - } - - case ProviderTypeAWSCognito: - return []string{ - "Create a User Pool in AWS Cognito", - "Create an App Client with 'Authorization code grant' enabled", - "Configure App Client settings and callback URLs", - "Use issuer URL format: https://cognito-idp.{region}.amazonaws.com/{userPoolId}", - } - - case ProviderTypeGitLab: - return []string{ - "Create an Application in GitLab (Admin Area > Applications)", - "Select 'openid', 'profile', 'email' scopes", - "Configure Redirect URI", - "Use issuer URL: https://gitlab.com (for GitLab.com)", - } - - default: - return []string{} - } -} - -// FormatProviderWarnings formats warnings for display. -func FormatProviderWarnings(warnings []ProviderWarning) string { - if len(warnings) == 0 { - return "" - } - - var result strings.Builder - for _, warning := range warnings { - result.WriteString(fmt.Sprintf("[%s] %s\n", strings.ToUpper(warning.Level), warning.Message)) - } - - return result.String() -} diff --git a/internal/providers/warnings_test.go b/internal/providers/warnings_test.go deleted file mode 100644 index 3fd7040..0000000 --- a/internal/providers/warnings_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package providers - -import ( - "strings" - "testing" -) - -// TestGetProviderWarnings tests that warnings are provided for providers with limitations -func TestGetProviderWarnings(t *testing.T) { - tests := []struct { - name string - checkContent string - providerType ProviderType - expectCount int - }{ - { - name: "GitHub has OAuth 2.0 warning", - providerType: ProviderTypeGitHub, - expectCount: 2, - checkContent: "OAuth 2.0", - }, - { - name: "Auth0 has offline_access info", - providerType: ProviderTypeAuth0, - expectCount: 1, - checkContent: "offline_access", - }, - { - name: "Okta has configuration info", - providerType: ProviderTypeOkta, - expectCount: 1, - checkContent: "admin console", - }, - { - name: "AWS Cognito has regional endpoint info", - providerType: ProviderTypeAWSCognito, - expectCount: 1, - checkContent: "regional endpoints", - }, - { - name: "Generic provider has no warnings", - providerType: ProviderTypeGeneric, - expectCount: 0, - checkContent: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - warnings := GetProviderWarnings(tt.providerType) - - if len(warnings) != tt.expectCount { - t.Errorf("Expected %d warnings, got %d", tt.expectCount, len(warnings)) - } - - if tt.checkContent != "" { - found := false - for _, warning := range warnings { - if strings.Contains(strings.ToLower(warning.Message), strings.ToLower(tt.checkContent)) { - found = true - break - } - } - if !found { - t.Errorf("Expected warning content '%s' not found", tt.checkContent) - } - } - }) - } -} - -// TestValidateProviderCompatibility tests OIDC compatibility validation -func TestValidateProviderCompatibility(t *testing.T) { - tests := []struct { - name string - providerType ProviderType - requiresOIDC bool - expectError bool - }{ - { - name: "GitHub with OIDC requirement should error", - providerType: ProviderTypeGitHub, - requiresOIDC: true, - expectError: true, - }, - { - name: "GitHub without OIDC requirement should pass", - providerType: ProviderTypeGitHub, - requiresOIDC: false, - expectError: false, - }, - { - name: "Auth0 with OIDC requirement should pass", - providerType: ProviderTypeAuth0, - requiresOIDC: true, - expectError: false, - }, - { - name: "Google with OIDC requirement should pass", - providerType: ProviderTypeGoogle, - requiresOIDC: true, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateProviderCompatibility(tt.providerType, tt.requiresOIDC) - - if tt.expectError && err == nil { - t.Error("Expected error but got none") - } - - if !tt.expectError && err != nil { - t.Errorf("Unexpected error: %v", err) - } - }) - } -} - -// TestGetProviderRecommendations tests that recommendations are provided -func TestGetProviderRecommendations(t *testing.T) { - tests := []struct { - name string - providerType ProviderType - expectMin int - }{ - { - name: "GitHub recommendations", - providerType: ProviderTypeGitHub, - expectMin: 3, - }, - { - name: "Auth0 recommendations", - providerType: ProviderTypeAuth0, - expectMin: 3, - }, - { - name: "AWS Cognito recommendations", - providerType: ProviderTypeAWSCognito, - expectMin: 3, - }, - { - name: "Generic provider no recommendations", - providerType: ProviderTypeGeneric, - expectMin: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recommendations := GetProviderRecommendations(tt.providerType) - - if len(recommendations) < tt.expectMin { - t.Errorf("Expected at least %d recommendations, got %d", tt.expectMin, len(recommendations)) - } - }) - } -} - -// TestFormatProviderWarnings tests warning formatting -func TestFormatProviderWarnings(t *testing.T) { - warnings := []ProviderWarning{ - { - ProviderType: ProviderTypeGitHub, - Level: "warning", - Message: "Test warning message", - }, - { - ProviderType: ProviderTypeGitHub, - Level: "info", - Message: "Test info message", - }, - } - - formatted := FormatProviderWarnings(warnings) - - if !strings.Contains(formatted, "[WARNING]") { - t.Error("Expected formatted output to contain [WARNING]") - } - - if !strings.Contains(formatted, "[INFO]") { - t.Error("Expected formatted output to contain [INFO]") - } - - if !strings.Contains(formatted, "Test warning message") { - t.Error("Expected formatted output to contain warning message") - } - - // Test empty warnings - emptyFormatted := FormatProviderWarnings([]ProviderWarning{}) - if emptyFormatted != "" { - t.Error("Expected empty string for no warnings") - } -} diff --git a/internal/recovery/circuit_breaker.go b/internal/recovery/circuit_breaker.go index 5f780af..ed963a0 100644 --- a/internal/recovery/circuit_breaker.go +++ b/internal/recovery/circuit_breaker.go @@ -177,8 +177,18 @@ func (cb *CircuitBreaker) recordSuccess() { state := CircuitBreakerState(atomic.LoadInt32(&cb.state)) // #nosec G115 -- SuccessThreshold is a small config value that fits in int32 - if state == CircuitBreakerHalfOpen && successes >= int32(cb.config.SuccessThreshold) { - cb.transitionToClosed() + if state == CircuitBreakerHalfOpen { + if successes >= int32(cb.config.SuccessThreshold) { + cb.transitionToClosed() // resets halfOpenRequests to 0 + } else { + // Release the probe slot so the half-open gate can accept the + // next request. Without this, halfOpenRequests stays pinned at + // MaxRequests once the first batch of probes completes, every + // later request is rejected, and consecutiveSuccesses can never + // reach SuccessThreshold if it exceeds MaxRequests — leaving the + // circuit stuck half-open forever. + atomic.AddInt32(&cb.halfOpenRequests, -1) + } } } diff --git a/internal/recovery/review_halfopen_regression_test.go b/internal/recovery/review_halfopen_regression_test.go new file mode 100644 index 0000000..d553728 --- /dev/null +++ b/internal/recovery/review_halfopen_regression_test.go @@ -0,0 +1,59 @@ +package recovery + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestCircuitBreaker_HalfOpenRecoversWhenSuccessThresholdExceedsMaxRequests +// guards the fix that releases the half-open probe slot when a probe +// completes below the success threshold. +// +// Repro scenario: SuccessThreshold (3) exceeds MaxRequests (1). Without +// the fix, the single allowed half-open probe stays counted in +// halfOpenRequests after it completes, so every later request is rejected +// by the MaxRequests gate and consecutiveSuccesses can never reach the +// threshold — the circuit hangs half-open forever. With the fix the slot +// is released on completion, so sequential successful probes accumulate +// to the threshold and the circuit closes. +func TestCircuitBreaker_HalfOpenRecoversWhenSuccessThresholdExceedsMaxRequests(t *testing.T) { + config := CircuitBreakerConfig{ + FailureThreshold: 1, + SuccessThreshold: 3, + Timeout: 50 * time.Millisecond, + MaxRequests: 1, + } + cb := NewCircuitBreaker(config, &mockLogger{}) + + // Drive the circuit open with a single failure. + if err := cb.ExecuteWithContext(context.Background(), func() error { + return errors.New("fail") + }); err == nil { + t.Fatal("expected error from failed execution") + } + if cb.GetState() != CircuitBreakerOpen { + t.Fatalf("expected open after failure, got %s", cb.GetState()) + } + + // Wait for the open timeout so the next request transitions to half-open. + time.Sleep(70 * time.Millisecond) + + // Accumulate successes one at a time (MaxRequests == 1). With the fix + // each completed probe releases its slot, so the breaker reaches the + // success threshold and closes. + for i := 0; i < config.SuccessThreshold; i++ { + err := cb.ExecuteWithContext(context.Background(), func() error { + return nil + }) + if err != nil { + t.Fatalf("successful execution %d returned error: %v (state %s)", i, err, cb.GetState()) + } + } + + if cb.GetState() != CircuitBreakerClosed { + t.Fatalf("expected circuit to close after %d half-open successes, got %s", + config.SuccessThreshold, cb.GetState()) + } +} diff --git a/internal/utils/utils_url_test.go b/internal/utils/utils_url_test.go new file mode 100644 index 0000000..9713361 --- /dev/null +++ b/internal/utils/utils_url_test.go @@ -0,0 +1,93 @@ +package utils + +import ( + "crypto/tls" + "net/http" + "testing" +) + +// TestDetermineScheme verifies the scheme priority: forceHTTPS > X-Forwarded-Proto > TLS > http. +func TestDetermineScheme(t *testing.T) { + cases := []struct { + name string + forceHTTPS bool + fwdProto string + tls bool + want string + }{ + {"force https wins over header", true, "http", false, "https"}, + {"force https wins over tls absence", true, "", false, "https"}, + {"forwarded proto used", false, "https", false, "https"}, + {"forwarded proto http", false, "http", true, "http"}, + {"tls connection", false, "", true, "https"}, + {"default http", false, "", false, "http"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := &http.Request{} + if c.fwdProto != "" { + req.Header = http.Header{"X-Forwarded-Proto": {c.fwdProto}} + } + if c.tls { + req.TLS = &tls.ConnectionState{} + } + if got := DetermineScheme(req, c.forceHTTPS); got != c.want { + t.Fatalf("DetermineScheme() = %q, want %q", got, c.want) + } + }) + } +} + +// TestDetermineHost verifies forwarding-header preference and fallback to req.Host. +func TestDetermineHost(t *testing.T) { + cases := []struct { + name string + fwd string + host string + want string + }{ + {"forwarded host used", "api.example.com", "backend.local", "api.example.com"}, + {"first of comma list taken", "a.example.com, b.example.com", "backend.local", "a.example.com"}, + {"empty forwarded falls back to host", "", "backend.local", "backend.local"}, + {"whitespace forwarded falls back", " ", "backend.local", "backend.local"}, + {"control-char forwarded rejected", "ok.example.com\r\nInjected: yes", "backend.local", "backend.local"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := &http.Request{Host: c.host} + if c.fwd != "" { + req.Header = http.Header{"X-Forwarded-Host": {c.fwd}} + } + if got := DetermineHost(req); got != c.want { + t.Fatalf("DetermineHost() = %q, want %q", got, c.want) + } + }) + } +} + +// TestSanitizeForwardedHost verifies CRLF/newline rejection so crafted headers cannot +// poison redirect URLs built from the result. +func TestSanitizeForwardedHost(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty rejected", "", ""}, + {"plain accepted", "api.example.com", "api.example.com"}, + {"first of comma list", "a.example.com,b.example.com", "a.example.com"}, + {"leading/trailing space trimmed", " api.example.com ", "api.example.com"}, + {"internal space rejected", "api example.com", ""}, + {"CRLF rejected", "api.example.com\r\nX-Injected: 1", ""}, + {"bare LF rejected", "api.example.com\ngo", ""}, + {"tab rejected", "api.example.com\x09evil", ""}, + {"DEL rejected", "api.example.com\x7f", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := sanitizeForwardedHost(c.in); got != c.want { + t.Fatalf("sanitizeForwardedHost(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} diff --git a/issue67_regression_test.go b/issue67_regression_test.go index 8d2b2a5..7ef5c4c 100644 --- a/issue67_regression_test.go +++ b/issue67_regression_test.go @@ -460,11 +460,15 @@ func TestRefreshCoordinatorIntegration(t *testing.T) { return nil, fmt.Errorf("service unavailable") } - // Trigger circuit breaker + // Trigger circuit breaker with genuinely distinct failing operations. + // The 100ms dedup delay keeps a rapid same-token call from joining + // the previous operation, so sleep so each iteration degrades into + // its own refresh operation and records one real failure. for i := 0; i < 4; i++ { ctx := context.Background() _, _ = coordinator.CoordinateRefresh(ctx, fmt.Sprintf("cb_session_%d", i), "refresh_cb", failingRefresh) + time.Sleep(150 * time.Millisecond) } // Next request should be blocked by circuit breaker diff --git a/main_initialization_test.go b/main_initialization_test.go index 6260f20..7b7d3e7 100644 --- a/main_initialization_test.go +++ b/main_initialization_test.go @@ -278,7 +278,7 @@ func TestInitializeMetadata_Concurrency(t *testing.T) { mu.Unlock() if finalCount != numInstances { - t.Logf("Made %d requests for %d instances (some may have been cached)", finalCount, numInstances) + t.Errorf("expected %d metadata requests from %d independent instances, got %d (some instances may have been blocked, deduplicated, or failed to initialize)", numInstances, numInstances, finalCount) } } diff --git a/refresh_coordinator.go b/refresh_coordinator.go index 254e690..77b4c32 100644 --- a/refresh_coordinator.go +++ b/refresh_coordinator.go @@ -244,14 +244,15 @@ func (rc *RefreshCoordinator) CoordinateRefresh( operation.mutex.RUnlock() if result != nil { - // Record metrics based on result + // Per-request counters only. The circuit breaker and per-session + // success/failure trackers must be recorded exactly once per + // refresh OPERATION (in executeRefreshAsync), not once per waiter: + // N requests coalescing onto a single refresh would otherwise + // multiply one upstream result into N failure records and + // over-trigger the circuit breaker (MaxFailures=3). if result.err != nil { - rc.circuitBreaker.RecordFailure() - rc.recordRefreshFailure(sessionID) atomic.AddInt64(&rc.metrics.failedRefreshes, 1) } else { - rc.circuitBreaker.RecordSuccess() - rc.recordRefreshSuccess(sessionID) atomic.AddInt64(&rc.metrics.successfulRefreshes, 1) } return result.tokenResponse, result.err @@ -371,7 +372,7 @@ func (rc *RefreshCoordinator) failCandidate(tokenHash string, op *refreshOperati // executeRefreshAsync performs the actual refresh operation asynchronously func (rc *RefreshCoordinator) executeRefreshAsync( operation *refreshOperation, - _ string, // sessionID - reserved for future metrics/logging + sessionID string, tokenHash string, refreshFunc func() (*TokenResponse, error), ) { @@ -426,6 +427,19 @@ func (rc *RefreshCoordinator) executeRefreshAsync( } operation.mutex.Unlock() } + + // Record the circuit breaker and per-session outcome exactly once per + // refresh operation. This function runs once per operation regardless of + // how many requests coalesced onto it (see CoordinateRefresh), so a + // single upstream refresh yields a single CB/session record rather than + // N (one per waiter), which would over-trigger the circuit breaker. + if err := operation.result.err; err != nil { + rc.circuitBreaker.RecordFailure() + rc.recordRefreshFailure(sessionID) + } else { + rc.circuitBreaker.RecordSuccess() + rc.recordRefreshSuccess(sessionID) + } } // scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning diff --git a/refresh_coordinator_test.go b/refresh_coordinator_test.go index 998a1c7..6b06acf 100644 --- a/refresh_coordinator_test.go +++ b/refresh_coordinator_test.go @@ -215,7 +215,9 @@ func TestCircuitBreakerProtection(t *testing.T) { return nil, fmt.Errorf("service unavailable") } - // Cause circuit breaker to trip + // Cause circuit breaker to trip with genuinely distinct failing + // operations (sleep so each same-token call is not absorbed as a join + // on the still-in-flight previous operation). var tripCount int for i := 0; i < 5; i++ { ctx := context.Background() @@ -225,6 +227,7 @@ func TestCircuitBreakerProtection(t *testing.T) { "refresh_token", refreshFunc, ) + time.Sleep(150 * time.Millisecond) if err != nil && err.Error() == "refresh circuit breaker is open due to repeated failures" { tripCount++ diff --git a/review_auth_flow_test.go b/review_auth_flow_test.go new file mode 100644 index 0000000..478deb3 --- /dev/null +++ b/review_auth_flow_test.go @@ -0,0 +1,90 @@ +package traefikoidc + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// TestAuthCallback_ExchangeFailureInvalidatesState guards the fix that +// persists the one-time auth-state cleanup when the authorization-code +// exchange fails. The browser's session cookie must no longer hold a valid +// CSRF/nonce/code_verifier so a partially-failed flow cannot be continued +// or the (possibly unconsumed) code replayed against a stale state. +func TestAuthCallback_ExchangeFailureInvalidatesState(t *testing.T) { + testLogger := newNoOpLogger() + + sessionManager, err := NewSessionManager( + "test-encryption-key-32-bytes-long!!", + false, + "", + "", + 0, + testLogger, + ) + if err != nil { + t.Fatalf("NewSessionManager: %v", err) + } + defer sessionManager.Shutdown() + + oidc := &TraefikOidc{ + sessionManager: sessionManager, + logger: testLogger, + enablePKCE: true, + tokenExchanger: &EnhancedMockTokenExchanger{ExchangeErr: errors.New("token exchange failed")}, + } + + csrfToken := "valid-csrf-token" + req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil) + rw := httptest.NewRecorder() + + session, err := sessionManager.GetSession(req) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + session.SetCSRF(csrfToken) + session.SetNonce("test-nonce") + session.SetCodeVerifier("test-code-verifier") + if err := session.Save(req, rw); err != nil { + t.Fatalf("Save: %v", err) + } + session.returnToPoolSafely() + + // Drive the callback with the session cookies from the first save. + req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil) + for _, c := range rw.Result().Cookies() { + req2.AddCookie(c) + } + rw2 := httptest.NewRecorder() + oidc.handleCallback(rw2, req2, "https://example.com/callback") + + if rw2.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 on exchange failure, got %d", rw2.Code) + } + + // Reload the session from the *callback response* cookies. The + // one-time auth state must have been cleared and persisted. + req3 := httptest.NewRequest(http.MethodGet, "/callback", nil) + if n := len(rw2.Result().Cookies()); n == 0 { + t.Fatal("expected session cookies in the callback response") + } + for _, c := range rw2.Result().Cookies() { + req3.AddCookie(c) + } + session3, err := sessionManager.GetSession(req3) + if err != nil { + t.Fatalf("GetSession after callback: %v", err) + } + defer session3.returnToPoolSafely() + + if got := session3.GetCSRF(); got != "" { + t.Errorf("expected CSRF cleared after failed exchange, got %q", got) + } + if got := session3.GetNonce(); got != "" { + t.Errorf("expected nonce cleared after failed exchange, got %q", got) + } + if got := session3.GetCodeVerifier(); got != "" { + t.Errorf("expected code_verifier cleared after failed exchange, got %q", got) + } +} diff --git a/review_bgtask_recover_test.go b/review_bgtask_recover_test.go new file mode 100644 index 0000000..b651516 --- /dev/null +++ b/review_bgtask_recover_test.go @@ -0,0 +1,35 @@ +package traefikoidc + +import ( + "sync/atomic" + "testing" + "time" +) + +// TestReview_BackgroundTaskRecoversFromPanic verifies that a panicking +// background task function is recovered and the periodic loop keeps running. +// Previously a panic in taskFunc unwound the goroutine, permanently killing +// the background task (metadata refresh, autocleanup, memory monitor) with no +// log. An unrecovered panic here would crash the whole test binary. +func TestReview_BackgroundTaskRecoversFromPanic(t *testing.T) { + ResetGlobalTaskRegistry() + defer ResetGlobalTaskRegistry() + + var runCount int32 + logger := newNoOpLogger() + task := NewBackgroundTask("review-recover-task", 40*time.Millisecond, func() { + n := atomic.AddInt32(&runCount, 1) + if n == 1 { + panic("review: first run panics") + } + }, logger) + task.Start() + defer task.Stop() + + // Give the task time to run several intervals past the initial panic. + time.Sleep(GetTestDuration(250 * time.Millisecond)) + + if got := atomic.LoadInt32(&runCount); got < 2 { + t.Fatalf("Background task should have recovered and continued running after the panic; runCount=%d", got) + } +} diff --git a/review_failopen_regression_test.go b/review_failopen_regression_test.go new file mode 100644 index 0000000..b397d45 --- /dev/null +++ b/review_failopen_regression_test.go @@ -0,0 +1,127 @@ +package traefikoidc + +import ( + "net/http/httptest" + "testing" + "time" +) + +// TestReviewFailClosed_ExpiredJWTNoIDToken verifies the fail-closed contract: +// a JWT access token that fails verification (expired) with no ID token and +// no refresh token must NOT authenticate. Previously this path returned +// (authenticated=true) — fail-open, trusting an invalid access token. +func TestReviewFailClosed_ExpiredJWTNoIDToken(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Validly-signed but already-expired access token. + accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ + "iss": "https://test-issuer.com", + "aud": "test-client-id", + "exp": float64(time.Now().Add(-1 * time.Hour).Unix()), + "iat": float64(time.Now().Add(-2 * time.Hour).Unix()), + "sub": "test-user", + "jti": "expired-access-jti", + }) + if err != nil { + t.Fatalf("Failed to create access token: %v", err) + } + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + session, err := ts.tOidc.sessionManager.GetSession(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + session.SetAuthenticated(true) + session.SetAccessToken(accessToken) + // No ID token, no refresh token: nothing left to corroborate the access token. + + rs := (&requestState{}).captureSession(session) + authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs) + if authenticated { + t.Errorf("Expired JWT access token with no ID token must NOT authenticate. Got auth=%v refresh=%v expired=%v", + authenticated, needsRefresh, expired) + } + if !expired { + t.Errorf("Expired JWT access token with no ID token should force re-authentication (expired=true). Got expired=%v", expired) + } + if needsRefresh { + t.Errorf("No refresh token present, needsRefresh must be false. Got %v", needsRefresh) + } +} + +// TestReviewFailClosed_ExpiredJWTWithRefreshRequestsRefresh verifies that an +// expired JWT access token with no ID token but an available refresh token is +// not authenticated but signals a refresh rather than failing open. +func TestReviewFailClosed_ExpiredJWTWithRefreshRequestsRefresh(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ + "iss": "https://test-issuer.com", + "aud": "test-client-id", + "exp": float64(time.Now().Add(-1 * time.Hour).Unix()), + "iat": float64(time.Now().Add(-2 * time.Hour).Unix()), + "sub": "test-user", + "jti": "expired-access-with-refresh-jti", + }) + if err != nil { + t.Fatalf("Failed to create access token: %v", err) + } + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + session, err := ts.tOidc.sessionManager.GetSession(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + session.SetAuthenticated(true) + session.SetAccessToken(accessToken) + session.SetRefreshToken("refresh-token") + + rs := (&requestState{}).captureSession(session) + authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs) + if authenticated { + t.Errorf("Expired access token must not authenticate even with refresh available. Got auth=%v", authenticated) + } + if !needsRefresh { + t.Errorf("Expired access token with refresh token available should request refresh. Got needsRefresh=%v", needsRefresh) + } + if expired { + t.Errorf("With refresh available, should prefer refresh over forced expiry. Got expired=%v", expired) + } +} + +// TestReview_ValidJWTAccessTokenNoIDToken_StillAuthenticates guards against +// over-correction: a still-valid JWT access token with no ID token must +// continue to authenticate (used by access-token-only providers like GitHub). +func TestReview_ValidJWTAccessTokenNoIDToken_StillAuthenticates(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ + "iss": "https://test-issuer.com", + "aud": "test-client-id", + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "iat": float64(time.Now().Add(-2 * time.Minute).Unix()), + "sub": "test-user", + "jti": "valid-access-jti", + }) + if err != nil { + t.Fatalf("Failed to create access token: %v", err) + } + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + session, err := ts.tOidc.sessionManager.GetSession(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + session.SetAuthenticated(true) + session.SetAccessToken(accessToken) + + rs := (&requestState{}).captureSession(session) + authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs) + if !authenticated { + t.Errorf("Valid JWT access token with no ID token should authenticate. Got auth=%v refresh=%v expired=%v", + authenticated, needsRefresh, expired) + } +} diff --git a/review_redis_env_test.go b/review_redis_env_test.go new file mode 100644 index 0000000..66eea0a --- /dev/null +++ b/review_redis_env_test.go @@ -0,0 +1,52 @@ +package traefikoidc + +import "testing" + +// TestRedisConfig_ApplyEnvFallbacks_Resilience guards the fix that wires the +// documented REDIS_* resilience env fallbacks (examples/redis-config.yaml) into +// ApplyEnvFallbacks. Previously an operator following the documented names got +// no effect: the fields existed but the env vars were never read. +func TestRedisConfig_ApplyEnvFallbacks_Resilience(t *testing.T) { + t.Setenv("REDIS_ENABLED", "true") + t.Setenv("REDIS_ENABLE_CIRCUIT_BREAKER", "true") + t.Setenv("REDIS_CIRCUIT_BREAKER_THRESHOLD", "7") + t.Setenv("REDIS_CIRCUIT_BREAKER_TIMEOUT", "90") + t.Setenv("REDIS_ENABLE_HEALTH_CHECK", "true") + t.Setenv("REDIS_HEALTH_CHECK_INTERVAL", "45") + + rc := &RedisConfig{} + rc.ApplyEnvFallbacks() + + if !rc.Enabled { + t.Error("expected Redis enabled from REDIS_ENABLED") + } + if !rc.EnableCircuitBreaker { + t.Error("expected EnableCircuitBreaker from REDIS_ENABLE_CIRCUIT_BREAKER") + } + if rc.CircuitBreakerThreshold != 7 { + t.Errorf("expected CircuitBreakerThreshold 7, got %d", rc.CircuitBreakerThreshold) + } + if rc.CircuitBreakerTimeout != 90 { + t.Errorf("expected CircuitBreakerTimeout 90, got %d", rc.CircuitBreakerTimeout) + } + if !rc.EnableHealthCheck { + t.Error("expected EnableHealthCheck from REDIS_ENABLE_HEALTH_CHECK") + } + if rc.HealthCheckInterval != 45 { + t.Errorf("expected HealthCheckInterval 45, got %d", rc.HealthCheckInterval) + } +} + +// TestRedisConfig_ApplyEnvFallbacks_ConfigTakesPrecedence ensures explicitly-set +// config fields are not overridden by the env fallbacks. +func TestRedisConfig_ApplyEnvFallbacks_ConfigTakesPrecedence(t *testing.T) { + t.Setenv("REDIS_ENABLED", "true") + t.Setenv("REDIS_CIRCUIT_BREAKER_THRESHOLD", "9") + + rc := &RedisConfig{Enabled: true, CircuitBreakerThreshold: 4} + rc.ApplyEnvFallbacks() + + if rc.CircuitBreakerThreshold != 4 { + t.Errorf("expected config value 4 to take precedence, got %d", rc.CircuitBreakerThreshold) + } +} diff --git a/review_refresh_cb_test.go b/review_refresh_cb_test.go new file mode 100644 index 0000000..4dace2f --- /dev/null +++ b/review_refresh_cb_test.go @@ -0,0 +1,116 @@ +package traefikoidc + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestRefreshCoordinator_SingleCircuitBreakerRecordPerOperation guards the +// fix that moves circuit-breaker / per-session recording into +// executeRefreshAsync so it runs exactly once per refresh OPERATION. +// +// Regression: CoordinateRefresh previously recorded cb.RecordFailure and +// recordRefreshFailure once per WAITER. When N requests coalesced onto a +// single failing refresh operation, one upstream failure was written as N +// failures — over-triggering the circuit breaker (MaxFailures=3) from a +// single provider error. +func TestRefreshCoordinator_SingleCircuitBreakerRecordPerOperation(t *testing.T) { + logger := GetSingletonNoOpLogger() + config := DefaultRefreshCoordinatorConfig() + config.MaxRefreshAttempts = 1000 // keep cooldown out of the way + coordinator := NewRefreshCoordinator(config, logger) + defer coordinator.Shutdown() + + const waiters = 4 // >= MaxFailures(3): enough to over-trigger the old behavior + + // One shared failing refresh operation. + var upstreamCalls int32 + failingRefresh := func() (*TokenResponse, error) { + atomic.AddInt32(&upstreamCalls, 1) + return nil, errors.New("provider_down") + } + + sameToken := "shared_rotating_token" + sameSession := "shared_session" + + var wg sync.WaitGroup + wg.Add(waiters) + var gotErrs int32 + for range waiters { + go func() { + defer wg.Done() + _, err := coordinator.CoordinateRefresh(context.Background(), sameSession, sameToken, failingRefresh) + if err != nil { + atomic.AddInt32(&gotErrs, 1) + } + }() + } + wg.Wait() + + if atomic.LoadInt32(&gotErrs) != waiters { + t.Fatalf("all waiters should observe the failure, got %d/%d errors", gotErrs, waiters) + } + if atomic.LoadInt32(&upstreamCalls) != 1 { + t.Fatalf("one shared operation must make exactly one upstream call, got %d", upstreamCalls) + } + + // A single upstream failure (one operation) must leave the circuit + // breaker CLOSED: 1 failure < MaxFailures(3). Before the fix, 4 + // waiters recorded 4 failures and opened it. + success := func() (*TokenResponse, error) { + return &TokenResponse{ + AccessToken: "new_access_token", + RefreshToken: "new_refresh_token", + IDToken: "new_id_token", + ExpiresIn: 3600, + }, nil + } + + resp, err := coordinator.CoordinateRefresh(context.Background(), "other_session", "other_token", success) + if err != nil { + t.Fatalf("follow-up refresh must be allowed while the circuit breaker is closed (got %v): %v", + coordinator.circuitBreaker.GetState(), err) + } + if resp == nil || resp.AccessToken != "new_access_token" { + t.Fatalf("expected successful follow-up refresh, got %+v", resp) + } +} + +// TestRefreshCoordinator_SuccessOnlyClosesAfterRealFailures verifies that +// several DISTINCT failing operations still trip the breaker as intended +// (sanity check that the once-per-operation recording did not neuter it). +func TestRefreshCoordinator_SuccessOnlyAfterCombinedOperations(t *testing.T) { + logger := GetSingletonNoOpLogger() + config := DefaultRefreshCoordinatorConfig() + config.MaxRefreshAttempts = 1000 + coordinator := NewRefreshCoordinator(config, logger) + defer coordinator.Shutdown() + + failingRefresh := func() (*TokenResponse, error) { + return nil, errors.New("provider_down") + } + + // MaxFailures=3 -> three distinct failing operations must open it. + for range 3 { + _, err := coordinator.CoordinateRefresh( + context.Background(), + "target_session", + "target_token", // same token so all three are distinct sequential ops + failingRefresh, + ) + if err == nil { + t.Fatal("expected refresh to fail") + } + // Let the op drain from the in-flight map so the next attempt is a + // new operation (not a join on the same one). + time.Sleep(150 * time.Millisecond) + } + + if got := coordinator.circuitBreaker.GetState(); got != "open" { + t.Fatalf("three real failures should open the circuit breaker, got state %q", got) + } +} diff --git a/settings.go b/settings.go index 882f928..a0a3776 100644 --- a/settings.go +++ b/settings.go @@ -1159,6 +1159,41 @@ func (rc *RedisConfig) ApplyEnvFallbacks() { } } } + + // Resilience fallbacks documented in examples/redis-config.yaml. These were + // previously absent: an operator following the documented env var names got + // no effect. Match the bool/numeric patterns used above. + if !rc.EnableCircuitBreaker { + if env := os.Getenv("REDIS_ENABLE_CIRCUIT_BREAKER"); env == "true" || env == "1" { + rc.EnableCircuitBreaker = true + } + } + if rc.CircuitBreakerThreshold == 0 { + if s := os.Getenv("REDIS_CIRCUIT_BREAKER_THRESHOLD"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + rc.CircuitBreakerThreshold = v + } + } + } + if rc.CircuitBreakerTimeout == 0 { + if s := os.Getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + rc.CircuitBreakerTimeout = v + } + } + } + if !rc.EnableHealthCheck { + if env := os.Getenv("REDIS_ENABLE_HEALTH_CHECK"); env == "true" || env == "1" { + rc.EnableHealthCheck = true + } + } + if rc.HealthCheckInterval == 0 { + if s := os.Getenv("REDIS_HEALTH_CHECK_INTERVAL"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + rc.HealthCheckInterval = v + } + } + } } func isOriginAllowed(origin string, allowedOrigins []string) bool { diff --git a/token_manager.go b/token_manager.go index 8ae46b0..7345745 100644 --- a/token_manager.go +++ b/token_manager.go @@ -140,7 +140,7 @@ func (t *TraefikOidc) verifyTokenWithOpts(token string, opts verifyOpts) error { if expClaim, expOk := jwt.Claims["exp"].(float64); expOk { expTime := time.Unix(int64(expClaim), 0) tokenDuration := time.Until(expTime) - if tokenDuration > defaultBlacklistDuration && tokenDuration < (24*time.Hour) { + if tokenDuration > 0 && tokenDuration < defaultBlacklistDuration { expiry = expTime } // else: keep default expiry for expired tokens or tokens >24h diff --git a/token_validation_rs.go b/token_validation_rs.go index f3d9ad8..ebca83a 100644 --- a/token_validation_rs.go +++ b/token_validation_rs.go @@ -190,6 +190,7 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo // JWT access token present. accessTokenValid := false + lenientAudienceOnly := false if err := t.verifyToken(rs.accessToken); err != nil { errMsg := err.Error() if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") { @@ -199,7 +200,10 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo } return false, false, true } - // Fall through to ID-token validation. + // Lenient audience validation: the token's signature verified; only + // the audience was left unchecked. Fall through to ID-token + // validation, remembering the access token was structurally valid. + lenientAudienceOnly = true } } else { accessTokenValid = true @@ -209,10 +213,22 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo if accessTokenValid { return t.validateTokenExpiryRS(rs, rs.accessToken) } - if rs.refreshToken != "" { - return true, true, false + if lenientAudienceOnly { + // Access token signature verified; audience check was lenient and + // there is no ID token to compare against. Treat as authenticated. + if rs.refreshToken != "" { + return true, true, false + } + return true, false, false } - return true, false, false + // Access token failed verification (expired, bad signature, issuer, ...) + // and there is no ID token to corroborate it. Fail closed rather than + // trusting an unverified token: refresh if possible, otherwise force + // re-authentication. + if rs.refreshToken != "" { + return false, true, false + } + return false, false, true } if err := t.verifyToken(rs.idToken); err != nil { diff --git a/utilities.go b/utilities.go index e2d34b5..85bef8d 100644 --- a/utilities.go +++ b/utilities.go @@ -240,7 +240,8 @@ func (t *TraefikOidc) Close() error { // instance. Only stop it when the LAST instance is shutting down; // otherwise one instance's teardown (e.g. a single config reload) would // kill chunked-session/token cleanup for all surviving instances (rank 12). - if unregisterLiveInstance() <= 0 { + lastInstance := unregisterLiveInstance() <= 0 + if lastInstance { _ = rm.StopBackgroundTask("singleton-token-cleanup") // best effort, last instance only } // Stop metadata refresh task using same hash-based name as startMetadataRefresh @@ -343,10 +344,17 @@ func (t *TraefikOidc) Close() error { t.safeLogDebug("Error recovery manager graceful degradation closed") } - // Stop all global background tasks - taskRegistry := GetGlobalTaskRegistry() - taskRegistry.StopAllTasks() - t.safeLogDebug("All global background tasks stopped") + // Stop all process-global background tasks, but ONLY when the last + // instance is shutting down. The global TaskRegistry holds shared + // singleton tasks (singleton-token-cleanup, singleton-metadata-refresh-*, + // memory-monitor); stopping them on any single instance's Close (e.g. one + // config reload) would kill cleanup for all surviving instances — the same + // rationale as the targeted StopBackgroundTask above. + if lastInstance { + taskRegistry := GetGlobalTaskRegistry() + taskRegistry.StopAllTasks() + t.safeLogDebug("All global background tasks stopped") + } // Note: Centralized pool in internal/pool is singleton-managed and doesn't require explicit cleanup t.safeLogDebug("Memory pools managed by singleton pattern")