From dbe3455f49421228f2a12cde7899a373666b8824 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 24 Jun 2025 17:56:19 +0100 Subject: [PATCH] Abstract the provider logic into a separate package. --- internal/providers/adapter.go | 127 +++++++++++++++++++++++++ internal/providers/azure.go | 111 ++++++++++++++++++++++ internal/providers/base.go | 141 +++++++++++++++++++++++++++ internal/providers/factory.go | 118 +++++++++++++++++++++++ internal/providers/generic.go | 18 ++++ internal/providers/google.go | 59 ++++++++++++ internal/providers/interfaces.go | 102 ++++++++++++++++++++ internal/providers/registry.go | 109 +++++++++++++++++++++ internal/providers/validation.go | 157 +++++++++++++++++++++++++++++++ 9 files changed, 942 insertions(+) create mode 100644 internal/providers/adapter.go create mode 100644 internal/providers/azure.go create mode 100644 internal/providers/base.go create mode 100644 internal/providers/factory.go create mode 100644 internal/providers/generic.go create mode 100644 internal/providers/google.go create mode 100644 internal/providers/interfaces.go create mode 100644 internal/providers/registry.go create mode 100644 internal/providers/validation.go diff --git a/internal/providers/adapter.go b/internal/providers/adapter.go new file mode 100644 index 0000000..9ad8d63 --- /dev/null +++ b/internal/providers/adapter.go @@ -0,0 +1,127 @@ +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() + + // When overrideScopes is true, use exactly the scopes provided without modification + if a.legacySettings.IsOverrideScopes() { + // Use scopes as-is, don't let provider add anything + finalParams := params + finalParams.Set("scope", strings.Join(scopes, " ")) + + // For provider-specific parameters, we still need to check the provider type + switch a.provider.GetType() { + case ProviderTypeGoogle: + // Google-specific parameters + finalParams.Set("access_type", "offline") + finalParams.Set("prompt", "consent") + case ProviderTypeAzure: + // Azure-specific parameters + finalParams.Set("response_mode", "query") + } + + return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams) + } + + // When overrideScopes is false, let the provider add necessary scopes + authParams, err := a.provider.BuildAuthParams(params, scopes) + if err != nil { + // Log the error appropriately + return "" + } + + finalParams := authParams.URLValues + finalParams.Set("scope", strings.Join(authParams.Scopes, " ")) + + // Build the full URL with params + return a.buildURLWithParams(a.legacySettings.GetAuthURL(), finalParams) +} + +// buildURLWithParams takes a base URL and query parameters and constructs a full URL string. +// If the baseURL is relative (doesn't start with http/https), it prepends the scheme and host +// from the configured issuerURL. +func (a *Adapter) buildURLWithParams(baseURL string, params url.Values) string { + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + // Relative URL - resolve against issuer URL + 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() + } + + // Absolute URL + u, err := url.Parse(baseURL) + if err != nil { + return "" + } + + u.RawQuery = params.Encode() + return u.String() +} + +// ValidateTokens validates tokens using the adapted provider. +func (a *Adapter) ValidateTokens(session Session) (*ValidationResult, error) { + return a.provider.ValidateTokens(session, a.tokenVerifier, a.tokenCache, a.legacySettings.GetRefreshGracePeriod()) +} + +// GetType returns the underlying provider's type. +func (a *Adapter) GetType() ProviderType { + return a.provider.GetType() +} diff --git a/internal/providers/azure.go b/internal/providers/azure.go new file mode 100644 index 0000000..8223690 --- /dev/null +++ b/internal/providers/azure.go @@ -0,0 +1,111 @@ +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", // Azure AD prefers access token validation + } +} + +// BuildAuthParams configures Azure-specific authentication parameters. +func (p *AzureProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { + baseParams.Set("response_mode", "query") + + // Ensure "offline_access" scope is present for refresh tokens + 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 +} + +// ValidateTokens overrides the default token validation to implement Azure-specific logic. +// 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 +} + +// ValidateConfig validates Azure-specific configuration requirements. +// Azure requires specific tenant configuration and scope handling. +func (p *AzureProvider) ValidateConfig() error { + // Azure provider validation - ensure we have the necessary configuration + // In a real implementation, this might check for tenant ID, proper issuer URL format, etc. + return p.BaseProvider.ValidateConfig() +} diff --git a/internal/providers/base.go b/internal/providers/base.go new file mode 100644 index 0000000..4929131 --- /dev/null +++ b/internal/providers/base.go @@ -0,0 +1,141 @@ +package providers + +import ( + "net/url" + "strings" + "time" +) + +// BaseProvider provides a common foundation for OIDC provider implementations. +// It can be embedded in specific provider structs to share common logic. +type BaseProvider struct { + // Common configuration or dependencies can be added here. +} + +// GetType returns the default provider type, which is Generic. +// This should be overridden by specific provider implementations. +func (p *BaseProvider) GetType() ProviderType { + return ProviderTypeGeneric +} + +// GetCapabilities returns a default set of capabilities for a generic OIDC provider. +// 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 provides a default token validation implementation. +// 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 provides common token expiry validation logic that can be used by all providers. +// 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 provides a default implementation for building authorization parameters. +// It includes the "offline_access" scope by default. +func (p *BaseProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) { + // Ensure offline_access is included if not already present + 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 provides a default implementation for token refresh handling. +// By default, it does nothing and assumes the standard token response is sufficient. +func (p *BaseProvider) HandleTokenRefresh(tokenData *TokenResult) error { + // No provider-specific refresh handling by default. + return nil +} + +// ValidateConfig provides a default implementation for configuration validation. +// By default, it assumes the configuration is valid. +func (p *BaseProvider) ValidateConfig() error { + // No provider-specific config validation by default. + return nil +} + +// NewBaseProvider creates a new BaseProvider. +func NewBaseProvider() *BaseProvider { + return &BaseProvider{} +} diff --git a/internal/providers/factory.go b/internal/providers/factory.go new file mode 100644 index 0000000..7920382 --- /dev/null +++ b/internal/providers/factory.go @@ -0,0 +1,118 @@ +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() + + // Register all available providers + registry.RegisterProvider(NewGenericProvider()) + registry.RegisterProvider(NewGoogleProvider()) + registry.RegisterProvider(NewAzureProvider()) + + return &ProviderFactory{ + registry: registry, + } +} + +// CreateProvider creates and returns the appropriate provider for the given 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") + } + + // Validate URL format + 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) + } + + // Validate the provider configuration if it implements config validation + if err := provider.ValidateConfig(); err != nil { + return nil, fmt.Errorf("provider configuration validation failed: %w", err) + } + + return provider, nil +} + +// CreateProviderByType creates a provider instance for a specific provider 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: {"*"}, // Generic supports any issuer + ProviderTypeGoogle: {"accounts.google.com"}, + ProviderTypeAzure: {"login.microsoftonline.com", "sts.windows.net"}, + } +} + +// DetectProviderType returns the provider type that would be used 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 +} diff --git a/internal/providers/generic.go b/internal/providers/generic.go new file mode 100644 index 0000000..8ec3b37 --- /dev/null +++ b/internal/providers/generic.go @@ -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 +} diff --git a/internal/providers/google.go b/internal/providers/google.go new file mode 100644 index 0000000..0d81abc --- /dev/null +++ b/internal/providers/google.go @@ -0,0 +1,59 @@ +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, // 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 "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 +} + +// ValidateConfig validates Google-specific configuration requirements. +// Google requires specific scopes and client configuration for proper operation. +func (p *GoogleProvider) ValidateConfig() error { + // Google provider doesn't require additional validation beyond the base implementation + // All Google-specific requirements are handled in BuildAuthParams + return p.BaseProvider.ValidateConfig() +} diff --git a/internal/providers/interfaces.go b/internal/providers/interfaces.go new file mode 100644 index 0000000..aefc548 --- /dev/null +++ b/internal/providers/interfaces.go @@ -0,0 +1,102 @@ +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 represents a standard, compliant OIDC provider. + ProviderTypeGeneric ProviderType = iota + // ProviderTypeGoogle represents Google as the OIDC provider. + ProviderTypeGoogle + // ProviderTypeAzure represents Microsoft Azure AD as the OIDC provider. + ProviderTypeAzure +) + +// ProviderCapabilities defines the specific features and behaviors of an OIDC provider. +type ProviderCapabilities struct { + // SupportsRefreshTokens indicates if the provider issues refresh tokens. + SupportsRefreshTokens bool + // RequiresOfflineAccessScope indicates if the "offline_access" scope is needed for refresh tokens. + RequiresOfflineAccessScope bool + // RequiresPromptConsent indicates if "prompt=consent" is needed to ensure a refresh token is issued. + RequiresPromptConsent bool + // PreferredTokenValidation specifies the recommended token type to validate (e.g., "access" or "id"). + PreferredTokenValidation string +} + +// ValidationResult holds the outcome of a token validation check. +type ValidationResult struct { + // Authenticated is true if the token is valid and the user is authenticated. + Authenticated bool + // NeedsRefresh is true if the token is approaching its expiry and should be refreshed. + NeedsRefresh bool + // IsExpired is true if the token has expired or is invalid. + IsExpired bool +} + +// AuthParams contains the provider-specific parameters for building the authorization URL. +type AuthParams struct { + // URLValues are the query parameters to be added to the authorization URL. + URLValues url.Values + // Scopes is the list of scopes to be requested. + Scopes []string +} + +// TokenResult holds the tokens returned by the provider. +type TokenResult struct { + // IDToken is the OIDC ID token. + IDToken string + // AccessToken is the OAuth2 access token. + AccessToken string + // RefreshToken is the OAuth2 refresh token. + RefreshToken string +} + +// OIDCProvider defines the interface for an OIDC provider implementation. +// This abstraction allows for provider-specific logic to be encapsulated. +type OIDCProvider interface { + // GetType returns the type of the provider (e.g., Google, Azure, Generic). + GetType() ProviderType + + // GetCapabilities returns the feature set of the provider. + GetCapabilities() ProviderCapabilities + + // ValidateTokens performs token validation according to the provider's specific rules. + // It should check the validity of the access and/or ID tokens from the session. + ValidateTokens(session Session, verifier TokenVerifier, tokenCache TokenCache, refreshGracePeriod time.Duration) (*ValidationResult, error) + + // BuildAuthParams modifies the authorization URL parameters for the provider. + // This can be used to add provider-specific parameters like "access_type" for Google. + BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) + + // HandleTokenRefresh manages the token refresh process for the provider. + // It can modify the token request or handle the response as needed. + HandleTokenRefresh(tokenData *TokenResult) error + + // ValidateConfig checks if the user's configuration is valid for this provider. + ValidateConfig() error +} + +// Session represents the session data required by providers for validation. +// This interface decouples the providers from the main session management implementation. +type Session interface { + GetIDToken() string + GetAccessToken() string + GetRefreshToken() string + GetAuthenticated() bool +} diff --git a/internal/providers/registry.go b/internal/providers/registry.go new file mode 100644 index 0000000..2a0abfd --- /dev/null +++ b/internal/providers/registry.go @@ -0,0 +1,109 @@ +package providers + +import ( + "net/url" + "strings" + "sync" +) + +// ProviderRegistry holds and manages the available OIDC provider implementations. +// It provides thread-safe access to provider instances and caches detection results. +type ProviderRegistry struct { + mu sync.RWMutex + providers []OIDCProvider + cache map[string]OIDCProvider + typeMap map[ProviderType]OIDCProvider // Maps provider type to instance +} + +// 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), + } +} + +// 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 returns a provider instance for the specified 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) +} + +// DetectProvider determines the most appropriate provider for a given issuer URL. +// It iterates through the registered providers and returns the first one that matches. +// Detection is based on URL patterns and other provider-specific criteria. +func (r *ProviderRegistry) DetectProvider(issuerURL string) OIDCProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + // Check cache first for performance + if provider, found := r.cache[issuerURL]; found { + return provider + } + + // Normalize issuer URL for consistent matching + normalizedURL, err := url.Parse(issuerURL) + if err != nil { + // Log error or handle it appropriately + return nil + } + host := normalizedURL.Host + + // Iterate through registered providers to find a match + for _, p := range r.providers { + switch p.GetType() { + case ProviderTypeGoogle: + if strings.Contains(host, "accounts.google.com") { + r.cache[issuerURL] = p + return p + } + case ProviderTypeAzure: + if strings.Contains(host, "login.microsoftonline.com") || strings.Contains(host, "sts.windows.net") { + r.cache[issuerURL] = p + return p + } + } + } + + // Fallback to the generic provider if no specific provider is detected + for _, p := range r.providers { + if p.GetType() == ProviderTypeGeneric { + r.cache[issuerURL] = p + return p + } + } + + return nil +} diff --git a/internal/providers/validation.go b/internal/providers/validation.go new file mode 100644 index 0000000..b8f8f07 --- /dev/null +++ b/internal/providers/validation.go @@ -0,0 +1,157 @@ +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") + } + + // Check for required OIDC scope + 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 { + // Google-specific validation logic + 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 { + // Azure-specific validation logic + 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") + } + } + + // Check for tenant ID in the URL + 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 { + // Simple check for GUID-like structure (tenant ID) + 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 { + // Generic provider validation - basic checks only + return nil +}