mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
* feat: add RFC 8707 resource indicator via resource config `Config.Resource` is an alternative to `audience` for IdPs that implement RFC 8707. `buildAuthURL` sends it as the `resource` parameter on the authorization request, before `extraAuthParams` so an operator-configured `resource` key there cannot override or duplicate it. `exchangeTokens` repeats it on both the `authorization_code` and `refresh_token` grants (RFC 8707 §2.2), so a refreshed token keeps the audience binding of the original grant. Only one resource is accepted. RFC 8707 permits a list, but no provider that supports the parameter handles a list usefully: one rejects any request naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts several and issues an opaque token bound to none of them. `Config.Validate()` requires an absolute URI of at most 256 characters with no fragment, wildcard, or control characters. `http`/`https` values go through `isValidSecureURL`, so HTTPS is required outside loopback; other schemes (`urn:`) pass as-is. When `resource` is set and `audience` is not, `audience` defaults to the resource for access-token validation; `strictAudienceValidation` applies the same way whichever of the two is set. That default is validation-only. A new `TraefikOidc.explicitAudience` mirrors `Config.Audience` verbatim, and `buildAuthURL` keys the non-standard `audience` request parameter (the Auth0 custom-API pattern) on it rather than on `audience`, so a resource-derived default is never sent as an unsolicited `audience` parameter alongside `resource` — a combination that misbehaves against providers such as Ory Hydra, which give `audience` a different meaning. `audience_test.go` and `url_helpers_ultra_test.go` set `audience` on the struct directly, bypassing `New()`, and now set `explicitAudience` too. docs/CONFIGURATION.md carries only the reference-table row and a short pointer; the new docs/RFC8707.md holds the request behaviour, the single-resource rationale, the `resource`/`audience` relationship, and a provider-support table from interop testing: node-oidc-provider supports it; Zitadel accepts and silently ignores it (`oidc.AuthRequest` has no `resource` field, confirmed against a live instance); Authentik returns `invalid_target`; Keycloak has no support through 26.6; Ory Hydra/Fosite uses its own `audience` parameter; Azure AD/Entra ID v2 is reported to reject `resource` combined with v2-style `scope`; Auth0 and AWS Cognito claims are unverified. README.md, docs/index.html, and .traefik.yml each point at docs/RFC8707.md instead of repeating it, and the comments on `Config.Resource`, in main.go, and in url_helpers.go defer to it as well. rfc8707_resource_test.go covers the authorization-URL parameter (set, unset, query-bearing value percent-encoded rather than merged, `extraAuthParams` unable to override), both grants sending `resource` in the form body, the `Config.Validate()` rules table, audience defaulting through `New()`, and a regression pair: a resource-derived audience default is not sent as `audience`, while an explicitly configured one still is. * fix: accept Resource in place of Audience for bearer-auth validation `NewWithContext` rejected `EnableBearerAuth=true` whenever `config.Audience` was empty, even though `t.audience` already falls back to `config.Resource` in that case. The check now fails only when both `Audience` and `Resource` are empty, and the error names both fields. `TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience` covers the `Resource`-only configuration. `docs/RFC8707.md` links the `audience` parameter note to `docs/AUTH0_AUDIENCE_GUIDE.md` instead of restating it, and drops the provider table's GitHub row and the interop-testing paragraph, which are covered elsewhere.
230 lines
9.8 KiB
Go
230 lines
9.8 KiB
Go
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"text/template"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// CacheInterface defines the common cache operations
|
|
type CacheInterface interface {
|
|
Set(key string, value any, ttl time.Duration)
|
|
Get(key string) (any, bool)
|
|
Delete(key string)
|
|
SetMaxSize(size int)
|
|
Size() int
|
|
Clear()
|
|
Cleanup()
|
|
Close()
|
|
GetStats() map[string]any // For testing and monitoring
|
|
}
|
|
|
|
// TokenVerifier interface defines token verification capabilities.
|
|
// Implementations should validate token format, signature, and claims.
|
|
type TokenVerifier interface {
|
|
VerifyToken(token string) error
|
|
}
|
|
|
|
// JWTVerifier interface defines JWT-specific verification capabilities.
|
|
// Implementations should validate JWT structure, signature using JWKs, and standard claims.
|
|
type JWTVerifier interface {
|
|
VerifyJWTSignatureAndClaims(jwt *JWT, token string) error
|
|
}
|
|
|
|
// TokenExchanger interface defines OAuth 2.0 and OpenID Connect token exchange capabilities.
|
|
// Implementations should handle authorization code exchange, refresh tokens, and revocation
|
|
// according to the OAuth 2.0 and OpenID Connect specifications.
|
|
type TokenExchanger interface {
|
|
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
|
GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error)
|
|
RevokeTokenWithProvider(token, tokenType string) error
|
|
}
|
|
|
|
// ProviderMetadata represents OIDC provider configuration data.
|
|
// This data is typically retrieved from the provider's .well-known/openid-configuration endpoint
|
|
// and contains essential URLs for authentication, token exchange, and key retrieval.
|
|
type ProviderMetadata struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthURL string `json:"authorization_endpoint"`
|
|
TokenURL string `json:"token_endpoint"`
|
|
JWKSURL string `json:"jwks_uri"`
|
|
RevokeURL string `json:"revocation_endpoint"`
|
|
EndSessionURL string `json:"end_session_endpoint"`
|
|
IntrospectionURL string `json:"introspection_endpoint,omitempty"`
|
|
RegistrationURL string `json:"registration_endpoint,omitempty"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
}
|
|
|
|
// TraefikOidc is the main middleware struct that implements OIDC authentication for Traefik.
|
|
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles
|
|
// the complete authentication flow. It's designed to work seamlessly with Traefik's
|
|
// plugin system and provides flexible configuration options.
|
|
// MetadataSnapshot is an immutable bundle of provider-metadata URLs that the
|
|
// plugin needs on the hot request path. Published atomically via
|
|
// TraefikOidc.metadataSnapshot; readers do exactly one atomic.Value.Load to
|
|
// access all fields. Replaces 3 per-request metadataMu.RLock acquisitions
|
|
// in middleware.ServeHTTP + token_manager paths, each of which paid
|
|
// 1-5ms of Yaegi-dispatch overhead.
|
|
//
|
|
// The fields are a strict subset of the metadataMu-guarded TraefikOidc
|
|
// fields; the legacy fields are still written under metadataMu for
|
|
// less-frequent code paths that have not been migrated.
|
|
type MetadataSnapshot struct {
|
|
IssuerURL string
|
|
JWKSURL string
|
|
TokenURL string
|
|
AuthURL string
|
|
RevocationURL string
|
|
EndSessionURL string
|
|
IntrospectionURL string
|
|
RegistrationURL string
|
|
}
|
|
|
|
type TraefikOidc struct {
|
|
// metadataSnapshot atomically publishes the read-mostly URL bundle.
|
|
// Hot-path readers (middleware.ServeHTTP, token verification) load it
|
|
// directly; less-frequent paths still acquire metadataMu.RLock and
|
|
// read the individual fields below.
|
|
metadataSnapshot atomic.Value
|
|
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata
|
|
// recovery attempt. Stored atomically so the hot ServeHTTP path can
|
|
// throttle retries without acquiring metadataRetryMutex on every request.
|
|
lastMetadataRetryNano int64
|
|
// firstRequestStarted is 0 until the very first non-health request fires
|
|
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
|
|
// firstRequestMutex + firstRequestReceived combo which previously took
|
|
// a write lock on every non-health request forever.
|
|
firstRequestStarted int32
|
|
// metadataRefreshStartedAtomic is the CAS-only variant of the old
|
|
// metadataRefreshStarted bool. Both flags live under the same atomic so
|
|
// concurrent first-request goroutines race exactly once.
|
|
metadataRefreshStartedAtomic int32
|
|
jwkCache JWKCacheInterface
|
|
jwtVerifier JWTVerifier
|
|
ctx context.Context
|
|
tokenVerifier TokenVerifier
|
|
next http.Handler
|
|
tokenExchanger TokenExchanger
|
|
tokenBlacklist CacheInterface
|
|
tokenTypeCache CacheInterface
|
|
introspectionCache CacheInterface
|
|
initComplete chan struct{}
|
|
limiter *rate.Limiter
|
|
headerTemplates map[string]*template.Template
|
|
sessionManager *SessionManager
|
|
tokenCleanupStopChan chan struct{}
|
|
excludedURLs map[string]struct{}
|
|
extractClaimsFunc func(tokenString string) (map[string]any, error)
|
|
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
|
|
metadataCache *MetadataCache
|
|
allowedRolesAndGroups map[string]struct{}
|
|
allowedUsers map[string]struct{}
|
|
allowedUserDomains map[string]struct{}
|
|
tokenCache *TokenCache
|
|
httpClient *http.Client
|
|
tokenHTTPClient *http.Client
|
|
logger *Logger
|
|
metadataRefreshStopChan chan struct{}
|
|
cancelFunc context.CancelFunc
|
|
errorRecoveryManager *ErrorRecoveryManager
|
|
tokenResilienceManager *TokenResilienceManager
|
|
refreshCoordinator *RefreshCoordinator
|
|
goroutineWG *sync.WaitGroup
|
|
dcrConfig *DynamicClientRegistrationConfig
|
|
dynamicClientRegistrar *DynamicClientRegistrar
|
|
scopeFilter *ScopeFilter
|
|
securityHeadersApplier func(http.ResponseWriter, *http.Request)
|
|
userIdentifierClaim string
|
|
revocationURL string
|
|
name string
|
|
redirURLPath string
|
|
logoutURLPath string
|
|
tokenURL string
|
|
authURL string
|
|
endSessionURL string
|
|
postLogoutRedirectURI string
|
|
jwksURL string
|
|
issuerURL string
|
|
groupClaimName string
|
|
introspectionURL string
|
|
providerURL string
|
|
roleClaimName string
|
|
audience string
|
|
clientID string
|
|
clientSecret string
|
|
clientAuthMethod string
|
|
clientAssertion *ClientAssertionSigner
|
|
registrationURL string
|
|
// configRevocationURL, configEndSessionURL, and configIntrospectionURL
|
|
// hold operator-configured endpoint overrides (Config.RevocationURL,
|
|
// Config.OIDCEndSessionURL, Config.IntrospectionURL). They are set once at
|
|
// construction and never mutated afterwards, so they are read lock-free,
|
|
// the same contract as providerURL, which updateMetadataEndpoints already
|
|
// reads unlocked. A manual override always wins over discovery; an empty
|
|
// string means use the discovered value.
|
|
configRevocationURL string
|
|
configEndSessionURL string
|
|
configIntrospectionURL string
|
|
backchannelLogoutPath string
|
|
frontchannelLogoutPath string
|
|
scopesSupported []string
|
|
scopes []string
|
|
extraAuthParams map[string]string
|
|
// resource holds the RFC 8707 resource indicator from Config.Resource
|
|
// (Config.Validate() already guarantees it is either empty or a
|
|
// non-empty absolute URI). Sent as the `resource` param on the
|
|
// authorization request and on both the authorization_code and
|
|
// refresh_token grants.
|
|
resource string
|
|
// explicitAudience mirrors Config.Audience verbatim, with no
|
|
// resource- or clientID-based defaulting applied. audience (below) does
|
|
// carry that defaulting, for token-validation purposes; explicitAudience
|
|
// exists so buildAuthURL can decide whether to send the non-standard
|
|
// `audience` request parameter without that default leaking onto the
|
|
// wire.
|
|
explicitAudience string
|
|
refreshGracePeriod time.Duration
|
|
maxRefreshTokenAge time.Duration
|
|
metadataMu sync.RWMutex
|
|
shutdownOnce sync.Once
|
|
sessionInvalidationCache CacheInterface
|
|
refreshResultCache CacheInterface
|
|
minimalHeaders bool
|
|
stripAuthCookies bool
|
|
enableBackchannelLogout bool
|
|
enableFrontchannelLogout bool
|
|
requireTokenIntrospection bool
|
|
allowPrivateIPAddresses bool
|
|
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
|
|
// validation (validateHost). Derived at construction time from a loopback
|
|
// providerURL (local development), never operator-set directly. Mirrors
|
|
// the allowLoopback derivation in updateMetadataEndpoints.
|
|
allowLoopbackHosts bool
|
|
disableReplayDetection bool
|
|
allowOpaqueTokens bool
|
|
strictAudienceValidation bool
|
|
overrideScopes bool
|
|
enablePKCE bool
|
|
forceHTTPS bool
|
|
suppressDiagnosticLogs bool
|
|
|
|
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
|
|
bearerIdentifierClaim string
|
|
bearerFailureTracker *bearerFailureTracker
|
|
maxTokenAge time.Duration
|
|
maxIdentifierLength int
|
|
bearerFailureThreshold int
|
|
bearerFailureWindow time.Duration
|
|
bearerFailurePenalty time.Duration
|
|
enableBearerAuth bool
|
|
stripAuthorizationHeader bool
|
|
bearerEmitWWWAuthenticate bool
|
|
bearerOverridesCookie bool
|
|
}
|