Audit review (rounds 1-31): fix confirmed issues, remove dead code, harden tests

Security/correctness fixes:
- fail-open token validation preserved with lenient-audience; access token that
  fails verification and has no ID token to corroborate now fails closed
- token_manager blacklist-expiry: fix impossible tautology (x>24h && x<24h)
- constant-time OAuth state compare; clear state/nonce/code_verifier on
  callback token-exchange failure to prevent replay of half-failed flows
- StopAllTasks gated on last-instance close
- background-task panic recovery; remove task-registry counter leak
- circuit breaker half-open: release probe slot; CB + per-session outcome
  recorded once per refresh operation, not once per waiter
- Redis connection pool: handle nil receive when pool closed concurrently
- FileStore: atomic temp-write + rename
- errors_recovery health-check data race -> atomic

Test-isolation / hardening:
- reset helpers for Once-pinned global cache manager and transport pool
- coverage: utils 70->93% (redirect URL security contract); fuzz targets for
  bearer parsers; concurrent ServeHTTP race tests for the shared request path

Dead-code removal (verified orphaned, build+suite green): internal/providers
(30 files, 0 importers), internal/compat, internal/features, input_validation.

Verified: govulncheck, go mod tidy, yaegi-validate, cross-compile (darwin+
linux), golangci-lint (new-from-rev clean), 3x full race+shuffle green.
This commit is contained in:
2026-08-12 23:08:03 +01:00
parent f728f61777
commit 2ff14556d0
66 changed files with 1064 additions and 10046 deletions
+12 -1
View File
@@ -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
}
+24 -5
View File
@@ -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)
+46
View File
@@ -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)
}
})
}
+5
View File
@@ -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)
+95
View File
@@ -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()
}
+12 -6
View File
@@ -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()
-727
View File
@@ -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{
"<script", "</script>", "javascript:", "vbscript:",
"onload=", "onerror=", "onclick=", "onmouseover=",
"<iframe", "<object", "<embed", "<link", "<meta",
},
pathTraversalPatterns: []string{
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
"..%2f", "..%5c", "%252e%252e%252f",
},
logger: logger,
}, nil
}
// ValidateToken validates JWT tokens and similar token strings
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty token
if token == "" {
result.IsValid = false
result.Errors = append(result.Errors, "token cannot be empty")
return result
}
// Check length limits
if len(token) > iv.maxTokenLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
return result
}
// Check for minimum reasonable length
if len(token) < 10 {
result.IsValid = false
result.Errors = append(result.Errors, "token is too short to be valid")
return result
}
// Check for valid JWT structure (3 parts separated by dots)
parts := strings.Split(token, ".")
if len(parts) != 3 {
result.IsValid = false
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
return result
}
// Validate each part is base64url encoded
for i, part := range parts {
if !iv.isValidBase64URL(part) {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
return result
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(token); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for null bytes and control characters
if iv.containsNullBytes(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains null bytes")
return result
}
if iv.containsControlCharacters(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
return result
}
result.SanitizedValue = token
return result
}
// ValidateEmail validates email addresses
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty email
if email == "" {
result.IsValid = false
result.Errors = append(result.Errors, "email cannot be empty")
return result
}
// Check length limits
if len(email) > iv.maxEmailLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
return result
}
// Sanitize email (trim whitespace, convert to lowercase)
sanitized := strings.TrimSpace(strings.ToLower(email))
// Check regex pattern
if !iv.emailRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "email format is invalid")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Additional email-specific validations
parts := strings.Split(sanitized, "@")
if len(parts) != 2 {
result.IsValid = false
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
return result
}
localPart, domain := parts[0], parts[1]
// Validate local part
if len(localPart) == 0 || len(localPart) > 64 {
result.IsValid = false
result.Errors = append(result.Errors, "email local part length is invalid")
return result
}
// Validate domain
if len(domain) == 0 || len(domain) > 253 {
result.IsValid = false
result.Errors = append(result.Errors, "email domain length is invalid")
return result
}
// Check for consecutive dots
if strings.Contains(sanitized, "..") {
result.IsValid = false
result.Errors = append(result.Errors, "email contains consecutive dots")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateURL validates URLs
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty URL
if urlStr == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL cannot be empty")
return result
}
// Check length limits
if len(urlStr) > iv.maxURLLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
return result
}
// Sanitize URL (trim whitespace)
sanitized := strings.TrimSpace(urlStr)
// Parse URL
parsedURL, err := url.Parse(sanitized)
if err != nil {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
return result
}
// Check scheme
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
result.IsValid = false
result.Errors = append(result.Errors, "URL scheme must be http or https")
return result
}
// Prefer HTTPS
if parsedURL.Scheme == "http" {
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
}
// Check host
if parsedURL.Host == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL must have a valid host")
return result
}
// Check for localhost or private IPs for security
// Allow localhost for HTTPS (development/testing) but warn about it
hostname := strings.ToLower(parsedURL.Hostname())
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
if parsedURL.Scheme == "https" {
// Allow HTTPS localhost for development but warn
result.Warnings = append(result.Warnings, "localhost URLs should only be used for development/testing")
} else {
// Reject non-HTTPS localhost for security
result.IsValid = false
result.Errors = append(result.Errors, "non-HTTPS localhost URLs are not allowed for security")
return result
}
}
// Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled
if !iv.allowPrivateIPAddresses {
if strings.HasPrefix(hostname, "10.") ||
strings.HasPrefix(hostname, "192.168.") ||
strings.HasPrefix(hostname, "172.") {
// For 172.x check if it's in the 172.16.0.0/12 range
if strings.HasPrefix(hostname, "172.") {
parts := strings.Split(hostname, ".")
if len(parts) >= 2 {
if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 {
result.IsValid = false
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
return result
}
}
} else {
result.IsValid = false
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
return result
}
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for path traversal attempts
if iv.containsPathTraversal(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "URL contains path traversal patterns")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateUsername validates usernames
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty username
if username == "" {
result.IsValid = false
result.Errors = append(result.Errors, "username cannot be empty")
return result
}
// Check length limits
if len(username) > iv.maxUsernameLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
return result
}
// Check minimum length
if len(username) < 2 {
result.IsValid = false
result.Errors = append(result.Errors, "username must be at least 2 characters long")
return result
}
// Sanitize username (trim whitespace)
sanitized := strings.TrimSpace(username)
// Check regex pattern
if !iv.usernameRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = sanitized
return result
}
// ValidateClaim validates individual JWT claims
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check claim name
if claimName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "claim name cannot be empty")
return result
}
// Check claim value length
if len(claimValue) > iv.maxClaimLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
return result
}
// Check for null bytes and control characters
if iv.containsNullBytes(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains null bytes")
return result
}
if iv.containsControlCharacters(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
result.SecurityRisk = risk
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
return result
}
// Check for excessive unicode (emojis and special characters)
unicodeCount := 0
runeCount := 0
for _, r := range claimValue {
runeCount++
if r > 127 { // Non-ASCII character
unicodeCount++
}
}
// If more than 50% of the characters are unicode, consider it suspicious
if runeCount > 0 && unicodeCount > runeCount/2 {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains excessive unicode characters")
return result
}
// Specific validations based on claim name
switch claimName {
case "email":
emailResult := iv.ValidateEmail(claimValue)
if !emailResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, emailResult.Errors...)
}
result.Warnings = append(result.Warnings, emailResult.Warnings...)
result.SanitizedValue = emailResult.SanitizedValue
case "iss", "aud":
urlResult := iv.ValidateURL(claimValue)
if !urlResult.IsValid {
// For issuer/audience, we're more lenient - just warn
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
}
result.SanitizedValue = claimValue
case "preferred_username", "username":
usernameResult := iv.ValidateUsername(claimValue)
if !usernameResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, usernameResult.Errors...)
}
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
result.SanitizedValue = usernameResult.SanitizedValue
default:
// Generic string validation
result.SanitizedValue = strings.TrimSpace(claimValue)
}
return result
}
// ValidateHeader validates HTTP header values
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check header name
if headerName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "header name cannot be empty")
return result
}
// Check for control characters in header name (including CRLF)
if iv.containsControlCharacters(headerName) {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains control characters")
return result
}
// Check for CRLF injection in header name
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
return result
}
// Check header value length
if len(headerValue) > iv.maxHeaderLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
return result
}
// Check for null bytes and control characters (except allowed ones)
if iv.containsNullBytes(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains null bytes")
return result
}
// Check for CRLF injection
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
return result
}
// Check for control characters in header value
if iv.containsControlCharacters(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
result.SecurityRisk = risk
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
return result
}
result.SanitizedValue = strings.TrimSpace(headerValue)
return result
}
// isValidBase64URL checks if a string is valid base64url encoding
func (iv *InputValidator) isValidBase64URL(s string) bool {
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
for _, r := range s {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// containsNullBytes checks if a string contains null bytes
func (iv *InputValidator) containsNullBytes(s string) bool {
return strings.Contains(s, "\x00")
}
// containsControlCharacters checks if a string contains control characters
func (iv *InputValidator) containsControlCharacters(s string) bool {
for _, r := range s {
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
}
// containsPathTraversal checks for path traversal patterns
func (iv *InputValidator) containsPathTraversal(s string) bool {
lowerS := strings.ToLower(s)
for _, pattern := range iv.pathTraversalPatterns {
if strings.Contains(lowerS, pattern) {
return true
}
}
return false
}
// detectSecurityRisk detects potential security risks in input
func (iv *InputValidator) detectSecurityRisk(input string) string {
lowerInput := strings.ToLower(input)
// Check for SQL injection patterns
for _, pattern := range iv.sqlInjectionPatterns {
if strings.Contains(lowerInput, pattern) {
return "sql_injection"
}
}
// Check for XSS patterns
for _, pattern := range iv.xssPatterns {
if strings.Contains(lowerInput, pattern) {
return "xss"
}
}
// Check for path traversal
if iv.containsPathTraversal(input) {
return "path_traversal"
}
// Check for excessive length (potential DoS)
if len(input) > 10000 {
return "excessive_length"
}
// Check for suspicious character patterns
if iv.containsNullBytes(input) {
return "null_bytes"
}
// Check for binary data patterns
nonPrintableCount := 0
for _, r := range input {
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
nonPrintableCount++
}
}
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
return "binary_data"
}
return ""
}
// SanitizeInput provides general input sanitization
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
// Trim whitespace
sanitized := strings.TrimSpace(input)
// Truncate if too long
if len(sanitized) > maxLength {
sanitized = sanitized[:maxLength]
}
// Remove null bytes
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
// Remove other control characters except tab, newline, carriage return
var result strings.Builder
for _, r := range sanitized {
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
result.WriteRune(r)
}
}
return result.String()
}
// ValidateBoundaryValues validates numeric boundary values
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
var numValue int64
switch v := value.(type) {
case int:
numValue = int64(v)
case int32:
numValue = int64(v)
case int64:
numValue = v
case float64:
numValue = int64(v)
if float64(numValue) != v {
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
}
default:
result.IsValid = false
result.Errors = append(result.Errors, "value is not a numeric type")
return result
}
if numValue < min {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
}
if numValue > max {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
}
return result
}
-895
View File
@@ -1,895 +0,0 @@
package traefikoidc
import (
"strings"
"testing"
)
func TestInputValidator(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid token validation", func(t *testing.T) {
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc" // trufflehog:ignore
result := validator.ValidateToken(validToken)
if !result.IsValid {
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
}
})
t.Run("Invalid token validation", func(t *testing.T) {
invalidTokens := []string{
"", // Empty token
"invalid.token", // Invalid format
"a.b", // Too few parts
"a.b.c.d", // Too many parts
}
for _, token := range invalidTokens {
result := validator.ValidateToken(token)
if result.IsValid {
t.Errorf("Expected invalid token '%s' to fail validation", token)
}
}
})
t.Run("Valid email validation", func(t *testing.T) {
validEmails := []string{
"[email protected]",
"[email protected]",
"[email protected]",
}
for _, email := range validEmails {
result := validator.ValidateEmail(email)
if !result.IsValid {
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
}
}
})
t.Run("Invalid email validation", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"invalid", // No @ symbol
"@domain.com", // No local part
"user@", // No domain
"user@domain", // No TLD
"[email protected]", // Double dots
}
for _, email := range invalidEmails {
result := validator.ValidateEmail(email)
if result.IsValid {
t.Errorf("Expected invalid email '%s' to fail validation", email)
}
}
})
t.Run("Valid URL validation", func(t *testing.T) {
validURLs := []string{
"https://example.com",
"https://sub.domain.com/path",
"https://localhost:8080/callback",
}
for _, url := range validURLs {
result := validator.ValidateURL(url)
if !result.IsValid {
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
}
}
})
t.Run("Invalid URL validation", func(t *testing.T) {
invalidURLs := []string{
"", // Empty
"not-a-url", // Invalid format
"ftp://example.com", // Wrong scheme
"https://", // No host
}
for _, url := range invalidURLs {
result := validator.ValidateURL(url)
if result.IsValid {
t.Errorf("Expected invalid URL '%s' to fail validation", url)
}
}
})
t.Run("Valid username validation", func(t *testing.T) {
validUsernames := []string{
"user123",
"test_user",
"user-name",
}
for _, username := range validUsernames {
result := validator.ValidateUsername(username)
if !result.IsValid {
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
}
}
})
t.Run("Invalid username validation", func(t *testing.T) {
invalidUsernames := []string{
"", // Empty
"a", // Too short
strings.Repeat("a", 100), // Too long
"user name", // Spaces
}
for _, username := range invalidUsernames {
result := validator.ValidateUsername(username)
if result.IsValid {
t.Errorf("Expected invalid username '%s' to fail validation", username)
}
}
})
t.Run("Valid claim validation", func(t *testing.T) {
validClaims := map[string]string{
"sub": "user123",
"email": "[email protected]",
"name": "John Doe",
}
for key, value := range validClaims {
result := validator.ValidateClaim(key, value)
if !result.IsValid {
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid claim validation", func(t *testing.T) {
invalidClaims := map[string]string{
"": "value", // Empty key
"long_key": strings.Repeat("a", 10000), // Too long value
}
for key, value := range invalidClaims {
result := validator.ValidateClaim(key, value)
if result.IsValid {
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
}
}
})
t.Run("Valid header validation", func(t *testing.T) {
validHeaders := map[string]string{
"Authorization": "Bearer token123",
"Content-Type": "application/json",
"X-Custom": "custom-value",
}
for key, value := range validHeaders {
result := validator.ValidateHeader(key, value)
if !result.IsValid {
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid header validation", func(t *testing.T) {
invalidHeaders := map[string]string{
"": "value", // Empty key
"Invalid\nKey": "value", // Control characters in key
"key": "value\r\n", // Control characters in value
}
for key, value := range invalidHeaders {
result := validator.ValidateHeader(key, value)
if result.IsValid {
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
}
}
})
}
func TestSanitizeInput(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
tests := []struct {
name string
input string
expected string
maxLen int
}{
{
name: "Normal text",
input: "Hello World",
maxLen: 100,
expected: "Hello World",
},
{
name: "Control characters",
input: "text\x00with\x01control\x02chars",
maxLen: 100,
expected: "textwithcontrolchars",
},
{
name: "Truncation",
input: "very long text",
maxLen: 5,
expected: "very ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.SanitizeInput(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestValidateBoundaryValues(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid boundary values", func(t *testing.T) {
validValues := []interface{}{
int(50),
int64(100),
float64(75.5),
}
for _, value := range validValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if !result.IsValid {
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
}
}
})
t.Run("Invalid boundary values", func(t *testing.T) {
invalidValues := []interface{}{
int(-1),
int64(2000),
"not a number",
}
for _, value := range invalidValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if result.IsValid {
t.Errorf("Expected invalid boundary value %v to fail validation", value)
}
}
})
}
func TestDefaultInputValidationConfig(t *testing.T) {
config := DefaultInputValidationConfig()
if config.MaxTokenLength <= 0 {
t.Error("Expected positive MaxTokenLength")
}
if config.MaxEmailLength <= 0 {
t.Error("Expected positive MaxEmailLength")
}
if config.MaxUsernameLength <= 0 {
t.Error("Expected positive MaxUsernameLength")
}
if config.MaxClaimLength <= 0 {
t.Error("Expected positive MaxClaimLength")
}
if config.MaxHeaderLength <= 0 {
t.Error("Expected positive MaxHeaderLength")
}
if !config.StrictMode {
t.Error("Expected StrictMode to be true by default")
}
}
func TestInputValidationHelpers(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("isValidBase64URL", func(t *testing.T) {
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
if !validator.isValidBase64URL(validBase64URL) {
t.Error("Expected valid base64url to be recognized")
}
invalidBase64URL := "invalid+base64/with+padding="
if validator.isValidBase64URL(invalidBase64URL) {
t.Error("Expected invalid base64url to be rejected")
}
})
t.Run("containsNullBytes", func(t *testing.T) {
withNull := "text\x00with\x00null"
if !validator.containsNullBytes(withNull) {
t.Error("Expected string with null bytes to be detected")
}
withoutNull := "normal text"
if validator.containsNullBytes(withoutNull) {
t.Error("Expected string without null bytes to pass")
}
})
t.Run("containsControlCharacters", func(t *testing.T) {
withControl := "text\x01with\x02control"
if !validator.containsControlCharacters(withControl) {
t.Error("Expected string with control characters to be detected")
}
withoutControl := "normal text"
if validator.containsControlCharacters(withoutControl) {
t.Error("Expected string without control characters to pass")
}
})
t.Run("containsPathTraversal", func(t *testing.T) {
withTraversal := "../../../etc/passwd"
if !validator.containsPathTraversal(withTraversal) {
t.Error("Expected path traversal to be detected")
}
normalPath := "/normal/path"
if validator.containsPathTraversal(normalPath) {
t.Error("Expected normal path to pass")
}
})
t.Run("detectSecurityRisk", func(t *testing.T) {
riskyInputs := []string{
"<script>alert('xss')</script>",
"'; 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: "[email protected]",
expectValid: true,
description: "Valid email should pass validation",
},
{
name: "ValidEmailWithSubdomain",
email: "[email protected]",
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\[email protected]",
expectValid: false,
description: "Email with control characters should fail validation",
},
{
name: "MaliciousEmailWithScriptTag",
email: "user<script>@example.com",
expectValid: false,
description: "Email with script tag should fail validation",
},
{
name: "EmailWithUnicodeCharacters",
email: "üser@éxample.com",
expectValid: false,
description: "Email with unicode should fail basic validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateEmail(tt.email)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateURL tests URL validation with security focus
func TestInputValidatorValidateURL(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
url string
description string
expectValid bool
}{
{
name: "ValidHTTPSURL",
url: "https://example.com/path",
expectValid: true,
description: "Valid HTTPS URL should pass validation",
},
{
name: "ValidHTTPURL",
url: "http://example.com/path",
expectValid: true,
description: "Valid HTTP URL should pass validation",
},
{
name: "EmptyURL",
url: "",
expectValid: false,
description: "Empty URL should fail validation",
},
{
name: "InvalidScheme",
url: "ftp://example.com",
expectValid: false,
description: "URL with invalid scheme should fail validation",
},
{
name: "URLWithNullBytes",
url: "https://example\x00.com",
expectValid: false,
description: "URL with null bytes should fail validation",
},
{
name: "URLTooLong",
url: "https://" + strings.Repeat("a", config.MaxURLLength) + ".com",
expectValid: false,
description: "URL exceeding max length should fail validation",
},
{
name: "MalformedURL",
url: "https://",
expectValid: false,
description: "Malformed URL should fail validation",
},
{
name: "HTTPSLocalhostURL",
url: "https://localhost:8080/path",
expectValid: true,
description: "HTTPS localhost URL should be allowed for development",
},
{
name: "HTTPLocalhostURL",
url: "http://localhost:8080/path",
expectValid: false,
description: "HTTP localhost URL should fail validation for security",
},
{
name: "PrivateIPURL",
url: "https://192.168.1.1/path",
expectValid: false,
description: "Private IP URL should fail validation for security",
},
{
name: "JavaScriptURL",
url: "javascript:alert(1)",
expectValid: false,
description: "JavaScript URL should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateURL(tt.url)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateClaim tests claim validation with security focus
func TestInputValidatorValidateClaim(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
claimName string
claimValue string
description string
expectValid bool
}{
{
name: "ValidStringClaim",
claimName: "email",
claimValue: "[email protected]",
expectValid: true,
description: "Valid string claim should pass validation",
},
{
name: "ValidNumberClaim",
claimName: "exp",
claimValue: "1516239022",
expectValid: true,
description: "Valid number claim should pass validation",
},
{
name: "EmptyClaimName",
claimName: "",
claimValue: "value",
expectValid: false,
description: "Empty claim name should fail validation",
},
{
name: "ClaimWithNullBytes",
claimName: "test",
claimValue: "value\x00with_null",
expectValid: false,
description: "Claim with null bytes should fail validation",
},
{
name: "ClaimValueTooLong",
claimName: "test",
claimValue: strings.Repeat("a", config.MaxClaimLength+1),
expectValid: false,
description: "Claim value exceeding max length should fail validation",
},
{
name: "ClaimWithControlCharacters",
claimName: "test",
claimValue: "value\x01with_control",
expectValid: false,
description: "Claim with control characters should fail validation",
},
{
name: "MaliciousClaimWithHTML",
claimName: "test",
claimValue: "<script>alert('xss')</script>",
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: "<script>alert('xss')</script>",
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)
}
})
}
}
+10
View File
@@ -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()
+41 -1
View File
@@ -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)
+7
View File
@@ -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
+9
View File
@@ -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 {
-320
View File
@@ -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
}
-495
View File
@@ -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()
}
+10 -2
View File
@@ -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
+67
View File
@@ -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)
}
}
-235
View File
@@ -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)
}
-483
View File
@@ -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")
}
}
+28 -12
View File
@@ -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{
+15 -5
View File
@@ -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
-115
View File
@@ -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()
}
-72
View File
@@ -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()
}
-124
View File
@@ -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)
}
}
-74
View File
@@ -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()
}
-295
View File
@@ -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)
}
}
}
-106
View File
@@ -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()
}
-584
View File
@@ -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)
}
}
-155
View File
@@ -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{}
}
-652
View File
@@ -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)
}
}
-150
View File
@@ -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
}
-624
View File
@@ -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)
}
}
-18
View File
@@ -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
}
-246
View File
@@ -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
}
-61
View File
@@ -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()
}
-110
View File
@@ -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)
}
}
-73
View File
@@ -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()
}
-322
View File
@@ -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)
}
}
}
-56
View File
@@ -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()
}
-350
View File
@@ -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()
}
}
-93
View File
@@ -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
}
-72
View File
@@ -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()
}
-232
View File
@@ -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)
}
}
}
-72
View File
@@ -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()
}
-200
View File
@@ -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)
}
}
})
}
}
-174
View File
@@ -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
}
-747
View File
@@ -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)
}
}
-151
View File
@@ -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
}
-563
View File
@@ -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)
}
}
})
}
}
-152
View File
@@ -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()
}
-195
View File
@@ -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")
}
}
+12 -2
View File
@@ -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)
}
}
}
@@ -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())
}
}
+93
View File
@@ -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)
}
})
}
}
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
}
+20 -6
View File
@@ -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
+4 -1
View File
@@ -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++
+90
View File
@@ -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)
}
}
+35
View File
@@ -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)
}
}
+127
View File
@@ -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)
}
}
+52
View File
@@ -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)
}
}
+116
View File
@@ -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)
}
}
+35
View File
@@ -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 {
+1 -1
View File
@@ -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
+20 -4
View File
@@ -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 {
+13 -5
View File
@@ -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")