Files
traefikoidc/url_helpers_ultra_test.go
T
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

615 lines
19 KiB
Go

package traefikoidc
import (
"crypto/tls"
"net/http/httptest"
"net/url"
"testing"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test TLS connection state for testing HTTPS detection
var testTLSState = tls.ConnectionState{
Version: tls.VersionTLS13,
HandshakeComplete: true,
ServerName: "example.com",
}
// createMinimalMiddleware creates a minimal TraefikOidc instance for testing URL helpers
func createMinimalMiddleware() *TraefikOidc {
logger := newNoOpLogger()
return &TraefikOidc{
logger: logger,
issuerURL: "https://provider.example.com",
clientID: "test-client",
clientSecret: "test-secret",
authURL: "https://provider.example.com/authorize",
tokenURL: "https://provider.example.com/token",
excludedURLs: make(map[string]struct{}),
scopes: []string{"openid", "profile", "email"},
enablePKCE: false,
}
}
// TestDetermineScheme tests scheme determination edge cases
func TestDetermineScheme(t *testing.T) {
t.Run("forceHTTPS=false: backward compatibility", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = false
t.Run("defaults to http when no headers or TLS", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "http", scheme)
})
t.Run("uses X-Forwarded-Proto when present", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.Header.Set("X-Forwarded-Proto", "https")
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme)
})
t.Run("X-Forwarded-Proto takes precedence over TLS", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com/auth", nil)
req.TLS = &testTLSState
req.Header.Set("X-Forwarded-Proto", "http")
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "http", scheme)
})
t.Run("uses TLS when present and no X-Forwarded-Proto", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com/auth", nil)
req.TLS = &testTLSState
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme)
})
})
t.Run("forceHTTPS=true: overrides all detection", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = true
t.Run("returns https with no headers or TLS", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme, "forceHTTPS should override default http")
})
t.Run("returns https even with X-Forwarded-Proto: http", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.Header.Set("X-Forwarded-Proto", "http")
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme, "forceHTTPS should override X-Forwarded-Proto")
})
t.Run("returns https with X-Forwarded-Proto: https", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.Header.Set("X-Forwarded-Proto", "https")
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme)
})
t.Run("returns https with TLS connection", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com/auth", nil)
req.TLS = &testTLSState
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme)
})
t.Run("returns https even when all indicators suggest http", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.TLS = nil
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme, "forceHTTPS should be absolute override")
})
})
t.Run("AWS ALB scenario: TLS termination at load balancer", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = true
t.Run("simulates ALB overwriting X-Forwarded-Proto to http", func(t *testing.T) {
// This simulates the issue from GitHub #82:
// - Client connects via HTTPS to ALB
// - ALB terminates TLS and forwards HTTP to Traefik
// - Traefik overwrites X-Forwarded-Proto based on its view (HTTP)
// - Plugin receives X-Forwarded-Proto: http (incorrect)
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.Header.Set("X-Forwarded-Proto", "http") // Overwritten by Traefik
req.TLS = nil // No TLS at plugin level
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme, "forceHTTPS should ensure HTTPS redirect_uri despite incorrect header")
})
t.Run("simulates missing X-Forwarded-Proto header", func(t *testing.T) {
// Some configurations may not set the header at all
req := httptest.NewRequest("GET", "http://example.com/auth", nil)
req.TLS = nil
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
assert.Equal(t, "https", scheme, "forceHTTPS should ensure HTTPS even without headers")
})
})
}
// TestBuildURLWithParamsErrorPaths tests error handling in buildURLWithParams
func TestBuildURLWithParamsErrorPaths(t *testing.T) {
middleware := createMinimalMiddleware()
t.Run("invalid issuer URL returns empty string", func(t *testing.T) {
middleware.issuerURL = "://invalid"
params := url.Values{}
params.Set("test", "value")
result := middleware.buildURLWithParams("/path", params)
assert.Empty(t, result)
})
t.Run("invalid relative URL returns empty string", func(t *testing.T) {
middleware.issuerURL = "https://provider.example.com"
params := url.Values{}
result := middleware.buildURLWithParams("://invalid-relative", params)
assert.Empty(t, result)
})
t.Run("invalid absolute URL returns empty string", func(t *testing.T) {
params := url.Values{}
result := middleware.buildURLWithParams("http://[invalid-url", params)
assert.Empty(t, result)
})
t.Run("dangerous host in absolute URL returns empty string", func(t *testing.T) {
params := url.Values{}
result := middleware.buildURLWithParams("https://localhost/callback", params)
assert.Empty(t, result)
})
t.Run("successful relative URL resolution", func(t *testing.T) {
middleware.issuerURL = "https://provider.example.com"
params := url.Values{}
params.Set("key", "value")
result := middleware.buildURLWithParams("/oauth/authorize", params)
assert.NotEmpty(t, result)
assert.Contains(t, result, "https://provider.example.com/oauth/authorize")
assert.Contains(t, result, "key=value")
})
t.Run("successful absolute URL", func(t *testing.T) {
params := url.Values{}
params.Set("client_id", "test")
result := middleware.buildURLWithParams("https://api.example.com/endpoint", params)
assert.NotEmpty(t, result)
assert.Contains(t, result, "https://api.example.com/endpoint")
assert.Contains(t, result, "client_id=test")
})
}
// TestValidateParsedURLCases tests URL validation edge cases
func TestValidateParsedURLCases(t *testing.T) {
middleware := createMinimalMiddleware()
t.Run("disallowed schemes rejected", func(t *testing.T) {
invalidSchemes := []string{
"ftp://example.com",
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,test",
}
for _, urlStr := range invalidSchemes {
u, _ := url.Parse(urlStr)
err := middleware.validateParsedURL(u)
assert.Error(t, err, "should reject scheme: %s", urlStr)
assert.Contains(t, err.Error(), "disallowed URL scheme")
}
})
t.Run("http scheme allowed with warning", func(t *testing.T) {
u, _ := url.Parse("http://example.com/path")
err := middleware.validateParsedURL(u)
assert.NoError(t, err)
})
t.Run("missing host rejected", func(t *testing.T) {
u := &url.URL{
Scheme: "https",
Host: "",
Path: "/path",
}
err := middleware.validateParsedURL(u)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing host")
})
t.Run("path traversal rejected", func(t *testing.T) {
u, _ := url.Parse("https://example.com/../../etc/passwd")
err := middleware.validateParsedURL(u)
assert.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
})
t.Run("valid URLs accepted", func(t *testing.T) {
validURLs := []string{
"https://example.com",
"https://example.com/path",
"https://sub.example.com:8080/path?query=value",
}
for _, urlStr := range validURLs {
u, _ := url.Parse(urlStr)
err := middleware.validateParsedURL(u)
assert.NoError(t, err, "should accept: %s", urlStr)
}
})
}
// TestValidateHostComprehensive tests comprehensive host validation
func TestValidateHostComprehensive(t *testing.T) {
middleware := createMinimalMiddleware()
t.Run("loopback IPs rejected", func(t *testing.T) {
loopbacks := []string{
"127.0.0.1",
"127.255.255.255",
"::1",
}
for _, ip := range loopbacks {
err := middleware.validateHost(ip)
assert.Error(t, err, "should reject loopback: %s", ip)
}
})
t.Run("private IPs rejected", func(t *testing.T) {
privateIPs := []string{
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"fd00::1",
}
for _, ip := range privateIPs {
err := middleware.validateHost(ip)
assert.Error(t, err, "should reject private IP: %s", ip)
}
})
t.Run("link-local IPs rejected", func(t *testing.T) {
linkLocal := []string{
"169.254.1.1",
"fe80::1",
}
for _, ip := range linkLocal {
err := middleware.validateHost(ip)
assert.Error(t, err, "should reject link-local: %s", ip)
}
})
t.Run("unspecified and multicast rejected", func(t *testing.T) {
special := []string{
"0.0.0.0",
"::",
"224.0.0.1",
"ff02::1",
}
for _, ip := range special {
err := middleware.validateHost(ip)
assert.Error(t, err, "should reject special IP: %s", ip)
}
})
t.Run("dangerous hostnames rejected", func(t *testing.T) {
dangerous := []string{
"localhost",
"LOCALHOST",
"169.254.169.254",
"metadata.google.internal",
}
for _, host := range dangerous {
err := middleware.validateHost(host)
assert.Error(t, err, "should reject: %s", host)
}
})
t.Run("invalid host format rejected", func(t *testing.T) {
invalid := []string{
"[::1:invalid",
}
for _, host := range invalid {
err := middleware.validateHost(host)
assert.Error(t, err, "should reject invalid format: %s", host)
}
})
t.Run("hosts with ports", func(t *testing.T) {
err := middleware.validateHost("localhost:8080")
assert.Error(t, err)
err = middleware.validateHost("192.168.1.1:443")
assert.Error(t, err)
err = middleware.validateHost("example.com:443")
assert.NoError(t, err)
})
t.Run("valid public IPs accepted", func(t *testing.T) {
publicIPs := []string{
"8.8.8.8",
"1.1.1.1",
"93.184.216.34",
}
for _, ip := range publicIPs {
err := middleware.validateHost(ip)
assert.NoError(t, err, "should accept public IP: %s", ip)
}
})
t.Run("valid hostnames accepted", func(t *testing.T) {
validHosts := []string{
"example.com",
"sub.example.com",
"api.service.example.com:443",
}
for _, host := range validHosts {
err := middleware.validateHost(host)
assert.NoError(t, err, "should accept: %s", host)
}
})
}
// TestValidateURLEdgeCasesComprehensive tests the validateURL wrapper
func TestValidateURLEdgeCasesComprehensive(t *testing.T) {
middleware := createMinimalMiddleware()
t.Run("empty URL rejected", func(t *testing.T) {
err := middleware.validateURL("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "empty URL")
})
t.Run("invalid URL format rejected", func(t *testing.T) {
err := middleware.validateURL("ht tp://invalid url")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid URL format")
})
t.Run("valid URLs accepted", func(t *testing.T) {
validURLs := []string{
"https://example.com/path",
"https://example.com/path?key=value",
}
for _, urlStr := range validURLs {
err := middleware.validateURL(urlStr)
assert.NoError(t, err, "should accept: %s", urlStr)
}
})
t.Run("URL with dangerous host rejected", func(t *testing.T) {
err := middleware.validateURL("https://localhost/path")
assert.Error(t, err)
require.Contains(t, err.Error(), "invalid host")
})
}
// TestBuildAuthURLAudienceParameter tests audience parameter handling
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",
"state123",
"nonce456",
"",
)
assert.Contains(t, authURL, "audience=")
})
t.Run("audience not added when empty", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.audience = ""
middleware.explicitAudience = ""
authURL := middleware.buildAuthURL(
"https://app.com/callback",
"state123",
"nonce456",
"",
)
assert.NotContains(t, authURL, "audience=")
})
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",
"state123",
"nonce456",
"",
)
assert.NotContains(t, authURL, "audience=")
})
}
// TestBuildAuthURLPKCEParameters tests PKCE parameter handling
func TestBuildAuthURLPKCEParameters(t *testing.T) {
t.Run("PKCE parameters added when enabled with challenge", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.enablePKCE = true
authURL := middleware.buildAuthURL(
"https://app.com/callback",
"state123",
"nonce456",
"challenge789",
)
assert.Contains(t, authURL, "code_challenge=challenge789")
assert.Contains(t, authURL, "code_challenge_method=S256")
})
t.Run("PKCE parameters not added when challenge empty", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.enablePKCE = true
authURL := middleware.buildAuthURL(
"https://app.com/callback",
"state123",
"nonce456",
"", // Empty challenge
)
assert.NotContains(t, authURL, "code_challenge=")
})
t.Run("PKCE parameters not added when disabled", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.enablePKCE = false
authURL := middleware.buildAuthURL(
"https://app.com/callback",
"state123",
"nonce456",
"challenge789",
)
assert.NotContains(t, authURL, "code_challenge=")
})
}
// TestForceHTTPSIntegration tests the complete flow of building redirect URIs with forceHTTPS
func TestForceHTTPSIntegration(t *testing.T) {
t.Run("redirect_uri uses https when forceHTTPS=true", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = true
// Simulate AWS ALB scenario: HTTP request with incorrect X-Forwarded-Proto
req := httptest.NewRequest("GET", "http://service.example.com/protected", nil)
req.Header.Set("X-Forwarded-Proto", "http") // Traefik overwrote it
req.Host = "service.example.com"
req.TLS = nil
// Build the full redirect URL as middleware does
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
host := utils.DetermineHost(req)
redirectURL := buildFullURL(scheme, host, "/oauth2/callback")
assert.Equal(t, "https", scheme, "scheme should be https due to forceHTTPS")
assert.Equal(t, "https://service.example.com/oauth2/callback", redirectURL,
"redirect_uri should use https scheme")
})
t.Run("buildAuthURL contains https redirect_uri with forceHTTPS", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = true
// Simulate building auth URL with HTTP redirect_uri
req := httptest.NewRequest("GET", "http://service.example.com/protected", nil)
req.Header.Set("X-Forwarded-Proto", "http")
req.Host = "service.example.com"
req.TLS = nil
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
host := utils.DetermineHost(req)
redirectURL := buildFullURL(scheme, host, "/oauth2/callback")
authURL := middleware.buildAuthURL(redirectURL, "state123", "nonce456", "")
assert.Contains(t, authURL, "redirect_uri=https%3A%2F%2Fservice.example.com%2Foauth2%2Fcallback",
"auth URL should contain HTTPS redirect_uri")
assert.NotContains(t, authURL, "redirect_uri=http%3A",
"auth URL should not contain HTTP redirect_uri")
})
t.Run("without forceHTTPS respects X-Forwarded-Proto", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.forceHTTPS = false
req := httptest.NewRequest("GET", "http://service.example.com/protected", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "service.example.com"
scheme := utils.DetermineScheme(req, middleware.forceHTTPS)
host := utils.DetermineHost(req)
redirectURL := buildFullURL(scheme, host, "/oauth2/callback")
assert.Equal(t, "https://service.example.com/oauth2/callback", redirectURL,
"should use https from X-Forwarded-Proto when forceHTTPS is false")
})
}
// TestBuildAuthURLExtraAuthParams verifies operator-configured extra
// authorization parameters are appended to the authorization URL, and that
// they can never override parameters the plugin itself manages.
func TestBuildAuthURLExtraAuthParams(t *testing.T) {
t.Run("extra params are added (e.g. screen_hint=signup)", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.extraAuthParams = map[string]string{
"screen_hint": "signup",
"ui_locales": "en",
}
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
assert.Contains(t, authURL, "screen_hint=signup")
assert.Contains(t, authURL, "ui_locales=en")
})
t.Run("nil/empty extraAuthParams is a no-op", func(t *testing.T) {
middleware := createMinimalMiddleware()
// extraAuthParams left nil
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
assert.Contains(t, authURL, "client_id=test-client")
assert.NotContains(t, authURL, "screen_hint")
})
t.Run("extra params CANNOT override plugin-managed params", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.extraAuthParams = map[string]string{
"client_id": "ATTACKER",
"state": "ATTACKER",
"redirect_uri": "https://evil.example.com",
"response_type": "token",
}
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
// Plugin-managed values must win; injected values must be absent.
assert.Contains(t, authURL, "client_id=test-client")
assert.NotContains(t, authURL, "ATTACKER")
assert.NotContains(t, authURL, "evil.example.com")
assert.Contains(t, authURL, "response_type=code")
})
}