Files
traefikoidc/settings.go
T
lukaszraczylo a548665edb feat: opt-in M2M bearer-token authentication (supersedes #93) (#140)
* docs: bearer-token auth design spec

* docs: harden bearer-auth spec with security review findings

* feat(bearer): opt-in M2M bearer-token authentication

Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine
clients. Replaces and supersedes the broken approach in PR #93
(synthetic-session that omitted user_identifier and skipped ID-token
rejection / replay-protection-semantics / kid-pinning / etc.).

Design

  Two auth entrypoints feed one shared post-auth pipeline:

    cookie path  ─┐
                  ├── forwardAuthorized(rw, req, *principal)
    bearer path  ─┘    (roles/groups, header injection, security
                        headers, cookie strip, forward)

  buildPrincipalFromSession and buildPrincipalFromBearerToken produce
  the same `principal` value type. forwardAuthorized is session-agnostic
  and runs the existing post-auth work; processAuthorizedRequest now
  wraps it with the session-specific concerns (backchannel-logout,
  dirty/Save). The cookie path's behaviour is byte-identical to before
  this PR; the existing test suite passes unmodified.

Security hardening baked into the bearer path

  - Audience MANDATORY. Startup fails when EnableBearerAuth=true and
    Audience is empty.
  - BearerIdentifierClaim defaults to "sub"; "email" is rejected at
    startup to avoid the unverified-email spoofing footgun. Cookie
    path's UserIdentifierClaim is unaffected and still defaults to
    "email".
  - ID tokens explicitly rejected via the existing detectTokenType
    helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID
    heuristics); belt-and-braces nonce/token_use=id rejection on top.
  - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE
    JWKS fetch, blocking alg=none and alg=HS* probes from amplifying
    into upstream calls.
  - kid length capped at 256 bytes and charset-restricted before JWKS
    fetch, blocking pathological-kid JWKS amplification.
  - Multi-audience tokens require azp == clientID.
  - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock-
    manipulation and forever-token abuse.
  - Identifier sanitization: length cap, control-char + bidi-override
    + delimiter (, ; =) rejection.
  - Per-IP failure throttle: configurable threshold/window/penalty;
    returns 429 + Retry-After. Limits offline-guessing-style attacks
    and protects the shared rate-limiter / JWKS endpoint.
  - JTI replay marking suppressed via new internal verifyOpts
    {skipReplayMarking} so the same bearer can be reused until exp;
    the blacklist Get stays active so RevokeToken still terminates a
    bearer token immediately. The existing exported VerifyToken
    interface is unchanged so all mocks continue to work.
  - Cookie wins by default when both bearer and cookie are present
    (safer against browser/extension/proxy bearer injection).
    Operator can flip via BearerOverridesCookie.
  - Authorization header stripped on forward by default; also stripped
    on excluded URLs so the token can't leak into health/metrics
    downstream logs.
  - Optional RFC 7662 introspection via existing
    requireTokenIntrospection. Introspection-endpoint failure returns
    503 (distinguishes infra from token rejection).
  - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure
    reason is logged at debug; raw tokens are never logged.

Implementation

  - principal.go: pure-data principal type and buildPrincipalFromSession.
  - bearer_auth.go: alg/kid pin, classifier, identifier sanitization,
    multi-aud azp gate, iat age check, per-IP failure tracker,
    handleBearerRequest, buildPrincipalFromBearerToken.
  - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts
    that accepts internal-only verifyOpts. Existing callers, the
    TokenVerifier interface, and all mocks unchanged.
  - middleware.go: extracted forwardAuthorized from
    processAuthorizedRequest; wired bearer detection after init wait
    + after bypass; excluded-URL Authorization strip when bearer
    enabled.
  - settings.go: ten new config fields with defaults applied in
    CreateConfig.
  - main.go: startup validation for audience + identifier-claim
    guard; bearer failure tracker init.

Tests

  - bearer_auth_test.go: table-driven helper tests for every new
    component (parseBearerJOSEHeader, sanitizeBearerIdentifier,
    resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge,
    bearerFailureTracker, detectBearerToken). Integration tests
    through ServeHTTP covering happy path, ID-token rejection,
    alg=none rejection, oversized kid, multi-aud with/without azp,
    iat-too-old, bidi identifier, replay (100x reuse), 429 throttle
    trip, excluded-URL strip, roles gate, cookie-wins precedence,
    BearerOverridesCookie, oversized token, malformed JWT,
    feature-off pass-through. Startup validation for audience-
    required and email-identifier-rejected.
  - All existing tests pass unmodified (cookie-path regression).
  - go vet clean. golangci-lint clean (0 issues). Race detector
    clean on bearer tests.

Documentation

  - README.md: bearer auth section with security highlights and
    config snippet; doc link in the index.
  - .traefik.yml: commented config block exposing every bearer knob.
  - docs/CONFIGURATION.md: new subsection with full parameter table.
  - docs/BEARER_AUTH.md: threat model, hardening matrix, failure
    response table, operational guidance, known follow-ups.
  - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md:
    design spec + security-review hardening history.

* fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging)

CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the
in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug.
Cache callers (token cache, blacklist, introspection cache) pass raw
access / refresh / id tokens as cache keys, so any debug-enabled
deployment would write them to log streams.

Pre-existing issue. CodeQL started flagging it on this PR because the
new bearer-auth path adds a data-flow source (req.Header.Get("Authorization"))
that reaches the existing logging sinks via the same cache. The cookie
path had the same risk but wasn't tracked as taint by CodeQL.

Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the
bearer-auth logger uses for principal identifiers (spec §13). Doesn't
change cache semantics — same key still produces the same hash, so
debug correlation across log lines is preserved without exposing the
raw value.

Touches both affected packages:
  - internal/cache/cache.go (2 sites: Set + LRU eviction)
  - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback)

New helper `redactKey` colocated with each package (unexported,
package-local) keeps the change blast radius narrow. Tests green; lint
clean.

* docs(bearer): how to obtain bearer tokens from the OIDC provider

