Files
traefikoidc/main.go
T
lukaszraczylo 937776516d feat(middleware): refresh expired sessions on SSE/WebSocket bypass
The SSE and WebSocket bypass does not check the token expiry. A session
stays usable for streaming until the session cookie expires, also after
the IdP disables the user. The bypass now forwards the configured headers
(#162), so this gap also gives the proxy secret to such a session.

Add the streamingRefresh option. The default is false. When it is true and
the access token has expired, the bypass refreshes the session. It uses
the ID token expiry if the access token is opaque. If the IdP rejects the
refresh with invalid_grant, the request gets 401. If the IdP cannot be
reached, the request is forwarded as before.

The bypass waits for the full coordinated refresh. A shorter wait can
drop a rotated refresh token, and the next refresh then logs the user out.

applyBypassUserHeaders now takes the ResponseWriter, so a refresh can
write the new session cookie. Add a yaegi check for the invalid_grant path
through the real token endpoint and RefreshCoordinator.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:31:18 +01:00

1073 lines
44 KiB
Go

// Package traefikoidc provides OIDC authentication middleware for Traefik.
// It supports multiple OIDC providers including Google, Azure AD, and generic OIDC providers
// with features like token refresh, session management, and provider-specific optimizations.
package traefikoidc
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"sync"
"text/template"
"time"
telemetry "github.com/lukaszraczylo/oss-telemetry"
"golang.org/x/time/rate"
)
const (
ConstSessionTimeout = 86400
)
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
// process. Traefik calls New once per route that uses the plugin; oss-telemetry
// does not deduplicate client-side (the server does), so the gate stays here.
var telemetryStartupOnce sync.Once
// isTestMode detects if the code is running in a test environment.
func isTestMode() bool {
if os.Getenv("SUPPRESS_DIAGNOSTIC_LOGS") == "1" {
return true
}
if strings.Contains(os.Args[0], ".test") ||
strings.Contains(os.Args[0], "go_build_") ||
os.Getenv("GO_TEST") == "1" ||
runtime.Compiler == "yaegi" {
return true
}
for _, arg := range os.Args {
if strings.Contains(arg, "-test") {
return true
}
}
return false
}
// mergeScopes combines default scopes with user-provided scopes, removing duplicates.
func mergeScopes(defaultScopes, userScopes []string) []string {
if len(userScopes) == 0 {
return append([]string(nil), defaultScopes...)
}
seen := make(map[string]bool)
var result []string
for _, scope := range defaultScopes {
if !seen[scope] {
seen[scope] = true
result = append(result, scope)
}
}
for _, scope := range userScopes {
if !seen[scope] {
seen[scope] = true
result = append(result, scope)
}
}
return result
}
// defaultExcludedURLs are the paths that are excluded from authentication
var defaultExcludedURLs = map[string]struct{}{
"/favicon": {},
}
// headerTemplateFuncMap returns the function map available to custom header
// value templates, gated by the effective claims whitelist (built-in +
// config.AllowedClaims). It exposes exactly two helpers:
// - default: substitute a fallback when a value is nil/empty.
// - get: safe map access RESTRICTED to whitelisted claim keys, so it cannot be
// used to read a non-whitelisted claim, a raw token, or the whole data map
// (issue #149 review). This is the sole runtime enforcement of the claims
// whitelist for `get`; static validation cannot reliably parse its arguments.
func headerTemplateFuncMap(allowedClaims map[string]bool) template.FuncMap {
if allowedClaims == nil {
allowedClaims = safeClaimsFields
}
return template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if !allowedClaims[key] {
return ""
}
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
}
// New creates a new TraefikOidc middleware instance.
// It initializes all components including caches, HTTP clients, session management,
// templates, and starts background processes for metadata discovery.
// Parameters:
// - ctx: The context for the middleware lifecycle.
// - next: The next HTTP handler in the middleware chain.
// - config: The OIDC configuration containing provider details, client credentials, etc.
// - name: The name of the middleware instance.
//
// Returns:
// - The configured TraefikOidc handler ready to process requests.
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
telemetryStartupOnce.Do(func() {
// Only stamped release builds phone home; dev/local/test builds keep the
// devPluginVersion sentinel (see version.go) and stay silent.
if traefikoidcPluginVersion != devPluginVersion {
telemetry.Send("traefikoidc", traefikoidcPluginVersion)
}
})
// Deliberately NOT `return NewWithContext(...)` (issue #151): that tail call
// converts (*TraefikOidc, error) to (http.Handler, error) in the return
// statement. Compiled, a failed construction then returns a typed-nil
// *TraefikOidc wrapped in a NON-nil http.Handler; under yaegi v0.16.1 (the
// interpreter Traefik and the plugin-catalog analyzer embed) the same
// statement zeroes BOTH results on the error path, so callers saw
// (nil, nil) and reported the opaque "invalid handler type: <nil>" instead
// of the real error. The explicit split returns an untyped nil handler and
// keeps the error intact in both worlds.
t, err := NewWithContext(ctx, config, next, name)
if err != nil {
return nil, err
}
return t, nil
}
// NewWithContext creates a new TraefikOidc middleware instance with proper context handling.
// This is the preferred constructor that ensures proper goroutine lifecycle management.
func NewWithContext(ctx context.Context, config *Config, next http.Handler, name string) (*TraefikOidc, error) {
if config == nil {
config = CreateConfig()
}
logger := NewLogger(config.LogLevel)
// Fail closed on invalid configuration. Validate() enforces the security
// constraints (required fields, HTTPS-only URLs, key length, excludedURLs
// safety, rate-limit floor, audience format, ...) that were previously
// unenforced because this constructor never called it. Crucially it rejects
// an empty or too-short SessionEncryptionKey instead of silently
// substituting a public hardcoded key, which would let an attacker forge
// any session. Traefik's yaegi plugin analyzer supplies a valid key via
// .traefik.yml testData, so it passes; only misconfigured deployments fail.
if err := config.Validate(); err != nil {
// Surface the concrete reason. When New() returns a nil handler, Traefik
// only logs the opaque router-level "invalid handler type: <nil>" and
// swallows this error, leaving operators no way to see WHY the plugin
// failed to build (issue #149). Log it explicitly at construction.
logger.Errorf("traefikoidc: invalid configuration, middleware not built: %v", err)
return nil, fmt.Errorf("invalid configuration: %w", err)
}
// Setup HTTP client
caPool, err := config.loadCACertPool()
if err != nil {
return nil, fmt.Errorf("failed to load CA certificates: %w", err)
}
if config.InsecureSkipVerify {
logger.Errorf("SECURITY WARNING: InsecureSkipVerify is enabled for the OIDC provider. TLS certificate verification is DISABLED. Do not use in production.")
}
var httpClient *http.Client
if config.HTTPClient != nil {
httpClient = config.HTTPClient
} else {
defaultCfg := DefaultHTTPClientConfig()
defaultCfg.RootCAs = caPool
defaultCfg.InsecureSkipVerify = config.InsecureSkipVerify
httpClient = CreatePooledHTTPClient(defaultCfg)
}
tokenCfg := TokenHTTPClientConfig()
tokenCfg.RootCAs = caPool
tokenCfg.InsecureSkipVerify = config.InsecureSkipVerify
tokenHTTPClient := CreatePooledHTTPClient(tokenCfg)
goroutineWG := &sync.WaitGroup{}
cacheManager := GetGlobalCacheManagerWithConfig(goroutineWG, config)
// Use provided context instead of creating new one
var pluginCtx context.Context
var cancelFunc context.CancelFunc
if ctx != nil {
pluginCtx, cancelFunc = context.WithCancel(ctx)
} else {
pluginCtx, cancelFunc = context.WithCancel(context.Background())
}
t := &TraefikOidc{
next: next,
name: name,
goroutineWG: goroutineWG,
redirURLPath: config.CallbackURL,
logoutURLPath: func() string {
if config.LogoutURL == "" {
return config.CallbackURL + "/logout"
}
return config.LogoutURL
}(),
postLogoutRedirectURI: func() string {
if config.PostLogoutRedirectURI == "" {
return "/"
}
return config.PostLogoutRedirectURI
}(),
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
tokenTypeCache: cacheManager.GetSharedTokenTypeCache(), // Cache for token type detection
jwkCache: cacheManager.GetSharedJWKCache(),
metadataCache: cacheManager.GetSharedMetadataCache(),
introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results
clientID: config.ClientID,
clientSecret: config.ClientSecret,
clientAuthMethod: func() string {
if config.ClientAuthMethod != "" {
return config.ClientAuthMethod
}
return "client_secret_post"
}(),
audience: func() string {
if config.Audience != "" {
return config.Audience
}
if config.Resource != "" {
return config.Resource
}
return config.ClientID
}(),
explicitAudience: config.Audience,
roleClaimName: func() string {
if config.RoleClaimName != "" {
return config.RoleClaimName
}
return "roles" // Backward compatible default
}(),
groupClaimName: func() string {
if config.GroupClaimName != "" {
return config.GroupClaimName
}
return "groups" // Backward compatible default
}(),
userIdentifierClaim: func() string {
if config.UserIdentifierClaim != "" {
return config.UserIdentifierClaim
}
return "email" // Backward compatible default
}(),
forceHTTPS: config.ForceHTTPS,
enablePKCE: config.EnablePKCE,
extraAuthParams: config.ExtraAuthParams,
resource: config.Resource,
overrideScopes: config.OverrideScopes,
strictAudienceValidation: config.StrictAudienceValidation,
allowOpaqueTokens: config.AllowOpaqueTokens,
requireTokenIntrospection: config.RequireTokenIntrospection,
disableReplayDetection: config.DisableReplayDetection,
// Operator-configured endpoint overrides (issue #152). Applied inside
// updateMetadataEndpoints so the override survives every metadata
// refresh, not just construction.
configRevocationURL: config.RevocationURL,
configEndSessionURL: config.OIDCEndSessionURL,
configIntrospectionURL: config.IntrospectionURL,
// Cold-start seed: discovery failure must not lose operator-configured
// endpoints; updateMetadataEndpoints re-applies these on every refresh.
// Seeding introspectionURL here does not populate metadataSnapshot
// (only updateMetadataEndpoints does); all three consumers
// (token_introspection.go, token_manager.go, helpers.go) read these
// metadataMu-guarded fields directly, not the snapshot.
revocationURL: config.RevocationURL,
endSessionURL: config.OIDCEndSessionURL,
introspectionURL: config.IntrospectionURL,
scopes: func() []string {
userProvidedScopes := deduplicateScopes(config.Scopes)
if config.OverrideScopes {
return userProvidedScopes
}
defaultSystemScopes := []string{"openid", "profile", "email"}
return deduplicateScopes(mergeScopes(defaultSystemScopes, userProvidedScopes))
}(),
limiter: rate.NewLimiter(rate.Limit(config.RateLimit), config.RateLimit),
perSourceLimiter: newPerSourceAuthLimiter(config.PerSourceLoginRateLimit),
tokenCache: cacheManager.GetSharedTokenCache(),
httpClient: httpClient,
tokenHTTPClient: tokenHTTPClient,
excludedURLs: createStringMap(config.ExcludedURLs),
allowedUserDomains: createCaseInsensitiveStringMap(config.AllowedUserDomains),
allowedUsers: createCaseInsensitiveStringMap(config.AllowedUsers),
allowedRolesAndGroups: createStringMap(config.AllowedRolesAndGroups),
initComplete: make(chan struct{}),
logger: logger,
refreshGracePeriod: func() time.Duration {
if config.RefreshGracePeriodSeconds > 0 {
return time.Duration(config.RefreshGracePeriodSeconds) * time.Second
}
return 60 * time.Second
}(),
maxRefreshTokenAge: func() time.Duration {
// 0 (or unset) disables the heuristic; negative is rejected by Validate.
if config.MaxRefreshTokenAgeSeconds > 0 {
return time.Duration(config.MaxRefreshTokenAgeSeconds) * time.Second
}
return 0
}(),
tokenCleanupStopChan: make(chan struct{}),
metadataRefreshStopChan: make(chan struct{}),
ctx: pluginCtx,
cancelFunc: cancelFunc,
suppressDiagnosticLogs: isTestMode(),
securityHeadersApplier: config.GetSecurityHeadersApplier(),
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
allowUnauthenticatedPreflight: config.AllowUnauthenticatedPreflight,
streamingRefresh: config.StreamingRefresh,
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
minimalHeaders: config.MinimalHeaders,
stripAuthCookies: config.StripAuthCookies,
enableBackchannelLogout: config.EnableBackchannelLogout,
enableFrontchannelLogout: config.EnableFrontchannelLogout,
backchannelLogoutPath: normalizeLogoutPath(config.BackchannelLogoutURL),
frontchannelLogoutPath: normalizeLogoutPath(config.FrontchannelLogoutURL),
sessionInvalidationCache: cacheManager.GetSharedSessionInvalidationCache(),
refreshResultCache: cacheManager.GetSharedRefreshResultCache(),
enableBearerAuth: config.EnableBearerAuth,
stripAuthorizationHeader: config.StripAuthorizationHeader,
bearerEmitWWWAuthenticate: config.BearerEmitWWWAuthenticate,
bearerOverridesCookie: config.BearerOverridesCookie,
bearerIdentifierClaim: func() string {
if config.BearerIdentifierClaim != "" {
return config.BearerIdentifierClaim
}
return "sub"
}(),
maxIdentifierLength: func() int {
if config.MaxIdentifierLength > 0 {
return config.MaxIdentifierLength
}
return 256
}(),
maxTokenAge: func() time.Duration {
if config.MaxTokenAgeSeconds > 0 {
return time.Duration(config.MaxTokenAgeSeconds) * time.Second
}
return 24 * time.Hour
}(),
bearerFailureThreshold: func() int {
if config.BearerFailureThreshold > 0 {
return config.BearerFailureThreshold
}
return 20
}(),
bearerFailureWindow: func() time.Duration {
if config.BearerFailureWindowSeconds > 0 {
return time.Duration(config.BearerFailureWindowSeconds) * time.Second
}
return 60 * time.Second
}(),
bearerFailurePenalty: func() time.Duration {
if config.BearerFailurePenaltySeconds > 0 {
return time.Duration(config.BearerFailurePenaltySeconds) * time.Second
}
return 60 * time.Second
}(),
}
// Log audience configuration
if config.Audience != "" && config.Audience != config.ClientID {
t.logger.Infof("Custom audience configured: %s", config.Audience)
} else {
t.logger.Debugf("No custom audience specified, using clientID as audience: %s", t.clientID)
}
// Log RFC 8707 resource indicator configuration
if t.resource != "" {
t.logger.Infof("RFC 8707 resource indicator configured: %s (effective audience: %s)", t.resource, t.audience)
}
// Bearer-auth startup validation. The bearer path is M2M-only and demands
// a non-default audience so tokens issued for a different resource cannot
// be replayed against this service. The BearerIdentifierClaim guard blocks
// the `email` claim explicitly — without email_verified enforcement (out of
// scope for M2M), trusting email is a spoofing vector for federated IdPs.
// See spec §7.9 / §13.
if config.EnableBearerAuth {
if config.Audience == "" && config.Resource == "" {
cancelFunc()
return nil, fmt.Errorf("EnableBearerAuth=true requires Audience or Resource to be set explicitly (cannot default to clientID — that path accepts ID tokens)")
}
if t.bearerIdentifierClaim == "email" {
cancelFunc()
return nil, fmt.Errorf("enableBearerAuth=true with bearerIdentifierClaim=%q is rejected: email-based identity without email_verified enforcement is a spoofing vector for federated IdPs (use \"sub\" or a custom claim; cookie-path userIdentifierClaim is unaffected)", t.bearerIdentifierClaim)
}
if !config.StrictAudienceValidation {
t.logger.Infof("EnableBearerAuth=true with StrictAudienceValidation=false: recommend enabling strict audience validation for hardening")
}
t.bearerFailureTracker = newBearerFailureTracker(
t.bearerFailureThreshold, t.bearerFailureWindow, t.bearerFailurePenalty,
)
t.logger.Infof("Bearer-token auth enabled: audience=%q identifierClaim=%q stripAuthz=%t bearerOverridesCookie=%t maxTokenAge=%s",
config.Audience, t.bearerIdentifierClaim, t.stripAuthorizationHeader, t.bearerOverridesCookie, t.maxTokenAge)
}
// Convert sessionMaxAge from seconds to duration (0 will use default 24 hours)
sessionMaxAge := time.Duration(config.SessionMaxAge) * time.Second
sessionManager, err := NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, config.CookiePrefix, sessionMaxAge, t.logger)
if err != nil {
cancelFunc()
return nil, fmt.Errorf("failed to create session manager: %w", err)
}
t.sessionManager = sessionManager
if config.CookiePath != "" {
t.sessionManager.cookiePath = config.CookiePath
t.logger.Debugf("Using configured cookie path: %s", config.CookiePath)
}
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
// Initialize token resilience manager with default configuration
tokenResilienceConfig := DefaultTokenResilienceConfig()
t.tokenResilienceManager = NewTokenResilienceManager(tokenResilienceConfig, t.logger)
// FIX-18 (minor): every error return below this point previously left
// both managers' GracefulDegradation instances registered in gdInstances
// forever — nothing closes them before the pluginCtx.Done() cleanup
// goroutine is wired up further down, and that goroutine never runs when
// this function itself returns an error. initSucceeded is set true only
// immediately before the final successful return, so any earlier return
// runs this cleanup instead.
initSucceeded := false
defer func() {
if !initSucceeded {
t.errorRecoveryManager.Close()
t.tokenResilienceManager.Close()
}
}()
// Coalesces concurrent refresh-token grants per refresh_token to one upstream
// call, preventing the thundering herd that yields invalid_grant when the IdP
// rotates refresh tokens (Zitadel/Authentik default).
t.refreshCoordinator = NewRefreshCoordinator(DefaultRefreshCoordinatorConfig(), t.logger)
if config.ClientAuthMethod == "private_key_jwt" {
signer, err := buildClientAssertionSignerFromConfig(config)
if err != nil {
cancelFunc()
return nil, fmt.Errorf("failed to build client assertion signer: %w", err)
}
t.clientAssertion = signer
}
t.extractClaimsFunc = extractClaims
t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
}
// Lazily build a client-assertion signer from the static key material,
// used only when DCR later provisions a private_key_jwt client (R162).
t.dcrClientAssertionBuilder = func() (*ClientAssertionSigner, error) {
return buildClientAssertionSignerFromConfig(config)
}
for k, v := range defaultExcludedURLs {
t.excludedURLs[k] = v
}
t.tokenVerifier = t
t.jwtVerifier = t
t.tokenExchanger = t
t.headerTemplates = make(map[string]*template.Template)
funcMap := headerTemplateFuncMap(claimsWhitelist(config.AllowedClaims))
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
parsedTmpl, err := tmpl.Parse(header.Value)
if err != nil {
// Fail closed: a configured identity/security header that does not
// compile must stop startup rather than silently run the middleware
// without it (R141). Validate() gates non-empty templates with the
// same engine, so this is a final safety net.
return nil, fmt.Errorf("failed to parse header template for %s: %w", header.Name, err)
}
t.headerTemplates[header.Name] = parsedTmpl
logger.Debugf("Parsed template for header %s: %s", header.Name, header.Value)
}
// Register this instance BEFORE adopting any process-global singleton
// task (FIX-35). A concurrent Close() elsewhere (an overlapping Traefik
// reload) decides whether it is the last live instance by reading this
// same counter; registering first guarantees that decision — made fresh,
// immediately before each singleton stop — always sees this instance
// counted before it can adopt a shared task below.
registerLiveInstance()
// Start memory monitoring for leak detection and performance insights.
// The interval is clamped to MinMemoryMonitorInterval (30s) inside
// StartMonitoring; tests that need deterministic sampling should call
// MemoryMonitor.Refresh() directly instead of waiting on a fast ticker.
memoryMonitor := GetGlobalMemoryMonitor()
memoryMonitor.StartMonitoring(pluginCtx, DefaultMemoryMonitorInterval)
logger.Debug("Started global memory monitoring")
logger.Debugf("TraefikOidc.New: Final t.scopes initialized to: %v", t.scopes)
// Log callback URL configuration to help diagnose redirect loop issues.
// If callbackURL is a full URL instead of a path, the callback matching
// in ServeHTTP will silently fail because req.URL.Path is compared directly.
logger.Debugf("TraefikOidc.New: callbackURL (redirURLPath) configured as: %q", t.redirURLPath)
logger.Debugf("TraefikOidc.New: logoutURLPath configured as: %q", t.logoutURLPath)
t.providerURL = config.ProviderURL
// Use singleton resource manager for metadata initialization
rm := GetResourceManager()
// Add reference for this instance
rm.AddReference(name)
// registerLiveInstance is now called earlier, before this instance
// adopts the memory-monitor singleton (FIX-35).
// Initialize metadata in a goroutine with proper tracking
if t.goroutineWG != nil {
t.goroutineWG.Add(1)
}
go func() {
defer func() {
if t.goroutineWG != nil {
t.goroutineWG.Done()
}
// Recover from panics to prevent goroutine leaks
if r := recover(); r != nil {
t.safeLogErrorf("Initialize metadata goroutine panic recovered: %v", r)
}
}()
t.initializeMetadata(config.ProviderURL)
}()
// Setup cleanup hook for when context is canceled
if pluginCtx != nil {
go func() {
<-pluginCtx.Done()
_ = t.Close() // Safe to ignore: cleanup on context cancellation
}()
}
initSucceeded = true
return t, nil
}
// initializeMetadata initializes OIDC provider metadata by fetching configuration.
// It retrieves the provider's .well-known/openid-configuration and updates
// internal endpoint URLs. Uses error recovery if available for resilient fetching.
// Parameters:
// - providerURL: The base URL of the OIDC provider.
func (t *TraefikOidc) initializeMetadata(providerURL string) {
t.safeLogDebug("Starting provider metadata discovery")
// Ensure initComplete is always closed, even on failure
defer func() {
select {
case <-t.initComplete:
// Already closed, do nothing
default:
close(t.initComplete)
}
}()
// Get metadata from cache or fetch it with error recovery if available
var metadata *ProviderMetadata
var err error
if t.errorRecoveryManager != nil {
metadata, err = t.metadataCache.GetMetadataWithRecovery(providerURL, t.httpClient, t.logger, t.errorRecoveryManager)
} else {
metadata, err = t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger)
}
if err != nil {
t.safeLogErrorf("Failed to get provider metadata: %v", err)
return
}
if metadata != nil {
t.safeLogDebug("Successfully initialized provider metadata")
t.updateMetadataEndpoints(metadata)
return
}
t.safeLogError("Received nil metadata during initialization")
}
// Discovered-endpoint names. jwks_uri, authorization and token have no
// operator-configured source at all (the plugin has no config field that
// can supply them), so a dropped instance of one of those three leaves
// login broken with no way to recover it (FIX-26). revocation, end_session
// and introspection DO each have a config override (config.RevocationURL,
// config.OIDCEndSessionURL, config.IntrospectionURL — settings.go), but
// only when the operator actually sets it.
const (
discoveredEndpointNameJWKSURI = "jwks_uri"
discoveredEndpointNameAuthorization = "authorization"
discoveredEndpointNameToken = "token"
discoveredEndpointNameRevocation = "revocation"
discoveredEndpointNameEndSession = "end_session"
discoveredEndpointNameIntrospection = "introspection"
)
// discoveredEndpointHasNoOverride reports whether a scheme-downgrade drop of
// the named discovered endpoint has no operator-configured override to fall
// back on, and therefore genuinely breaks whatever depends on that endpoint
// until the provider serves it over https.
//
// jwks_uri, authorization and token have no config field at all, so a
// dropped instance always qualifies. revocation, end_session and
// introspection each have one (configRevocationURL, configEndSessionURL,
// configIntrospectionURL) that replaces the endpoint right after sanitize
// runs (see updateMetadataEndpoints) -- but only once the operator has set
// it. With no override configured, the drop is exactly as terminal as it is
// for the three endpoints with no override at all, so it must qualify too
// (re-review finding at main.go:658: the previous static answer ignored
// whether an override was actually configured).
func (t *TraefikOidc) discoveredEndpointHasNoOverride(name string) bool {
switch name {
case discoveredEndpointNameJWKSURI, discoveredEndpointNameAuthorization, discoveredEndpointNameToken:
return true
case discoveredEndpointNameRevocation:
return t.configRevocationURL == ""
case discoveredEndpointNameEndSession:
return t.configEndSessionURL == ""
case discoveredEndpointNameIntrospection:
return t.configIntrospectionURL == ""
default:
return false
}
}
// updateMetadataEndpoints updates internal endpoint URLs with discovered metadata.
// It sets the authorization URL, token URL, JWKS URL, issuer URL, revocation URL,
// end session URL, introspection URL, and registration URL based on the provider's metadata.
// If Dynamic Client Registration is enabled and no ClientID is configured, it will
// automatically register the client with the provider.
// Parameters:
// - metadata: A pointer to the ProviderMetadata struct containing the discovered endpoints.
func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
// SSRF defense (audit ranks 3 & 4): a discovery document is attacker-
// influenced when the provider or its TLS is compromised. Reject any
// discovered endpoint pointed at a blocked address before the plugin issues
// outbound requests to it, so it can never be used to reach the cloud
// metadata service or an internal host.
// Recomputed from t.providerURL (not the allowLoopbackHosts field) so this
// stays correct for TraefikOidc values built outside New() (test helpers
// construct &TraefikOidc{providerURL: ...} directly, leaving
// allowLoopbackHosts at its zero value).
allowLoopback := isLoopbackProviderURL(t.providerURL)
sanitize := func(name, raw string) string {
if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil {
t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err)
// R146's https-pin drop gets a second, distinctly-tagged line
// whenever this endpoint has no operator override actually
// configured right now (discoveredEndpointHasNoOverride):
// jwks_uri, authorization and token never have one. revocation,
// end_session and introspection do have one each
// (configRevocationURL/configEndSessionURL/
// configIntrospectionURL), which replaces the endpoint right
// after sanitize runs (see below) -- but only when the operator
// has actually set it. With no override set, the drop is just as
// terminal for these three as it is for the other three, so it
// gets the same loud line (FIX-26; re-review at main.go:658).
if errors.Is(err, ErrDiscoveredEndpointSchemeDowngrade) && t.discoveredEndpointHasNoOverride(name) {
t.logger.Errorf("SECURITY: dropped the discovered %s endpoint %q: it is plaintext http while providerURL %q is https, and this check has no override; requests needing the %s endpoint will fail until the provider serves it over https", name, raw, t.providerURL, name)
}
return ""
}
return raw
}
metadata.JWKSURL = sanitize(discoveredEndpointNameJWKSURI, metadata.JWKSURL)
metadata.AuthURL = sanitize(discoveredEndpointNameAuthorization, metadata.AuthURL)
metadata.TokenURL = sanitize(discoveredEndpointNameToken, metadata.TokenURL)
metadata.RevokeURL = sanitize(discoveredEndpointNameRevocation, metadata.RevokeURL)
metadata.EndSessionURL = sanitize(discoveredEndpointNameEndSession, metadata.EndSessionURL)
metadata.RegistrationURL = sanitize("registration", metadata.RegistrationURL)
metadata.IntrospectionURL = sanitize(discoveredEndpointNameIntrospection, metadata.IntrospectionURL)
// The introspection request authenticates with the client secret via HTTP
// Basic, so the endpoint must live on the same host as the operator-
// configured provider; otherwise a poisoned discovery document could
// exfiltrate the client secret to an attacker-controlled host.
if metadata.IntrospectionURL != "" && t.providerURL != "" && !sameHost(metadata.IntrospectionURL, t.providerURL) {
t.logger.Errorf("Ignoring introspection endpoint %q: host does not match configured providerURL", metadata.IntrospectionURL)
metadata.IntrospectionURL = ""
}
// Token and revocation endpoints are NOT same-host pinned: mainstream
// OIDC providers split them across hosts (e.g. Google's issuer is
// accounts.google.com but token_endpoint is oauth2.googleapis.com).
// A same-host pin here would clear the real token endpoint on those
// providers, leaving tokenURL empty and breaking the code exchange.
// SSRF defense for these (and every endpoint) is handled above by
// validateDiscoveredEndpoint (blocks private/loopback/cloud-metadata
// hosts), which is the appropriate gate. The client secret travels to
// the (TLS-verified, SSRF-filtered) endpoint the IdP advertises,
// matching standard OIDC behavior (R158).
if metadata.TokenURL != "" && t.providerURL != "" && !sameHost(metadata.TokenURL, t.providerURL) {
t.logger.Debugf("Accepted discovered token endpoint on a different host than providerURL: %s", metadata.TokenURL)
}
if metadata.RevokeURL != "" && t.providerURL != "" && !sameHost(metadata.RevokeURL, t.providerURL) {
t.logger.Debugf("Accepted discovered revocation endpoint on a different host than providerURL: %s", metadata.RevokeURL)
}
// Pin the discovered issuer to the operator-configured provider host. The
// issuer is the trust anchor for JWT issuer validation, so a poisoned
// discovery document advertising an attacker-chosen issuer must never be
// stored. Real providers (Google, Azure, Keycloak, Okta, Auth0) keep the
// issuer on the same host as the configured providerURL. On mismatch, leave
// issuerURL empty/unchanged so downstream issuer validation fails closed
// rather than trusting the attacker-chosen value.
discoveredIssuer := metadata.Issuer
if discoveredIssuer != "" && t.providerURL != "" && !sameHost(discoveredIssuer, t.providerURL) {
t.logger.Errorf("Ignoring discovered issuer %q: host does not match configured providerURL", discoveredIssuer)
discoveredIssuer = ""
}
// Operator-configured endpoint overrides take precedence over discovered
// values. They deliberately skip validateDiscoveredEndpoint and the
// sameHost pin: those defend against a poisoned discovery document, while
// operator config sits at the same trust tier as providerURL itself and is
// already gated by isValidSecureURL at config time.
if t.configRevocationURL != "" {
metadata.RevokeURL = t.configRevocationURL
}
if t.configEndSessionURL != "" {
metadata.EndSessionURL = t.configEndSessionURL
}
if t.configIntrospectionURL != "" {
metadata.IntrospectionURL = t.configIntrospectionURL
}
t.metadataMu.Lock()
// Preserve the previously-good issuer when the discovered one is empty
// (a transiently-malformed discovery document on refresh, or a host
// mismatch nulled below). Unconditionally assigning the discovered
// value would wipe a working issuer to "" on a benign refresh,
// permanently bricking auth (503 + infinite recovery) until a later
// refresh happens to restore it (R124).
if discoveredIssuer == "" {
discoveredIssuer = t.issuerURL
}
// Preserve a previously-good endpoint when the discovered value is
// empty (a transiently-malformed discovery document on refresh, or an
// SSRF-sanitize blanking it). Mirror the issuer guard above (R124):
// unconditionally assigning the empty value would wipe a working
// jwks_uri (and therefore permanently break signature verification),
// auth, or token endpoint with no config override to recover (R151).
preserveGood := func(newV, cur string) string {
if newV == "" && cur != "" {
return cur
}
return newV
}
t.jwksURL = preserveGood(metadata.JWKSURL, t.jwksURL)
t.scopesSupported = metadata.ScopesSupported // Store supported scopes from discovery
t.authURL = preserveGood(metadata.AuthURL, t.authURL)
t.tokenURL = preserveGood(metadata.TokenURL, t.tokenURL)
t.issuerURL = discoveredIssuer
t.revocationURL = metadata.RevokeURL
t.endSessionURL = metadata.EndSessionURL
t.introspectionURL = metadata.IntrospectionURL // OAuth 2.0 Token Introspection endpoint (RFC 7662)
t.registrationURL = metadata.RegistrationURL // OIDC Dynamic Client Registration endpoint (RFC 7591)
// Copy values for logging after unlock to avoid race conditions
introspectionURL := t.introspectionURL
registrationURL := t.registrationURL
// Publish the read-mostly URL bundle atomically. Hot-path readers Load
// this directly instead of acquiring metadataMu.RLock per request.
t.metadataSnapshot.Store(&MetadataSnapshot{
IssuerURL: discoveredIssuer,
JWKSURL: metadata.JWKSURL,
TokenURL: metadata.TokenURL,
AuthURL: metadata.AuthURL,
RevocationURL: metadata.RevokeURL,
EndSessionURL: metadata.EndSessionURL,
IntrospectionURL: metadata.IntrospectionURL,
RegistrationURL: metadata.RegistrationURL,
})
t.metadataMu.Unlock()
// Log introspection endpoint availability for opaque token support
if introspectionURL != "" {
t.logger.Debugf("Token introspection endpoint discovered: %s", introspectionURL)
if t.allowOpaqueTokens {
t.logger.Debugf("Opaque token support enabled with introspection endpoint")
}
} else if t.allowOpaqueTokens || t.requireTokenIntrospection {
t.logger.Infof("⚠️ Opaque tokens enabled but no introspection endpoint available from provider")
}
// Log registration endpoint availability
if registrationURL != "" {
t.logger.Debugf("Dynamic client registration endpoint discovered: %s", registrationURL)
}
// Perform Dynamic Client Registration if enabled and ClientID is not set.
// dcrMu serializes the check and the registration so the concurrent
// metadata-refresh goroutine can't pass the same gate and register a
// second client (and so the clientID read here can't race the write in
// performDynamicClientRegistration).
t.dcrMu.Lock()
// Snapshot clientID under metadataMu: DCR writes it under
// metadataMu.Lock, so the dcrMu-guarded read must also snapshot under
// RLock to avoid a lock-mismatch data race (R137).
if t.dcrConfig != nil && t.dcrConfig.Enabled && t.dcrRegistrationNeeded() {
t.performDynamicClientRegistration()
}
t.dcrMu.Unlock()
}
// dcrRegistrationNeeded reports whether a DCR (re-)registration is needed:
// either no client is installed yet, or the installed client's secret has
// already expired (or is within the same 5-minute buffer used by
// areCredentialsValid when loading persisted credentials). Without the
// expiry branch a provider that returns a finite client_secret_expires_at
// would have its install be a one-shot: once the secret lapsed, the
// 2-hour metadata refresh (the only path that re-runs this gate) saw
// clientID != "" and never re-registered, so every token exchange then
// failed invalid_client with no self-heal (R180).
func (t *TraefikOidc) dcrRegistrationNeeded() bool {
t.metadataMu.RLock()
hasClient := t.clientID != ""
exp := t.clientSecretExpiresAt
t.metadataMu.RUnlock()
if !hasClient {
return true
}
if exp <= 0 {
return false // non-expiring secret → keep the installed client
}
return time.Now().Add(5 * time.Minute).After(time.Unix(exp, 0))
}
// performDynamicClientRegistration performs automatic client registration with the OIDC provider
func (t *TraefikOidc) performDynamicClientRegistration() {
t.logger.Info("Dynamic Client Registration enabled - registering client with provider")
// Initialize the DCR registrar if not already done
if t.dynamicClientRegistrar == nil {
t.dynamicClientRegistrar = NewDynamicClientRegistrar(
t.httpClient,
t.logger,
t.dcrConfig,
t.providerURL,
)
// Feed the merged runtime auth scopes so registration may advertise
// them (buildRegistrationRequest) for IdPs that only grant
// registered scopes (R124).
t.dynamicClientRegistrar.scopes = append([]string(nil), t.scopes...)
// 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). Guarded
// by metadataMu because updateMetadataEndpoints (refresh loop / recovery
// goroutine) writes registrationURL under metadataMu.Lock concurrently
// (R117 data race otherwise).
t.metadataMu.RLock()
registrationEndpoint := t.registrationURL
t.metadataMu.RUnlock()
if t.dcrConfig.RegistrationEndpoint != "" {
registrationEndpoint = t.dcrConfig.RegistrationEndpoint
}
// Perform registration
ctx, cancel := context.WithTimeout(t.ctx, 30*time.Second)
defer cancel()
resp, err := t.dynamicClientRegistrar.RegisterClient(ctx, registrationEndpoint)
if err != nil {
t.logger.Errorf("Dynamic Client Registration failed: %v", err)
return
}
// Update client credentials from registration response
t.metadataMu.Lock()
t.clientID = resp.ClientID
t.clientSecret = resp.ClientSecret
t.clientSecretExpiresAt = resp.ClientSecretExpiresAt
// The IdP provisioned this client according to the auth method requested
// at registration (ClientMetadata.TokenEndpointAuthMethod, RFC 7591).
// Point the runtime at the SAME method, or token exchanges will
// authenticate with a method the registered client does not support
// and fail silently (R157). The IdP is authoritative here, so this
// overrides any clientAuthMethod inherited from the static config.
if t.dynamicClientRegistrar != nil {
t.clientAuthMethod = t.dynamicClientRegistrar.EffectiveTokenEndpointAuthMethod()
}
if t.audience == "" {
t.audience = resp.ClientID // Default audience to client ID
}
// R162: DCR can provision a client whose method is private_key_jwt
// (RFC 7591 ClientMetadata.TokenEndpointAuthMethod) even when the
// static config did not set ClientAuthMethod=private_key_jwt. In that
// case no ClientAssertionSigner was built at construction and every
// token exchange silently fell back to client_secret (empty for a
// secretless private_key_jwt client) and failed invalid_client.
// Reconcile: if the effective method is private_key_jwt but no
// signer exists, build one from the static key material, and log
// loudly (rather than silently) if that material is missing.
// Written while holding metadataMu so it matches every reader of
// t.clientAssertion (clientCredentials) which snapshots under
// metadataMu.RLock — writing it after Unlock was a lock-mismatch
// data race with the request path (R180).
if t.clientAuthMethod == "private_key_jwt" && t.clientAssertion == nil {
if t.dcrClientAssertionBuilder == nil {
t.logger.Errorf("DCR registered a private_key_jwt client but no client-assertion signer builder is configured; token exchanges will fail")
} else if signer, err := t.dcrClientAssertionBuilder(); err != nil {
t.logger.Errorf("DCR registered a private_key_jwt client but no client-assertion signer could be built: %v", err)
} else {
t.clientAssertion = signer
}
}
t.metadataMu.Unlock()
t.logger.Infof("Dynamic Client Registration successful - client_id: %s", resp.ClientID)
// Log additional registration details
if resp.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
t.logger.Infof("Client secret expires at: %s", expiresAt.Format(time.RFC3339))
}
if resp.RegistrationClientURI != "" {
t.logger.Debugf("Registration management URI: %s", resp.RegistrationClientURI)
}
}
// startMetadataRefresh starts a background goroutine that periodically refreshes provider metadata.
// It runs every 2 hours and implements exponential backoff for consecutive failures.
// The refresh helps ensure endpoint URLs stay current and handles provider configuration changes.
// Parameters:
// - providerURL: The base URL of the OIDC provider, used for subsequent refresh attempts.
func (t *TraefikOidc) startMetadataRefresh(providerURL string) {
// Use singleton resource manager for metadata refresh
rm := GetResourceManager()
// Use last 6 chars of provider URL hash to create unique task name per realm
// This fixes multi-realm support where different Keycloak realms need separate refresh tasks
hash := sha256.Sum256([]byte(providerURL))
taskName := "singleton-metadata-refresh-" + hex.EncodeToString(hash[:])[0:6]
// Create refresh function
refreshFunc := func() {
if t.metadataCache == nil || t.httpClient == nil {
return
}
metadata, err := t.metadataCache.GetMetadata(providerURL, t.httpClient, t.logger)
if err != nil {
t.safeLogErrorf("Failed to refresh provider metadata: %v", err)
return
}
if metadata != nil {
t.updateMetadataEndpoints(metadata)
t.safeLogDebug("Successfully refreshed provider metadata")
}
}
// Register as singleton task - will return existing if already registered
err := rm.RegisterBackgroundTask(taskName, 2*time.Hour, refreshFunc)
if err != nil {
t.logger.Errorf("Failed to register metadata refresh task: %v", err)
return
}
// Start the task if not already running
if !rm.IsTaskRunning(taskName) {
_ = rm.StartBackgroundTask(taskName) // Safe to ignore: task registration succeeded, start is best-effort
t.logger.Debug("Started singleton metadata refresh task")
} else {
t.logger.Debug("Metadata refresh task already running, skipping duplicate")
}
}
// attemptMetadataRecovery tries to fetch provider metadata when the system is in a failed state.
// This is called periodically (every 30s) when requests come in and metadata is unavailable.
// It allows automatic recovery when the OIDC provider becomes available again.
func (t *TraefikOidc) attemptMetadataRecovery() {
if t.metadataCache == nil || t.httpClient == nil {
return
}
// Try to fetch metadata (single attempt, no aggressive retry here since this runs every 30s)
metadata, err := t.metadataCache.GetMetadata(t.providerURL, t.httpClient, t.logger)
if err != nil {
t.safeLogDebugf("Metadata recovery attempt failed: %v", err)
return
}
if metadata != nil {
t.updateMetadataEndpoints(metadata)
t.safeLogInfo("Successfully recovered OIDC provider metadata - service restored")
}
}
// createCaseInsensitiveStringMap creates a map with lowercase keys for case-insensitive matching.
// This is used for case-insensitive matching of email addresses.
// Parameters:
// - items: The string items to convert to lowercase keys.
//
// Returns:
// - A map with lowercase string keys for case-insensitive lookups.
func createCaseInsensitiveStringMap(items []string) map[string]struct{} {
result := make(map[string]struct{})
for _, item := range items {
result[strings.ToLower(item)] = struct{}{}
}
return result
}
// buildFullURL constructs a complete URL from scheme, host, and path components.
// It handles absolute URLs in the path and ensures proper URL formatting.
// Parameters:
// - scheme: The URL scheme ("http" or "https").
// - host: The host name and optional port.
// - path: The path component (may be absolute URL itself).
//
// Returns:
// - The combined absolute URL string (e.g., "https://example.com:8080/resource").
func buildFullURL(scheme, host, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return fmt.Sprintf("%s://%s%s", scheme, host, path)
}