diff --git a/.traefik.yml b/.traefik.yml
index 22021f6..a05a12f 100644
--- a/.traefik.yml
+++ b/.traefik.yml
@@ -107,3 +107,8 @@ testData:
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
# bearerFailurePenaltySeconds: 60 # 429 + Retry-After duration after threshold trips
+ # Optional: RFC 8707 resource indicator, alternative to `audience` for
+ # supporting IdPs. See docs/RFC8707.md for exact request behavior and
+ # provider support.
+ # resource: https://api.example.com
+
diff --git a/README.md b/README.md
index 958ce3e..3a01244 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,8 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `maxRefreshTokenAgeSeconds` | `21600` | Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set `0` to disable. Tune to match your IdP's RT TTL. |
| `rateLimit` | `100` | Requests/sec. Min `10`. |
| `logLevel` | `info` | `debug`, `info`, `error`. |
-| `audience` | `clientID` | Custom access-token audience (Auth0 custom APIs). |
+| `audience` | `clientID`, or `resource` if set | Custom access-token audience (Auth0 custom APIs). |
+| `resource` | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs (see [docs/RFC8707.md](docs/RFC8707.md)). |
| `strictAudienceValidation` | `false` | Reject mismatched audiences. **Set `true` in production.** |
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
diff --git a/audience_test.go b/audience_test.go
index 5f4c314..4daa4c2 100644
--- a/audience_test.go
+++ b/audience_test.go
@@ -288,6 +288,10 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
+ // buildAuthURL sources the outbound `audience` param from
+ // explicitAudience, not audience (see url_helpers.go); an explicitly
+ // configured custom audience must set both to behave like production.
+ ts.tOidc.explicitAudience = customAudience
// Create ID token with aud = client_id (OIDC standard)
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
diff --git a/bearer_auth_test.go b/bearer_auth_test.go
index 3f253e8..005290a 100644
--- a/bearer_auth_test.go
+++ b/bearer_auth_test.go
@@ -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()
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 7ff8a51..37d6e1b 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -235,7 +235,8 @@ SSE / WebSocket connection.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
-| `audience` | string | `clientID` | Expected audience for access token validation |
+| `audience` | string | `clientID`, or `resource` if set | Expected audience for access token validation |
+| `resource` | string | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs — see [docs/RFC8707.md](RFC8707.md) |
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
@@ -247,6 +248,16 @@ audience: "https://my-api.example.com"
strictAudienceValidation: true
```
+#### RFC 8707 Resource Indicators
+
+```yaml
+resource: "https://api.example.com"
+```
+
+Alternative to `audience` for IdPs that implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707).
+See [docs/RFC8707.md](RFC8707.md) for exactly what request is made, why only a single
+resource is supported, and current per-provider support.
+
#### Opaque Token Support
```yaml
diff --git a/docs/RFC8707.md b/docs/RFC8707.md
new file mode 100644
index 0000000..92bd815
--- /dev/null
+++ b/docs/RFC8707.md
@@ -0,0 +1,54 @@
+# RFC 8707 Resource Indicators
+
+`resource` ([Config.Resource](../settings.go)) is an alternative to `audience` for IdPs that
+implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). See
+[docs/CONFIGURATION.md](CONFIGURATION.md#audience-validation) for the config reference; this
+document covers exactly what request is made and which providers support it.
+
+## What the plugin sends
+
+- One `resource` parameter on the authorization request (`buildAuthURL`), added before
+ `extraAuthParams` so an operator-configured `resource` key there cannot override or
+ duplicate it.
+- The same `resource` parameter on both the `authorization_code` and `refresh_token` grants
+ (`exchangeTokens`), per RFC 8707 §2.2 — so a refreshed token keeps the same audience binding
+ as the original grant.
+
+Only a single resource is supported, not a list. RFC 8707 allows a server to accept multiple
+`resource` values and scope the issued token to a subset of them, but no provider found to
+support the parameter at all handles that usefully: one provider tested rejects a request
+naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts
+multiple resources but issues an opaque access token bound to none of them. A single value
+avoids both failure modes.
+
+## Relationship to `audience`
+
+`resource` and `audience` both feed the same access-token audience check —
+`strictAudienceValidation` applies identically to either — but they produce different requests
+on the wire:
+
+- `audience`, when explicitly configured, is sent as a non-standard `audience` request
+ parameter (see [docs/AUTH0\_AUDIENCE\_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md)).
+- `resource` is sent as the RFC 8707 `resource` parameter instead.
+- If `resource` is set and `audience` is not, the expected-audience check defaults to the
+ resource value, but this is validation-only: it does not cause an `audience` parameter to be
+ sent. Setting both is allowed — e.g. as a hedge against a provider that ignores `resource` —
+ in which case both parameters are sent.
+
+## Provider support
+
+Verified 2026-09-02 (Zitadel reverified separately against a live instance). RFC 8707 requires
+a server that does not support `resource` to *ignore* it, but not every implementation does —
+verify against your specific provider and version before relying on it.
+
+| Provider | Support | Notes |
+|---|---|---|
+| node-oidc-provider | Supported | Via its `resourceIndicators` feature. |
+| Zitadel | Ignored, not rejected | `oidc.AuthRequest` has no `resource` field; confirmed against a live instance — `/oauth/v2/authorize` returns the same `302` with or without `resource`, including a spec-invalid value. |
+| Authentik | Rejected | Returns `invalid_target` ("Resource indicators are not supported"). |
+| Keycloak | Not supported (through 26.6) | Its own guidance is an audience mapper plus `scope` instead ([keycloak/keycloak#41526](https://github.com/keycloak/keycloak/issues/41526)). |
+| Ory Hydra / Fosite | Not implemented | Uses its own non-standard `audience` request parameter instead. |
+| Azure AD / Entra ID v2.0 (MSAL) | Reported to reject | When combined with v2-style `scope`, rather than ignoring it ([writeup](https://www.groff.dev/blog/azure-entra-id-mcp-server-authentication-incompatibilities)). Do not enable against an Entra ID v2 tenant without confirming. The v1.0 endpoint has a separate, pre-RFC `resource` parameter with similar but not identical semantics. |
+| Auth0 | Not verified | Documents a Resource Parameter Compatibility Profile mapping `resource` → `audience` ([Auth0 docs](https://auth0.com/ai/docs/mcp/guides/resource-param-compatibility-profile)). |
+| AWS Cognito | Not verified | Announced Oct 2025 for user-pool authorization-code/implicit grants ([announcement](https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-cognito-resource-indicators-protection-oauth-2-0-resources)). |
+| Okta, Google, GitLab | Unverified | No confirming documentation found either way. |
diff --git a/docs/index.html b/docs/index.html
index 254de40..2ad7977 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -735,9 +735,14 @@ spec:
audience |
- clientID |
+ clientID, or resource if set |
Custom audience for access token validation |
+
+ resource |
+ none |
+ RFC 8707 resource indicator, alternative to audience for supporting IdPs — see docs/RFC8707.md. |
+
strictAudienceValidation |
false |
diff --git a/helpers.go b/helpers.go
index f6c17b1..6412c9f 100644
--- a/helpers.go
+++ b/helpers.go
@@ -126,6 +126,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
data.Set("refresh_token", codeOrToken)
}
+ // RFC 8707 §2.2: repeat the same resource indicator sent on the
+ // authorization request so refreshed tokens keep the same audience
+ // binding as the original grant.
+ if t.resource != "" {
+ data.Set("resource", t.resource)
+ }
+
client := t.tokenHTTPClient
if client == nil {
// Use shared transport pool to prevent memory leaks
diff --git a/main.go b/main.go
index ec20579..952102d 100644
--- a/main.go
+++ b/main.go
@@ -243,8 +243,12 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
if config.Audience != "" {
return config.Audience
}
+ if config.Resource != "" {
+ return config.Resource
+ }
return config.ClientID
}(),
+ explicitAudience: config.Audience,
roleClaimName: func() string {
if config.RoleClaimName != "" {
return config.RoleClaimName
@@ -266,6 +270,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
forceHTTPS: config.ForceHTTPS,
enablePKCE: config.EnablePKCE,
extraAuthParams: config.ExtraAuthParams,
+ resource: config.Resource,
overrideScopes: config.OverrideScopes,
strictAudienceValidation: config.StrictAudienceValidation,
allowOpaqueTokens: config.AllowOpaqueTokens,
@@ -386,6 +391,11 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
t.logger.Debugf("No custom audience specified, using clientID as audience: %s", t.clientID)
}
+ // Log RFC 8707 resource indicator configuration
+ if t.resource != "" {
+ t.logger.Infof("RFC 8707 resource indicator configured: %s (effective audience: %s)", t.resource, t.audience)
+ }
+
// 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
@@ -393,9 +403,9 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
// scope for M2M), trusting email is a spoofing vector for federated IdPs.
// See spec §7.9 / §13.
if config.EnableBearerAuth {
- if config.Audience == "" {
+ if config.Audience == "" && config.Resource == "" {
cancelFunc()
- return nil, fmt.Errorf("EnableBearerAuth=true requires Audience to be set explicitly (cannot default to clientID — that path accepts ID tokens)")
+ return nil, fmt.Errorf("EnableBearerAuth=true requires Audience or Resource to be set explicitly (cannot default to clientID — that path accepts ID tokens)")
}
if t.bearerIdentifierClaim == "email" {
cancelFunc()
diff --git a/rfc8707_resource_test.go b/rfc8707_resource_test.go
new file mode 100644
index 0000000..053f6f4
--- /dev/null
+++ b/rfc8707_resource_test.go
@@ -0,0 +1,342 @@
+package traefikoidc
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestBuildAuthURLResourceParameter verifies the RFC 8707 `resource`
+// parameter is appended to the authorization URL, and that it does not
+// interact with the unrelated audience/extraAuthParams handling.
+func TestBuildAuthURLResourceParameter(t *testing.T) {
+ t.Run("resource is added", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ middleware.resource = "https://api.example.com"
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ parsed, err := url.Parse(authURL)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"https://api.example.com"}, parsed.Query()["resource"])
+ })
+
+ t.Run("empty resource is a no-op", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ // resource left unset
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ assert.NotContains(t, authURL, "resource=")
+ })
+
+ t.Run("a resource carrying a query component is percent-encoded, not merged", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ middleware.resource = "https://api.example.com/v1?tenant=acme"
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ parsed, err := url.Parse(authURL)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"https://api.example.com/v1?tenant=acme"}, parsed.Query()["resource"])
+ })
+
+ t.Run("extraAuthParams cannot override or duplicate the resource parameter", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ middleware.resource = "https://api.example.com"
+ middleware.extraAuthParams = map[string]string{
+ "resource": "https://attacker.example",
+ }
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ parsed, err := url.Parse(authURL)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"https://api.example.com"}, parsed.Query()["resource"])
+ assert.NotContains(t, authURL, "attacker.example")
+ })
+
+ // Regression: a Resource-only config derives a validation-time audience
+ // default (see TestAudienceDefaultsFromSingleResource), but that default
+ // must never be sent as the non-standard `audience` request parameter —
+ // manual interop testing found this made every resource-configured
+ // authorization request carry both `resource=` and `audience=`, which
+ // providers that give `audience` its own meaning (e.g. Ory Hydra) would
+ // misinterpret.
+ t.Run("a resource-derived audience default is not sent as the audience parameter", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ middleware.resource = "https://api.example.com"
+ middleware.audience = "https://api.example.com" // as main.go's default would set it
+ middleware.explicitAudience = "" // Config.Audience was left unset
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ assert.NotContains(t, authURL, "audience=")
+ })
+
+ t.Run("an explicitly configured audience is still sent alongside resource", func(t *testing.T) {
+ middleware := createMinimalMiddleware()
+ middleware.resource = "https://api.example.com"
+ middleware.audience = "https://explicit.example.com"
+ middleware.explicitAudience = "https://explicit.example.com"
+
+ authURL := middleware.buildAuthURL(
+ "https://app.com/callback", "state123", "nonce456", "",
+ )
+
+ parsed, err := url.Parse(authURL)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"https://explicit.example.com"}, parsed.Query()["audience"])
+ assert.Equal(t, []string{"https://api.example.com"}, parsed.Query()["resource"])
+ })
+}
+
+// newExchangeTestMiddleware builds a minimal TraefikOidc wired to server for
+// exchangeTokens tests.
+func newExchangeTestMiddleware(server *httptest.Server, resource string) *TraefikOidc {
+ return &TraefikOidc{
+ tokenURL: server.URL + "/token",
+ clientID: "test_client",
+ clientSecret: "test_secret",
+ audience: "test_client",
+ resource: resource,
+ tokenHTTPClient: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ logger: NewLogger("debug"),
+ initComplete: make(chan struct{}),
+ }
+}
+
+func newResourceEchoTokenServer(t *testing.T) (*httptest.Server, *url.Values) {
+ var captured url.Values
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ values, err := url.ParseQuery(string(body))
+ require.NoError(t, err)
+ captured = values
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(TokenResponse{
+ AccessToken: "test_access_token",
+ IDToken: "test_id_token",
+ RefreshToken: "test_refresh_token",
+ TokenType: "Bearer",
+ ExpiresIn: 3600,
+ })
+ }))
+ return server, &captured
+}
+
+// TestExchangeTokensResourceParameter verifies RFC 8707 §2.2: the same
+// resource indicator configured for the authorization request is also sent
+// on both the authorization_code and refresh_token grants.
+func TestExchangeTokensResourceParameter(t *testing.T) {
+ t.Run("authorization_code grant sends the resource parameter", func(t *testing.T) {
+ server, captured := newResourceEchoTokenServer(t)
+ defer server.Close()
+
+ middleware := newExchangeTestMiddleware(server, "https://api.example.com")
+
+ _, err := middleware.exchangeTokens(context.Background(), "authorization_code", "auth-code", "https://app.com/callback", "")
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"https://api.example.com"}, (*captured)["resource"])
+ })
+
+ t.Run("refresh_token grant sends the resource parameter", func(t *testing.T) {
+ server, captured := newResourceEchoTokenServer(t)
+ defer server.Close()
+
+ middleware := newExchangeTestMiddleware(server, "https://api.example.com")
+
+ _, err := middleware.exchangeTokens(context.Background(), "refresh_token", "refresh-token-value", "", "")
+ require.NoError(t, err)
+
+ assert.Equal(t, []string{"https://api.example.com"}, (*captured)["resource"])
+ })
+
+ t.Run("no resource configured sends no resource parameter", func(t *testing.T) {
+ server, captured := newResourceEchoTokenServer(t)
+ defer server.Close()
+
+ middleware := newExchangeTestMiddleware(server, "")
+
+ _, err := middleware.exchangeTokens(context.Background(), "authorization_code", "auth-code", "https://app.com/callback", "")
+ require.NoError(t, err)
+
+ assert.NotContains(t, *captured, "resource")
+ })
+}
+
+// TestConfigValidateResources exercises Config.Validate()'s RFC 8707
+// resource-indicator rules.
+func TestConfigValidateResources(t *testing.T) {
+ baseConfig := func() *Config {
+ return &Config{
+ ProviderURL: "https://provider.example.com",
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ CallbackURL: "/callback",
+ SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
+ RateLimit: 100,
+ }
+ }
+
+ tests := []struct {
+ name string
+ resource string
+ audience string
+ wantErr string // substring; empty means no error expected
+ }{
+ {
+ name: "unset resource, no audience needed",
+ resource: "",
+ },
+ {
+ name: "valid https resource, no audience needed",
+ resource: "https://api.example.com",
+ },
+ {
+ name: "valid urn resource",
+ resource: "urn:example:api",
+ },
+ {
+ name: "resource with unrelated explicit audience is allowed",
+ resource: "https://api.example.com",
+ audience: "https://explicit.example.com",
+ },
+ {
+ name: "fragment rejected",
+ resource: "https://api.example.com/v1#fragment",
+ wantErr: "must not contain a fragment",
+ },
+ {
+ name: "relative URI rejected",
+ resource: "/api/v1",
+ wantErr: "must be an absolute URI",
+ },
+ {
+ name: "non-loopback http rejected",
+ resource: "http://api.example.com",
+ wantErr: "must be a valid HTTPS URL",
+ },
+ {
+ name: "loopback http accepted",
+ resource: "http://127.0.0.1:8080/api",
+ },
+ {
+ name: "wildcard rejected",
+ resource: "https://*.example.com",
+ wantErr: "must not contain wildcards",
+ },
+ {
+ name: "control character rejected",
+ resource: "https://api.example.com/\n",
+ wantErr: "contains invalid characters",
+ },
+ {
+ name: "too long rejected",
+ resource: "https://api.example.com/" + stringOfLength(250),
+ wantErr: "must not exceed 256 characters",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := baseConfig()
+ cfg.Resource = tt.resource
+ cfg.Audience = tt.audience
+
+ err := cfg.Validate()
+ if tt.wantErr == "" {
+ assert.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func stringOfLength(n int) string {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = 'a'
+ }
+ return string(b)
+}
+
+// TestAudienceDefaultsFromSingleResource verifies main.go's audience
+// derivation: a configured resource becomes the effective validation
+// audience when Audience is unset, matching what the IdP will put in the
+// access token's `aud` claim.
+func TestAudienceDefaultsFromSingleResource(t *testing.T) {
+ baseConfig := func() *Config {
+ return &Config{
+ ProviderURL: "https://provider.example.com",
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ CallbackURL: "/callback",
+ SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
+ RateLimit: 100,
+ }
+ }
+
+ t.Run("resource, no audience: audience defaults to the resource", func(t *testing.T) {
+ cfg := baseConfig()
+ cfg.Resource = "https://api.example.com"
+
+ mw, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "test")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = mw.(*TraefikOidc).Close() })
+
+ assert.Equal(t, "https://api.example.com", mw.(*TraefikOidc).audience)
+ assert.Equal(t, "https://api.example.com", mw.(*TraefikOidc).resource)
+ // The default must be validation-only: Config.Audience was never set,
+ // so nothing should be sent as the outbound `audience` parameter.
+ assert.Empty(t, mw.(*TraefikOidc).explicitAudience)
+ })
+
+ t.Run("explicit audience wins over resource", func(t *testing.T) {
+ cfg := baseConfig()
+ cfg.Resource = "https://api.example.com"
+ cfg.Audience = "https://explicit.example.com"
+
+ mw, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "test")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = mw.(*TraefikOidc).Close() })
+
+ assert.Equal(t, "https://explicit.example.com", mw.(*TraefikOidc).audience)
+ assert.Equal(t, "https://explicit.example.com", mw.(*TraefikOidc).explicitAudience)
+ })
+
+ t.Run("no resource: audience defaults to clientID as before", func(t *testing.T) {
+ cfg := baseConfig()
+
+ mw, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "test")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = mw.(*TraefikOidc).Close() })
+
+ assert.Equal(t, "test-client", mw.(*TraefikOidc).audience)
+ })
+}
diff --git a/settings.go b/settings.go
index eedda23..8cc06f8 100644
--- a/settings.go
+++ b/settings.go
@@ -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 {
diff --git a/types.go b/types.go
index 2cc5430..456321a 100644
--- a/types.go
+++ b/types.go
@@ -168,14 +168,27 @@ type TraefikOidc struct {
// 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
+ 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
diff --git a/url_helpers.go b/url_helpers.go
index 3d3f649..c4f2c8e 100644
--- a/url_helpers.go
+++ b/url_helpers.go
@@ -71,13 +71,21 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
params.Set("state", state)
params.Set("nonce", nonce)
- // Add audience parameter for custom API audiences (e.g., Auth0 APIs)
- // This allows access tokens to have the correct audience claim
- // Only add if audience is configured and different from client_id
- // ID tokens will always have aud=client_id per OIDC spec
- if t.audience != "" && t.audience != t.clientID {
- params.Set("audience", t.audience)
- t.logger.Debugf("Adding audience parameter to authorize URL: %s", t.audience)
+ // 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 != "" {
diff --git a/url_helpers_ultra_test.go b/url_helpers_ultra_test.go
index 5bcd231..e5addae 100644
--- a/url_helpers_ultra_test.go
+++ b/url_helpers_ultra_test.go
@@ -407,7 +407,12 @@ func TestValidateURLEdgeCasesComprehensive(t *testing.T) {
func TestBuildAuthURLAudienceParameter(t *testing.T) {
t.Run("audience added when different from client_id", func(t *testing.T) {
middleware := createMinimalMiddleware()
+ // buildAuthURL sources the outbound `audience` param from
+ // explicitAudience (an explicitly configured Config.Audience), not
+ // from audience (the effective validation audience, which can also
+ // carry a Resource-derived default that must never reach the wire).
middleware.audience = "https://api.example.com"
+ middleware.explicitAudience = "https://api.example.com"
authURL := middleware.buildAuthURL(
"https://app.com/callback",
@@ -422,6 +427,7 @@ func TestBuildAuthURLAudienceParameter(t *testing.T) {
t.Run("audience not added when empty", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.audience = ""
+ middleware.explicitAudience = ""
authURL := middleware.buildAuthURL(
"https://app.com/callback",
@@ -436,6 +442,7 @@ func TestBuildAuthURLAudienceParameter(t *testing.T) {
t.Run("audience not added when equal to client_id", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.audience = middleware.clientID
+ middleware.explicitAudience = middleware.clientID
authURL := middleware.buildAuthURL(
"https://app.com/callback",