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 @@
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
Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:
+| Parameter | +Default | +Description | +
|---|---|---|
dynamicClientRegistration.enabled |
+ false | +Enable dynamic client registration | +
dynamicClientRegistration.persistCredentials |
+ true | +Persist registered credentials across restarts | +
dynamicClientRegistration.storageBackend |
+ auto | +Storage backend: file, redis, or auto (uses Redis if available) |
+
dynamicClientRegistration.redisKeyPrefix |
+ dcr:creds: | +Redis key prefix for DCR credentials | +
dynamicClientRegistration.clientMetadata.redirect_uris |
+ - | +Redirect URIs for the registered client (required) | +