mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
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:
+12
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
Vendored
+9
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user