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:
Alex Sedov
2026-09-10 14:49:53 +01:00
committed by GitHub
parent 61e60733a5
commit d3012d5169
14 changed files with 568 additions and 41 deletions
+18
View File
@@ -800,6 +800,24 @@ func TestStartupValidation_BearerRequiresAudience(t *testing.T) {
}
}
func TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.ProviderURL = "https://issuer.example.com"
cfg.ClientID = "id"
cfg.ClientSecret = "secret"
cfg.CallbackURL = "/oauth/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
cfg.EnableBearerAuth = true
cfg.Audience = ""
cfg.Resource = "https://api.example.com"
mw, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
if err != nil {
t.Fatalf("expected Resource to satisfy the bearer-auth audience requirement, got %v", err)
}
defer mw.(*TraefikOidc).Close()
}
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
t.Parallel()
cfg := CreateConfig()