diff --git a/.traefik.yml b/.traefik.yml index c64a221..1967079 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -1630,3 +1630,234 @@ configuration: Default: 30 seconds required: false + + dynamicClientRegistration: + type: object + description: | + Configuration for OIDC Dynamic Client Registration (RFC 7591/7592). + + Dynamic Client Registration allows the middleware to automatically register + itself as an OAuth 2.0 client with the OIDC provider, eliminating the need + to manually create and manage client credentials. + + This is particularly useful for: + - Automated deployments where manual client creation is impractical + - Multi-tenant scenarios requiring per-deployment client isolation + - Development and testing environments + - Kubernetes environments with multiple replicas + + For multi-replica deployments (Kubernetes), enable Redis storage to share + credentials across all instances and prevent registration race conditions. + + Example configuration: + ```yaml + dynamicClientRegistration: + enabled: true + persistCredentials: true + storageBackend: "redis" # Use Redis for distributed storage + clientMetadata: + redirect_uris: + - https://app.example.com/oauth2/callback + client_name: "My Application" + application_type: "web" + ``` + required: false + properties: + enabled: + type: boolean + description: | + Enable dynamic client registration with the OIDC provider. + When enabled and clientID is not set, the middleware will automatically + register itself with the provider using the configuration in clientMetadata. + + Default: false + required: false + + persistCredentials: + type: boolean + description: | + Enable persistence of client credentials after registration. + When enabled, credentials are saved to the configured storage backend + and reloaded on restart to avoid re-registration. + + Default: false + required: false + + storageBackend: + type: string + description: | + Storage backend for persisting DCR credentials. + + Options: + - "file": Store credentials in a local file (default for backward compatibility) + - "redis": Store credentials in Redis (recommended for multi-replica deployments) + - "auto": Use Redis if available, fall back to file storage + + For Kubernetes deployments with multiple replicas, use "redis" to ensure + all instances share the same client credentials and prevent registration + race conditions where each replica registers its own client. + + Default: "auto" + required: false + enum: + - file + - redis + - auto + + credentialsFile: + type: string + description: | + Path to store client credentials when using file-based storage. + The file will be created with restrictive permissions (0600). + + Default: "/tmp/oidc-client-credentials.json" + required: false + + redisKeyPrefix: + type: string + description: | + Prefix for Redis keys when using Redis storage. + Useful for isolating credentials between different applications + or environments sharing the same Redis instance. + + Default: "dcr:creds:" + required: false + + registrationEndpoint: + type: string + description: | + Override the registration endpoint URL. + If not specified, the endpoint will be discovered from provider metadata. + + Some providers may not advertise their registration endpoint in metadata, + in which case you need to specify it explicitly. + + Example: "https://auth.example.com/oauth/register" + required: false + + initialAccessToken: + type: string + description: | + Initial Access Token for protected registration endpoints. + Some providers require an access token to authorize client registration. + + If your provider requires authentication for registration, obtain an + initial access token from the provider and configure it here. + + For Kubernetes, you can use secret references: + urn:k8s:secret:namespace:secret-name:key + required: false + + clientMetadata: + type: object + description: | + Client metadata to include in the registration request (RFC 7591). + This defines the properties of the OAuth 2.0 client to be registered. + required: false + properties: + redirect_uris: + type: array + description: | + Array of redirect URIs for the client. Required for registration. + These must match the callback URLs that will be used in authentication flows. + + Example: ["https://app.example.com/oauth2/callback"] + required: true + items: + type: string + + client_name: + type: string + description: | + Human-readable name of the client. + This is typically displayed in consent screens. + + Example: "My Application" + required: false + + application_type: + type: string + description: | + Type of application. Affects security defaults. + + Options: + - "web": Server-side web application (default) + - "native": Native/mobile application + + Default: "web" + required: false + + grant_types: + type: array + description: | + OAuth 2.0 grant types the client will use. + + Default: ["authorization_code", "refresh_token"] + required: false + items: + type: string + + response_types: + type: array + description: | + OAuth 2.0 response types the client will use. + + Default: ["code"] + required: false + items: + type: string + + token_endpoint_auth_method: + type: string + description: | + Authentication method for the token endpoint. + + Options: + - "client_secret_basic": HTTP Basic authentication (default) + - "client_secret_post": Client credentials in POST body + - "none": Public client (no authentication) + + Default: "client_secret_basic" + required: false + + scope: + type: string + description: | + Space-separated list of scopes the client is authorized to request. + + Example: "openid profile email" + required: false + + contacts: + type: array + description: | + Array of contact email addresses for the client administrator. + + Example: ["admin@example.com"] + required: false + items: + type: string + + logo_uri: + type: string + description: | + URL to the client's logo image for consent screens. + required: false + + client_uri: + type: string + description: | + URL to the client's home page. + required: false + + policy_uri: + type: string + description: | + URL to the client's privacy policy. + required: false + + tos_uri: + type: string + description: | + URL to the client's terms of service. + required: false diff --git a/README.md b/README.md index b06a5e9..ab6b10f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit - **Universal provider support**: Works with 9+ OIDC providers including Google, Azure AD, Auth0, Okta, Keycloak, AWS Cognito, GitLab, and more - **Automatic provider detection**: Automatically detects and configures provider-specific settings -- **Dynamic Client Registration (RFC 7591)**: Automatic client registration with OIDC providers without manual pre-registration +- **Dynamic Client Registration (RFC 7591)**: Automatic client registration with OIDC providers without manual pre-registration, with Redis storage support for multi-replica deployments - **Automatic scope filtering**: Intelligently filters OAuth scopes based on provider capabilities declared in OIDC discovery documents, preventing authentication failures with unsupported scopes - **Security headers**: Comprehensive security headers with CORS, CSP, HSTS, and custom profiles - **Domain restrictions**: Limit access to specific email domains or individual users diff --git a/dcr_storage_compat.go b/dcr_storage_compat.go new file mode 100644 index 0000000..5098654 --- /dev/null +++ b/dcr_storage_compat.go @@ -0,0 +1,290 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik +package traefikoidc + +import ( + "context" + "fmt" + "time" + + "github.com/lukaszraczylo/traefikoidc/internal/dcrstorage" +) + +// DCRStorageBackend represents the type of storage backend for DCR credentials. +// Alias for internal package type for backward compatibility. +type DCRStorageBackend = dcrstorage.StorageBackend + +const ( + // DCRStorageBackendFile uses file-based storage (default for backward compatibility) + DCRStorageBackendFile DCRStorageBackend = dcrstorage.StorageBackendFile + + // DCRStorageBackendRedis uses Redis for distributed storage + DCRStorageBackendRedis DCRStorageBackend = dcrstorage.StorageBackendRedis + + // DCRStorageBackendAuto automatically selects Redis if available, otherwise file + DCRStorageBackendAuto DCRStorageBackend = dcrstorage.StorageBackendAuto +) + +// DCRCredentialsStore defines the interface for storing DCR credentials. +// This abstraction allows different storage backends (file, Redis) to be used +// for persisting OIDC Dynamic Client Registration credentials across nodes. +type DCRCredentialsStore interface { + // Save stores the client registration response for a provider + // The providerURL is used as a key to support multi-tenant scenarios + Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error + + // Load retrieves stored credentials for a provider + // Returns nil, nil if no credentials exist (not an error) + Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) + + // Delete removes stored credentials for a provider + Delete(ctx context.Context, providerURL string) error + + // Exists checks if credentials exist for a provider + Exists(ctx context.Context, providerURL string) (bool, error) +} + +// loggerAdapter adapts our Logger to the dcrstorage.Logger interface +type loggerAdapter struct { + logger *Logger +} + +func (l *loggerAdapter) Debug(msg string) { l.logger.Debug("%s", msg) } +func (l *loggerAdapter) Debugf(format string, args ...any) { l.logger.Debugf(format, args...) } +func (l *loggerAdapter) Info(msg string) { l.logger.Info("%s", msg) } +func (l *loggerAdapter) Infof(format string, args ...any) { l.logger.Infof(format, args...) } +func (l *loggerAdapter) Error(msg string) { l.logger.Error("%s", msg) } +func (l *loggerAdapter) Errorf(format string, args ...any) { l.logger.Errorf(format, args...) } + +// cacheAdapter adapts UniversalCache to dcrstorage.Cache interface +type cacheAdapter struct { + cache *UniversalCache +} + +func (c *cacheAdapter) Get(key string) (any, bool) { + return c.cache.Get(key) +} + +func (c *cacheAdapter) Set(key string, value any, ttl time.Duration) error { + return c.cache.Set(key, value, ttl) +} + +func (c *cacheAdapter) Delete(key string) { + c.cache.Delete(key) +} + +// fileStoreWrapper wraps dcrstorage.FileStore to implement DCRCredentialsStore +type fileStoreWrapper struct { + inner *dcrstorage.FileStore +} + +func (w *fileStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error { + innerCreds := convertCredsToInternal(creds) + return w.inner.Save(ctx, providerURL, innerCreds) +} + +func (w *fileStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) { + innerCreds, err := w.inner.Load(ctx, providerURL) + if err != nil || innerCreds == nil { + return nil, err + } + return convertCredsFromInternal(innerCreds), nil +} + +func (w *fileStoreWrapper) Delete(ctx context.Context, providerURL string) error { + return w.inner.Delete(ctx, providerURL) +} + +func (w *fileStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) { + return w.inner.Exists(ctx, providerURL) +} + +// basePath returns the base path used for storing credentials (for backward compatibility in tests) +func (w *fileStoreWrapper) basePath() string { + return w.inner.BasePath() +} + +// getFilePath returns the file path for storing credentials for a specific provider (for backward compatibility in tests) +func (w *fileStoreWrapper) getFilePath(providerURL string) string { + return w.inner.GetFilePath(providerURL) +} + +// redisStoreWrapper wraps dcrstorage.RedisStore to implement DCRCredentialsStore +type redisStoreWrapper struct { + inner *dcrstorage.RedisStore +} + +func (w *redisStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error { + innerCreds := convertCredsToInternal(creds) + return w.inner.Save(ctx, providerURL, innerCreds) +} + +func (w *redisStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) { + innerCreds, err := w.inner.Load(ctx, providerURL) + if err != nil || innerCreds == nil { + return nil, err + } + return convertCredsFromInternal(innerCreds), nil +} + +func (w *redisStoreWrapper) Delete(ctx context.Context, providerURL string) error { + return w.inner.Delete(ctx, providerURL) +} + +func (w *redisStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) { + return w.inner.Exists(ctx, providerURL) +} + +// FileCredentialsStore implements DCRCredentialsStore using file-based storage. +// This is the default storage backend for backward compatibility with existing deployments. +type FileCredentialsStore = fileStoreWrapper + +// RedisCredentialsStore implements DCRCredentialsStore using Redis-backed cache. +// This storage backend enables sharing DCR credentials across multiple Traefik instances. +type RedisCredentialsStore = redisStoreWrapper + +// NewFileCredentialsStore creates a new file-based credentials store. +// If basePath is empty, defaults to /tmp/oidc-client-credentials.json +func NewFileCredentialsStore(basePath string, logger *Logger) *FileCredentialsStore { + var dcrLogger dcrstorage.Logger + if logger != nil { + dcrLogger = &loggerAdapter{logger: logger} + } + inner := dcrstorage.NewFileStore(basePath, dcrLogger) + return &fileStoreWrapper{inner: inner} +} + +// NewRedisCredentialsStore creates a new Redis-backed credentials store. +// The cache should be configured with a Redis backend for distributed storage. +// If keyPrefix is empty, defaults to "dcr:creds:" +func NewRedisCredentialsStore(cache *UniversalCache, keyPrefix string, logger *Logger) *RedisCredentialsStore { + var dcrLogger dcrstorage.Logger + if logger != nil { + dcrLogger = &loggerAdapter{logger: logger} + } + cacheAdapt := &cacheAdapter{cache: cache} + inner := dcrstorage.NewRedisStore(cacheAdapt, keyPrefix, dcrLogger) + return &redisStoreWrapper{inner: inner} +} + +// Helper functions to convert between main package and internal package types +func convertCredsToInternal(creds *ClientRegistrationResponse) *dcrstorage.ClientRegistrationResponse { + if creds == nil { + return nil + } + return &dcrstorage.ClientRegistrationResponse{ + SubjectType: creds.SubjectType, + LogoURI: creds.LogoURI, + RegistrationAccessToken: creds.RegistrationAccessToken, + RegistrationClientURI: creds.RegistrationClientURI, + Scope: creds.Scope, + TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod, + TOSURI: creds.TOSURI, + PolicyURI: creds.PolicyURI, + ClientSecret: creds.ClientSecret, + ApplicationType: creds.ApplicationType, + ClientID: creds.ClientID, + ClientName: creds.ClientName, + JWKSURI: creds.JWKSURI, + ClientURI: creds.ClientURI, + Contacts: creds.Contacts, + GrantTypes: creds.GrantTypes, + ResponseTypes: creds.ResponseTypes, + RedirectURIs: creds.RedirectURIs, + ClientSecretExpiresAt: creds.ClientSecretExpiresAt, + ClientIDIssuedAt: creds.ClientIDIssuedAt, + } +} + +func convertCredsFromInternal(creds *dcrstorage.ClientRegistrationResponse) *ClientRegistrationResponse { + if creds == nil { + return nil + } + return &ClientRegistrationResponse{ + SubjectType: creds.SubjectType, + LogoURI: creds.LogoURI, + RegistrationAccessToken: creds.RegistrationAccessToken, + RegistrationClientURI: creds.RegistrationClientURI, + Scope: creds.Scope, + TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod, + TOSURI: creds.TOSURI, + PolicyURI: creds.PolicyURI, + ClientSecret: creds.ClientSecret, + ApplicationType: creds.ApplicationType, + ClientID: creds.ClientID, + ClientName: creds.ClientName, + JWKSURI: creds.JWKSURI, + ClientURI: creds.ClientURI, + Contacts: creds.Contacts, + GrantTypes: creds.GrantTypes, + ResponseTypes: creds.ResponseTypes, + RedirectURIs: creds.RedirectURIs, + ClientSecretExpiresAt: creds.ClientSecretExpiresAt, + ClientIDIssuedAt: creds.ClientIDIssuedAt, + } +} + +// NewDCRCredentialsStore creates a DCRCredentialsStore based on configuration. +// This factory function handles backend selection logic: +// - "file": Use file-based storage (default for backward compatibility) +// - "redis": Use Redis exclusively (fails if Redis unavailable) +// - "auto": Use Redis if available, fallback to file +func NewDCRCredentialsStore( + config *DynamicClientRegistrationConfig, + cacheManager *CacheManager, + logger *Logger, +) (DCRCredentialsStore, error) { + if config == nil { + return nil, fmt.Errorf("DCR config is nil") + } + + if logger == nil { + logger = GetSingletonNoOpLogger() + } + + backend := config.StorageBackend + if backend == "" { + backend = string(DCRStorageBackendAuto) // Default to auto selection + } + + switch DCRStorageBackend(backend) { + case DCRStorageBackendFile: + logger.Info("Using file-based storage for DCR credentials") + return NewFileCredentialsStore(config.CredentialsFile, logger), nil + + case DCRStorageBackendRedis: + cache := getDCRCache(cacheManager) + if cache == nil { + return nil, fmt.Errorf("redis storage requested but Redis/cache not configured") + } + logger.Info("Using Redis storage for DCR credentials") + return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil + + case DCRStorageBackendAuto: + // Try Redis first, fallback to file + cache := getDCRCache(cacheManager) + if cache != nil && cache.backend != nil { + logger.Info("Auto-selected Redis storage for DCR credentials") + return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil + } + logger.Info("Redis not available, using file storage for DCR credentials") + return NewFileCredentialsStore(config.CredentialsFile, logger), nil + + default: + return nil, fmt.Errorf("unknown DCR storage backend: %s", backend) + } +} + +// getDCRCache safely retrieves the DCR credentials cache from the cache manager +func getDCRCache(cacheManager *CacheManager) *UniversalCache { + if cacheManager == nil { + return nil + } + cacheManager.mu.RLock() + defer cacheManager.mu.RUnlock() + + if cacheManager.manager == nil { + return nil + } + + return cacheManager.manager.GetDCRCredentialsCache() +} diff --git a/dcr_storage_test.go b/dcr_storage_test.go new file mode 100644 index 0000000..6818dbc --- /dev/null +++ b/dcr_storage_test.go @@ -0,0 +1,663 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik +package traefikoidc + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// TestFileCredentialsStore_SaveLoad tests the file-based credentials store +func TestFileCredentialsStore_SaveLoad(t *testing.T) { + t.Parallel() + + // Create a temp directory for test files + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + testCreds := &ClientRegistrationResponse{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + RegistrationAccessToken: "test-access-token", + RegistrationClientURI: "https://example.com/register/test-client-id", + RedirectURIs: []string{"https://app.example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "client_secret_basic", + } + + ctx := context.Background() + providerURL := "https://auth.example.com" + + t.Run("save and load credentials", func(t *testing.T) { + // Save credentials + err := store.Save(ctx, providerURL, testCreds) + if err != nil { + t.Fatalf("Failed to save credentials: %v", err) + } + + // Load credentials + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load credentials: %v", err) + } + + if loaded == nil { + t.Fatal("Expected credentials but got nil") + } + + // Verify fields + if loaded.ClientID != testCreds.ClientID { + t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID) + } + if loaded.ClientSecret != testCreds.ClientSecret { + t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret) + } + if loaded.RegistrationAccessToken != testCreds.RegistrationAccessToken { + t.Errorf("RegistrationAccessToken mismatch: got %s, want %s", loaded.RegistrationAccessToken, testCreds.RegistrationAccessToken) + } + }) + + t.Run("load non-existent credentials", func(t *testing.T) { + tempDir2 := t.TempDir() + store2 := NewFileCredentialsStore(filepath.Join(tempDir2, "nonexistent.json"), logger) + + loaded, err := store2.Load(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Unexpected error for non-existent file: %v", err) + } + if loaded != nil { + t.Error("Expected nil for non-existent credentials") + } + }) + + t.Run("exists check", func(t *testing.T) { + exists, err := store.Exists(ctx, providerURL) + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if !exists { + t.Error("Expected credentials to exist") + } + + exists, err = store.Exists(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if exists { + t.Error("Expected credentials to not exist") + } + }) + + t.Run("delete credentials", func(t *testing.T) { + err := store.Delete(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to delete credentials: %v", err) + } + + exists, _ := store.Exists(ctx, providerURL) + if exists { + t.Error("Expected credentials to be deleted") + } + }) + + t.Run("delete non-existent credentials", func(t *testing.T) { + // Should not error + err := store.Delete(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Delete should not error for non-existent: %v", err) + } + }) +} + +// TestFileCredentialsStore_MultiProvider tests multi-provider support +func TestFileCredentialsStore_MultiProvider(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + ctx := context.Background() + + provider1 := "https://auth1.example.com" + provider2 := "https://auth2.example.com" + + creds1 := &ClientRegistrationResponse{ + ClientID: "client-1", + ClientSecret: "secret-1", + } + creds2 := &ClientRegistrationResponse{ + ClientID: "client-2", + ClientSecret: "secret-2", + } + + // Save credentials for both providers + if err := store.Save(ctx, provider1, creds1); err != nil { + t.Fatalf("Failed to save creds1: %v", err) + } + if err := store.Save(ctx, provider2, creds2); err != nil { + t.Fatalf("Failed to save creds2: %v", err) + } + + // Load and verify each provider's credentials + loaded1, err := store.Load(ctx, provider1) + if err != nil { + t.Fatalf("Failed to load creds1: %v", err) + } + if loaded1.ClientID != "client-1" { + t.Errorf("Provider 1 ClientID mismatch: got %s", loaded1.ClientID) + } + + loaded2, err := store.Load(ctx, provider2) + if err != nil { + t.Fatalf("Failed to load creds2: %v", err) + } + if loaded2.ClientID != "client-2" { + t.Errorf("Provider 2 ClientID mismatch: got %s", loaded2.ClientID) + } + + // Delete one shouldn't affect the other + if err := store.Delete(ctx, provider1); err != nil { + t.Fatalf("Failed to delete creds1: %v", err) + } + + exists, _ := store.Exists(ctx, provider2) + if !exists { + t.Error("Provider 2 credentials should still exist") + } +} + +// TestFileCredentialsStore_ConcurrentAccess tests thread safety +func TestFileCredentialsStore_ConcurrentAccess(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + ctx := context.Background() + providerURL := "https://auth.example.com" + + creds := &ClientRegistrationResponse{ + ClientID: "test-client", + ClientSecret: "test-secret", + } + + var wg sync.WaitGroup + concurrency := 10 + + // Concurrent saves + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = store.Save(ctx, providerURL, creds) + }() + } + wg.Wait() + + // Concurrent loads + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = store.Load(ctx, providerURL) + }() + } + wg.Wait() + + // Final verification + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load after concurrent access: %v", err) + } + if loaded == nil || loaded.ClientID != "test-client" { + t.Error("Credentials corrupted after concurrent access") + } +} + +// TestFileCredentialsStore_InvalidInput tests error handling +func TestFileCredentialsStore_InvalidInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + ctx := context.Background() + + t.Run("save nil credentials", func(t *testing.T) { + err := store.Save(ctx, "https://example.com", nil) + if err == nil { + t.Error("Expected error for nil credentials") + } + }) + + t.Run("empty provider URL uses default path", func(t *testing.T) { + creds := &ClientRegistrationResponse{ClientID: "test"} + err := store.Save(ctx, "", creds) + if err != nil { + t.Fatalf("Save with empty provider URL failed: %v", err) + } + + loaded, err := store.Load(ctx, "") + if err != nil { + t.Fatalf("Load with empty provider URL failed: %v", err) + } + if loaded == nil || loaded.ClientID != "test" { + t.Error("Failed to load credentials with empty provider URL") + } + }) +} + +// TestFileCredentialsStore_DefaultPath tests default path behavior +func TestFileCredentialsStore_DefaultPath(t *testing.T) { + t.Parallel() + + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore("", logger) + + // Just verify we can create with empty path and it has a default + if store.basePath() == "" { + t.Error("Expected default base path") + } +} + +// TestRedisCredentialsStore_WithMemoryCache tests Redis store with in-memory cache +func TestRedisCredentialsStore_WithMemoryCache(t *testing.T) { + t.Parallel() + + // Create an in-memory cache for testing + cache := NewUniversalCache(UniversalCacheConfig{ + Type: CacheTypeGeneral, + MaxSize: 100, + DefaultTTL: time.Hour, + Logger: GetSingletonNoOpLogger(), + }) + defer cache.Close() + + logger := GetSingletonNoOpLogger() + store := NewRedisCredentialsStore(cache, "", logger) + + ctx := context.Background() + providerURL := "https://auth.example.com" + + testCreds := &ClientRegistrationResponse{ + ClientID: "redis-test-client", + ClientSecret: "redis-test-secret", + ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + RegistrationAccessToken: "redis-test-token", + RedirectURIs: []string{"https://app.example.com/callback"}, + } + + t.Run("save and load credentials", func(t *testing.T) { + err := store.Save(ctx, providerURL, testCreds) + if err != nil { + t.Fatalf("Failed to save credentials: %v", err) + } + + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load credentials: %v", err) + } + + if loaded == nil { + t.Fatal("Expected credentials but got nil") + } + if loaded.ClientID != testCreds.ClientID { + t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID) + } + if loaded.ClientSecret != testCreds.ClientSecret { + t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret) + } + }) + + t.Run("exists check", func(t *testing.T) { + exists, err := store.Exists(ctx, providerURL) + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if !exists { + t.Error("Expected credentials to exist") + } + }) + + t.Run("delete credentials", func(t *testing.T) { + err := store.Delete(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to delete credentials: %v", err) + } + + exists, _ := store.Exists(ctx, providerURL) + if exists { + t.Error("Expected credentials to be deleted") + } + }) + + t.Run("load non-existent credentials", func(t *testing.T) { + loaded, err := store.Load(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Unexpected error for non-existent: %v", err) + } + if loaded != nil { + t.Error("Expected nil for non-existent credentials") + } + }) +} + +// TestRedisCredentialsStore_TTLFromExpiry tests TTL calculation +func TestRedisCredentialsStore_TTLFromExpiry(t *testing.T) { + t.Parallel() + + cache := NewUniversalCache(UniversalCacheConfig{ + Type: CacheTypeGeneral, + MaxSize: 100, + DefaultTTL: time.Hour, + Logger: GetSingletonNoOpLogger(), + }) + defer cache.Close() + + logger := GetSingletonNoOpLogger() + store := NewRedisCredentialsStore(cache, "", logger) + + ctx := context.Background() + + t.Run("expired credentials should fail", func(t *testing.T) { + expiredCreds := &ClientRegistrationResponse{ + ClientID: "expired-client", + ClientSecret: "expired-secret", + ClientSecretExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // Already expired + } + + err := store.Save(ctx, "https://expired.example.com", expiredCreds) + if err == nil { + t.Error("Expected error for expired credentials") + } + }) + + t.Run("credentials without expiry use default TTL", func(t *testing.T) { + creds := &ClientRegistrationResponse{ + ClientID: "no-expiry-client", + ClientSecret: "no-expiry-secret", + ClientSecretExpiresAt: 0, // No expiry + } + + err := store.Save(ctx, "https://noexpiry.example.com", creds) + if err != nil { + t.Fatalf("Failed to save credentials without expiry: %v", err) + } + }) +} + +// TestRedisCredentialsStore_InvalidInput tests error handling +func TestRedisCredentialsStore_InvalidInput(t *testing.T) { + t.Parallel() + + cache := NewUniversalCache(UniversalCacheConfig{ + Type: CacheTypeGeneral, + MaxSize: 100, + DefaultTTL: time.Hour, + Logger: GetSingletonNoOpLogger(), + }) + defer cache.Close() + + logger := GetSingletonNoOpLogger() + store := NewRedisCredentialsStore(cache, "", logger) + + ctx := context.Background() + + t.Run("save nil credentials", func(t *testing.T) { + err := store.Save(ctx, "https://example.com", nil) + if err == nil { + t.Error("Expected error for nil credentials") + } + }) +} + +// TestDCRStorageFactory tests the factory function +func TestDCRStorageFactory(t *testing.T) { + t.Parallel() + + logger := GetSingletonNoOpLogger() + + t.Run("nil config returns error", func(t *testing.T) { + _, err := NewDCRCredentialsStore(nil, nil, logger) + if err == nil { + t.Error("Expected error for nil config") + } + }) + + t.Run("file backend creates file store", func(t *testing.T) { + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + StorageBackend: "file", + CredentialsFile: "/tmp/test-creds.json", + } + + store, err := NewDCRCredentialsStore(config, nil, logger) + if err != nil { + t.Fatalf("Failed to create file store: %v", err) + } + if store == nil { + t.Error("Expected store but got nil") + } + + _, ok := store.(*FileCredentialsStore) + if !ok { + t.Error("Expected FileCredentialsStore") + } + }) + + t.Run("redis backend without cache manager returns error", func(t *testing.T) { + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + StorageBackend: "redis", + } + + _, err := NewDCRCredentialsStore(config, nil, logger) + if err == nil { + t.Error("Expected error for redis backend without cache manager") + } + }) + + t.Run("auto backend without redis falls back to file", func(t *testing.T) { + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + StorageBackend: "auto", + } + + store, err := NewDCRCredentialsStore(config, nil, logger) + if err != nil { + t.Fatalf("Failed to create auto store: %v", err) + } + + _, ok := store.(*FileCredentialsStore) + if !ok { + t.Error("Expected FileCredentialsStore for auto without redis") + } + }) + + t.Run("unknown backend returns error", func(t *testing.T) { + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + StorageBackend: "unknown", + } + + _, err := NewDCRCredentialsStore(config, nil, logger) + if err == nil { + t.Error("Expected error for unknown backend") + } + }) + + t.Run("empty backend defaults to auto", func(t *testing.T) { + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + StorageBackend: "", + } + + store, err := NewDCRCredentialsStore(config, nil, logger) + if err != nil { + t.Fatalf("Failed to create store with empty backend: %v", err) + } + + // Should default to file (auto without redis) + _, ok := store.(*FileCredentialsStore) + if !ok { + t.Error("Expected FileCredentialsStore for empty backend") + } + }) +} + +// TestDynamicClientRegistrar_WithStore tests registrar with store +func TestDynamicClientRegistrar_WithStore(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + } + + registrar := NewDynamicClientRegistrarWithStore( + nil, // httpClient + logger, + config, + "https://auth.example.com", + store, + ) + + if registrar == nil { + t.Fatal("Expected registrar but got nil") + } + + if registrar.store == nil { + t.Error("Expected store to be set") + } + + // Test SetStore + newStore := NewFileCredentialsStore(filepath.Join(tempDir, "new.json"), logger) + registrar.SetStore(newStore) + + if registrar.store != newStore { + t.Error("SetStore did not update the store") + } +} + +// TestDynamicClientRegistrar_CredentialsFromStore tests loading from store +func TestDynamicClientRegistrar_CredentialsFromStore(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + providerURL := "https://auth.example.com" + ctx := context.Background() + + // Pre-save credentials + testCreds := &ClientRegistrationResponse{ + ClientID: "pre-saved-client", + ClientSecret: "pre-saved-secret", + ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + if err := store.Save(ctx, providerURL, testCreds); err != nil { + t.Fatalf("Failed to pre-save credentials: %v", err) + } + + config := &DynamicClientRegistrationConfig{ + Enabled: true, + PersistCredentials: true, + } + + registrar := NewDynamicClientRegistrarWithStore( + nil, + logger, + config, + providerURL, + store, + ) + + // Test loading via the internal method + loaded, err := registrar.loadCredentialsFromStore(ctx) + if err != nil { + t.Fatalf("Failed to load from store: %v", err) + } + if loaded == nil { + t.Fatal("Expected credentials but got nil") + } + if loaded.ClientID != "pre-saved-client" { + t.Errorf("ClientID mismatch: got %s", loaded.ClientID) + } +} + +// TestFileCredentialsStore_CorruptedFile tests handling of corrupted files +func TestFileCredentialsStore_CorruptedFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(basePath, logger) + + ctx := context.Background() + providerURL := "https://auth.example.com" + + // Write corrupted JSON + filePath := store.getFilePath(providerURL) + if err := os.WriteFile(filePath, []byte("{corrupted json"), 0600); err != nil { + t.Fatalf("Failed to write corrupted file: %v", err) + } + + // Should return error for corrupted file + _, err := store.Load(ctx, providerURL) + if err == nil { + t.Error("Expected error for corrupted JSON") + } +} + +// TestFileCredentialsStore_DirectoryCreation tests auto directory creation +func TestFileCredentialsStore_DirectoryCreation(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + deepPath := filepath.Join(tempDir, "deep", "nested", "path", "credentials.json") + logger := GetSingletonNoOpLogger() + store := NewFileCredentialsStore(deepPath, logger) + + ctx := context.Background() + creds := &ClientRegistrationResponse{ClientID: "test"} + + err := store.Save(ctx, "https://example.com", creds) + if err != nil { + t.Fatalf("Failed to save with nested directory: %v", err) + } + + loaded, err := store.Load(ctx, "https://example.com") + if err != nil { + t.Fatalf("Failed to load after nested directory creation: %v", err) + } + if loaded == nil || loaded.ClientID != "test" { + t.Error("Failed to load credentials from nested directory") + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d0dc6a3..10fe5d0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -384,10 +384,14 @@ scopes: ### Dynamic Client Registration (RFC 7591) +Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials. + +**Basic Configuration (Single Instance):** + ```yaml dynamicClientRegistration: enabled: true - initialAccessToken: "your-token" # Optional + initialAccessToken: "your-token" # Optional, if provider requires it persistCredentials: true credentialsFile: "/tmp/oidc-credentials.json" clientMetadata: @@ -400,6 +404,35 @@ dynamicClientRegistration: - "refresh_token" ``` +**Multi-Replica Deployment (Kubernetes):** + +For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions: + +```yaml +dynamicClientRegistration: + enabled: true + persistCredentials: true + storageBackend: "redis" # Share credentials via Redis + redisKeyPrefix: "myapp:dcr:" # Optional custom prefix + clientMetadata: + redirect_uris: + - "https://your-app.com/oauth2/callback" + client_name: "My Application" + +redis: + enabled: true + address: "redis:6379" + cacheMode: "redis" +``` + +**Storage Backend Options:** + +| Backend | Description | Use Case | +|---------|-------------|----------| +| `file` | Store credentials in local file | Single instance deployments | +| `redis` | Store credentials in Redis | Multi-replica Kubernetes deployments | +| `auto` | Use Redis if available, fallback to file | Flexible deployments (default) | + ### Multi-Replica Deployment Without Redis, disable replay detection: diff --git a/docs/index.html b/docs/index.html index af99239..63d5c39 100644 --- a/docs/index.html +++ b/docs/index.html @@ -193,7 +193,7 @@

