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
|
Default: 30 seconds
|
||||||
required: false
|
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
|
- **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
|
- **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
|
- **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
|
- **Security headers**: Comprehensive security headers with CORS, CSP, HSTS, and custom profiles
|
||||||
- **Domain restrictions**: Limit access to specific email domains or individual users
|
- **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 (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
|
```yaml
|
||||||
dynamicClientRegistration:
|
dynamicClientRegistration:
|
||||||
enabled: true
|
enabled: true
|
||||||
initialAccessToken: "your-token" # Optional
|
initialAccessToken: "your-token" # Optional, if provider requires it
|
||||||
persistCredentials: true
|
persistCredentials: true
|
||||||
credentialsFile: "/tmp/oidc-credentials.json"
|
credentialsFile: "/tmp/oidc-credentials.json"
|
||||||
clientMetadata:
|
clientMetadata:
|
||||||
@@ -400,6 +404,35 @@ dynamicClientRegistration:
|
|||||||
- "refresh_token"
|
- "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
|
### Multi-Replica Deployment
|
||||||
|
|
||||||
Without Redis, disable replay detection:
|
Without Redis, disable replay detection:
|
||||||
|
|||||||
+43
-1
@@ -193,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -862,6 +862,48 @@ spec:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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
|
logger *Logger
|
||||||
config *DynamicClientRegistrationConfig
|
config *DynamicClientRegistrationConfig
|
||||||
registrationResponse *ClientRegistrationResponse
|
registrationResponse *ClientRegistrationResponse
|
||||||
|
store DCRCredentialsStore // Storage backend for credentials
|
||||||
providerURL string
|
providerURL string
|
||||||
mu sync.RWMutex
|
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
|
// 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.
|
// then registers a new client if no valid credentials exist.
|
||||||
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
|
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
|
||||||
if r.config == nil || !r.config.Enabled {
|
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
|
// Try to load existing credentials if persistence is enabled
|
||||||
if r.config.PersistCredentials {
|
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)
|
// Check if credentials are still valid (not expired)
|
||||||
if r.areCredentialsValid(resp) {
|
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.mu.Lock()
|
||||||
r.registrationResponse = resp
|
r.registrationResponse = resp
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -179,7 +212,7 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
|||||||
|
|
||||||
// Persist credentials if enabled
|
// Persist credentials if enabled
|
||||||
if r.config.PersistCredentials {
|
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)
|
r.logger.Errorf("Failed to persist client credentials: %v", err)
|
||||||
// Don't fail registration if persistence fails
|
// Don't fail registration if persistence fails
|
||||||
}
|
}
|
||||||
@@ -315,7 +348,44 @@ func (r *DynamicClientRegistrar) credentialsFilePath() string {
|
|||||||
return "/tmp/oidc-client-credentials.json"
|
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 {
|
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
|
||||||
filePath := r.credentialsFilePath()
|
filePath := r.credentialsFilePath()
|
||||||
|
|
||||||
@@ -333,7 +403,7 @@ func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationRespons
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCredentials loads client credentials from a file
|
// loadCredentials loads client credentials from a file (legacy method)
|
||||||
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
|
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
|
||||||
filePath := r.credentialsFilePath()
|
filePath := r.credentialsFilePath()
|
||||||
|
|
||||||
@@ -420,7 +490,7 @@ func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (
|
|||||||
|
|
||||||
// Persist updated credentials if enabled
|
// Persist updated credentials if enabled
|
||||||
if r.config.PersistCredentials {
|
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)
|
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.registrationResponse = nil
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
// Remove credentials file if persistence is enabled
|
// Remove credentials from storage if persistence is enabled
|
||||||
if r.config.PersistCredentials {
|
if r.config.PersistCredentials {
|
||||||
filePath := r.credentialsFilePath()
|
if err := r.deleteCredentialsFromStore(ctx); err != nil {
|
||||||
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
r.logger.Errorf("Failed to remove credentials from storage: %v", err)
|
||||||
r.logger.Errorf("Failed to remove credentials file: %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.dcrConfig,
|
||||||
t.providerURL,
|
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)
|
// Get registration endpoint (from metadata or config override)
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ type DynamicClientRegistrationConfig struct {
|
|||||||
InitialAccessToken string `json:"initialAccessToken,omitempty"`
|
InitialAccessToken string `json:"initialAccessToken,omitempty"`
|
||||||
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
|
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
|
||||||
CredentialsFile string `json:"credentialsFile,omitempty"`
|
CredentialsFile string `json:"credentialsFile,omitempty"`
|
||||||
|
// 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"`
|
Enabled bool `json:"enabled"`
|
||||||
PersistCredentials bool `json:"persistCredentials"`
|
PersistCredentials bool `json:"persistCredentials"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type UniversalCacheManager struct {
|
|||||||
introspectionCache *UniversalCache
|
introspectionCache *UniversalCache
|
||||||
tokenCache *UniversalCache
|
tokenCache *UniversalCache
|
||||||
metadataCache *UniversalCache
|
metadataCache *UniversalCache
|
||||||
|
dcrCredentialsCache *UniversalCache // DCR credentials storage for distributed environments
|
||||||
logger *Logger
|
logger *Logger
|
||||||
blacklistCache *UniversalCache
|
blacklistCache *UniversalCache
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -349,6 +350,19 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
|
|||||||
SkipAutoCleanup: true, // Managed cleanup
|
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)
|
logger.Infof("Cache manager initialized with %s backend configuration", redisConfig.CacheMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +410,7 @@ func (m *UniversalCacheManager) performConsolidatedCleanup() {
|
|||||||
m.sessionCache,
|
m.sessionCache,
|
||||||
m.introspectionCache,
|
m.introspectionCache,
|
||||||
m.tokenTypeCache,
|
m.tokenTypeCache,
|
||||||
|
m.dcrCredentialsCache,
|
||||||
}
|
}
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -458,6 +473,13 @@ func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache {
|
|||||||
return m.tokenTypeCache
|
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
|
// Close shuts down all caches and the consolidated cleanup routine
|
||||||
func (m *UniversalCacheManager) Close() error {
|
func (m *UniversalCacheManager) Close() error {
|
||||||
// Stop the consolidated cleanup routine first
|
// 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)
|
// Close all caches first (they won't close the shared backend)
|
||||||
for _, cache := range []*UniversalCache{
|
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 {
|
if cache != nil {
|
||||||
_ = cache.Close() // Safe to ignore: best effort cache cleanup
|
_ = cache.Close() // Safe to ignore: best effort cache cleanup
|
||||||
|
|||||||
Reference in New Issue
Block a user