mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
* fix(settings): allow documented range + static header templates (#149)
Upgrading past the release where NewWithContext began calling
config.Validate() (546ceb9) broke `headers` configs that worked in 0.8.25:
New() returned a nil handler and Traefik logged the opaque "invalid handler
type: <nil>". Two header-validation checks were stricter than the documented,
legitimate usage:
- validateTemplateSecure rejected the documented multi-valued-claim join
"{{range $i,$e := .Claims.groups}}...{{end}}" as a dangerous {{range}},
even though README/CONFIGURATION show exactly that for groups/roles.
- Config.Validate required every header value to contain {{ }}, rejecting
static/literal values such as a shared proxy secret.
Fixes:
- Allow static/literal header values (reject only CR/LF/NUL header-injection
chars); flag unbalanced {{ }} explicitly.
- Allow {{range}} only when its clause targets .Claims.<field>; a range over
the bare .Claims map stays blocked and a decoy {{.Claims.x}} elsewhere no
longer launders it. Generalize the claims whitelist scan to .Claims. so a
claim reached via range/with/default is whitelisted identically to a direct
{{.Claims.x}} reference.
- normalizeTemplateDelimiters now collapses ALL whitespace inside {{ }}
(newline/CR/FF too), closing a newline-obfuscation bypass ({{\nrange,
{{\nindex) found in review.
- Log the concrete validation reason at construction so operators see WHY the
middleware failed to build instead of only "invalid handler type: <nil>".
Adds issue149_regression_test.go: pins the exact issue config, the documented
groups template rendering "admin,users" past the runtime sanitizer, static and
delimiter edge cases, and the security boundary (whole-Claims-map dump via
range, non-whitelisted claims, call/define/template/printf, and
newline-obfuscated actions all rejected).
* fix(settings): AST-based header template validation, close whitelist bypasses
Follow-up hardening to the #149 work. An adversarial review showed the
substring-based validateTemplateSecure could not actually bound what a header
template reads: get/index with arbitrary keys, {{with .Claims}} field access,
and a whitelisted-decoy + bare {{.}}/{{$}}/{{.Claims}} render all leaked the
whole root (every raw token) or the whole Claims map past validation. The core
problem is structural — the meaning of "." depends on scope, which substrings
cannot see.
Replace the substring validator with an AST walk (text/template/parse):
- Parse with only get/default known; every other function (printf, call, index,
js, urlquery, ...) then fails to parse and is rejected — no blocklist.
- Reject {{define}}/{{block}} (they produce extra trees).
- Walk the tree tracking whether "." is the root. Permit ONLY: literal text;
the four root token fields (AccessToken, IdToken, IDToken, RefreshToken);
whitelisted .Claims.<field>; range/with over a whitelisted .Claims.<field>
("." inside the body is the already-whitelisted element); get on .Claims with
a whitelisted literal key; default over safe values; {{if}} control flow and
$-scoped range/with/decl variables. Everything else — bare {{.}} / {{$}}
(whole-root dump), bare {{.Claims}} (whole-map dump), non-whitelisted claims,
$.Claims root reach — is rejected.
Also closes the get/default exfil and the with-over-bare-map vectors, and fixes
ID-token forwarding: templateData now carries both IDToken and IdToken and the
validator accepts {{.IdToken}} (documented) and {{.IDToken}}; previously the
documented spelling rendered empty and the working one was rejected.
Runtime `get` additionally enforces the claims whitelist on its key, and
safeClaimsFields is promoted to a package-level single source of truth shared by
the validator and the get helper. The header template funcMap is extracted to
headerTemplateFuncMap() so tests exercise the exact production map. Removes the
now-dead normalizeTemplateDelimiters/regex (the parser handles whitespace).
Extends issue149_regression_test.go: renders through the real funcMap to prove
get cannot leak; asserts the whole-root/whole-map/decoy/var-launder/index
vectors are rejected while every documented pattern (groups/roles join,
with .Claims.email, get .Claims "internal_role", default, both IdToken
spellings) still validates and renders.
* feat(settings): allowedClaims to extend the header-template claim whitelist
The AST validator and the runtime `get` only permit a fixed set of claim fields
in header value templates. Providers issue custom claims (e.g. employee_id) that
aren't built in, and there was no supported way to forward them — the previous
`get` escape hatch was closed by the security hardening.
Add `allowedClaims []string` to Config. Listed names extend the effective
whitelist for BOTH static validation and the runtime `get` (a single
claimsWhitelist() = built-in safeClaimsFields + AllowedClaims, so the two never
drift). Built-in claims need not be listed; unlisted claims stay rejected.
allowedClaims:
- employee_id
headers:
- name: X-Employee-Id
value: "{{`{{.Claims.employee_id}}`}}"
Implementation: the whitelist is threaded through the validator (now a small
templateValidator struct carrying the allowed set) and headerTemplateFuncMap
instead of the package global, so it is per-config with no shared mutable state.
Docs: README and docs/CONFIGURATION document the whitelist, the validation
behaviour, and allowedClaims (grounded in safeClaimsFields).
Tests: TestIssue149_AllowedClaimsExtendsWhitelist pins that a custom claim is
rejected by default and validates + renders once listed, via both direct access
and get, while an unlisted claim stays rejected. A regression assertion that
coupled to the old validator's "dangerous" wording now checks the stable
"failed security validation" wrapper (the templates are still rejected).
811 lines
30 KiB
Go
811 lines
30 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"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"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)
|
|
}
|
|
})
|
|
return NewWithContext(ctx, config, next, name)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return config.ClientID
|
|
}(),
|
|
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,
|
|
overrideScopes: config.OverrideScopes,
|
|
strictAudienceValidation: config.StrictAudienceValidation,
|
|
allowOpaqueTokens: config.AllowOpaqueTokens,
|
|
requireTokenIntrospection: config.RequireTokenIntrospection,
|
|
disableReplayDetection: config.DisableReplayDetection,
|
|
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.Every(time.Second), config.RateLimit),
|
|
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,
|
|
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)
|
|
}
|
|
|
|
// 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 == "" {
|
|
cancelFunc()
|
|
return nil, fmt.Errorf("EnableBearerAuth=true requires Audience 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
|
|
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
|
|
|
|
// Initialize token resilience manager with default configuration
|
|
tokenResilienceConfig := DefaultTokenResilienceConfig()
|
|
t.tokenResilienceManager = NewTokenResilienceManager(tokenResilienceConfig, t.logger)
|
|
|
|
// 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)
|
|
}
|
|
|
|
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 {
|
|
logger.Errorf("Failed to parse header template for %s: %v", header.Name, err)
|
|
continue
|
|
}
|
|
|
|
t.headerTemplates[header.Name] = parsedTmpl
|
|
logger.Debugf("Parsed template for header %s: %s", header.Name, header.Value)
|
|
}
|
|
|
|
startReplayCacheCleanup(pluginCtx, logger)
|
|
|
|
// 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()
|
|
|
|
// 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
|
|
}()
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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.
|
|
allowLoopback := false
|
|
if pu, err := url.Parse(t.providerURL); err == nil {
|
|
allowLoopback = isLoopbackHost(pu.Hostname())
|
|
}
|
|
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)
|
|
return ""
|
|
}
|
|
return raw
|
|
}
|
|
metadata.JWKSURL = sanitize("jwks_uri", metadata.JWKSURL)
|
|
metadata.AuthURL = sanitize("authorization", metadata.AuthURL)
|
|
metadata.TokenURL = sanitize("token", metadata.TokenURL)
|
|
metadata.RevokeURL = sanitize("revocation", metadata.RevokeURL)
|
|
metadata.EndSessionURL = sanitize("end_session", metadata.EndSessionURL)
|
|
metadata.RegistrationURL = sanitize("registration", metadata.RegistrationURL)
|
|
metadata.IntrospectionURL = sanitize("introspection", 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 = ""
|
|
}
|
|
|
|
// 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 = ""
|
|
}
|
|
|
|
t.metadataMu.Lock()
|
|
|
|
t.jwksURL = metadata.JWKSURL
|
|
t.scopesSupported = metadata.ScopesSupported // Store supported scopes from discovery
|
|
t.authURL = metadata.AuthURL
|
|
t.tokenURL = metadata.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
|
|
if t.dcrConfig != nil && t.dcrConfig.Enabled && t.clientID == "" {
|
|
t.performDynamicClientRegistration()
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
|
|
// 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)
|
|
registrationEndpoint := t.registrationURL
|
|
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
|
|
if t.audience == "" {
|
|
t.audience = resp.ClientID // Default audience to client ID
|
|
}
|
|
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)
|
|
}
|