Adds a section walking operators through the OAuth 2.0 client_credentials
flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523),
with a worked Auth0-shape curl example, a per-provider quick reference
(Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational
notes (token TTL, caching, JWKS rotation, revocation, scope vs audience,
secret hygiene), and a three-line validation loop.

Most common operator confusion: "I enabled the feature but tokens get
401'd" — almost always missing or wrong audience. The new section makes
the audience-matching requirement loud, with per-provider parameter
names so people don't have to dig through IdP docs.

Locations:
  - docs/BEARER_AUTH.md  — full section under "Quick start"
  - README.md            — short snippet + deep link
2026-05-18 17:35:37 +01:00

1260 lines
50 KiB
Go

package traefikoidc
import (
"crypto/x509"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
)
// TemplatedHeader represents a custom HTTP header with a templated value.
// The value can contain template expressions that will be evaluated for each
// authenticated request, such as {{.claims.email}} or {{.accessToken}}.
type TemplatedHeader struct {
// Name is the HTTP header name to set (e.g., "X-Forwarded-Email")
Name string `json:"name"`
// Value is the template string for the header value
// Example: "{{.claims.email}}", "Bearer {{.accessToken}}"
Value string `json:"value"`
}
// Config holds the configuration for the OIDC middleware.
// It provides all necessary settings to configure OpenID Connect authentication
// with various providers like Auth0, Logto, or any standard OIDC provider.
type Config struct {
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"`
Redis *RedisConfig `json:"redis,omitempty"`
HTTPClient *http.Client `json:"-"`
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
LogLevel string `json:"logLevel"`
LogoutURL string `json:"logoutURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
Audience string `json:"audience,omitempty"`
CookiePrefix string `json:"cookiePrefix"`
CallbackURL string `json:"callbackURL"`
SessionEncryptionKey string `json:"sessionEncryptionKey"`
ProviderURL string `json:"providerURL"`
RevocationURL string `json:"revocationURL"`
UserIdentifierClaim string `json:"userIdentifierClaim,omitempty"`
GroupClaimName string `json:"groupClaimName,omitempty"`
RoleClaimName string `json:"roleClaimName,omitempty"`
CookieDomain string `json:"cookieDomain"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
// a stored refresh token. Once the token has been in the session longer
// than this, requests treat it as expired up-front - returning 401 to
// AJAX callers and triggering full re-auth on navigations - instead of
// hammering the IdP with grants that will only fail with invalid_grant.
// IdPs do not expose RT TTL on the wire, so this is intentionally a
// conservative heuristic; tune to match your provider configuration.
// Default 21600 (6h). Set to 0 to disable the check.
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
SessionMaxAge int `json:"sessionMaxAge"`
RateLimit int `json:"rateLimit"`
OverrideScopes bool `json:"overrideScopes"`
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
EnablePKCE bool `json:"enablePKCE"`
ForceHTTPS bool `json:"forceHTTPS"`
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
// CACertPath is an optional filesystem path to a PEM-encoded CA bundle used
// to verify the OIDC provider's TLS certificate. Use this when the provider
// is signed by an internal/private CA that is not in the system trust store.
CACertPath string `json:"caCertPath,omitempty"`
// CACertPEM is an optional inline PEM-encoded CA bundle, equivalent to
// CACertPath but supplied directly in the middleware configuration. Both
// may be set; certificates from both sources are combined.
CACertPEM string `json:"caCertPEM,omitempty"`
// InsecureSkipVerify disables TLS certificate verification for the OIDC
// provider. Intended ONLY for local development against self-signed
// providers. Enabling this in production is a security hole — prefer
// CACertPath/CACertPEM. Emits a loud warning at startup.
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
// ClientAuthMethod selects the OAuth 2.0 client authentication method used
// at the token / revocation / introspection endpoints. Supported values:
//
// - "client_secret_post" (default, current behavior): clientSecret is
// sent in the request body alongside client_id.
// - "private_key_jwt" (RFC 7523 §2.2): the plugin signs a short-lived JWT
// assertion with a configured private key and sends it as
// client_assertion. Use this when your IdP enforces short-lived secrets
// or mandates secretless client auth (Entra ID, Okta, Auth0, Keycloak).
//
// When set to "private_key_jwt", clientSecret may be left empty and one of
// clientAssertionPrivateKey / clientAssertionKeyPath must be configured.
ClientAuthMethod string `json:"clientAuthMethod,omitempty"`
// ClientAssertionPrivateKey is an inline PEM-encoded private key used to
// sign client_assertion JWTs. Mutually exclusive with
// ClientAssertionKeyPath. Supports PKCS#8, PKCS#1 (RSA), and SEC1 (EC).
ClientAssertionPrivateKey string `json:"clientAssertionPrivateKey,omitempty"`
// ClientAssertionKeyPath is a filesystem path to a PEM-encoded private key,
// equivalent to ClientAssertionPrivateKey but loaded from disk.
ClientAssertionKeyPath string `json:"clientAssertionKeyPath,omitempty"`
// ClientAssertionKeyID is the JWK key id (kid) advertised in the JWS
// header. Required when using private_key_jwt so the IdP can locate the
// matching public key registered for the client.
ClientAssertionKeyID string `json:"clientAssertionKeyID,omitempty"`
// ClientAssertionAlg is the JWS signing algorithm. Defaults to RS256.
// Supported: RS256/384/512, PS256/384/512, ES256/384/512.
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
// --- Bearer-token auth (opt-in M2M path) ---
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
// Default false. When true, Audience MUST be set or startup fails. The
// bearer path is M2M-only: it accepts validated access-token JWTs, rejects
// ID tokens, and forwards principal headers downstream without creating a
// cookie session. See docs/BEARER_AUTH.md for the threat model.
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
// BearerIdentifierClaim names the JWT claim used as the principal identifier
// on the bearer-token auth path. Default "sub". Decoupled from
// UserIdentifierClaim (which defaults to "email" and drives the cookie path)
// so M2M bearer flow never accidentally relies on an unverified email.
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
// StripAuthorizationHeader removes the Authorization header from the
// forwarded request after successful bearer auth, so downstream services
// never see the raw token. Default true. Disable only when a downstream
// explicitly needs to re-validate the bearer.
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
// BearerEmitWWWAuthenticate controls whether 401 responses on the bearer
// path include a WWW-Authenticate: Bearer error="invalid_token" hint per
// RFC 6750 §3. Default true. Disable to reduce reconnaissance signal.
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
// BearerOverridesCookie controls precedence when both Authorization:
// Bearer and a session cookie are present. Default false: cookie wins
// (safer against browser/extension/proxy bearer injection). Set true for
// the bearer-wins convention used by AWS/GCP/Kubernetes API gateways.
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
// MaxTokenAgeSeconds caps how old (iat-based) a bearer token may be.
// Default 86400 (24h). Bounds clock-manipulation tokens with implausibly
// distant iat values.
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
// MaxIdentifierLength bounds the post-sanitisation length of the bearer
// principal identifier (the value injected as X-Forwarded-User). Default
// 256.
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
// BearerFailureThreshold is the number of consecutive 401s from one
// source IP within BearerFailureWindowSeconds that trips the throttle.
// Default 20.
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
// BearerFailureWindowSeconds is the rolling window (seconds) over which
// 401s are counted for throttling. Default 60.
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
// BearerFailurePenaltySeconds is how long an IP is parked in the 429
// penalty box after BearerFailureThreshold is exceeded. Default 60.
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
}
// loadCACertPool assembles an x509.CertPool from CACertPath and CACertPEM.
// Returns (nil, nil) when neither is configured — callers should fall back to
// the system trust store. Returns a descriptive error if a PEM source is
// configured but contains no parseable certificates, so misconfigurations
// surface at startup rather than as unexplained TLS failures at runtime.
func (c *Config) loadCACertPool() (*x509.CertPool, error) {
if c.CACertPath == "" && c.CACertPEM == "" {
return nil, nil
}
pool := x509.NewCertPool()
if c.CACertPath != "" {
data, err := os.ReadFile(c.CACertPath)
if err != nil {
return nil, fmt.Errorf("read caCertPath %q: %w", c.CACertPath, err)
}
if !pool.AppendCertsFromPEM(data) {
return nil, fmt.Errorf("caCertPath %q: no valid PEM certificates found", c.CACertPath)
}
}
if c.CACertPEM != "" {
if !pool.AppendCertsFromPEM([]byte(c.CACertPEM)) {
return nil, fmt.Errorf("caCertPEM: no valid PEM certificates found")
}
}
return pool, nil
}
// RedisConfig configures Redis cache backend settings for distributed caching.
// All fields support both JSON and YAML configuration for compatibility with Traefik's
// dynamic configuration (labels, YAML files, etc.)
type RedisConfig struct {
KeyPrefix string `json:"keyPrefix" yaml:"keyPrefix"`
Address string `json:"address" yaml:"address"`
Password string `json:"password,omitempty" yaml:"password,omitempty"`
CacheMode string `json:"cacheMode" yaml:"cacheMode"`
WriteTimeout int `json:"writeTimeout" yaml:"writeTimeout"`
CircuitBreakerThreshold int `json:"circuitBreakerThreshold" yaml:"circuitBreakerThreshold"`
ConnectTimeout int `json:"connectTimeout" yaml:"connectTimeout"`
ReadTimeout int `json:"readTimeout" yaml:"readTimeout"`
PoolSize int `json:"poolSize" yaml:"poolSize"`
HealthCheckInterval int `json:"healthCheckInterval" yaml:"healthCheckInterval"`
CircuitBreakerTimeout int `json:"circuitBreakerTimeout" yaml:"circuitBreakerTimeout"`
DB int `json:"db" yaml:"db"`
HybridL1Size int `json:"hybridL1Size" yaml:"hybridL1Size"`
HybridL1MemoryMB int64 `json:"hybridL1MemoryMB" yaml:"hybridL1MemoryMB"`
Enabled bool `json:"enabled" yaml:"enabled"`
EnableCircuitBreaker bool `json:"enableCircuitBreaker" yaml:"enableCircuitBreaker"`
TLSSkipVerify bool `json:"tlsSkipVerify" yaml:"tlsSkipVerify"`
EnableHealthCheck bool `json:"enableHealthCheck" yaml:"enableHealthCheck"`
EnableTLS bool `json:"enableTLS" yaml:"enableTLS"`
}
// DynamicClientRegistrationConfig configures OIDC Dynamic Client Registration (RFC 7591)
type DynamicClientRegistrationConfig struct {
ClientMetadata *ClientRegistrationMetadata `json:"clientMetadata,omitempty"`
InitialAccessToken string `json:"initialAccessToken,omitempty"`
RegistrationEndpoint string `json:"registrationEndpoint,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"`
PersistCredentials bool `json:"persistCredentials"`
}
// ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591)
type ClientRegistrationMetadata struct {
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
TOSURI string `json:"tos_uri,omitempty"`
Scope string `json:"scope,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ClientName string `json:"client_name,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
ClientURI string `json:"client_uri,omitempty"`
PolicyURI string `json:"policy_uri,omitempty"`
JWKSURI string `json:"jwks_uri,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Contacts []string `json:"contacts,omitempty"`
RedirectURIs []string `json:"redirect_uris"`
DefaultACRValues []string `json:"default_acr_values,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
DefaultMaxAge int `json:"default_max_age,omitempty"`
RequireAuthTime bool `json:"require_auth_time,omitempty"`
}
// SecurityHeadersConfig configures security headers for the plugin
type SecurityHeadersConfig struct {
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
PermissionsPolicy string `json:"permissionsPolicy,omitempty"`
Profile string `json:"profile"`
ContentSecurityPolicy string `json:"contentSecurityPolicy,omitempty"`
CrossOriginResourcePolicy string `json:"crossOriginResourcePolicy,omitempty"`
CrossOriginOpenerPolicy string `json:"crossOriginOpenerPolicy,omitempty"`
CrossOriginEmbedderPolicy string `json:"crossOriginEmbedderPolicy,omitempty"`
FrameOptions string `json:"frameOptions,omitempty"`
ContentTypeOptions string `json:"contentTypeOptions,omitempty"`
XSSProtection string `json:"xssProtection,omitempty"`
ReferrerPolicy string `json:"referrerPolicy,omitempty"`
CORSAllowedHeaders []string `json:"corsAllowedHeaders,omitempty"`
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
CORSAllowedMethods []string `json:"corsAllowedMethods,omitempty"`
StrictTransportSecurityMaxAge int `json:"strictTransportSecurityMaxAge"`
CORSMaxAge int `json:"corsMaxAge"`
StrictTransportSecurityPreload bool `json:"strictTransportSecurityPreload"`
StrictTransportSecuritySubdomains bool `json:"strictTransportSecuritySubdomains"`
CORSEnabled bool `json:"corsEnabled"`
Enabled bool `json:"enabled"`
CORSAllowCredentials bool `json:"corsAllowCredentials"`
StrictTransportSecurity bool `json:"strictTransportSecurity"`
DisableServerHeader bool `json:"disableServerHeader"`
DisablePoweredByHeader bool `json:"disablePoweredByHeader"`
}
const (
// DefaultRateLimit defines the default rate limit for requests per second
DefaultRateLimit = 100
// MinRateLimit defines the minimum allowed rate limit to prevent DOS
MinRateLimit = 10
// DefaultLogLevel defines the default logging level
DefaultLogLevel = "info"
// MinSessionEncryptionKeyLength defines the minimum length for session encryption key
MinSessionEncryptionKeyLength = 32
)
// CreateConfig creates a new Config with secure default values.
// Default values are set for optional fields:
// - Scopes: ["openid", "profile", "email"]
// - LogLevel: "info"
// - LogoutURL: CallbackURL + "/logout"
// - RateLimit: 100 requests per second
// - PostLogoutRedirectURI: "/"
// - ForceHTTPS: true (for security)
// - EnablePKCE: false (PKCE is opt-in)
// - Redis: nil (disabled by default, can be configured via Traefik config or env vars)
//
// CreateConfig initializes a new Config struct with default values for optional fields.
// It sets default scopes, log level, rate limit, enables ForceHTTPS, and sets the
// default refresh grace period. Required fields like ProviderURL, ClientID, ClientSecret,
// CallbackURL, and SessionEncryptionKey must be set explicitly after creation.
// Redis configuration can be provided through Traefik's dynamic configuration or
// as a fallback through environment variables.
//
// Returns:
// - A pointer to a new Config struct with default settings applied.
func CreateConfig() *Config {
c := &Config{
Scopes: []string{"openid", "profile", "email"},
LogLevel: DefaultLogLevel,
RateLimit: DefaultRateLimit,
ForceHTTPS: true, // Secure by default
EnablePKCE: false, // PKCE is opt-in
OverrideScopes: false, // Default to appending scopes, not overriding
RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds
MaxRefreshTokenAgeSeconds: 21600, // 6h - conservative heuristic, see field doc
SecurityHeaders: createDefaultSecurityConfig(),
Redis: nil, // Redis is disabled by default, configure via Traefik or env vars
// Bearer-auth defaults. EnableBearerAuth=false leaves the feature
// dormant; the rest are values that apply only when bearer is enabled.
EnableBearerAuth: false,
BearerIdentifierClaim: "sub",
StripAuthorizationHeader: true,
BearerEmitWWWAuthenticate: true,
BearerOverridesCookie: false,
MaxTokenAgeSeconds: 86400,
MaxIdentifierLength: 256,
BearerFailureThreshold: 20,
BearerFailureWindowSeconds: 60,
BearerFailurePenaltySeconds: 60,
}
return c
}
// createDefaultSecurityConfig creates a default security headers configuration
func createDefaultSecurityConfig() *SecurityHeadersConfig {
return &SecurityHeadersConfig{
Enabled: true,
Profile: "default",
// Default security headers
StrictTransportSecurity: true,
StrictTransportSecurityMaxAge: 31536000, // 1 year
StrictTransportSecuritySubdomains: true,
StrictTransportSecurityPreload: true,
FrameOptions: "DENY",
ContentTypeOptions: "nosniff",
XSSProtection: "1; mode=block",
ReferrerPolicy: "strict-origin-when-cross-origin",
// CORS disabled by default
CORSEnabled: false,
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
CORSAllowedHeaders: []string{"Authorization", "Content-Type"},
CORSAllowCredentials: false,
CORSMaxAge: 86400, // 24 hours
// Security features
DisableServerHeader: true,
DisablePoweredByHeader: true,
}
}
// Validate checks the configuration settings for validity.
// It ensures that required fields (ProviderURL, CallbackURL, ClientID, ClientSecret, SessionEncryptionKey)
// are present and that URLs are well-formed (HTTPS where required). It also validates
// the session key length, log level, rate limit, and refresh grace period.
//
// Returns:
// - nil if the configuration is valid.
// - An error describing the first validation failure encountered.
func (c *Config) Validate() error {
// Validate provider URL
if c.ProviderURL == "" {
return fmt.Errorf("providerURL is required")
}
if !isValidSecureURL(c.ProviderURL) {
return fmt.Errorf("providerURL must be a valid HTTPS URL")
}
// Validate callback URL
if c.CallbackURL == "" {
return fmt.Errorf("callbackURL is required")
}
if !strings.HasPrefix(c.CallbackURL, "/") {
return fmt.Errorf("callbackURL must start with /")
}
// Validate client credentials
if c.ClientID == "" {
return fmt.Errorf("clientID is required")
}
authMethod := c.ClientAuthMethod
if authMethod == "" {
authMethod = "client_secret_post"
}
switch authMethod {
case "client_secret_post", "client_secret_basic":
if c.ClientSecret == "" {
return fmt.Errorf("clientSecret is required when clientAuthMethod is %q", authMethod)
}
case "private_key_jwt":
if c.ClientAssertionPrivateKey == "" && c.ClientAssertionKeyPath == "" {
return fmt.Errorf("clientAssertionPrivateKey or clientAssertionKeyPath is required when clientAuthMethod is private_key_jwt")
}
if c.ClientAssertionPrivateKey != "" && c.ClientAssertionKeyPath != "" {
return fmt.Errorf("only one of clientAssertionPrivateKey or clientAssertionKeyPath may be set")
}
if c.ClientAssertionKeyID == "" {
return fmt.Errorf("clientAssertionKeyID is required when clientAuthMethod is private_key_jwt")
}
if c.ClientAssertionAlg != "" && !isSupportedClientAssertionAlg(c.ClientAssertionAlg) {
return fmt.Errorf("clientAssertionAlg %q is not supported (use RS256/384/512, PS256/384/512, or ES256/384/512)", c.ClientAssertionAlg)
}
default:
return fmt.Errorf("clientAuthMethod %q is not supported", authMethod)
}
// Validate session encryption key
if c.SessionEncryptionKey == "" {
return fmt.Errorf("sessionEncryptionKey is required")
}
if len(c.SessionEncryptionKey) < MinSessionEncryptionKeyLength {
return fmt.Errorf("sessionEncryptionKey must be at least %d characters long", MinSessionEncryptionKeyLength)
}
// Validate log level
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
return fmt.Errorf("logLevel must be one of: debug, info, error")
}
// Validate excluded URLs
for _, url := range c.ExcludedURLs {
if !strings.HasPrefix(url, "/") {
return fmt.Errorf("excluded URL must start with /: %s", url)
}
if strings.Contains(url, "..") {
return fmt.Errorf("excluded URL must not contain path traversal: %s", url)
}
if strings.Contains(url, "*") {
return fmt.Errorf("excluded URL must not contain wildcards: %s", url)
}
}
// Validate revocation URL if set
if c.RevocationURL != "" && !isValidSecureURL(c.RevocationURL) {
return fmt.Errorf("revocationURL must be a valid HTTPS URL")
}
// Validate end session URL if set
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
if !isValidSecureURL(c.PostLogoutRedirectURI) && !strings.HasPrefix(c.PostLogoutRedirectURI, "/") {
return fmt.Errorf("postLogoutRedirectURI must be either a valid HTTPS URL or start with /")
}
}
// Validate rate limit
if c.RateLimit < MinRateLimit {
return fmt.Errorf("rateLimit must be at least %d", MinRateLimit)
}
// Validate refresh grace period
if c.RefreshGracePeriodSeconds < 0 {
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
}
// Validate refresh-token max-age heuristic
if c.MaxRefreshTokenAgeSeconds < 0 {
return fmt.Errorf("maxRefreshTokenAgeSeconds cannot be negative")
}
// Validate audience if specified
if c.Audience != "" {
// Validate audience format - should be a valid identifier or URL
if len(c.Audience) > 256 {
return fmt.Errorf("audience must not exceed 256 characters")
}
// If audience looks like a URL, validate it's HTTPS
if strings.HasPrefix(c.Audience, "http://") {
return fmt.Errorf("audience URL must use HTTPS, not HTTP")
}
// Prevent wildcard audiences which could weaken security
if strings.Contains(c.Audience, "*") {
return fmt.Errorf("audience must not contain wildcards")
}
// Validate that audience doesn't contain obvious injection patterns
if strings.ContainsAny(c.Audience, "\n\r\t\x00") {
return fmt.Errorf("audience contains invalid characters")
}
}
// Validate Redis configuration if provided
if c.Redis != nil && c.Redis.Enabled {
if err := c.Redis.Validate(); err != nil {
return fmt.Errorf("redis configuration error: %w", err)
}
}
// Validate headers configuration for template security
for _, header := range c.Headers {
if header.Name == "" {
return fmt.Errorf("header name cannot be empty")
}
if header.Value == "" {
return fmt.Errorf("header value template cannot be empty")
}
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
}
// Provide more helpful guidance for common template errors BEFORE security validation
if strings.Contains(header.Value, "{{.claims") {
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.accessToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.idToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)", header.Value)
}
if strings.Contains(header.Value, "{{.refreshToken") {
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
}
// Validate template syntax and security
if err := validateTemplateSecure(header.Value); err != nil {
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
}
}
return nil
}
// validateTemplateSecure validates template expressions for security vulnerabilities.
// It checks for dangerous template patterns that could lead to code execution or data leaks
// while allowing safe custom functions for field access and default values.
func validateTemplateSecure(templateStr string) error {
// Allow our specific safe custom functions
// These are added specifically to handle missing fields safely (issue #60)
safeCustomFunctions := []string{
"{{get ", // Safe map access function
"{{default ", // Safe default value function
}
// Check if template uses safe custom functions
usesSafeFunctions := false
for _, safeFn := range safeCustomFunctions {
if strings.Contains(templateStr, safeFn) {
usesSafeFunctions = true
// These functions are explicitly allowed for safe field access
}
}
// Check for dangerous template functions and patterns
// Skip certain checks if using our safe functions
dangerousPatterns := []string{
"{{call", // Function calls (except our safe ones)
"{{range", // Range over arbitrary data
"{{define", // Template definitions
"{{template", // Template inclusions
"{{block", // Block definitions
"{{/*", // Comments that could hide malicious code
"{{-", // Trim whitespace (could be used to obfuscate)
"-}}", // Trim whitespace (could be used to obfuscate)
"{{printf", // Printf functions
"{{print", // Print functions (but not our safe ones)
"{{println", // Println functions
"{{html", // HTML functions
"{{js", // JavaScript functions
"{{urlquery", // URL query functions
"{{index", // Index access to arbitrary data
"{{slice", // Slice operations
"{{len", // Length operations on arbitrary data
"{{eq", // Comparison operations
"{{ne", // Comparison operations
"{{lt", // Comparison operations
"{{le", // Comparison operations
"{{gt", // Comparison operations
"{{ge", // Comparison operations
"{{and", // Logical operations
"{{or", // Logical operations
"{{not", // Logical operations
}
// Allow 'with' for safe conditional access
if !strings.Contains(templateStr, "{{with .Claims") {
dangerousPatterns = append(dangerousPatterns, "{{with")
}
templateLower := strings.ToLower(templateStr)
for _, pattern := range dangerousPatterns {
// Skip check if it's one of our safe functions
if usesSafeFunctions && (pattern == "{{call" || pattern == "{{print") {
// Allow these if we're using safe functions
continue
}
// Special handling for comparison operators to avoid false positives with "get" and "default"
if pattern == "{{ge" && (strings.Contains(templateStr, "{{get ") || strings.Contains(templateStr, "{{default ")) {
// Skip {{ge check if we're using the safe {{get or {{default functions
continue
}
// Skip {{de checks if using {{default
if pattern == "{{define" && strings.Contains(templateStr, "{{default ") {
continue
}
if strings.Contains(templateLower, strings.ToLower(pattern)) {
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
}
}
// Validate template variables against whitelist
allowedPatterns := []string{
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
"{{.Claims.",
"{{get ", // Safe custom function
"{{default ", // Safe custom function
"{{with ", // Safe conditional (when used with Claims)
}
// Check if template contains only allowed patterns
hasAllowedPattern := false
for _, pattern := range allowedPatterns {
if strings.Contains(templateStr, pattern) {
hasAllowedPattern = true
break
}
}
if !hasAllowedPattern {
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, Claims.*, or safe functions (get, default, with)")
}
// Validate claims access patterns
if strings.Contains(templateStr, "{{.Claims.") {
// Simple validation - ensure claims access is to known safe fields
// This list includes standard OIDC claims and common provider-specific claims
safeClaimsFields := map[string]bool{
// Standard OIDC claims
"email": true,
"name": true,
"given_name": true,
"family_name": true,
"preferred_username": true,
"sub": true,
"iss": true,
"aud": true,
"exp": true,
"iat": true,
"groups": true,
"roles": true,
// Common custom claims
"internal_role": true, // Custom roles field (issue #60)
"role": true, // Alternative role field
"department": true, // Organization info
"organization": true, // Organization info
// Provider-specific claims
"realm_access": true, // Keycloak specific
"resource_access": true, // Keycloak specific
"oid": true, // Azure AD object ID
"tid": true, // Azure AD tenant ID
"upn": true, // Azure AD User Principal Name
"hd": true, // Google hosted domain
"picture": true, // Profile picture
// Additional standard claims
"locale": true, // User locale
"zoneinfo": true, // Timezone
"phone_number": true, // Contact info
"email_verified": true, // Email verification status
"updated_at": true, // Last update time
}
// Extract field names from Claims access
start := strings.Index(templateStr, "{{.Claims.")
for start != -1 {
end := strings.Index(templateStr[start:], "}}")
if end == -1 {
return fmt.Errorf("malformed Claims template syntax")
}
// Extract the content between "{{.Claims." and "}}"
// start+10 skips "{{.Claims." and start+end is the position of "}}"
claimsContent := templateStr[start+10 : start+end]
// Get the field name (first part before any dots)
fieldName := strings.Split(claimsContent, ".")[0]
if !safeClaimsFields[fieldName] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
}
// Search for next occurrence
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
if nextStart != -1 {
start = start + end + 2 + nextStart
} else {
start = -1
}
}
}
// Prevent code injection through template syntax
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
// Count opening and closing braces
openCount := strings.Count(templateStr, "{{")
closeCount := strings.Count(templateStr, "}}")
if openCount != closeCount {
return fmt.Errorf("unbalanced template braces")
}
}
return nil
}
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
// It uses url.Parse and checks for a nil error, an "https" scheme, and a non-empty host.
//
// Parameters:
// - s: The URL string to validate.
//
// Returns:
// - true if the string is a valid HTTPS URL, false otherwise.
//
// isValidSecureURL validates that a URL string is well-formed and uses HTTPS.
// Returns true if the URL is valid and secure (HTTPS), false otherwise.
func isValidSecureURL(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme == "https" && u.Host != ""
}
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
//
// Parameters:
// - level: The log level string to validate.
//
// Returns:
// - true if the log level is valid, false otherwise.
//
// isValidLogLevel checks if the provided log level is supported.
// Valid log levels are: debug, info, error.
func isValidLogLevel(level string) bool {
return level == "debug" || level == "info" || level == "error"
}
// Logger provides structured logging capabilities with different severity levels.
// It supports error, info, and debug levels with appropriate output streams
// and formatting for each level.
type Logger struct {
// logError handles error-level messages, writing to stderr
logError *log.Logger
// logInfo handles informational messages, writing to stdout
logInfo *log.Logger
// logDebug handles debug-level messages, writing to stdout when debug is enabled
logDebug *log.Logger
}
// NewLogger creates and configures a new Logger instance based on the provided log level.
// It initializes loggers for ERROR (stderr), INFO (stdout), and DEBUG (stdout) levels,
// enabling output based on the specified level:
// - "error": Only ERROR messages are output.
// - "info": INFO and ERROR messages are output.
// - "debug": DEBUG, INFO, and ERROR messages are output.
//
// If an invalid level is provided, it defaults to behavior similar to "error".
//
// Parameters:
// - logLevel: The desired logging level ("debug", "info", or "error").
//
// Returns:
// - A pointer to the configured Logger instance.
//
// NewLogger creates a new logger instance with the specified log level.
// If logLevel is empty, defaults to "info". Invalid log levels default to "info".
func NewLogger(logLevel string) *Logger {
logError := log.New(io.Discard, "ERROR: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logInfo := log.New(io.Discard, "INFO: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logDebug := log.New(io.Discard, "DEBUG: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
logError.SetOutput(os.Stderr)
if logLevel == "debug" || logLevel == "info" {
logInfo.SetOutput(os.Stdout)
}
if logLevel == "debug" {
logDebug.SetOutput(os.Stdout)
}
return &Logger{
logError: logError,
logInfo: logInfo,
logDebug: logDebug,
}
}
// Info logs a message at the INFO level using Printf style formatting.
// Output is directed to stdout if the configured log level is "info" or "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Info logs an informational message if the logger's level allows it.
func (l *Logger) Info(format string, args ...interface{}) {
l.logInfo.Printf(format, args...)
}
// Debug logs a message at the DEBUG level.
// Output is directed to stdout only if the configured log level is "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Debug logs a debug message if the logger's level allows it.
func (l *Logger) Debug(format string, args ...interface{}) {
l.logDebug.Printf(format, args...)
}
// Error logs a message at the ERROR level using Printf style formatting.
// Output is always directed to stderr, regardless of the configured log level.
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Error logs an error message. Errors are always logged regardless of level.
func (l *Logger) Error(format string, args ...interface{}) {
l.logError.Printf(format, args...)
}
// Infof logs a message at the INFO level using Printf style formatting.
// Equivalent to calling l.Info(format, args...).
// Output is directed to stdout if the configured log level is "info" or "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Infof logs a formatted informational message if the logger's level allows it.
func (l *Logger) Infof(format string, args ...interface{}) {
l.logInfo.Printf(format, args...)
}
// Debugf logs a formatted message at the DEBUG level.
// Equivalent to calling l.Debug(format, args...).
// Output is directed to stdout only if the configured log level is "debug".
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Debugf logs a formatted debug message if the logger's level allows it.
func (l *Logger) Debugf(format string, args ...interface{}) {
l.logDebug.Printf(format, args...)
}
// Errorf logs a message at the ERROR level using Printf style formatting.
// Equivalent to calling l.Error(format, args...).
// Output is always directed to stderr, regardless of the configured log level.
//
// Parameters:
// - format: The format string (as in fmt.Printf).
// - args: The arguments for the format string.
//
// Errorf logs a formatted error message. Errors are always logged regardless of level.
func (l *Logger) Errorf(format string, args ...interface{}) {
l.logError.Printf(format, args...)
}
// IsDebug reports whether debug-level logging is enabled.
// Callers should use this to avoid expensive format-string expansion
// (e.g. on hot paths under yaegi) when debug output would be discarded.
func (l *Logger) IsDebug() bool {
if l == nil || l.logDebug == nil {
return false
}
return l.logDebug.Writer() != io.Discard
}
// newNoOpLogger creates a logger that discards all output.
//
// Deprecated: Use GetSingletonNoOpLogger() instead for better memory efficiency.
func newNoOpLogger() *Logger {
return GetSingletonNoOpLogger()
}
// handleError logs an error message using the provided logger and sends an HTTP error
// response to the client with the specified message and status code.
//
// Parameters:
// - w: The http.ResponseWriter to send the error response to.
// - message: The error message string.
// - code: The HTTP status code for the response.
// - logger: The Logger instance to use for logging the error.
//
// GetSecurityHeadersApplier returns a function that applies security headers
func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Request) {
if c.SecurityHeaders == nil || !c.SecurityHeaders.Enabled {
return nil
}
return func(rw http.ResponseWriter, req *http.Request) {
headers := rw.Header()
// Apply basic security headers based on configuration
if c.SecurityHeaders.FrameOptions != "" {
headers.Set("X-Frame-Options", c.SecurityHeaders.FrameOptions)
}
if c.SecurityHeaders.ContentTypeOptions != "" {
headers.Set("X-Content-Type-Options", c.SecurityHeaders.ContentTypeOptions)
}
if c.SecurityHeaders.XSSProtection != "" {
headers.Set("X-XSS-Protection", c.SecurityHeaders.XSSProtection)
}
if c.SecurityHeaders.ReferrerPolicy != "" {
headers.Set("Referrer-Policy", c.SecurityHeaders.ReferrerPolicy)
}
if c.SecurityHeaders.ContentSecurityPolicy != "" {
headers.Set("Content-Security-Policy", c.SecurityHeaders.ContentSecurityPolicy)
}
// HSTS for HTTPS
if (req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https") && c.SecurityHeaders.StrictTransportSecurity {
hstsValue := fmt.Sprintf("max-age=%d", c.SecurityHeaders.StrictTransportSecurityMaxAge)
if c.SecurityHeaders.StrictTransportSecuritySubdomains {
hstsValue += "; includeSubDomains"
}
if c.SecurityHeaders.StrictTransportSecurityPreload {
hstsValue += "; preload"
}
headers.Set("Strict-Transport-Security", hstsValue)
}
// CORS headers
if c.SecurityHeaders.CORSEnabled {
origin := req.Header.Get("Origin")
if origin != "" && isOriginAllowed(origin, c.SecurityHeaders.CORSAllowedOrigins) {
headers.Set("Access-Control-Allow-Origin", origin)
}
if len(c.SecurityHeaders.CORSAllowedMethods) > 0 {
headers.Set("Access-Control-Allow-Methods", strings.Join(c.SecurityHeaders.CORSAllowedMethods, ", "))
}
if len(c.SecurityHeaders.CORSAllowedHeaders) > 0 {
headers.Set("Access-Control-Allow-Headers", strings.Join(c.SecurityHeaders.CORSAllowedHeaders, ", "))
}
if c.SecurityHeaders.CORSAllowCredentials {
headers.Set("Access-Control-Allow-Credentials", "true")
}
if c.SecurityHeaders.CORSMaxAge > 0 {
headers.Set("Access-Control-Max-Age", strconv.Itoa(c.SecurityHeaders.CORSMaxAge))
}
}
// Custom headers
for name, value := range c.SecurityHeaders.CustomHeaders {
headers.Set(name, value)
}
// Remove server headers
if c.SecurityHeaders.DisableServerHeader {
headers.Del("Server")
}
if c.SecurityHeaders.DisablePoweredByHeader {
headers.Del("X-Powered-By")
}
}
}
// isOriginAllowed checks if an origin is in the allowed list
// Validate checks if the Redis configuration is valid
func (rc *RedisConfig) Validate() error {
if !rc.Enabled {
return nil
}
if rc.Address == "" {
return fmt.Errorf("redis address is required when Redis is enabled")
}
// Validate cache mode
if rc.CacheMode != "" {
validModes := map[string]bool{
"redis": true,
"hybrid": true,
"memory": true,
}
if !validModes[rc.CacheMode] {
return fmt.Errorf("invalid cache mode: %s (must be 'redis', 'hybrid', or 'memory')", rc.CacheMode)
}
}
// Validate connection settings
if rc.PoolSize < 0 {
return fmt.Errorf("pool size cannot be negative")
}
if rc.ConnectTimeout < 0 {
return fmt.Errorf("connect timeout cannot be negative")
}
if rc.ReadTimeout < 0 {
return fmt.Errorf("read timeout cannot be negative")
}
if rc.WriteTimeout < 0 {
return fmt.Errorf("write timeout cannot be negative")
}
// Validate hybrid mode settings
if rc.CacheMode == "hybrid" {
if rc.HybridL1Size < 0 {
return fmt.Errorf("hybrid L1 size cannot be negative")
}
if rc.HybridL1MemoryMB < 0 {
return fmt.Errorf("hybrid L1 memory cannot be negative")
}
}
// Validate circuit breaker settings
if rc.CircuitBreakerThreshold < 0 {
return fmt.Errorf("circuit breaker threshold cannot be negative")
}
if rc.CircuitBreakerTimeout < 0 {
return fmt.Errorf("circuit breaker timeout cannot be negative")
}
// Validate health check settings
if rc.HealthCheckInterval < 0 {
return fmt.Errorf("health check interval cannot be negative")
}
return nil
}
// ApplyDefaults sets default values for Redis configuration when fields are not explicitly set.
// This ensures reasonable defaults while allowing full customization through configuration.
func (rc *RedisConfig) ApplyDefaults() {
// Only apply defaults if Redis is enabled
if !rc.Enabled {
return
}
// Connection defaults
if rc.KeyPrefix == "" {
rc.KeyPrefix = "traefikoidc:"
}
if rc.PoolSize == 0 {
rc.PoolSize = 10
}
if rc.ConnectTimeout == 0 {
rc.ConnectTimeout = 5
}
if rc.ReadTimeout == 0 {
rc.ReadTimeout = 3
}
if rc.WriteTimeout == 0 {
rc.WriteTimeout = 3
}
// Cache mode defaults
if rc.CacheMode == "" {
rc.CacheMode = "redis" // Default to redis-only mode for simplicity
}
// Hybrid mode specific defaults
if rc.CacheMode == "hybrid" {
if rc.HybridL1Size == 0 {
rc.HybridL1Size = 500
}
if rc.HybridL1MemoryMB == 0 {
rc.HybridL1MemoryMB = 10
}
}
// Resilience features - these use a different pattern to detect if they were explicitly set
// Since bool fields default to false, we need to be careful about defaults
// For now, we'll enable by default only if not explicitly disabled via environment
if rc.CircuitBreakerThreshold == 0 {
rc.CircuitBreakerThreshold = 5
}
if rc.CircuitBreakerTimeout == 0 {
rc.CircuitBreakerTimeout = 60
}
if rc.HealthCheckInterval == 0 {
rc.HealthCheckInterval = 30
}
}
// ApplyEnvFallbacks applies environment variable values as fallbacks for empty config fields.
// This allows environment variables to be used as optional overrides only when the
// corresponding config field is not set through Traefik's dynamic configuration.
// The plugin configuration takes precedence over environment variables.
func (rc *RedisConfig) ApplyEnvFallbacks() {
// Only apply env fallbacks if Redis is not already configured
if !rc.Enabled {
// Check if Redis should be enabled from environment
enabledStr := os.Getenv("REDIS_ENABLED")
if enabledStr == "true" || enabledStr == "1" {
rc.Enabled = true
}
}
// Only apply other env vars if Redis is enabled
if !rc.Enabled {
return
}
// Apply environment variables only for empty fields
if rc.Address == "" {
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
rc.Address = addr
}
}
if rc.Password == "" {
rc.Password = os.Getenv("REDIS_PASSWORD")
}
if rc.KeyPrefix == "" {
if prefix := os.Getenv("REDIS_KEY_PREFIX"); prefix != "" {
rc.KeyPrefix = prefix
}
}
if rc.CacheMode == "" {
if mode := os.Getenv("REDIS_CACHE_MODE"); mode != "" {
rc.CacheMode = mode
}
}
// Apply numeric values only if not already set
if rc.DB == 0 {
if dbStr := os.Getenv("REDIS_DB"); dbStr != "" {
if db, err := strconv.Atoi(dbStr); err == nil && db > 0 {
rc.DB = db
}
}
}
if rc.PoolSize == 0 {
if poolSizeStr := os.Getenv("REDIS_POOL_SIZE"); poolSizeStr != "" {
if poolSize, err := strconv.Atoi(poolSizeStr); err == nil && poolSize > 0 {
rc.PoolSize = poolSize
}
}
}
if rc.ConnectTimeout == 0 {
if timeoutStr := os.Getenv("REDIS_CONNECT_TIMEOUT"); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
rc.ConnectTimeout = timeout
}
}
}
if rc.ReadTimeout == 0 {
if timeoutStr := os.Getenv("REDIS_READ_TIMEOUT"); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
rc.ReadTimeout = timeout
}
}
}
if rc.WriteTimeout == 0 {
if timeoutStr := os.Getenv("REDIS_WRITE_TIMEOUT"); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
rc.WriteTimeout = timeout
}
}
}
// Apply boolean values from env only if not already set in config
if !rc.EnableTLS {
if tlsStr := os.Getenv("REDIS_ENABLE_TLS"); tlsStr == "true" || tlsStr == "1" {
rc.EnableTLS = true
}
}
if !rc.TLSSkipVerify {
if skipStr := os.Getenv("REDIS_TLS_SKIP_VERIFY"); skipStr == "true" || skipStr == "1" {
rc.TLSSkipVerify = true
}
}
// Hybrid mode settings
if rc.HybridL1Size == 0 {
if sizeStr := os.Getenv("REDIS_HYBRID_L1_SIZE"); sizeStr != "" {
if size, err := strconv.Atoi(sizeStr); err == nil && size > 0 {
rc.HybridL1Size = size
}
}
}
if rc.HybridL1MemoryMB == 0 {
if memStr := os.Getenv("REDIS_HYBRID_L1_MEMORY_MB"); memStr != "" {
if mem, err := strconv.ParseInt(memStr, 10, 64); err == nil && mem > 0 {
rc.HybridL1MemoryMB = mem
}
}
}
}
func isOriginAllowed(origin string, allowedOrigins []string) bool {
for _, allowed := range allowedOrigins {
if origin == allowed || allowed == "*" {
return true
}
// Simple wildcard matching for subdomains
if strings.Contains(allowed, "*") {
if strings.HasPrefix(allowed, "https://*.") {
domain := strings.TrimPrefix(allowed, "https://*.")
if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain {
return true
}
}
if strings.HasPrefix(allowed, "http://*.") {
domain := strings.TrimPrefix(allowed, "http://*.")
if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain {
return true
}
}
}
}
return false
}