mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-16 04:29:15 +00:00
feat: add RFC 8707 resource indicator via resource config (#161)
* 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.
This commit is contained in:
+63
-21
@@ -64,9 +64,19 @@ type Config struct {
|
||||
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
|
||||
// template validation and the runtime get helper. Built-in claims (email,
|
||||
// groups, roles, realm_access, ...) need not be listed.
|
||||
AllowedClaims []string `json:"allowedClaims,omitempty"`
|
||||
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
|
||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
||||
AllowedClaims []string `json:"allowedClaims,omitempty"`
|
||||
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
|
||||
// Resource declares an RFC 8707 resource indicator: an alternative to
|
||||
// Audience for IdPs that support it. strictAudienceValidation applies
|
||||
// the same way regardless of which of the two is set; only the request
|
||||
// made to the IdP differs. Only a single resource is supported. See
|
||||
// docs/RFC8707.md for exact request behavior, why multiple resources
|
||||
// aren't, and current provider support. When Resource is set and
|
||||
// Audience is unset, Audience defaults to Resource for access-token
|
||||
// validation; this does not affect what is sent on the wire — see
|
||||
// TraefikOidc.explicitAudience.
|
||||
Resource string `json:"resource,omitempty"`
|
||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
||||
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
|
||||
// a stored refresh token. Once the token has been in the session longer
|
||||
// than this, requests treat it as expired up-front - returning 401 to
|
||||
@@ -75,30 +85,30 @@ type Config struct {
|
||||
// IdPs do not expose RT TTL on the wire, so this is intentionally a
|
||||
// conservative heuristic; tune to match your provider configuration.
|
||||
// Default 21600 (6h). Set to 0 to disable the check.
|
||||
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
|
||||
SessionMaxAge int `json:"sessionMaxAge"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
OverrideScopes bool `json:"overrideScopes"`
|
||||
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
||||
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
||||
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
||||
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
||||
EnablePKCE bool `json:"enablePKCE"`
|
||||
ForceHTTPS bool `json:"forceHTTPS"`
|
||||
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
||||
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
|
||||
SessionMaxAge int `json:"sessionMaxAge"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
OverrideScopes bool `json:"overrideScopes"`
|
||||
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
||||
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
||||
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
||||
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
||||
EnablePKCE bool `json:"enablePKCE"`
|
||||
ForceHTTPS bool `json:"forceHTTPS"`
|
||||
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
||||
// CookiePath restricts session cookies to a specific path prefix instead of "/".
|
||||
// When traefikoidc protects some but not all paths on a domain, set this to the
|
||||
// middleware's path prefix (e.g. "/app-protegido") so the browser does not send
|
||||
// the OIDC session cookies to unprotected paths — preventing "Request Header
|
||||
// Or Cookie Too Large" (431) errors on those paths.
|
||||
// Default "/" (all paths, current behaviour).
|
||||
CookiePath string `json:"cookiePath,omitempty"`
|
||||
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
||||
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
||||
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
||||
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
||||
CookiePath string `json:"cookiePath,omitempty"`
|
||||
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
||||
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
||||
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
||||
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
||||
// CACertPath is an optional filesystem path to a PEM-encoded CA bundle used
|
||||
// to verify the OIDC provider's TLS certificate. Use this when the provider
|
||||
// is signed by an internal/private CA that is not in the system trust store.
|
||||
@@ -544,6 +554,38 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the RFC 8707 resource indicator if specified
|
||||
if c.Resource != "" {
|
||||
resource := c.Resource
|
||||
if len(resource) > 256 {
|
||||
return fmt.Errorf("resource %q must not exceed 256 characters", resource)
|
||||
}
|
||||
if strings.Contains(resource, "*") {
|
||||
return fmt.Errorf("resource %q must not contain wildcards", resource)
|
||||
}
|
||||
if strings.ContainsAny(resource, "\n\r\t\x00") {
|
||||
return fmt.Errorf("resource %q contains invalid characters", resource)
|
||||
}
|
||||
|
||||
// RFC 8707 §2: the resource parameter MUST NOT include a fragment.
|
||||
if strings.Contains(resource, "#") {
|
||||
return fmt.Errorf("resource %q must not contain a fragment", resource)
|
||||
}
|
||||
|
||||
u, err := url.Parse(resource)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return fmt.Errorf("resource %q must be an absolute URI", resource)
|
||||
}
|
||||
|
||||
// Enforce HTTPS (loopback HTTP permitted) for http/https resources;
|
||||
// other absolute schemes (e.g. urn:) are accepted as-is.
|
||||
if u.Scheme == "http" || u.Scheme == "https" {
|
||||
if !isValidSecureURL(resource) {
|
||||
return fmt.Errorf("resource %q must be a valid HTTPS URL", resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Redis configuration if provided
|
||||
if c.Redis != nil && c.Redis.Enabled {
|
||||
if err := c.Redis.Validate(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user