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",