Compare commits

..
6 Commits
Author SHA1 Message Date
github-actions[bot] 124601d559 chore(release): v1.0.36 2026-09-10 13:52:07 +00:00
Alex SedovandGitHub d3012d5169 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.
2026-09-10 14:49:53 +01:00
Martin FreyandGitHub 61e60733a5 fix: workaround for yaegi type checking using reflection (#159) 2026-09-02 16:42:47 +01:00
Martin FreyandGitHub 7e44725edc fix: avoid nil dereference in Close (#157) 2026-08-28 00:47:30 +01:00
15dba0e8d3 feat: add cookiePath config to scope session cookies to subpath (#143)
Fixes #122.

Co-authored-by: Hermes Agent <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-19 17:49:52 +01:00
lukaszraczylo f728f61777 fix(config): wire introspectionURL override and repair dead endpoint overrides (#152)
Issue #152 asks for a manual introspection endpoint because some IdPs
omit introspection_endpoint from discovery. Analysis found the two
documented overrides revocationURL and oidcEndSessionURL were dead
code: the 1b49e13 rebuild dropped their constructor wiring.

Add the IntrospectionURL config field and wire all three overrides.
The overrides apply inside updateMetadataEndpoints, so they survive
every metadata refresh. A manual value always wins over discovery.
The constructor also seeds the live fields, so a failed discovery
does not lose operator configuration.

Manual values skip the poisoned-discovery defences on purpose. They
sit at the same trust tier as providerURL and isValidSecureURL gates
them at config time.

Add issue152_regression_test.go: 8 tests pin precedence, refresh
survival, cold-start seeding, end-to-end introspection over HTTP,
and the validation contract.
2026-08-09 01:59:47 +01:00
19 changed files with 1234 additions and 65 deletions
+6
View File
@@ -100,9 +100,15 @@ testData:
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
# bearerOverridesCookie: false # default; cookie wins when both are present
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
# introspectionURL: https://idp.example.com/oauth/introspect # manual override; set when discovery omits introspection_endpoint
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
# 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
+3 -1
View File
@@ -121,12 +121,14 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `enablePKCE` | `false` | PKCE on the auth code flow. |
| `cookieDomain` | auto | Set explicitly for multi-subdomain setups (`.example.com`). |
| `cookiePrefix` | `_oidc_raczylo_` | Unique prefix per middleware instance to isolate sessions. |
| `cookiePath` | `/` | Restrict cookies to a path prefix. Set to the middleware's path (e.g. `/app`) to prevent the browser from sending OIDC cookies to unprotected paths, avoiding 431 "Request Header Or Cookie Too Large" errors on mixed-use domains. |
| `sessionMaxAge` | `86400` | Session lifetime in seconds. |
| `refreshGracePeriodSeconds` | `60` | Proactively refresh tokens this many seconds before expiry. |
| `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. |
+4
View File
@@ -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{}{
+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()
+9 -2
View File
@@ -298,13 +298,20 @@ strictAudienceValidation: true
```
Look for `introspection_endpoint`
2. **Disable required introspection (less secure):**
2. **Configure the endpoint manually:**
```yaml
introspectionURL: https://YOUR_DOMAIN/oauth/introspect
```
Set this when Auth0 omits `introspection_endpoint` from discovery. The
manual value takes precedence over the discovered value.
3. **Disable required introspection (less secure):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: false # Falls back to ID token
```
3. **Use JWT access tokens instead** (recommended)
4. **Use JWT access tokens instead** (recommended)
---
+15 -3
View File
@@ -179,8 +179,9 @@ clientSecret: your-client-secret
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
| `rateLimit` | int | `100` | Maximum requests per second |
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
@@ -234,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 |
@@ -246,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
+54
View File
@@ -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. |
+6 -1
View File
@@ -735,9 +735,14 @@ spec:
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code></td>
<td class="py-2 px-3">clientID</td>
<td class="py-2 px-3">clientID, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code> if set</td>
<td class="py-2 px-3">Custom audience for access token validation</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">RFC 8707 resource indicator, alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code> for supporting IdPs — see docs/RFC8707.md.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
<td class="py-2 px-3">false</td>
+7
View File
@@ -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
+568
View File
@@ -0,0 +1,568 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// issue152_regression_test.go — regression tests for issue #152.
//
// Three provider endpoints — token revocation, OIDC end-session (logout), and
// RFC 7662 token introspection — were documented Config fields
// (RevocationURL, OIDCEndSessionURL) or a brand-new one (IntrospectionURL)
// that operators can set when their IdP omits an endpoint from discovery, or
// to pin a non-standard one. RevocationURL and OIDCEndSessionURL were dead:
// Config.Validate() checked them but nothing ever copied them onto the live
// TraefikOidc fields consumers read, so setting them silently did nothing.
// IntrospectionURL did not exist at all.
//
// The fix adds three unexported write-once fields on TraefikOidc
// (configRevocationURL, configEndSessionURL, configIntrospectionURL),
// populated from Config at construction (main.go NewWithContext). The same
// three values also cold-start-seed the live fields (revocationURL,
// endSessionURL, introspectionURL) so a discovery failure does not lose an
// operator override. Inside updateMetadataEndpoints, after the SSRF sanitize
// pass, the introspection same-host pin, and the issuer pin — but before the
// metadataMu.Lock() that publishes the live fields — each non-empty config*
// field overwrites the corresponding metadata.* value, so a manual override
// wins over discovery on every refresh, not just at construction. An empty
// override leaves the discovered value alone. Manual overrides deliberately
// skip validateDiscoveredEndpoint and the introspection same-host pin: they
// sit at the same trust tier as providerURL itself, already gated by
// isValidSecureURL at config-validation time.
//
// These tests pin: default (no overrides) behavior is unchanged, overrides
// win over both absent and present discovered values, a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host endpoint does not, overrides survive repeated metadata
// refreshes, NewWithContext wires the three config* fields and cold-start
// seeds correctly (including when discovery fails outright), the override
// is honored end-to-end by introspectToken and RevokeTokenWithProvider, the
// logout URL builder emits the override, and Config.Validate / New enforce
// the same "invalid configuration" + untyped-nil-handler contract (#151)
// for a bad IntrospectionURL that the sibling fields already had.
// issue152BaseConfig returns a complete, valid Config, following the
// minimal-valid-config pattern used elsewhere in this package (e.g.
// issue154BaseConfig in issue154_regression_test.go).
func issue152BaseConfig() *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,
}
}
// issue152Endpoints reads the three metadataMu-guarded live endpoint fields
// under lock, so callers stay race-clean regardless of whether a background
// discovery goroutine may still touch them.
func issue152Endpoints(oidc *TraefikOidc) (revocation, endSession, introspection string) {
oidc.metadataMu.RLock()
defer oidc.metadataMu.RUnlock()
return oidc.revocationURL, oidc.endSessionURL, oidc.introspectionURL
}
// TestIssue152DefaultPreservation proves the fix is additive: with no
// operator overrides, updateMetadataEndpoints behaves exactly as before —
// discovered values become the live fields and the published snapshot.
func TestIssue152DefaultPreservation(t *testing.T) {
t.Run("discovered values populate live fields and snapshot", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/revoke",
EndSessionURL: "https://provider.example.com/endsession",
IntrospectionURL: "https://provider.example.com/introspect",
RegistrationURL: "https://provider.example.com/register",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != metadata.RevokeURL {
t.Fatalf("revocationURL = %q, want discovered %q", revocation, metadata.RevokeURL)
}
if endSession != metadata.EndSessionURL {
t.Fatalf("endSessionURL = %q, want discovered %q", endSession, metadata.EndSessionURL)
}
if introspection != metadata.IntrospectionURL {
t.Fatalf("introspectionURL = %q, want discovered %q", introspection, metadata.IntrospectionURL)
}
snap := oidc.metadataSnap()
if snap == nil {
t.Fatal("metadataSnap() returned nil after updateMetadataEndpoints")
}
if snap.RevocationURL != metadata.RevokeURL || snap.EndSessionURL != metadata.EndSessionURL || snap.IntrospectionURL != metadata.IntrospectionURL {
t.Fatalf("metadataSnapshot = %+v, want it to match discovered metadata %+v", snap, metadata)
}
})
t.Run("discovery omits introspection and no override leaves it empty", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, _, introspection := issue152Endpoints(oidc)
if introspection != "" {
t.Fatalf("introspectionURL = %q, want empty when discovery omits it and no override is set", introspection)
}
})
}
// TestIssue152OverridesWin proves a non-empty config* override always wins
// over discovery, whether discovery is silent on the endpoint (a) or
// actively supplies a different value (b), and that a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host introspection endpoint does not (c).
func TestIssue152OverridesWin(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
newOverriddenOidc := func() *TraefikOidc {
return &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
}
t.Run("discovery omits endpoints, overrides fill them in", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q)",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
snap := oidc.metadataSnap()
if snap == nil || snap.RevocationURL != overrideRevocation || snap.EndSessionURL != overrideEndSession || snap.IntrospectionURL != overrideIntrospection {
t.Fatalf("metadataSnapshot = %+v, want overrides", snap)
}
})
t.Run("discovery supplies different values, overrides win anyway", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/discovered-revoke",
EndSessionURL: "https://provider.example.com/discovered-endsession",
IntrospectionURL: "https://provider.example.com/discovered-introspect",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to beat discovered values",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("cross-host introspection: discovered is zeroed, override survives", func(t *testing.T) {
// No override: a discovered introspection endpoint on a different host
// than providerURL must be zeroed by the same-host pin.
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
IntrospectionURL: "https://attacker.example.com/introspect",
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != "" {
t.Fatalf("introspectionURL = %q, want empty: discovered cross-host introspection must be zeroed by the same-host pin", introspection)
}
// Same cross-host discovered value, but now with a cross-host override:
// the override must survive because it skips the same-host pin.
oidcWithOverride := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configIntrospectionURL: overrideIntrospection,
}
oidcWithOverride.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidcWithOverride); introspection != overrideIntrospection {
t.Fatalf("introspectionURL = %q, want override %q to survive despite being cross-host", introspection, overrideIntrospection)
}
})
}
// TestIssue152RefreshSurvival proves overrides are re-applied inside
// updateMetadataEndpoints itself, so they survive every metadata refresh —
// not just the first call after construction.
func TestIssue152RefreshSurvival(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
firstRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh1-revoke",
EndSessionURL: "https://provider.example.com/refresh1-endsession",
IntrospectionURL: "https://provider.example.com/refresh1-introspect",
}
oidc.updateMetadataEndpoints(firstRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 1st refresh: got (%q, %q, %q), want overrides to hold", revocation, endSession, introspection)
}
secondRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh2-revoke",
EndSessionURL: "https://provider.example.com/refresh2-endsession",
IntrospectionURL: "https://provider.example.com/refresh2-introspect",
}
oidc.updateMetadataEndpoints(secondRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 2nd refresh: got (%q, %q, %q), want overrides to still hold", revocation, endSession, introspection)
}
}
// TestIssue152NewWithContextWiring proves NewWithContext wires
// Config.RevocationURL / OIDCEndSessionURL / IntrospectionURL onto the three
// unexported config* fields and cold-start-seeds the live fields from them
// (main.go, the configRevocationURL/configEndSessionURL/configIntrospectionURL
// and revocationURL/endSessionURL/introspectionURL initializers), both when
// discovery succeeds with different values (override must still win) and
// when discovery fails outright (the cold-start seed must not be lost).
func TestIssue152NewWithContextWiring(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
t.Run("discovery succeeds with different values, overrides still win", func(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ProviderMetadata{
Issuer: srv.URL,
AuthURL: srv.URL + "/auth",
TokenURL: srv.URL + "/token",
JWKSURL: srv.URL + "/jwks",
RevokeURL: srv.URL + "/discovered-revoke",
EndSessionURL: srv.URL + "/discovered-endsession",
IntrospectionURL: srv.URL + "/discovered-introspect",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-success")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config with overrides, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // wait for the discovery goroutine so field reads below are race-safe.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to win over successful discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("discovery fails outright, cold-start seed holds", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-coldstart")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config even though discovery will fail, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // discovery has failed and returned by now.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want the constructor's cold-start seed (%q, %q, %q) to survive a failed discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
}
// TestIssue152EndToEndIntrospection proves the override is honored one layer
// up from the field: with a discovery document that omits
// introspection_endpoint entirely, introspectToken (token_introspection.go)
// must still reach the manually-configured endpoint and return its result.
func TestIssue152EndToEndIntrospection(t *testing.T) {
var hits int32
var gotToken string
introspectionSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"active": true})
}))
defer introspectionSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configIntrospectionURL: introspectionSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted: the override is the only
// source of the endpoint.
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != introspectionSrv.URL {
t.Fatalf("introspectionURL = %q, want override %q wired before calling introspectToken", introspection, introspectionSrv.URL)
}
resp, err := oidc.introspectToken("opaque-test-token")
if err != nil {
t.Fatalf("introspectToken must succeed against the manual override endpoint, got: %v", err)
}
if !resp.Active {
t.Fatal("introspectToken response Active = false, want true")
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("introspection endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("introspection request token = %q, want %q", gotToken, "opaque-test-token")
}
}
// TestIssue152SiblingRevocationAndLogout proves the two dead siblings this
// issue repaired are wired end-to-end: RevokeTokenWithProvider
// (token_manager.go) posts to a configRevocationURL override, and the
// end-session URL BuildLogoutURL (helpers.go) builds from is the
// configEndSessionURL override. The full handleLogout HTTP handler needs a
// live SessionManager and an authenticated cookie to reach BuildLogoutURL,
// which is disproportionate for this fix; instead this asserts the live
// field wiring plus a direct BuildLogoutURL call with that wired value,
// which is the same function handleLogout calls.
func TestIssue152SiblingRevocationAndLogout(t *testing.T) {
t.Run("RevokeTokenWithProvider posts to the override", func(t *testing.T) {
var hits int32
var gotToken string
revocationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.WriteHeader(http.StatusOK)
}))
defer revocationSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configRevocationURL: revocationSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// RevokeURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
if err := oidc.RevokeTokenWithProvider("opaque-test-token", "access_token"); err != nil {
t.Fatalf("RevokeTokenWithProvider must succeed against the manual override endpoint, got: %v", err)
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("revocation endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("revocation request token = %q, want %q", gotToken, "opaque-test-token")
}
})
t.Run("logout URL builder emits the override", func(t *testing.T) {
overrideEndSession := "https://override.example.com/endsession"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configEndSessionURL: overrideEndSession,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// EndSessionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, endSession, _ := issue152Endpoints(oidc)
if endSession != overrideEndSession {
t.Fatalf("endSessionURL = %q, want override %q wired before building the logout URL", endSession, overrideEndSession)
}
logoutURL, err := BuildLogoutURL(endSession, "idtok123", "https://myapp.example.com/")
if err != nil {
t.Fatalf("BuildLogoutURL must succeed with the wired override, got: %v", err)
}
if !strings.HasPrefix(logoutURL, overrideEndSession+"?") {
t.Fatalf("logoutURL = %q, want prefix %q", logoutURL, overrideEndSession+"?")
}
if !strings.Contains(logoutURL, "id_token_hint=idtok123") {
t.Fatalf("logoutURL = %q, want it to contain the id_token_hint", logoutURL)
}
})
}
// TestIssue152ConfigValidation pins Config.Validate's IntrospectionURL check:
// empty is fine (feature is opt-in), a valid HTTPS URL is fine, plaintext
// HTTP is fine only for a loopback host (isValidSecureURL's RFC 8252-style
// allowance, mirrored from the ProviderURL/RevocationURL/OIDCEndSessionURL
// checks), and anything else — plaintext HTTP to a remote host, or a
// malformed URL — is rejected with the introspectionURL-specific message.
func TestIssue152ConfigValidation(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
errContains string
}{
{"valid HTTPS URL", "https://idp.example.com/introspect", false, ""},
{"empty is valid (opt-in feature)", "", false, ""},
{"plaintext HTTP allowed for loopback", "http://localhost:8080/introspect", false, ""},
{"plaintext HTTP rejected for remote host", "http://evil.example.com/x", true, "introspectionURL must be a valid HTTPS URL"},
{"malformed URL rejected", "://garbage", true, "introspectionURL must be a valid HTTPS URL"},
{"URL with no host rejected", "https:///nohost", true, "introspectionURL must be a valid HTTPS URL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = tc.value
err := cfg.Validate()
if tc.wantErr {
if err == nil {
t.Fatalf("Validate() with IntrospectionURL=%q must fail", tc.value)
}
if !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.errContains)
}
return
}
if err != nil {
t.Fatalf("Validate() with IntrospectionURL=%q must succeed, got: %v", tc.value, err)
}
})
}
}
// TestIssue152InvalidIntrospectionURLNewContract proves an invalid
// IntrospectionURL is rejected by New() through the same fail-closed,
// untyped-nil-handler contract issue #151 established for every other
// Config.Validate failure: a concrete "invalid configuration" error, and h
// itself must be an untyped nil (not a typed-nil *TraefikOidc boxed into a
// non-nil http.Handler interface value).
func TestIssue152InvalidIntrospectionURLNewContract(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = "http://evil.example.com/x" // plaintext HTTP to a remote host: rejected.
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "issue152-invalid-introspection")
if err == nil {
t.Fatal("New() must reject an invalid IntrospectionURL")
}
if !strings.Contains(err.Error(), "invalid configuration") {
t.Fatalf("New() error = %q, want it to contain %q", err.Error(), "invalid configuration")
}
if h != nil {
t.Fatalf("New() must return an untyped nil handler on error, got %T", h)
}
}
+45 -2
View File
@@ -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,11 +270,27 @@ 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,
requireTokenIntrospection: config.RequireTokenIntrospection,
disableReplayDetection: config.DisableReplayDetection,
// Operator-configured endpoint overrides (issue #152). Applied inside
// updateMetadataEndpoints so the override survives every metadata
// refresh, not just construction.
configRevocationURL: config.RevocationURL,
configEndSessionURL: config.OIDCEndSessionURL,
configIntrospectionURL: config.IntrospectionURL,
// Cold-start seed: discovery failure must not lose operator-configured
// endpoints; updateMetadataEndpoints re-applies these on every refresh.
// Seeding introspectionURL here does not populate metadataSnapshot
// (only updateMetadataEndpoints does); all three consumers
// (token_introspection.go, token_manager.go, helpers.go) read these
// metadataMu-guarded fields directly, not the snapshot.
revocationURL: config.RevocationURL,
endSessionURL: config.OIDCEndSessionURL,
introspectionURL: config.IntrospectionURL,
scopes: func() []string {
userProvidedScopes := deduplicateScopes(config.Scopes)
@@ -371,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
@@ -378,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()
@@ -404,6 +429,10 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
return nil, fmt.Errorf("failed to create session manager: %w", err)
}
t.sessionManager = sessionManager
if config.CookiePath != "" {
t.sessionManager.cookiePath = config.CookiePath
t.logger.Debugf("Using configured cookie path: %s", config.CookiePath)
}
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
// Initialize token resilience manager with default configuration
@@ -602,6 +631,20 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
t.logger.Errorf("Ignoring discovered issuer %q: host does not match configured providerURL", discoveredIssuer)
discoveredIssuer = ""
}
// Operator-configured endpoint overrides take precedence over discovered
// values. They deliberately skip validateDiscoveredEndpoint and the
// sameHost pin: those defend against a poisoned discovery document, while
// operator config sits at the same trust tier as providerURL itself and is
// already gated by isValidSecureURL at config time.
if t.configRevocationURL != "" {
metadata.RevokeURL = t.configRevocationURL
}
if t.configEndSessionURL != "" {
metadata.EndSessionURL = t.configEndSessionURL
}
if t.configIntrospectionURL != "" {
metadata.IntrospectionURL = t.configIntrospectionURL
}
t.metadataMu.Lock()
+342
View File
@@ -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)
})
}
+7 -1
View File
@@ -423,6 +423,7 @@ type SessionManager struct {
cancel context.CancelFunc
cookieDomain string
cookiePrefix string
cookiePath string
sessionMaxAge time.Duration
activeSessions int64
poolHits int64
@@ -905,7 +906,12 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
}
options.HttpOnly = true
options.Path = "/" // Ensure cookies are available on all paths for OAuth flow
// Use configured cookie path (default "/" for backward compatibility)
cookiePath := sm.cookiePath
if cookiePath == "" {
cookiePath = "/"
}
options.Path = cookiePath
if sm.cookieDomain != "" {
options.Domain = sm.cookieDomain
+83 -26
View File
@@ -49,20 +49,34 @@ type Config struct {
RoleClaimName string `json:"roleClaimName,omitempty"`
CookieDomain string `json:"cookieDomain"`
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
// IntrospectionURL overrides the RFC 7662 token introspection endpoint.
// Set it when the IdP omits introspection_endpoint from discovery. It
// takes precedence over the discovered value.
IntrospectionURL string `json:"introspectionURL"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
// AllowedClaims extends the built-in claims whitelist that header value
// templates may emit. Add the exact claim name (e.g. "employee_id") to allow
// {{.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
@@ -71,23 +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"`
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,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"`
// 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.
@@ -483,6 +504,10 @@ func (c *Config) Validate() error {
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate introspection URL if set
if c.IntrospectionURL != "" && !isValidSecureURL(c.IntrospectionURL) {
return fmt.Errorf("introspectionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
@@ -529,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 {
+40 -17
View File
@@ -161,23 +161,46 @@ type TraefikOidc struct {
clientAuthMethod string
clientAssertion *ClientAssertionSigner
registrationURL string
backchannelLogoutPath string
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
// configRevocationURL, configEndSessionURL, and configIntrospectionURL
// hold operator-configured endpoint overrides (Config.RevocationURL,
// Config.OIDCEndSessionURL, Config.IntrospectionURL). They are set once at
// construction and never mutated afterwards, so they are read lock-free,
// 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
// 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
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
// validation (validateHost). Derived at construction time from a loopback
// providerURL (local development), never operator-set directly. Mirrors
+15 -7
View File
@@ -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 != "" {
+7
View File
@@ -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",
+4 -4
View File
@@ -291,19 +291,19 @@ func (t *TraefikOidc) Close() error {
}
if t.httpClient != nil {
if transport, ok := t.httpClient.Transport.(*http.Transport); ok {
if transport, ok := t.httpClient.Transport.(*http.Transport); ok && transport != nil {
transport.CloseIdleConnections()
t.safeLogDebug("HTTP client idle connections closed")
}
}
if t.tokenHTTPClient != nil {
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok {
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok && transport != nil {
transport.CloseIdleConnections()
t.safeLogDebug("Token HTTP client idle connections closed")
}
if t.tokenHTTPClient.Transport != t.httpClient.Transport {
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok {
if t.httpClient == nil || t.tokenHTTPClient.Transport != t.httpClient.Transport {
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok && transport != nil {
transport.CloseIdleConnections()
t.safeLogDebug("Token HTTP client transport closed (separate from main)")
}
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.31"
const traefikoidcPluginVersion = "1.0.36"