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.
444 lines
15 KiB
Go
444 lines
15 KiB
Go
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
|
// This file contains URL-related helper methods for building, validating, and processing URLs
|
|
// used in the OIDC authentication flow.
|
|
package traefikoidc
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// determineExcludedURL checks if a URL path should bypass OIDC authentication.
|
|
// It compares the request path against configured excluded URL prefixes.
|
|
// Parameters:
|
|
// - currentRequest: The request path to check.
|
|
//
|
|
// Returns:
|
|
// - true if the URL should be excluded from authentication, false otherwise.
|
|
func (t *TraefikOidc) determineExcludedURL(currentRequest string) bool {
|
|
for excludedURL := range t.excludedURLs {
|
|
if pathExcluded(currentRequest, excludedURL) {
|
|
t.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pathExcluded reports whether requestPath is covered by an excluded prefix at a
|
|
// natural boundary: an exact match, a sub-path ("/public" → "/public/x"), or a
|
|
// file extension ("/favicon" → "/favicon.ico"). It deliberately does NOT match
|
|
// an unrelated sibling such as "/publicsecret", so a configured exclusion can no
|
|
// longer be widened into an authentication bypass on a different resource.
|
|
func pathExcluded(requestPath, excluded string) bool {
|
|
excluded = strings.TrimRight(excluded, "/")
|
|
if excluded == "" {
|
|
// A "/" (root) exclusion only matches the root path, not everything.
|
|
return requestPath == "" || requestPath == "/"
|
|
}
|
|
if requestPath == excluded {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(requestPath, excluded) {
|
|
return false
|
|
}
|
|
switch requestPath[len(excluded)] {
|
|
case '/', '.':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// buildAuthURL constructs the OIDC provider authorization URL.
|
|
// It builds the URL with all necessary parameters including client_id, scopes,
|
|
// PKCE parameters, and provider-specific parameters for Google and Azure.
|
|
// Parameters:
|
|
// - redirectURL: The callback URL for after authentication.
|
|
// - state: The CSRF token for state validation.
|
|
// - nonce: The nonce for replay protection.
|
|
// - codeChallenge: The PKCE code challenge (if PKCE is enabled).
|
|
//
|
|
// Returns:
|
|
// - The fully constructed authorization URL string.
|
|
func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge string) string {
|
|
params := url.Values{}
|
|
params.Set("client_id", t.clientID)
|
|
params.Set("response_type", "code")
|
|
params.Set("redirect_uri", redirectURL)
|
|
params.Set("state", state)
|
|
params.Set("nonce", nonce)
|
|
|
|
// Non-standard `audience` param for custom API audiences (e.g. Auth0).
|
|
// Keyed on explicitAudience, not audience — see TraefikOidc.explicitAudience.
|
|
// Only add if configured and different from client_id; ID tokens always
|
|
// have aud=client_id per OIDC spec.
|
|
if t.explicitAudience != "" && t.explicitAudience != t.clientID {
|
|
params.Set("audience", t.explicitAudience)
|
|
t.logger.Debugf("Adding audience parameter to authorize URL: %s", t.explicitAudience)
|
|
}
|
|
|
|
// RFC 8707 resource indicator — see Config.Resource. Runs before
|
|
// extraAuthParams so an operator-configured "resource" key there cannot
|
|
// override or duplicate it.
|
|
if t.resource != "" {
|
|
params.Set("resource", t.resource)
|
|
t.logger.Debugf("Adding resource parameter to authorize URL: %s", t.resource)
|
|
}
|
|
|
|
if t.enablePKCE && codeChallenge != "" {
|
|
params.Set("code_challenge", codeChallenge)
|
|
params.Set("code_challenge_method", "S256")
|
|
}
|
|
|
|
scopes := make([]string, len(t.scopes))
|
|
copy(scopes, t.scopes)
|
|
|
|
// Apply discovery-based scope filtering if available
|
|
// Read scopesSupported with RLock
|
|
t.metadataMu.RLock()
|
|
scopesSupported := t.scopesSupported
|
|
t.metadataMu.RUnlock()
|
|
|
|
if t.scopeFilter != nil && len(scopesSupported) > 0 {
|
|
scopes = t.scopeFilter.FilterSupportedScopes(scopes, scopesSupported, t.providerURL)
|
|
t.logger.Debugf("TraefikOidc.buildAuthURL: After discovery filtering: %v", scopes)
|
|
}
|
|
|
|
// Then apply provider-specific modifications
|
|
if t.isGoogleProvider() {
|
|
// Google: Remove offline_access if present, add access_type=offline
|
|
filteredScopes := make([]string, 0, len(scopes))
|
|
for _, scope := range scopes {
|
|
if scope != "offline_access" {
|
|
filteredScopes = append(filteredScopes, scope)
|
|
}
|
|
}
|
|
scopes = filteredScopes
|
|
|
|
params.Set("access_type", "offline")
|
|
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
|
|
|
|
params.Set("prompt", "consent")
|
|
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
|
} else if t.isAzureProvider() {
|
|
params.Set("response_mode", "query")
|
|
t.logger.Debug("Azure AD provider detected, added response_mode=query")
|
|
|
|
hasOfflineAccess := false
|
|
|
|
for _, scope := range scopes {
|
|
if scope == "offline_access" {
|
|
hasOfflineAccess = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) {
|
|
if !hasOfflineAccess {
|
|
scopes = append(scopes, "offline_access")
|
|
t.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes))
|
|
}
|
|
} else {
|
|
t.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes))
|
|
}
|
|
} else {
|
|
if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) {
|
|
hasOfflineAccess := false
|
|
for _, scope := range scopes {
|
|
if scope == "offline_access" {
|
|
hasOfflineAccess = true
|
|
break
|
|
}
|
|
}
|
|
if !hasOfflineAccess {
|
|
scopes = append(scopes, "offline_access")
|
|
t.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes))
|
|
}
|
|
} else {
|
|
t.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes))
|
|
}
|
|
}
|
|
|
|
// Final filtering pass to remove anything the provider doesn't support
|
|
// Read scopesSupported with RLock
|
|
t.metadataMu.RLock()
|
|
scopesSupported = t.scopesSupported
|
|
t.metadataMu.RUnlock()
|
|
|
|
if t.scopeFilter != nil && len(scopesSupported) > 0 {
|
|
scopes = t.scopeFilter.FilterSupportedScopes(scopes, scopesSupported, t.providerURL)
|
|
t.logger.Debugf("TraefikOidc.buildAuthURL: After final filtering: %v", scopes)
|
|
}
|
|
|
|
if len(scopes) > 0 {
|
|
finalScopeString := strings.Join(scopes, " ")
|
|
params.Set("scope", finalScopeString)
|
|
t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
|
|
}
|
|
|
|
// Apply operator-configured extra authorization parameters (e.g.
|
|
// screen_hint, login_hint, ui_locales, prompt). These are added last but
|
|
// can never override parameters the plugin itself manages (client_id,
|
|
// state, nonce, redirect_uri, code_challenge, scope, response_type, ...):
|
|
// a key already present in params is left untouched, so this cannot
|
|
// weaken security-critical parameters.
|
|
for key, value := range t.extraAuthParams {
|
|
if params.Get(key) == "" {
|
|
params.Set(key, value)
|
|
t.logger.Debugf("TraefikOidc.buildAuthURL: Added extra auth param %s", key)
|
|
} else {
|
|
t.logger.Debugf("TraefikOidc.buildAuthURL: Skipped extra auth param %s (already set by plugin)", key)
|
|
}
|
|
}
|
|
|
|
// Read authURL with RLock
|
|
t.metadataMu.RLock()
|
|
authURL := t.authURL
|
|
t.metadataMu.RUnlock()
|
|
|
|
return t.buildURLWithParams(authURL, params)
|
|
}
|
|
|
|
// buildURLWithParams constructs a URL by combining a base URL with query parameters.
|
|
// It handles both relative and absolute URLs, validates URL security,
|
|
// and properly encodes query parameters.
|
|
// Parameters:
|
|
// - baseURL: The base URL to append parameters to.
|
|
// - params: The query parameters to append.
|
|
//
|
|
// Returns:
|
|
// - The fully constructed URL string with appended query parameters.
|
|
func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string {
|
|
if baseURL != "" {
|
|
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
|
|
if err := t.validateURL(baseURL); err != nil {
|
|
t.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
|
// Read issuerURL with RLock
|
|
t.metadataMu.RLock()
|
|
issuerURL := t.issuerURL
|
|
t.metadataMu.RUnlock()
|
|
|
|
issuerURLParsed, err := url.Parse(issuerURL)
|
|
if err != nil {
|
|
t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", issuerURL, err)
|
|
return ""
|
|
}
|
|
|
|
baseURLParsed, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
t.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err)
|
|
return ""
|
|
}
|
|
|
|
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
|
|
|
|
if err := t.validateURL(resolvedURL.String()); err != nil {
|
|
t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
|
|
return ""
|
|
}
|
|
|
|
resolvedURL.RawQuery = params.Encode()
|
|
return resolvedURL.String()
|
|
}
|
|
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
|
|
return ""
|
|
}
|
|
|
|
if err := t.validateParsedURL(u); err != nil {
|
|
t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
|
|
return ""
|
|
}
|
|
|
|
u.RawQuery = params.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// validateURL performs security validation on URLs to prevent SSRF attacks.
|
|
// It checks for allowed schemes, validates hosts, and prevents access to private networks.
|
|
// Parameters:
|
|
// - urlStr: The URL string to validate.
|
|
//
|
|
// Returns:
|
|
// - An error if the URL is invalid or poses security risks, nil if valid.
|
|
func (t *TraefikOidc) validateURL(urlStr string) error {
|
|
if urlStr == "" {
|
|
return fmt.Errorf("empty URL")
|
|
}
|
|
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL format: %w", err)
|
|
}
|
|
|
|
return t.validateParsedURL(u)
|
|
}
|
|
|
|
// validateParsedURL validates a parsed URL structure for security.
|
|
// It checks schemes, hosts, and paths to prevent malicious URLs.
|
|
// Parameters:
|
|
// - u: The parsed URL to validate.
|
|
//
|
|
// Returns:
|
|
// - An error if the URL is invalid or dangerous, nil if safe.
|
|
func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
|
|
allowedSchemes := map[string]bool{
|
|
"https": true,
|
|
"http": true,
|
|
}
|
|
|
|
if !allowedSchemes[u.Scheme] {
|
|
return fmt.Errorf("disallowed URL scheme: %s", u.Scheme)
|
|
}
|
|
|
|
if u.Scheme == "http" {
|
|
t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
|
|
}
|
|
|
|
if u.Host == "" {
|
|
return fmt.Errorf("missing host in URL")
|
|
}
|
|
|
|
if err := t.validateHost(u.Host); err != nil {
|
|
return fmt.Errorf("invalid host: %w", err)
|
|
}
|
|
|
|
if strings.Contains(u.Path, "..") {
|
|
return fmt.Errorf("path traversal detected in URL path")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateDiscoveredEndpoint validates an endpoint URL obtained from the
|
|
// provider's OIDC/OAuth2 discovery document before the plugin issues any
|
|
// outbound request to it. A discovery document is attacker-influenced if the
|
|
// provider is malicious or its TLS is broken, so an unvalidated endpoint is an
|
|
// SSRF vector (e.g. jwks_uri or introspection_endpoint pointed at the cloud
|
|
// metadata service 169.254.169.254 or an internal host).
|
|
//
|
|
// Empty endpoints are allowed (they are optional). Link-local (which covers the
|
|
// 169.254.0.0/16 metadata range), multicast and unspecified addresses are
|
|
// always rejected. Private addresses are rejected unless allowPrivateIPAddresses
|
|
// is set. Loopback is rejected unless allowLoopback is true — which the caller
|
|
// sets only when the operator-configured providerURL is itself loopback (local
|
|
// development, in-cluster sidecars, tests), so production deployments pointed at
|
|
// a real provider still block loopback SSRF.
|
|
func (t *TraefikOidc) validateDiscoveredEndpoint(urlStr string, allowLoopback bool) error {
|
|
if urlStr == "" {
|
|
return nil
|
|
}
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL format: %w", err)
|
|
}
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return fmt.Errorf("disallowed URL scheme: %q", u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return fmt.Errorf("missing host in URL")
|
|
}
|
|
if ip := net.ParseIP(u.Hostname()); ip != nil {
|
|
switch {
|
|
case ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified():
|
|
return fmt.Errorf("endpoint host is a blocked address: %s", ip)
|
|
case ip.IsLoopback() && !allowLoopback:
|
|
return fmt.Errorf("endpoint host is a loopback address: %s", ip)
|
|
case ip.IsPrivate() && !t.allowPrivateIPAddresses:
|
|
return fmt.Errorf("endpoint host is a private address: %s", ip)
|
|
}
|
|
}
|
|
if strings.Contains(u.Path, "..") {
|
|
return fmt.Errorf("path traversal detected in URL path")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sameHost reports whether two URLs share the same host:port (case-insensitive).
|
|
// Used to pin the credential-bearing introspection endpoint to the operator-
|
|
// configured provider so a poisoned discovery document cannot redirect the
|
|
// client secret to an attacker-controlled host.
|
|
func sameHost(a, b string) bool {
|
|
ua, erra := url.Parse(a)
|
|
ub, errb := url.Parse(b)
|
|
if erra != nil || errb != nil || ua.Host == "" || ub.Host == "" {
|
|
return false
|
|
}
|
|
return strings.EqualFold(ua.Host, ub.Host)
|
|
}
|
|
|
|
// validateHost validates a hostname or IP address for security.
|
|
// It prevents access to localhost, private networks, and known metadata endpoints.
|
|
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
|
|
// Parameters:
|
|
// - host: The host string to validate (may include port).
|
|
//
|
|
// Returns:
|
|
// - An error if the host is dangerous or not allowed, nil if safe.
|
|
func (t *TraefikOidc) validateHost(host string) error {
|
|
hostname := host
|
|
if strings.Contains(host, ":") {
|
|
var err error
|
|
hostname, _, err = net.SplitHostPort(host)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid host format: %w", err)
|
|
}
|
|
}
|
|
|
|
ip := net.ParseIP(hostname)
|
|
if ip != nil {
|
|
// Loopback addresses are blocked by default, but allowed when the
|
|
// plugin was constructed with a loopback providerURL (local
|
|
// development) — see allowLoopbackHosts.
|
|
if ip.IsLoopback() && !t.allowLoopbackHosts {
|
|
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
|
|
}
|
|
|
|
// Link-local and multicast addresses are always blocked.
|
|
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
|
|
}
|
|
|
|
// Skip private IP check if allowPrivateIPAddresses is enabled
|
|
if !t.allowPrivateIPAddresses && ip.IsPrivate() {
|
|
return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String())
|
|
}
|
|
|
|
if ip.IsUnspecified() || ip.IsMulticast() {
|
|
return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String())
|
|
}
|
|
}
|
|
|
|
// Always blocked regardless of allowLoopbackHosts: these have no IP-branch
|
|
// backstop (net.ParseIP returns nil for hostnames), so bypassing them
|
|
// would open an SSRF path to the cloud metadata service.
|
|
alwaysDangerousHosts := map[string]bool{
|
|
"0.0.0.0": true,
|
|
"169.254.169.254": true,
|
|
"metadata.google.internal": true,
|
|
}
|
|
|
|
if alwaysDangerousHosts[strings.ToLower(hostname)] {
|
|
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
|
|
}
|
|
|
|
// localhost/loopback literals are blocked unless allowLoopbackHosts
|
|
// permits them (see allowLoopbackHosts doc comment on TraefikOidc).
|
|
if !t.allowLoopbackHosts && isLoopbackHost(hostname) {
|
|
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
|
|
}
|
|
|
|
return nil
|
|
}
|