Complete rebuild of the plugin

* Fix bug affecting Azure OIDC authentication ( and most likely others )

* Fixes issue #51

* Ensure that appended roles are unique. Update the documentation.

* Improvements targetting possible memory usage spikes.

* Additional fixes and cleanup

* Refactoring code to fix the issues identified by the users.

* Modernize run

* Fieldalignment

* Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.

* Fix claims issue.

* Add ability to overwrite the default scopes in the settings file

* Well.. that escalated quickly.

Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).

* Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* Abstract the provider logic into a separate package.

* Additional micro fixes and cleanups.

* Simplify all the things.

* fixup! Simplify all the things.

* fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! fixup! Simplify all the things.

* ...

* Cleanup tests.

* fixup! Cleanup tests.

* fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! fixup! Cleanup tests.

* Issue #53: Fix CSRF token handling in reverse proxy

1. ✅ HTTPS Detection Fixed (session.go:723)
- Now uses X-Forwarded-Proto header instead of r.URL.Scheme
- Properly detects HTTPS in reverse proxy environments
2. ✅ SameSite Cookie Attribute Fixed
- Removed automatic SameSiteStrictMode for HTTPS (would break OAuth)
- Keeps SameSiteLaxMode to allow OAuth callbacks from external domains
- Only uses Strict for AJAX requests which don't involve OAuth redirects
3. ✅ Cookie Domain Handling Fixed
- Now respects X-Forwarded-Host header for cookie domain
- Ensures cookies are set for the public domain, not internal proxy domain
4. ✅ EnhanceSessionSecurity Properly Integrated
- Function is now actually called during session save
- Applies security enhancements without breaking OAuth flow

Why Issue #53 Failed Before:

1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back)
2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail
3. Cookie domain might have been wrong (internal vs public domain)

Why It Works Now:

1. Cookies are properly marked Secure for HTTPS
2. Uses SameSite=Lax to allow OAuth provider callbacks
3. Cookie domain uses public domain from X-Forwarded-Host
4. CSRF token persists through the entire OAuth flow

* Next set of enhancements together with memory usage improvements.

* Memory leak fixes and optimisations.

* CSRF and Cookie Domain fixes

* fixup! CSRF and Cookie Domain fixes

* Metadata cache leak fix + profiling

* fixup! Metadata cache leak fix + profiling

* Memory leaks hunting, part 1337.

* Further pursue of perfection.

* fixup! Further pursue of perfection.

* fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* Clear race conditions

* fixup! Clear race conditions

* Weekend fun with memory leaks

* Splitting code into multiple files with reasonable testing coverage.

```
ok      github.com/lukaszraczylo/traefikoidc    117.017s        coverage: 72.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/auth       0.505s  coverage: 87.1% of statements
ok      github.com/lukaszraczylo/traefikoidc/circuit_breaker    0.283s  coverage: 99.0% of statements
        github.com/lukaszraczylo/traefikoidc/config             coverage: 0.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/handlers   0.349s  coverage: 98.2% of statements
ok      github.com/lukaszraczylo/traefikoidc/internal/providers (cached)        coverage: 94.3% of statements
ok      github.com/lukaszraczylo/traefikoidc/middleware 0.808s  coverage: 78.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/recovery   0.653s  coverage: 100.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/chunking   (cached)        coverage: 87.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/core       (cached)        coverage: 85.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/crypto     (cached)        coverage: 81.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/storage    (cached)        coverage: 93.5% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/validators (cached)        coverage: 98.8% of statements
````

* fixup! Splitting code into multiple files with reasonable testing coverage.

* fixup! fixup! Splitting code into multiple files with reasonable testing coverage.

* Weekend fun with further optimisations.

* fixup! Weekend fun with further optimisations.

* fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* Pre-release cleanup.

* Enhance test coverage.

* fixup! Enhance test coverage.

* fixup! fixup! Enhance test coverage.

* fixup! fixup! fixup! Enhance test coverage.
This commit is contained in:
2025-09-18 11:01:30 +01:00
committed by GitHub
parent 784b161732
commit 1b49e133da
181 changed files with 78067 additions and 8289 deletions
+115
View File
@@ -0,0 +1,115 @@
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()
}
+106
View File
@@ -0,0 +1,106 @@
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 == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
return &AuthParams{
URLValues: baseParams,
Scopes: 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()
}
+140
View File
@@ -0,0 +1,140 @@
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 == "offline_access" {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
}
return &AuthParams{
URLValues: baseParams,
Scopes: 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
}
// 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{}
}
+115
View File
@@ -0,0 +1,115 @@
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())
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")
}
if _, err := url.Parse(issuerURL); err != nil {
return nil, fmt.Errorf("invalid issuer URL format: %w", err)
}
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()
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"},
}
}
// 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
}
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
}
+18
View File
@@ -0,0 +1,18 @@
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
}
+56
View File
@@ -0,0 +1,56 @@
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,
RequiresOfflineAccessScope: false,
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 "offline_access" scope, so we remove it if present.
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
filteredScopes = append(filteredScopes, scope)
}
}
return &AuthParams{
URLValues: baseParams,
Scopes: filteredScopes,
}, nil
}
// Google requires specific scopes and client configuration for proper operation.
func (p *GoogleProvider) ValidateConfig() error {
return p.BaseProvider.ValidateConfig()
}
+79
View File
@@ -0,0 +1,79 @@
// 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
)
// 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
}
+140
View File
@@ -0,0 +1,140 @@
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
}
host := 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
}
}
}
for _, p := range r.providers {
if p.GetType() == ProviderTypeGeneric {
return p
}
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
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) == "openid" {
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
}