mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
feat: add RFC 8707 resource indicator via resource config (#161)
* feat: add RFC 8707 resource indicator via resource config `Config.Resource` is an alternative to `audience` for IdPs that implement RFC 8707. `buildAuthURL` sends it as the `resource` parameter on the authorization request, before `extraAuthParams` so an operator-configured `resource` key there cannot override or duplicate it. `exchangeTokens` repeats it on both the `authorization_code` and `refresh_token` grants (RFC 8707 §2.2), so a refreshed token keeps the audience binding of the original grant. Only one resource is accepted. RFC 8707 permits a list, but no provider that supports the parameter handles a list usefully: one rejects any request naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts several and issues an opaque token bound to none of them. `Config.Validate()` requires an absolute URI of at most 256 characters with no fragment, wildcard, or control characters. `http`/`https` values go through `isValidSecureURL`, so HTTPS is required outside loopback; other schemes (`urn:`) pass as-is. When `resource` is set and `audience` is not, `audience` defaults to the resource for access-token validation; `strictAudienceValidation` applies the same way whichever of the two is set. That default is validation-only. A new `TraefikOidc.explicitAudience` mirrors `Config.Audience` verbatim, and `buildAuthURL` keys the non-standard `audience` request parameter (the Auth0 custom-API pattern) on it rather than on `audience`, so a resource-derived default is never sent as an unsolicited `audience` parameter alongside `resource` — a combination that misbehaves against providers such as Ory Hydra, which give `audience` a different meaning. `audience_test.go` and `url_helpers_ultra_test.go` set `audience` on the struct directly, bypassing `New()`, and now set `explicitAudience` too. docs/CONFIGURATION.md carries only the reference-table row and a short pointer; the new docs/RFC8707.md holds the request behaviour, the single-resource rationale, the `resource`/`audience` relationship, and a provider-support table from interop testing: node-oidc-provider supports it; Zitadel accepts and silently ignores it (`oidc.AuthRequest` has no `resource` field, confirmed against a live instance); Authentik returns `invalid_target`; Keycloak has no support through 26.6; Ory Hydra/Fosite uses its own `audience` parameter; Azure AD/Entra ID v2 is reported to reject `resource` combined with v2-style `scope`; Auth0 and AWS Cognito claims are unverified. README.md, docs/index.html, and .traefik.yml each point at docs/RFC8707.md instead of repeating it, and the comments on `Config.Resource`, in main.go, and in url_helpers.go defer to it as well. rfc8707_resource_test.go covers the authorization-URL parameter (set, unset, query-bearing value percent-encoded rather than merged, `extraAuthParams` unable to override), both grants sending `resource` in the form body, the `Config.Validate()` rules table, audience defaulting through `New()`, and a regression pair: a resource-derived audience default is not sent as `audience`, while an explicitly configured one still is. * fix: accept Resource in place of Audience for bearer-auth validation `NewWithContext` rejected `EnableBearerAuth=true` whenever `config.Audience` was empty, even though `t.audience` already falls back to `config.Resource` in that case. The check now fails only when both `Audience` and `Resource` are empty, and the error names both fields. `TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience` covers the `Resource`-only configuration. `docs/RFC8707.md` links the `audience` parameter note to `docs/AUTH0_AUDIENCE_GUIDE.md` instead of restating it, and drops the provider table's GitHub row and the interop-testing paragraph, which are covered elsewhere.
This commit is contained in:
@@ -107,3 +107,8 @@ testData:
|
|||||||
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
|
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
|
||||||
# bearerFailurePenaltySeconds: 60 # 429 + Retry-After duration after threshold trips
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| `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`. |
|
| `rateLimit` | `100` | Requests/sec. Min `10`. |
|
||||||
| `logLevel` | `info` | `debug`, `info`, `error`. |
|
| `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.** |
|
| `strictAudienceValidation` | `false` | Reject mismatched audiences. **Set `true` in production.** |
|
||||||
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
|
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
|
||||||
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
|
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
|
||||||
|
|||||||
@@ -288,6 +288,10 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
|||||||
|
|
||||||
customAudience := "https://my-api.example.com"
|
customAudience := "https://my-api.example.com"
|
||||||
ts.tOidc.audience = customAudience
|
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)
|
// Create ID token with aud = client_id (OIDC standard)
|
||||||
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
|||||||
@@ -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) {
|
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cfg := CreateConfig()
|
cfg := CreateConfig()
|
||||||
|
|||||||
+12
-1
@@ -235,7 +235,8 @@ SSE / WebSocket connection.
|
|||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| 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 |
|
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
|
||||||
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
||||||
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
||||||
@@ -247,6 +248,16 @@ audience: "https://my-api.example.com"
|
|||||||
strictAudienceValidation: true
|
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
|
#### Opaque Token Support
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -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
@@ -735,9 +735,14 @@ spec:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
<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"><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>
|
<td class="py-2 px-3">Custom audience for access token validation</td>
|
||||||
</tr>
|
</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">
|
<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"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
|
||||||
<td class="py-2 px-3">false</td>
|
<td class="py-2 px-3">false</td>
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
data.Set("refresh_token", codeOrToken)
|
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
|
client := t.tokenHTTPClient
|
||||||
if client == nil {
|
if client == nil {
|
||||||
// Use shared transport pool to prevent memory leaks
|
// Use shared transport pool to prevent memory leaks
|
||||||
|
|||||||
@@ -243,8 +243,12 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
if config.Audience != "" {
|
if config.Audience != "" {
|
||||||
return config.Audience
|
return config.Audience
|
||||||
}
|
}
|
||||||
|
if config.Resource != "" {
|
||||||
|
return config.Resource
|
||||||
|
}
|
||||||
return config.ClientID
|
return config.ClientID
|
||||||
}(),
|
}(),
|
||||||
|
explicitAudience: config.Audience,
|
||||||
roleClaimName: func() string {
|
roleClaimName: func() string {
|
||||||
if config.RoleClaimName != "" {
|
if config.RoleClaimName != "" {
|
||||||
return config.RoleClaimName
|
return config.RoleClaimName
|
||||||
@@ -266,6 +270,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
|||||||
forceHTTPS: config.ForceHTTPS,
|
forceHTTPS: config.ForceHTTPS,
|
||||||
enablePKCE: config.EnablePKCE,
|
enablePKCE: config.EnablePKCE,
|
||||||
extraAuthParams: config.ExtraAuthParams,
|
extraAuthParams: config.ExtraAuthParams,
|
||||||
|
resource: config.Resource,
|
||||||
overrideScopes: config.OverrideScopes,
|
overrideScopes: config.OverrideScopes,
|
||||||
strictAudienceValidation: config.StrictAudienceValidation,
|
strictAudienceValidation: config.StrictAudienceValidation,
|
||||||
allowOpaqueTokens: config.AllowOpaqueTokens,
|
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)
|
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
|
// Bearer-auth startup validation. The bearer path is M2M-only and demands
|
||||||
// a non-default audience so tokens issued for a different resource cannot
|
// a non-default audience so tokens issued for a different resource cannot
|
||||||
// be replayed against this service. The BearerIdentifierClaim guard blocks
|
// 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.
|
// scope for M2M), trusting email is a spoofing vector for federated IdPs.
|
||||||
// See spec §7.9 / §13.
|
// See spec §7.9 / §13.
|
||||||
if config.EnableBearerAuth {
|
if config.EnableBearerAuth {
|
||||||
if config.Audience == "" {
|
if config.Audience == "" && config.Resource == "" {
|
||||||
cancelFunc()
|
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" {
|
if t.bearerIdentifierClaim == "email" {
|
||||||
cancelFunc()
|
cancelFunc()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
+63
-21
@@ -64,9 +64,19 @@ type Config struct {
|
|||||||
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
|
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
|
||||||
// template validation and the runtime get helper. Built-in claims (email,
|
// template validation and the runtime get helper. Built-in claims (email,
|
||||||
// groups, roles, realm_access, ...) need not be listed.
|
// groups, roles, realm_access, ...) need not be listed.
|
||||||
AllowedClaims []string `json:"allowedClaims,omitempty"`
|
AllowedClaims []string `json:"allowedClaims,omitempty"`
|
||||||
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
|
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
|
||||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
// 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
|
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
|
||||||
// a stored refresh token. Once the token has been in the session longer
|
// 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
|
// 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
|
// IdPs do not expose RT TTL on the wire, so this is intentionally a
|
||||||
// conservative heuristic; tune to match your provider configuration.
|
// conservative heuristic; tune to match your provider configuration.
|
||||||
// Default 21600 (6h). Set to 0 to disable the check.
|
// Default 21600 (6h). Set to 0 to disable the check.
|
||||||
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
|
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
|
||||||
SessionMaxAge int `json:"sessionMaxAge"`
|
SessionMaxAge int `json:"sessionMaxAge"`
|
||||||
RateLimit int `json:"rateLimit"`
|
RateLimit int `json:"rateLimit"`
|
||||||
OverrideScopes bool `json:"overrideScopes"`
|
OverrideScopes bool `json:"overrideScopes"`
|
||||||
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
||||||
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
||||||
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
||||||
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
||||||
EnablePKCE bool `json:"enablePKCE"`
|
EnablePKCE bool `json:"enablePKCE"`
|
||||||
ForceHTTPS bool `json:"forceHTTPS"`
|
ForceHTTPS bool `json:"forceHTTPS"`
|
||||||
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
||||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||||
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
||||||
// CookiePath restricts session cookies to a specific path prefix instead of "/".
|
// 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
|
// 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
|
// middleware's path prefix (e.g. "/app-protegido") so the browser does not send
|
||||||
// the OIDC session cookies to unprotected paths — preventing "Request Header
|
// the OIDC session cookies to unprotected paths — preventing "Request Header
|
||||||
// Or Cookie Too Large" (431) errors on those paths.
|
// Or Cookie Too Large" (431) errors on those paths.
|
||||||
// Default "/" (all paths, current behaviour).
|
// Default "/" (all paths, current behaviour).
|
||||||
CookiePath string `json:"cookiePath,omitempty"`
|
CookiePath string `json:"cookiePath,omitempty"`
|
||||||
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
||||||
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
||||||
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
||||||
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
||||||
// CACertPath is an optional filesystem path to a PEM-encoded CA bundle used
|
// 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
|
// 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.
|
// 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
|
// Validate Redis configuration if provided
|
||||||
if c.Redis != nil && c.Redis.Enabled {
|
if c.Redis != nil && c.Redis.Enabled {
|
||||||
if err := c.Redis.Validate(); err != nil {
|
if err := c.Redis.Validate(); err != nil {
|
||||||
|
|||||||
@@ -168,14 +168,27 @@ type TraefikOidc struct {
|
|||||||
// the same contract as providerURL, which updateMetadataEndpoints already
|
// the same contract as providerURL, which updateMetadataEndpoints already
|
||||||
// reads unlocked. A manual override always wins over discovery; an empty
|
// reads unlocked. A manual override always wins over discovery; an empty
|
||||||
// string means use the discovered value.
|
// string means use the discovered value.
|
||||||
configRevocationURL string
|
configRevocationURL string
|
||||||
configEndSessionURL string
|
configEndSessionURL string
|
||||||
configIntrospectionURL string
|
configIntrospectionURL string
|
||||||
backchannelLogoutPath string
|
backchannelLogoutPath string
|
||||||
frontchannelLogoutPath string
|
frontchannelLogoutPath string
|
||||||
scopesSupported []string
|
scopesSupported []string
|
||||||
scopes []string
|
scopes []string
|
||||||
extraAuthParams map[string]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
|
refreshGracePeriod time.Duration
|
||||||
maxRefreshTokenAge time.Duration
|
maxRefreshTokenAge time.Duration
|
||||||
metadataMu sync.RWMutex
|
metadataMu sync.RWMutex
|
||||||
|
|||||||
+15
-7
@@ -71,13 +71,21 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
|
|||||||
params.Set("state", state)
|
params.Set("state", state)
|
||||||
params.Set("nonce", nonce)
|
params.Set("nonce", nonce)
|
||||||
|
|
||||||
// Add audience parameter for custom API audiences (e.g., Auth0 APIs)
|
// Non-standard `audience` param for custom API audiences (e.g. Auth0).
|
||||||
// This allows access tokens to have the correct audience claim
|
// Keyed on explicitAudience, not audience — see TraefikOidc.explicitAudience.
|
||||||
// Only add if audience is configured and different from client_id
|
// Only add if configured and different from client_id; ID tokens always
|
||||||
// ID tokens will always have aud=client_id per OIDC spec
|
// have aud=client_id per OIDC spec.
|
||||||
if t.audience != "" && t.audience != t.clientID {
|
if t.explicitAudience != "" && t.explicitAudience != t.clientID {
|
||||||
params.Set("audience", t.audience)
|
params.Set("audience", t.explicitAudience)
|
||||||
t.logger.Debugf("Adding audience parameter to authorize URL: %s", t.audience)
|
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 != "" {
|
if t.enablePKCE && codeChallenge != "" {
|
||||||
|
|||||||
@@ -407,7 +407,12 @@ func TestValidateURLEdgeCasesComprehensive(t *testing.T) {
|
|||||||
func TestBuildAuthURLAudienceParameter(t *testing.T) {
|
func TestBuildAuthURLAudienceParameter(t *testing.T) {
|
||||||
t.Run("audience added when different from client_id", func(t *testing.T) {
|
t.Run("audience added when different from client_id", func(t *testing.T) {
|
||||||
middleware := createMinimalMiddleware()
|
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.audience = "https://api.example.com"
|
||||||
|
middleware.explicitAudience = "https://api.example.com"
|
||||||
|
|
||||||
authURL := middleware.buildAuthURL(
|
authURL := middleware.buildAuthURL(
|
||||||
"https://app.com/callback",
|
"https://app.com/callback",
|
||||||
@@ -422,6 +427,7 @@ func TestBuildAuthURLAudienceParameter(t *testing.T) {
|
|||||||
t.Run("audience not added when empty", func(t *testing.T) {
|
t.Run("audience not added when empty", func(t *testing.T) {
|
||||||
middleware := createMinimalMiddleware()
|
middleware := createMinimalMiddleware()
|
||||||
middleware.audience = ""
|
middleware.audience = ""
|
||||||
|
middleware.explicitAudience = ""
|
||||||
|
|
||||||
authURL := middleware.buildAuthURL(
|
authURL := middleware.buildAuthURL(
|
||||||
"https://app.com/callback",
|
"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) {
|
t.Run("audience not added when equal to client_id", func(t *testing.T) {
|
||||||
middleware := createMinimalMiddleware()
|
middleware := createMinimalMiddleware()
|
||||||
middleware.audience = middleware.clientID
|
middleware.audience = middleware.clientID
|
||||||
|
middleware.explicitAudience = middleware.clientID
|
||||||
|
|
||||||
authURL := middleware.buildAuthURL(
|
authURL := middleware.buildAuthURL(
|
||||||
"https://app.com/callback",
|
"https://app.com/callback",
|
||||||
|
|||||||
Reference in New Issue
Block a user