Dynamic Registration

-

RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration

+

RFC 7591 Dynamic Client Registration with Redis storage support for multi-replica deployments

@@ -862,6 +862,48 @@ spec: +
+

Dynamic Client Registration (RFC 7591)

+

Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDefaultDescription
dynamicClientRegistration.enabledfalseEnable dynamic client registration
dynamicClientRegistration.persistCredentialstruePersist registered credentials across restarts
dynamicClientRegistration.storageBackendautoStorage backend: file, redis, or auto (uses Redis if available)
dynamicClientRegistration.redisKeyPrefixdcr:creds:Redis key prefix for DCR credentials
dynamicClientRegistration.clientMetadata.redirect_uris-Redirect URIs for the registered client (required)
+
+

Example: Security Headers with CORS

diff --git a/dynamic_client_registration.go b/dynamic_client_registration.go index 69f754f..83b50bb 100644 --- a/dynamic_client_registration.go +++ b/dynamic_client_registration.go @@ -50,6 +50,7 @@ type DynamicClientRegistrar struct { logger *Logger config *DynamicClientRegistrationConfig registrationResponse *ClientRegistrationResponse + store DCRCredentialsStore // Storage backend for credentials providerURL string mu sync.RWMutex } @@ -73,8 +74,37 @@ func NewDynamicClientRegistrar( } } +// NewDynamicClientRegistrarWithStore creates a new dynamic client registrar with a specific storage backend +func NewDynamicClientRegistrarWithStore( + httpClient *http.Client, + logger *Logger, + dcrConfig *DynamicClientRegistrationConfig, + providerURL string, + store DCRCredentialsStore, +) *DynamicClientRegistrar { + if logger == nil { + logger = GetSingletonNoOpLogger() + } + + return &DynamicClientRegistrar{ + httpClient: httpClient, + logger: logger, + config: dcrConfig, + providerURL: providerURL, + store: store, + } +} + +// SetStore sets the credentials store for the registrar +// This allows setting the store after creation when the cache manager is available +func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) { + r.mu.Lock() + defer r.mu.Unlock() + r.store = store +} + // RegisterClient performs dynamic client registration with the OIDC provider -// It first attempts to load existing credentials from a file if persistence is enabled, +// It first attempts to load existing credentials from storage if persistence is enabled, // then registers a new client if no valid credentials exist. func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) { if r.config == nil || !r.config.Enabled { @@ -83,10 +113,13 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio // Try to load existing credentials if persistence is enabled if r.config.PersistCredentials { - if resp, err := r.loadCredentials(); err == nil && resp != nil { + resp, err := r.loadCredentialsFromStore(ctx) + if err != nil { + r.logger.Debugf("Failed to load credentials from store: %v", err) + } else if resp != nil { // Check if credentials are still valid (not expired) if r.areCredentialsValid(resp) { - r.logger.Info("Loaded existing client credentials from file") + r.logger.Info("Loaded existing client credentials from storage") r.mu.Lock() r.registrationResponse = resp r.mu.Unlock() @@ -179,7 +212,7 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio // Persist credentials if enabled if r.config.PersistCredentials { - if err := r.saveCredentials(®Resp); err != nil { + if err := r.saveCredentialsToStore(ctx, ®Resp); err != nil { r.logger.Errorf("Failed to persist client credentials: %v", err) // Don't fail registration if persistence fails } @@ -315,7 +348,44 @@ func (r *DynamicClientRegistrar) credentialsFilePath() string { return "/tmp/oidc-client-credentials.json" } -// saveCredentials persists client credentials to a file +// loadCredentialsFromStore loads client credentials from the configured storage backend +// Falls back to legacy file-based loading if no store is configured +func (r *DynamicClientRegistrar) loadCredentialsFromStore(ctx context.Context) (*ClientRegistrationResponse, error) { + // Use store if available + if r.store != nil { + return r.store.Load(ctx, r.providerURL) + } + // Fallback to legacy file-based loading + return r.loadCredentials() +} + +// saveCredentialsToStore persists client credentials to the configured storage backend +// Falls back to legacy file-based saving if no store is configured +func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, resp *ClientRegistrationResponse) error { + // Use store if available + if r.store != nil { + return r.store.Save(ctx, r.providerURL, resp) + } + // Fallback to legacy file-based saving + return r.saveCredentials(resp) +} + +// deleteCredentialsFromStore removes credentials from the configured storage backend +// Falls back to legacy file-based deletion if no store is configured +func (r *DynamicClientRegistrar) deleteCredentialsFromStore(ctx context.Context) error { + // Use store if available + if r.store != nil { + return r.store.Delete(ctx, r.providerURL) + } + // Fallback to legacy file-based deletion + filePath := r.credentialsFilePath() + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// saveCredentials persists client credentials to a file (legacy method) func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error { filePath := r.credentialsFilePath() @@ -333,7 +403,7 @@ func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationRespons return nil } -// loadCredentials loads client credentials from a file +// loadCredentials loads client credentials from a file (legacy method) func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) { filePath := r.credentialsFilePath() @@ -420,7 +490,7 @@ func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) ( // Persist updated credentials if enabled if r.config.PersistCredentials { - if err := r.saveCredentials(®Resp); err != nil { + if err := r.saveCredentialsToStore(ctx, ®Resp); err != nil { r.logger.Errorf("Failed to persist updated credentials: %v", err) } } @@ -527,11 +597,10 @@ func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) e r.registrationResponse = nil r.mu.Unlock() - // Remove credentials file if persistence is enabled + // Remove credentials from storage if persistence is enabled if r.config.PersistCredentials { - filePath := r.credentialsFilePath() - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - r.logger.Errorf("Failed to remove credentials file: %v", err) + if err := r.deleteCredentialsFromStore(ctx); err != nil { + r.logger.Errorf("Failed to remove credentials from storage: %v", err) } } diff --git a/internal/dcrstorage/file.go b/internal/dcrstorage/file.go new file mode 100644 index 0000000..8fc630b --- /dev/null +++ b/internal/dcrstorage/file.go @@ -0,0 +1,155 @@ +package dcrstorage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// FileStore implements Store using file-based storage. +// This is the default storage backend for backward compatibility with existing deployments. +// For distributed environments, consider using RedisStore instead. +type FileStore struct { + basePath string + logger Logger + mu sync.RWMutex +} + +// NewFileStore creates a new file-based credentials store. +// If basePath is empty, defaults to /tmp/oidc-client-credentials.json +func NewFileStore(basePath string, logger Logger) *FileStore { + if basePath == "" { + basePath = "/tmp/oidc-client-credentials.json" + } + if logger == nil { + logger = NoOpLogger() + } + return &FileStore{ + basePath: basePath, + logger: logger, + } +} + +// BasePath returns the base path used for storing credentials +func (s *FileStore) BasePath() string { + return s.basePath +} + +// GetFilePath returns the file path for storing credentials for a specific provider. +// For multi-tenant scenarios, each provider gets a separate file based on URL hash. +func (s *FileStore) GetFilePath(providerURL string) string { + if providerURL == "" { + return s.basePath + } + + // Hash provider URL for filename safety and uniqueness + hash := sha256.Sum256([]byte(providerURL)) + hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes for shorter filename + + ext := filepath.Ext(s.basePath) + base := strings.TrimSuffix(s.basePath, ext) + if ext == "" { + ext = ".json" + } + + return fmt.Sprintf("%s-%s%s", base, hashStr, ext) +} + +// Save stores the client registration response to a file +func (s *FileStore) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error { + if creds == nil { + return fmt.Errorf("credentials cannot be nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + filePath := s.GetFilePath(providerURL) + + // Ensure parent directory exists + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create credentials directory: %w", err) + } + + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + 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 { + return fmt.Errorf("failed to write credentials file: %w", err) + } + + s.logger.Debugf("Saved client credentials to %s", filePath) + return nil +} + +// Load retrieves stored credentials from a file. +// Returns nil, nil if no credentials file exists (not an error). +func (s *FileStore) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + filePath := s.GetFilePath(providerURL) + + // #nosec G304 -- path is constructed from trusted config values via GetFilePath() + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No credentials file exists - not an error + } + return nil, fmt.Errorf("failed to read credentials file: %w", err) + } + + var creds ClientRegistrationResponse + if err := json.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials file: %w", err) + } + + s.logger.Debugf("Loaded client credentials from %s", filePath) + return &creds, nil +} + +// Delete removes the credentials file for a provider +func (s *FileStore) Delete(ctx context.Context, providerURL string) error { + s.mu.Lock() + defer s.mu.Unlock() + + filePath := s.GetFilePath(providerURL) + + if err := os.Remove(filePath); err != nil { + if os.IsNotExist(err) { + return nil // File doesn't exist, nothing to delete + } + return fmt.Errorf("failed to remove credentials file: %w", err) + } + + s.logger.Debugf("Deleted client credentials from %s", filePath) + return nil +} + +// Exists checks if credentials exist for a provider +func (s *FileStore) Exists(ctx context.Context, providerURL string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + filePath := s.GetFilePath(providerURL) + + _, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to check credentials file: %w", err) + } + + return true, nil +} diff --git a/internal/dcrstorage/redis.go b/internal/dcrstorage/redis.go new file mode 100644 index 0000000..f72707a --- /dev/null +++ b/internal/dcrstorage/redis.go @@ -0,0 +1,161 @@ +package dcrstorage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" +) + +// Cache defines the interface for cache operations needed by RedisStore. +// This allows the main package to provide a cache implementation without +// creating circular dependencies. +type Cache interface { + // Get retrieves a value from the cache + Get(key string) (any, bool) + // Set stores a value in the cache with a TTL + Set(key string, value any, ttl time.Duration) error + // Delete removes a value from the cache + Delete(key string) +} + +// RedisStore implements Store using a Cache-backed storage. +// This storage backend enables sharing DCR credentials across multiple Traefik instances +// in distributed environments (e.g., Kubernetes with multiple ingress pods). +type RedisStore struct { + cache Cache + keyPrefix string + logger Logger + mu sync.RWMutex +} + +// NewRedisStore creates a new cache-backed credentials store. +// The cache should be configured with a Redis backend for distributed storage. +// If keyPrefix is empty, defaults to "dcr:creds:" +func NewRedisStore(cache Cache, keyPrefix string, logger Logger) *RedisStore { + if keyPrefix == "" { + keyPrefix = "dcr:creds:" + } + if logger == nil { + logger = NoOpLogger() + } + return &RedisStore{ + cache: cache, + keyPrefix: keyPrefix, + logger: logger, + } +} + +// makeKey creates a unique cache key for a provider URL. +// Uses SHA256 hash of the provider URL for consistent key generation across nodes. +func (s *RedisStore) makeKey(providerURL string) string { + if providerURL == "" { + return s.keyPrefix + "default" + } + hash := sha256.Sum256([]byte(providerURL)) + return s.keyPrefix + hex.EncodeToString(hash[:]) +} + +// Save stores the client registration response in the cache. +// TTL is calculated based on client_secret_expires_at if available. +func (s *RedisStore) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error { + if creds == nil { + return fmt.Errorf("credentials cannot be nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + key := s.makeKey(providerURL) + + // Calculate TTL based on client_secret_expires_at if available + ttl := 30 * 24 * time.Hour // Default: 30 days + if creds.ClientSecretExpiresAt > 0 { + expiresAt := time.Unix(creds.ClientSecretExpiresAt, 0) + ttl = time.Until(expiresAt) + if ttl < 0 { + return fmt.Errorf("credentials already expired") + } + // Add a small buffer to ensure we don't serve expired credentials + if ttl > time.Minute { + ttl -= time.Minute + } + } + + // Serialize credentials to JSON for storage + data, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + + // Store as string in cache (will be serialized by the cache backend) + if err := s.cache.Set(key, string(data), ttl); err != nil { + return fmt.Errorf("failed to store credentials in cache: %w", err) + } + + s.logger.Debugf("Saved client credentials to cache with key %s (TTL: %v)", key, ttl) + return nil +} + +// Load retrieves stored credentials from the cache. +// Returns nil, nil if no credentials exist (not an error). +func (s *RedisStore) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + key := s.makeKey(providerURL) + + value, exists := s.cache.Get(key) + if !exists { + return nil, nil // No credentials stored - not an error + } + + // Handle different value types from cache + var jsonData string + switch v := value.(type) { + case string: + jsonData = v + case []byte: + jsonData = string(v) + default: + // Try to see if it's already the struct (from local cache) + if creds, ok := value.(*ClientRegistrationResponse); ok { + return creds, nil + } + return nil, fmt.Errorf("unexpected credentials type in cache: %T", value) + } + + var creds ClientRegistrationResponse + if err := json.Unmarshal([]byte(jsonData), &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials from cache: %w", err) + } + + s.logger.Debugf("Loaded client credentials from cache with key %s", key) + return &creds, nil +} + +// Delete removes stored credentials from the cache +func (s *RedisStore) Delete(ctx context.Context, providerURL string) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := s.makeKey(providerURL) + s.cache.Delete(key) + + s.logger.Debugf("Deleted client credentials from cache with key %s", key) + return nil +} + +// Exists checks if credentials exist in the cache for a provider +func (s *RedisStore) Exists(ctx context.Context, providerURL string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + key := s.makeKey(providerURL) + _, exists := s.cache.Get(key) + + return exists, nil +} diff --git a/internal/dcrstorage/storage.go b/internal/dcrstorage/storage.go new file mode 100644 index 0000000..84b6148 --- /dev/null +++ b/internal/dcrstorage/storage.go @@ -0,0 +1,90 @@ +// Package dcrstorage provides storage backends for OIDC Dynamic Client Registration credentials. +// It supports both file-based and Redis-based storage for persisting client credentials +// across application restarts and distributed deployments. +package dcrstorage + +import ( + "context" +) + +// StorageBackend represents the type of storage backend for DCR credentials +type StorageBackend string + +const ( + // StorageBackendFile uses file-based storage (default for backward compatibility) + StorageBackendFile StorageBackend = "file" + + // StorageBackendRedis uses Redis for distributed storage + StorageBackendRedis StorageBackend = "redis" + + // StorageBackendAuto automatically selects Redis if available, otherwise file + StorageBackendAuto StorageBackend = "auto" +) + +// Logger interface for DCR storage operations +type Logger interface { + Debug(msg string) + Debugf(format string, args ...any) + Info(msg string) + Infof(format string, args ...any) + Error(msg string) + Errorf(format string, args ...any) +} + +// ClientRegistrationResponse represents the response from a successful client registration (RFC 7591) +type ClientRegistrationResponse struct { + SubjectType string `json:"subject_type,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + RegistrationAccessToken string `json:"registration_access_token,omitempty"` + RegistrationClientURI string `json:"registration_client_uri,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + TOSURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + ApplicationType string `json:"application_type,omitempty"` + ClientID string `json:"client_id"` + ClientName string `json:"client_name,omitempty"` + JWKSURI string `json:"jwks_uri,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + Contacts []string `json:"contacts,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` +} + +// Store defines the interface for storing DCR credentials. +// This abstraction allows different storage backends (file, Redis) to be used +// for persisting OIDC Dynamic Client Registration credentials across nodes. +type Store interface { + // Save stores the client registration response for a provider + // The providerURL is used as a key to support multi-tenant scenarios + Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error + + // Load retrieves stored credentials for a provider + // Returns nil, nil if no credentials exist (not an error) + Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) + + // Delete removes stored credentials for a provider + Delete(ctx context.Context, providerURL string) error + + // Exists checks if credentials exist for a provider + Exists(ctx context.Context, providerURL string) (bool, error) +} + +// noOpLogger is a no-op implementation of Logger for default use +type noOpLogger struct{} + +func (n noOpLogger) Debug(msg string) {} +func (n noOpLogger) Debugf(format string, args ...any) {} +func (n noOpLogger) Info(msg string) {} +func (n noOpLogger) Infof(format string, args ...any) {} +func (n noOpLogger) Error(msg string) {} +func (n noOpLogger) Errorf(format string, args ...any) {} + +// NoOpLogger returns a no-op logger instance +func NoOpLogger() Logger { + return noOpLogger{} +} diff --git a/internal/dcrstorage/storage_test.go b/internal/dcrstorage/storage_test.go new file mode 100644 index 0000000..7920846 --- /dev/null +++ b/internal/dcrstorage/storage_test.go @@ -0,0 +1,464 @@ +package dcrstorage + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// mockCache implements Cache for testing +type mockCache struct { + data map[string]cacheEntry + mu sync.RWMutex +} + +type cacheEntry struct { + value any + expiresAt time.Time +} + +func newMockCache() *mockCache { + return &mockCache{data: make(map[string]cacheEntry)} +} + +func (m *mockCache) Get(key string) (any, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + entry, ok := m.data[key] + if !ok { + return nil, false + } + if time.Now().After(entry.expiresAt) { + return nil, false + } + return entry.value, true +} + +func (m *mockCache) Set(key string, value any, ttl time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.data[key] = cacheEntry{ + value: value, + expiresAt: time.Now().Add(ttl), + } + return nil +} + +func (m *mockCache) Delete(key string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, key) +} + +func TestFileStore_SaveLoad(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + + store := NewFileStore(basePath, nil) + + testCreds := &ClientRegistrationResponse{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + RegistrationAccessToken: "test-access-token", + RegistrationClientURI: "https://example.com/register/test-client-id", + RedirectURIs: []string{"https://app.example.com/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "client_secret_basic", + } + + ctx := context.Background() + providerURL := "https://auth.example.com" + + t.Run("save and load credentials", func(t *testing.T) { + err := store.Save(ctx, providerURL, testCreds) + if err != nil { + t.Fatalf("Failed to save credentials: %v", err) + } + + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load credentials: %v", err) + } + + if loaded == nil { + t.Fatal("Expected credentials but got nil") + } + + if loaded.ClientID != testCreds.ClientID { + t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID) + } + if loaded.ClientSecret != testCreds.ClientSecret { + t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret) + } + if loaded.RegistrationAccessToken != testCreds.RegistrationAccessToken { + t.Errorf("RegistrationAccessToken mismatch: got %s, want %s", loaded.RegistrationAccessToken, testCreds.RegistrationAccessToken) + } + }) + + t.Run("load non-existent credentials", func(t *testing.T) { + tempDir2 := t.TempDir() + store2 := NewFileStore(filepath.Join(tempDir2, "nonexistent.json"), nil) + + loaded, err := store2.Load(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Unexpected error for non-existent file: %v", err) + } + if loaded != nil { + t.Error("Expected nil for non-existent credentials") + } + }) + + t.Run("exists check", func(t *testing.T) { + exists, err := store.Exists(ctx, providerURL) + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if !exists { + t.Error("Expected credentials to exist") + } + + exists, err = store.Exists(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if exists { + t.Error("Expected credentials to not exist") + } + }) + + t.Run("delete credentials", func(t *testing.T) { + err := store.Delete(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to delete credentials: %v", err) + } + + exists, _ := store.Exists(ctx, providerURL) + if exists { + t.Error("Expected credentials to be deleted") + } + }) + + t.Run("delete non-existent credentials", func(t *testing.T) { + err := store.Delete(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Delete should not error for non-existent: %v", err) + } + }) +} + +func TestFileStore_MultiProvider(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + store := NewFileStore(basePath, nil) + + ctx := context.Background() + + provider1 := "https://auth1.example.com" + provider2 := "https://auth2.example.com" + + creds1 := &ClientRegistrationResponse{ + ClientID: "client-1", + ClientSecret: "secret-1", + } + creds2 := &ClientRegistrationResponse{ + ClientID: "client-2", + ClientSecret: "secret-2", + } + + if err := store.Save(ctx, provider1, creds1); err != nil { + t.Fatalf("Failed to save creds1: %v", err) + } + if err := store.Save(ctx, provider2, creds2); err != nil { + t.Fatalf("Failed to save creds2: %v", err) + } + + loaded1, err := store.Load(ctx, provider1) + if err != nil { + t.Fatalf("Failed to load creds1: %v", err) + } + if loaded1.ClientID != "client-1" { + t.Errorf("Provider 1 ClientID mismatch: got %s", loaded1.ClientID) + } + + loaded2, err := store.Load(ctx, provider2) + if err != nil { + t.Fatalf("Failed to load creds2: %v", err) + } + if loaded2.ClientID != "client-2" { + t.Errorf("Provider 2 ClientID mismatch: got %s", loaded2.ClientID) + } + + if err := store.Delete(ctx, provider1); err != nil { + t.Fatalf("Failed to delete creds1: %v", err) + } + + exists, _ := store.Exists(ctx, provider2) + if !exists { + t.Error("Provider 2 credentials should still exist") + } +} + +func TestFileStore_ConcurrentAccess(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: "test-client", + ClientSecret: "test-secret", + } + + var wg sync.WaitGroup + concurrency := 10 + + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + _ = store.Save(ctx, providerURL, creds) + }() + } + wg.Wait() + + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = store.Load(ctx, providerURL) + }() + } + wg.Wait() + + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load after concurrent access: %v", err) + } + if loaded == nil || loaded.ClientID != "test-client" { + t.Error("Credentials corrupted after concurrent access") + } +} + +func TestFileStore_InvalidInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "credentials.json") + store := NewFileStore(basePath, nil) + + ctx := context.Background() + + t.Run("save nil credentials", func(t *testing.T) { + err := store.Save(ctx, "https://example.com", nil) + if err == nil { + t.Error("Expected error for nil credentials") + } + }) + + t.Run("empty provider URL uses default path", func(t *testing.T) { + creds := &ClientRegistrationResponse{ClientID: "test"} + err := store.Save(ctx, "", creds) + if err != nil { + t.Fatalf("Save with empty provider URL failed: %v", err) + } + + loaded, err := store.Load(ctx, "") + if err != nil { + t.Fatalf("Load with empty provider URL failed: %v", err) + } + if loaded == nil || loaded.ClientID != "test" { + t.Error("Failed to load credentials with empty provider URL") + } + }) +} + +func TestFileStore_DefaultPath(t *testing.T) { + t.Parallel() + + store := NewFileStore("", nil) + + if store.BasePath() == "" { + t.Error("Expected default base path") + } +} + +func TestRedisStore_WithMockCache(t *testing.T) { + t.Parallel() + + cache := newMockCache() + store := NewRedisStore(cache, "", nil) + + ctx := context.Background() + providerURL := "https://auth.example.com" + + testCreds := &ClientRegistrationResponse{ + ClientID: "redis-test-client", + ClientSecret: "redis-test-secret", + ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + RegistrationAccessToken: "redis-test-token", + RedirectURIs: []string{"https://app.example.com/callback"}, + } + + t.Run("save and load credentials", func(t *testing.T) { + err := store.Save(ctx, providerURL, testCreds) + if err != nil { + t.Fatalf("Failed to save credentials: %v", err) + } + + loaded, err := store.Load(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to load credentials: %v", err) + } + + if loaded == nil { + t.Fatal("Expected credentials but got nil") + } + if loaded.ClientID != testCreds.ClientID { + t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID) + } + if loaded.ClientSecret != testCreds.ClientSecret { + t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret) + } + }) + + t.Run("exists check", func(t *testing.T) { + exists, err := store.Exists(ctx, providerURL) + if err != nil { + t.Fatalf("Exists check failed: %v", err) + } + if !exists { + t.Error("Expected credentials to exist") + } + }) + + t.Run("delete credentials", func(t *testing.T) { + err := store.Delete(ctx, providerURL) + if err != nil { + t.Fatalf("Failed to delete credentials: %v", err) + } + + exists, _ := store.Exists(ctx, providerURL) + if exists { + t.Error("Expected credentials to be deleted") + } + }) + + t.Run("load non-existent credentials", func(t *testing.T) { + loaded, err := store.Load(ctx, "https://nonexistent.example.com") + if err != nil { + t.Fatalf("Unexpected error for non-existent: %v", err) + } + if loaded != nil { + t.Error("Expected nil for non-existent credentials") + } + }) +} + +func TestRedisStore_TTLFromExpiry(t *testing.T) { + t.Parallel() + + cache := newMockCache() + store := NewRedisStore(cache, "", nil) + + ctx := context.Background() + + t.Run("expired credentials should fail", func(t *testing.T) { + expiredCreds := &ClientRegistrationResponse{ + ClientID: "expired-client", + ClientSecret: "expired-secret", + ClientSecretExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), + } + + err := store.Save(ctx, "https://expired.example.com", expiredCreds) + if err == nil { + t.Error("Expected error for expired credentials") + } + }) + + t.Run("credentials without expiry use default TTL", func(t *testing.T) { + creds := &ClientRegistrationResponse{ + ClientID: "no-expiry-client", + ClientSecret: "no-expiry-secret", + ClientSecretExpiresAt: 0, + } + + err := store.Save(ctx, "https://noexpiry.example.com", creds) + if err != nil { + t.Fatalf("Failed to save credentials without expiry: %v", err) + } + }) +} + +func TestRedisStore_InvalidInput(t *testing.T) { + t.Parallel() + + cache := newMockCache() + store := NewRedisStore(cache, "", nil) + + ctx := context.Background() + + t.Run("save nil credentials", func(t *testing.T) { + err := store.Save(ctx, "https://example.com", nil) + if err == nil { + t.Error("Expected error for nil credentials") + } + }) +} + +func TestFileStore_CorruptedFile(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" + + filePath := store.GetFilePath(providerURL) + if err := os.WriteFile(filePath, []byte("{corrupted json"), 0600); err != nil { + t.Fatalf("Failed to write corrupted file: %v", err) + } + + _, err := store.Load(ctx, providerURL) + if err == nil { + t.Error("Expected error for corrupted JSON") + } +} + +func TestFileStore_DirectoryCreation(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + deepPath := filepath.Join(tempDir, "deep", "nested", "path", "credentials.json") + store := NewFileStore(deepPath, nil) + + ctx := context.Background() + creds := &ClientRegistrationResponse{ClientID: "test"} + + err := store.Save(ctx, "https://example.com", creds) + if err != nil { + t.Fatalf("Failed to save with nested directory: %v", err) + } + + loaded, err := store.Load(ctx, "https://example.com") + if err != nil { + t.Fatalf("Failed to load after nested directory creation: %v", err) + } + if loaded == nil || loaded.ClientID != "test" { + t.Error("Failed to load credentials from nested directory") + } +} diff --git a/main.go b/main.go index e49402a..84a63e8 100644 --- a/main.go +++ b/main.go @@ -433,6 +433,19 @@ func (t *TraefikOidc) performDynamicClientRegistration() { t.dcrConfig, t.providerURL, ) + + // Set up storage backend for credentials persistence + if t.dcrConfig.PersistCredentials { + cacheManager := GetGlobalCacheManagerWithConfig(t.goroutineWG, nil) + store, err := NewDCRCredentialsStore(t.dcrConfig, cacheManager, t.logger) + if err != nil { + t.logger.Errorf("Failed to create DCR credentials store: %v", err) + // Continue without persistence - registration will still work + } else { + t.dynamicClientRegistrar.SetStore(store) + t.logger.Debugf("DCR credentials store initialized with backend: %s", t.dcrConfig.StorageBackend) + } + } } // Get registration endpoint (from metadata or config override) diff --git a/settings.go b/settings.go index 34aafec..c17a664 100644 --- a/settings.go +++ b/settings.go @@ -98,8 +98,15 @@ type DynamicClientRegistrationConfig struct { InitialAccessToken string `json:"initialAccessToken,omitempty"` RegistrationEndpoint string `json:"registrationEndpoint,omitempty"` CredentialsFile string `json:"credentialsFile,omitempty"` - Enabled bool `json:"enabled"` - PersistCredentials bool `json:"persistCredentials"` + // StorageBackend specifies where to store DCR credentials: "file", "redis", or "auto" + // - "file": Use file-based storage (default for backward compatibility) + // - "redis": Use Redis exclusively (fails if Redis unavailable) + // - "auto": Use Redis if available, fallback to file (default) + StorageBackend string `json:"storageBackend,omitempty"` + // RedisKeyPrefix is the prefix for Redis keys when using Redis storage (default: "dcr:creds:") + RedisKeyPrefix string `json:"redisKeyPrefix,omitempty"` + Enabled bool `json:"enabled"` + PersistCredentials bool `json:"persistCredentials"` } // ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591) diff --git a/universal_cache_singleton.go b/universal_cache_singleton.go index a3a0879..9f07e21 100644 --- a/universal_cache_singleton.go +++ b/universal_cache_singleton.go @@ -13,20 +13,21 @@ import ( // It runs a single consolidated cleanup goroutine for all caches, reducing // goroutine count and CPU overhead compared to per-cache cleanup routines. type UniversalCacheManager struct { - sharedBackend backends.CacheBackend - ctx context.Context - tokenTypeCache *UniversalCache - jwkCache *UniversalCache - sessionCache *UniversalCache - introspectionCache *UniversalCache - tokenCache *UniversalCache - metadataCache *UniversalCache - logger *Logger - blacklistCache *UniversalCache - cancel context.CancelFunc - wg sync.WaitGroup - mu sync.RWMutex - cleanupStarted bool + sharedBackend backends.CacheBackend + ctx context.Context + tokenTypeCache *UniversalCache + jwkCache *UniversalCache + sessionCache *UniversalCache + introspectionCache *UniversalCache + tokenCache *UniversalCache + metadataCache *UniversalCache + dcrCredentialsCache *UniversalCache // DCR credentials storage for distributed environments + logger *Logger + blacklistCache *UniversalCache + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + cleanupStarted bool } var ( @@ -349,6 +350,19 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r SkipAutoCleanup: true, // Managed cleanup }) + // DCR credentials cache - CRITICAL for distributed DCR across multiple nodes + // Uses Redis backend to share client credentials across all Traefik replicas + manager.dcrCredentialsCache = NewUniversalCacheWithBackend( + UniversalCacheConfig{ + Type: CacheTypeGeneral, + MaxSize: 100, // Few providers expected + DefaultTTL: 30 * 24 * time.Hour, // 30 days default (credentials are long-lived) + Logger: logger, + SkipAutoCleanup: true, // Managed cleanup + }, + createBackend("dcr"), + ) + logger.Infof("Cache manager initialized with %s backend configuration", redisConfig.CacheMode) } @@ -396,6 +410,7 @@ func (m *UniversalCacheManager) performConsolidatedCleanup() { m.sessionCache, m.introspectionCache, m.tokenTypeCache, + m.dcrCredentialsCache, } m.mu.RUnlock() @@ -458,6 +473,13 @@ func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache { return m.tokenTypeCache } +// GetDCRCredentialsCache returns the DCR credentials cache for distributed storage +func (m *UniversalCacheManager) GetDCRCredentialsCache() *UniversalCache { + m.mu.RLock() + defer m.mu.RUnlock() + return m.dcrCredentialsCache +} + // Close shuts down all caches and the consolidated cleanup routine func (m *UniversalCacheManager) Close() error { // Stop the consolidated cleanup routine first @@ -473,7 +495,7 @@ func (m *UniversalCacheManager) Close() error { // Close all caches first (they won't close the shared backend) for _, cache := range []*UniversalCache{ - m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache, + m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache, m.dcrCredentialsCache, } { if cache != nil { _ = cache.Close() // Safe to ignore: best effort cache cleanup