mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
feat(dcr): Add Redis storage support for multi-replica deployments (#109)
- [x] Add file and Redis storage backends for DCR credentials - [x] Implement storage abstraction with FileStore and RedisStore - [x] Add factory function for automatic backend selection (auto/file/redis) - [x] Integrate DCR credentials cache into UniversalCacheManager - [x] Add comprehensive tests for storage backends and factory - [x] Update configuration schema with storage backend options - [x] Update documentation with multi-replica deployment guidance - [x] Add Redis key prefix configuration for credential isolation
This commit is contained in:
+231
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -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:
|
||||
|
||||
+43
-1
@@ -193,7 +193,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration with Redis storage support for multi-replica deployments</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -862,6 +862,48 @@ spec:
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Dynamic Client Registration (RFC 7591)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:</p>
|
||||
<div class="overflow-x-auto mb-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Default</th>
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-600 dark:text-gray-400">
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.enabled</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Enable dynamic client registration</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.persistCredentials</code></td>
|
||||
<td class="py-2 px-3">true</td>
|
||||
<td class="py-2 px-3">Persist registered credentials across restarts</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.storageBackend</code></td>
|
||||
<td class="py-2 px-3">auto</td>
|
||||
<td class="py-2 px-3">Storage backend: <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">file</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis</code>, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">auto</code> (uses Redis if available)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.redisKeyPrefix</code></td>
|
||||
<td class="py-2 px-3">dcr:creds:</td>
|
||||
<td class="py-2 px-3">Redis key prefix for DCR credentials</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.clientMetadata.redirect_uris</code></td>
|
||||
<td class="py-2 px-3">-</td>
|
||||
<td class="py-2 px-3">Redirect URIs for the registered client (required)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Security Headers with CORS</h3>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+9
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user