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{
- "",
- "'; 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")