Files
traefikoidc/issue135_regression_test.go
T
lukaszraczylo 1e33bb0a4d feat(auth): support private_key_jwt and client_secret_basic (#137)
revocation endpoints, joining the existing client_secret_post default.
Both are opt-in via the new clientAuthMethod config field. Closes #135.

private_key_jwt (RFC 7523 §2.2 / OpenID Connect Core §9)
========================================================
Plugin signs a short-lived JWT with a configured private key and presents
it as client_assertion. Use when the IdP enforces short secret TTLs or
requires secretless client auth (Microsoft Entra ID / Azure AD, Okta,
Auth0, Keycloak).

New Config fields:
  clientAuthMethod          (default: client_secret_post)
  clientAssertionPrivateKey (inline PEM)
  clientAssertionKeyPath    (PEM file path; mutually exclusive)
  clientAssertionKeyID      (JWS kid header — required)
  clientAssertionAlg        (default: RS256; RS/PS/ES 256–512 supported)

PEM forms accepted: PKCS#8, PKCS#1, SEC1.
Assertion claims: iss=sub=clientID, aud=tokenURL, iat=now, exp=now+60s,
random 16-byte hex jti per request. ECDSA signatures are raw r||s per
RFC 7515 (not ASN.1).

client_secret_basic (RFC 6749 §2.3.1)
=====================================
Sends credentials in the Authorization: Basic header instead of the
body. Both halves are form-urlencoded individually before base64 — that
encoding step is required by the spec and is NOT what stdlib's
http.Request.SetBasicAuth does, so the plugin uses its own helper. The
form body omits client_id and client_secret on this path.

Wire-up
=======
Both methods are dispatched at the same two call sites:
  helpers.go:exchangeTokens — auth_code + refresh_token grants
  token_manager.go:RevokeTokenWithProvider — RFC 7009 revocation

Existing clientSecret deployments are unaffected — empty
clientAuthMethod maps to the historical client_secret_post behavior, and
clientAssertion remains nil unless the new fields are set.

Yaegi compatibility
===================
All required crypto/rsa, crypto/ecdsa, crypto/x509, encoding/pem and
crypto/sha256/384/512 symbols are exposed by the traefik/yaegi stdlib
symbol tables (RSA SignPKCS1v15 + SignPSS, ECDSA Sign,
ParsePKCS8/1PrivateKey, ParseECPrivateKey).

Tests (16 new)
==============
Algorithm-family coverage:
  TestIssue135_SignerRSAFamily — RS256/384/512 + PS256/384/512
  TestIssue135_SignerECDSAFamily — ES256/384/512, raw r||s shape
  TestIssue135_SignerRejectsAlgKeyMismatch
  TestIssue135_SignerJTIUniqueness — 50 sigs, all jti distinct
  TestIssue135_SignerPEMVariants — PKCS#8, PKCS#1, SEC1

Config validation:
  TestIssue135_ConfigValidation — full Validate() matrix
  TestIssue135_ConfigKeyPathLoadsFile

Wire-up:
  TestIssue135_AuthCodeExchangeUsesAssertion
  TestIssue135_RefreshTokenUsesAssertion
  TestIssue135_BackcompatClientSecretPath
  TestIssue135_RevocationUsesAssertion
  TestIssue135_BuildSignerFromInlineConfig
  TestIssue135_BuildSignerDefaultsToRS256
  TestIssue135_ClientSecretBasicAuth — Authorization header, no body creds
  TestIssue135_ClientSecretBasicURLEncodesReservedChars — :, +, /, @, =, &
  TestIssue135_ClientSecretBasicRevocation — revocation parity

Documentation
=============
  README.md — required-row note + 5 optional rows + dedicated section
  docs/CONFIGURATION.md — new Client Authentication section with three
    method subsections, OpenSSL keygen snippet, RFC links
  docs/index.html — 5 new config-table rows + Private Key JWT
    explainer card
  .traefik.yml + examples/complete-traefik-config.yaml — commented
    opt-in example

Out of scope (deferred)
=======================
mTLS / tls_client_auth (RFC 8705) — separate change; requires per-call
http.Client with tls.Config.Certificates and conflicts with the current
pooled HTTP client architecture.
2026-05-09 18:02:41 +01:00

926 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package traefikoidc
// issue135_regression_test.go — regression tests for RFC 7523 private_key_jwt
// client authentication (issue #135).
//
// These tests guard:
// - Correct JWT construction and cryptographic signature for all supported
// algorithms (RS*/PS*/ES*).
// - Proper validation of alg/key type combinations and empty-kid rejection.
// - JTI uniqueness across concurrent calls.
// - PEM variant tolerance (PKCS#8, PKCS#1, SEC1).
// - Config.Validate() behavior for all private_key_jwt configuration paths.
// - buildClientAssertionSignerFromConfig: inline PEM, file-backed PEM, default alg.
// - Wire-up in exchangeTokens: assertion fields sent, client_secret absent.
// - Wire-up in RevokeTokenWithProvider: assertion fields sent, audience = tokenURL.
// - Back-compat: client_secret_post path unchanged when clientAssertion == nil.
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ── A. Signer unit tests ──────────────────────────────────────────────────────
// TestIssue135_SignerRSAFamily verifies that NewClientAssertionSigner + Sign
// produces a well-formed, cryptographically valid JWT for every RSA-family
// algorithm (RS256/RS384/RS512/PS256/PS384/PS512).
func TestIssue135_SignerRSAFamily(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
cases := []struct {
alg string
hashFn func([]byte) []byte
isPS bool
hash crypto.Hash
}{
{"RS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, false, crypto.SHA256},
{"RS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, false, crypto.SHA384},
{"RS512", func(b []byte) []byte { h := sha512.Sum512(b); return h[:] }, false, crypto.SHA512},
{"PS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, true, crypto.SHA256},
{"PS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, true, crypto.SHA384},
{"PS512", func(b []byte) []byte { h := sha512.Sum512(b); return h[:] }, true, crypto.SHA512},
}
const (
audience = "https://example.com/token"
clientID = "client-abc"
kid = "kid-1"
)
for _, tc := range cases {
t.Run(tc.alg, func(t *testing.T) {
signer, err := NewClientAssertionSigner(pemBytes, tc.alg, kid)
require.NoError(t, err)
jwtStr, err := signer.Sign(audience, clientID)
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3, "JWT must have three dot-separated parts")
// Decode and check header.
hdr := decodeJSONPart(t, parts[0])
assert.Equal(t, tc.alg, hdr["alg"])
assert.Equal(t, "JWT", hdr["typ"])
assert.Equal(t, kid, hdr["kid"])
// Decode and check claims.
clms := decodeJSONPart(t, parts[1])
assert.Equal(t, clientID, clms["iss"])
assert.Equal(t, clientID, clms["sub"])
assert.Equal(t, audience, clms["aud"])
iat, ok := clms["iat"].(float64)
require.True(t, ok, "iat must be numeric")
exp, ok := clms["exp"].(float64)
require.True(t, ok, "exp must be numeric")
assert.InDelta(t, 60, exp-iat, 2, "exp-iat must equal ~60s")
now := float64(time.Now().Unix())
assert.True(t, iat <= now+2 && iat >= now-5, "iat must be current time ±5s")
jti, ok := clms["jti"].(string)
require.True(t, ok, "jti must be a string")
assert.Len(t, jti, 32, "jti must be 32-char hex (16 bytes → hex)")
// Verify cryptographic signature.
sigInput := parts[0] + "." + parts[1]
digest := tc.hashFn([]byte(sigInput))
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
pub := &rsaKey.PublicKey
if tc.isPS {
opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: tc.hash}
assert.NoError(t, rsa.VerifyPSS(pub, tc.hash, digest, sigBytes, opts),
"PSS signature verification failed for %s", tc.alg)
} else {
assert.NoError(t, rsa.VerifyPKCS1v15(pub, tc.hash, digest, sigBytes),
"PKCS1v15 signature verification failed for %s", tc.alg)
}
})
}
}
// TestIssue135_SignerECDSAFamily verifies correct JWT production for all
// ECDSA algorithms (ES256/ES384/ES512) including that the signature is the
// raw r||s encoding (not ASN.1 DER) and is verifiable with the matching key.
func TestIssue135_SignerECDSAFamily(t *testing.T) {
cases := []struct {
alg string
curve elliptic.Curve
hashFn func([]byte) []byte
hash crypto.Hash
}{
{"ES256", elliptic.P256(), func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, crypto.SHA256},
{"ES384", elliptic.P384(), func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, crypto.SHA384},
{"ES512", elliptic.P521(), func(b []byte) []byte { h := sha512.Sum512(b); return h[:] }, crypto.SHA512},
}
const (
audience = "https://idp.example.com/token"
clientID = "ec-client"
kid = "ec-kid"
)
for _, tc := range cases {
t.Run(tc.alg, func(t *testing.T) {
ecKey, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
require.NoError(t, err)
pemBytes := encodeECPKCS8(t, ecKey)
signer, err := NewClientAssertionSigner(pemBytes, tc.alg, kid)
require.NoError(t, err)
jwtStr, err := signer.Sign(audience, clientID)
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
byteLen := (tc.curve.Params().BitSize + 7) / 8
assert.Len(t, sigBytes, 2*byteLen,
"ECDSA signature must be raw r||s (2×%d bytes for %s)", byteLen, tc.alg)
r := new(big.Int).SetBytes(sigBytes[:byteLen])
s := new(big.Int).SetBytes(sigBytes[byteLen:])
sigInput := parts[0] + "." + parts[1]
digest := tc.hashFn([]byte(sigInput))
ok := ecdsa.Verify(&ecKey.PublicKey, digest, r, s)
assert.True(t, ok, "ECDSA signature verification failed for %s", tc.alg)
})
}
}
// TestIssue135_SignerRejectsAlgKeyMismatch verifies that the signer constructor
// rejects type mismatches between key type and algorithm, unknown algorithms,
// and an empty kid.
func TestIssue135_SignerRejectsAlgKeyMismatch(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
rsaPEM := encodeRSAPKCS8(t, rsaKey)
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
ecPEM := encodeECPKCS8(t, ecKey)
cases := []struct {
name string
pemBytes []byte
alg string
kid string
wantErr string
}{
{
name: "RSA key with ES256",
pemBytes: rsaPEM,
alg: "ES256",
kid: "k1",
wantErr: "EC key",
},
{
name: "EC key with RS256",
pemBytes: ecPEM,
alg: "RS256",
kid: "k1",
wantErr: "RSA key",
},
{
name: "unknown alg HS256",
pemBytes: rsaPEM,
alg: "HS256",
kid: "k1",
wantErr: "unsupported",
},
{
name: "empty kid",
pemBytes: rsaPEM,
alg: "RS256",
kid: "",
wantErr: "kid must not be empty",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewClientAssertionSigner(tc.pemBytes, tc.alg, tc.kid)
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.wantErr),
"error should mention %q", tc.wantErr)
})
}
}
// TestIssue135_SignerJTIUniqueness signs 50 assertions with the same signer
// and asserts all jti values are distinct. Guards against broken entropy reuse.
func TestIssue135_SignerJTIUniqueness(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "jti-kid")
require.NoError(t, err)
seen := make(map[string]bool, 50)
for i := range 50 {
jwtStr, err := signer.Sign("https://example.com/token", "client-x")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
clms := decodeJSONPart(t, parts[1])
jti, ok := clms["jti"].(string)
require.True(t, ok)
assert.False(t, seen[jti], "jti %q was reused at iteration %d", jti, i)
seen[jti] = true
}
}
// TestIssue135_SignerPEMVariants confirms that all PEM block types understood
// by NewClientAssertionSigner are parsed correctly: PKCS#8 ("PRIVATE KEY"),
// PKCS#1 ("RSA PRIVATE KEY"), and SEC1 ("EC PRIVATE KEY").
func TestIssue135_SignerPEMVariants(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
t.Run("RSA PKCS8", func(t *testing.T) {
pemBytes := encodeRSAPKCS8(t, rsaKey)
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "k1")
require.NoError(t, err)
assertValidRSAJWT(t, rsaKey, signer, "RS256")
})
t.Run("RSA PKCS1", func(t *testing.T) {
der := x509.MarshalPKCS1PrivateKey(rsaKey)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "k1")
require.NoError(t, err)
assertValidRSAJWT(t, rsaKey, signer, "RS256")
})
t.Run("EC PKCS8", func(t *testing.T) {
pemBytes := encodeECPKCS8(t, ecKey)
signer, err := NewClientAssertionSigner(pemBytes, "ES256", "k1")
require.NoError(t, err)
jwtStr, err := signer.Sign("https://example.com/token", "cid")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
})
t.Run("EC SEC1", func(t *testing.T) {
der, err := x509.MarshalECPrivateKey(ecKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
signer, err := NewClientAssertionSigner(pemBytes, "ES256", "k1")
require.NoError(t, err)
jwtStr, err := signer.Sign("https://example.com/token", "cid")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
})
}
// ── B. Config validation ──────────────────────────────────────────────────────
// TestIssue135_ConfigValidation table-drives Config.Validate() for every
// client-authentication-related validation branch.
func TestIssue135_ConfigValidation(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
validPEM := string(encodeRSAPKCS8(t, rsaKey))
// baseConfig returns the minimum valid config, modified per test case.
base := func() *Config {
return &Config{
ProviderURL: "https://idp.example.com",
CallbackURL: "/cb",
ClientID: "cid",
ClientSecret: "secret",
SessionEncryptionKey: "01234567890123456789012345678901", // 32 chars
RateLimit: 100,
}
}
cases := []struct {
name string
mutate func(*Config)
wantErr string // empty = expect nil error
}{
{
name: "default empty method + secret ok",
mutate: func(c *Config) { /* nothing extra */ },
wantErr: "",
},
{
name: "explicit client_secret_post + secret ok",
mutate: func(c *Config) {
c.ClientAuthMethod = "client_secret_post"
},
wantErr: "",
},
{
name: "private_key_jwt inline key + kid ok",
mutate: func(c *Config) {
c.ClientAuthMethod = "private_key_jwt"
c.ClientSecret = ""
c.ClientAssertionPrivateKey = validPEM
c.ClientAssertionKeyID = "k1"
},
wantErr: "",
},
{
name: "private_key_jwt no key at all",
mutate: func(c *Config) {
c.ClientAuthMethod = "private_key_jwt"
c.ClientSecret = ""
c.ClientAssertionKeyID = "k1"
},
wantErr: "clientAssertionPrivateKey",
},
{
name: "private_key_jwt both inline and path",
mutate: func(c *Config) {
c.ClientAuthMethod = "private_key_jwt"
c.ClientSecret = ""
c.ClientAssertionPrivateKey = validPEM
c.ClientAssertionKeyPath = "/tmp/key.pem"
c.ClientAssertionKeyID = "k1"
},
wantErr: "only one of",
},
{
name: "private_key_jwt key but no kid",
mutate: func(c *Config) {
c.ClientAuthMethod = "private_key_jwt"
c.ClientSecret = ""
c.ClientAssertionPrivateKey = validPEM
},
wantErr: "clientAssertionKeyID",
},
{
name: "private_key_jwt unsupported alg HS256",
mutate: func(c *Config) {
c.ClientAuthMethod = "private_key_jwt"
c.ClientSecret = ""
c.ClientAssertionPrivateKey = validPEM
c.ClientAssertionKeyID = "k1"
c.ClientAssertionAlg = "HS256"
},
wantErr: "is not supported",
},
{
name: "unknown client auth method",
mutate: func(c *Config) {
c.ClientAuthMethod = "weird"
},
wantErr: "is not supported",
},
{
name: "client_secret_post with no secret",
mutate: func(c *Config) {
c.ClientAuthMethod = "client_secret_post"
c.ClientSecret = ""
},
wantErr: "clientSecret is required",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := base()
tc.mutate(cfg)
err := cfg.Validate()
if tc.wantErr == "" {
assert.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr,
"error must mention %q", tc.wantErr)
}
})
}
}
// TestIssue135_ConfigKeyPathLoadsFile verifies that buildClientAssertionSignerFromConfig
// reads the PEM key from disk when ClientAssertionKeyPath is set.
func TestIssue135_ConfigKeyPathLoadsFile(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
dir := t.TempDir()
keyFile := dir + "/private.pem"
require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600))
cfg := &Config{
ClientAuthMethod: "private_key_jwt",
ClientAssertionKeyPath: keyFile,
ClientAssertionKeyID: "file-kid",
ClientAssertionAlg: "RS256",
}
signer, err := buildClientAssertionSignerFromConfig(cfg)
require.NoError(t, err, "should load signer from key file")
require.NotNil(t, signer)
// Confirm signer produces a valid JWT.
jwtStr, err := signer.Sign("https://example.com/token", "client-from-file")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3, "should produce a 3-part JWT")
}
// ── C. Wire-up — exchangeTokens ───────────────────────────────────────────────
// TestIssue135_AuthCodeExchangeUsesAssertion confirms that exchangeTokens sends
// client_assertion + client_assertion_type instead of client_secret when a
// ClientAssertionSigner is configured, and that the assertion JWT is valid.
func TestIssue135_AuthCodeExchangeUsesAssertion(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
var capturedBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
capturedBody = body
w.Header().Set("Content-Type", "application/json")
// Return a minimal token response so exchangeTokens doesn't error.
_ = json.NewEncoder(w).Encode(TokenResponse{
AccessToken: "at",
IDToken: "it",
RefreshToken: "rt",
TokenType: "Bearer",
ExpiresIn: 3600,
})
}))
defer server.Close()
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "wire-kid")
require.NoError(t, err)
oidc := &TraefikOidc{
clientID: "wire-client",
tokenHTTPClient: server.Client(),
clientAssertion: signer,
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = server.URL
_, err = oidc.exchangeTokens(context.Background(), "authorization_code", "code-x", "https://app/cb", "")
require.NoError(t, err)
form, err := url.ParseQuery(string(capturedBody))
require.NoError(t, err)
assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
form.Get("client_assertion_type"), "client_assertion_type must be set")
assertionJWT := form.Get("client_assertion")
assert.NotEmpty(t, assertionJWT, "client_assertion must be present")
assert.Empty(t, form.Get("client_secret"), "client_secret must not be sent when using assertion")
assert.Equal(t, "wire-client", form.Get("client_id"))
assert.Equal(t, "code-x", form.Get("code"))
assert.Equal(t, "authorization_code", form.Get("grant_type"))
// Verify assertion JWT: header, claims, signature.
parts := strings.Split(assertionJWT, ".")
require.Len(t, parts, 3)
hdr := decodeJSONPart(t, parts[0])
assert.Equal(t, "RS256", hdr["alg"])
clms := decodeJSONPart(t, parts[1])
assert.Equal(t, "wire-client", clms["iss"])
assert.Equal(t, "wire-client", clms["sub"])
assert.Equal(t, server.URL, clms["aud"],
"audience must be the tokenURL (RFC 7523 §3)")
// Verify signature with RSA public key.
sigInput := parts[0] + "." + parts[1]
digest := sha256SumBytes([]byte(sigInput))
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
assert.NoError(t, rsa.VerifyPKCS1v15(&rsaKey.PublicKey, crypto.SHA256, digest, sigBytes))
}
// TestIssue135_RefreshTokenUsesAssertion verifies that the refresh_token grant
// type also sends client_assertion and the correct form fields.
func TestIssue135_RefreshTokenUsesAssertion(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
var capturedForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
capturedForm = r.Form
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{
AccessToken: "new-at",
TokenType: "Bearer",
ExpiresIn: 3600,
})
}))
defer server.Close()
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "rt-kid")
require.NoError(t, err)
oidc := &TraefikOidc{
clientID: "rt-client",
tokenHTTPClient: server.Client(),
clientAssertion: signer,
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = server.URL
_, err = oidc.exchangeTokens(context.Background(), "refresh_token", "rt-y", "", "")
require.NoError(t, err)
assert.Equal(t, "refresh_token", capturedForm.Get("grant_type"))
assert.Equal(t, "rt-y", capturedForm.Get("refresh_token"))
assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
capturedForm.Get("client_assertion_type"))
assert.NotEmpty(t, capturedForm.Get("client_assertion"))
assert.Empty(t, capturedForm.Get("client_secret"))
}
// TestIssue135_BackcompatClientSecretPath confirms that exchangeTokens sends
// client_secret and does NOT send client_assertion when clientAssertion is nil.
func TestIssue135_BackcompatClientSecretPath(t *testing.T) {
var capturedForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
capturedForm = r.Form
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{
AccessToken: "at",
TokenType: "Bearer",
ExpiresIn: 3600,
})
}))
defer server.Close()
oidc := &TraefikOidc{
clientID: "legacy-client",
clientSecret: "legacy-secret",
tokenHTTPClient: server.Client(),
clientAssertion: nil, // back-compat path
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = server.URL
_, err := oidc.exchangeTokens(context.Background(), "authorization_code", "code-bc", "https://app/cb", "")
require.NoError(t, err)
assert.Equal(t, "legacy-secret", capturedForm.Get("client_secret"),
"client_secret must be sent on the classic path")
assert.Empty(t, capturedForm.Get("client_assertion"),
"client_assertion must NOT be present on the classic path")
assert.Empty(t, capturedForm.Get("client_assertion_type"),
"client_assertion_type must NOT be present on the classic path")
}
// TestIssue135_ClientSecretBasicAuth verifies that when clientAuthMethod is
// "client_secret_basic", exchangeTokens sends an HTTP Basic Authorization
// header carrying url-encoded client_id:client_secret per RFC 6749 §2.3.1,
// and that neither client_id nor client_secret appears in the form body.
func TestIssue135_ClientSecretBasicAuth(t *testing.T) {
var capturedAuth string
var capturedForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, r.ParseForm())
capturedForm = r.Form
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{
AccessToken: "at-basic", TokenType: "Bearer", ExpiresIn: 3600,
})
}))
defer server.Close()
oidc := &TraefikOidc{
clientID: "basic-client",
clientSecret: "basic-secret",
clientAuthMethod: "client_secret_basic",
tokenHTTPClient: server.Client(),
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = server.URL
_, err := oidc.exchangeTokens(context.Background(), "authorization_code", "code-bb", "https://app/cb", "")
require.NoError(t, err)
require.True(t, strings.HasPrefix(capturedAuth, "Basic "),
"Authorization header must start with 'Basic ', got %q", capturedAuth)
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(capturedAuth, "Basic "))
require.NoError(t, err, "Authorization payload must be valid base64")
user, pass, ok := strings.Cut(string(raw), ":")
require.True(t, ok, "Authorization payload must contain a single ':' separator")
assert.Equal(t, "basic-client", user, "client_id should round-trip through QueryEscape")
assert.Equal(t, "basic-secret", pass, "client_secret should round-trip through QueryEscape")
assert.Empty(t, capturedForm.Get("client_id"),
"client_id must NOT be in the body when using client_secret_basic")
assert.Empty(t, capturedForm.Get("client_secret"),
"client_secret must NOT be in the body when using client_secret_basic")
assert.Empty(t, capturedForm.Get("client_assertion"),
"client_assertion must NOT be present on the basic-auth path")
}
// TestIssue135_ClientSecretBasicURLEncodesReservedChars verifies that
// credentials containing reserved characters (`:`, `+`, `/`, etc.) are
// form-urlencoded before base64 per RFC 6749 §2.3.1, so the receiving
// authorization server can decode them deterministically.
func TestIssue135_ClientSecretBasicURLEncodesReservedChars(t *testing.T) {
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{AccessToken: "at", TokenType: "Bearer", ExpiresIn: 3600})
}))
defer server.Close()
const (
clientID = "weird:id+1"
clientSecret = "p@ss/word=&" //nolint:gosec // test fixture
)
oidc := &TraefikOidc{
clientID: clientID,
clientSecret: clientSecret,
clientAuthMethod: "client_secret_basic",
tokenHTTPClient: server.Client(),
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = server.URL
_, err := oidc.exchangeTokens(context.Background(), "authorization_code", "c", "https://app/cb", "")
require.NoError(t, err)
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(capturedAuth, "Basic "))
require.NoError(t, err)
wantUser := url.QueryEscape(clientID)
wantPass := url.QueryEscape(clientSecret)
assert.Equal(t, wantUser+":"+wantPass, string(raw),
"both halves must be form-urlencoded before the base64 step")
}
// TestIssue135_ClientSecretBasicRevocation verifies that the revocation path
// honors client_secret_basic identically to the token path.
func TestIssue135_ClientSecretBasicRevocation(t *testing.T) {
var capturedAuth string
var capturedForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, r.ParseForm())
capturedForm = r.Form
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
oidc := &TraefikOidc{
clientID: "rev-basic",
clientSecret: "rev-secret",
clientAuthMethod: "client_secret_basic",
httpClient: server.Client(),
logger: GetSingletonNoOpLogger(),
}
oidc.tokenURL = "https://idp.example.com/token"
oidc.revocationURL = server.URL
require.NoError(t, oidc.RevokeTokenWithProvider("opaque-tok", "access_token"))
require.True(t, strings.HasPrefix(capturedAuth, "Basic "), "got %q", capturedAuth)
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(capturedAuth, "Basic "))
require.NoError(t, err)
assert.Equal(t, "rev-basic:rev-secret", string(raw))
assert.Equal(t, "opaque-tok", capturedForm.Get("token"))
assert.Equal(t, "access_token", capturedForm.Get("token_type_hint"))
assert.Empty(t, capturedForm.Get("client_id"),
"client_id must NOT be in body on Basic-auth revocation")
assert.Empty(t, capturedForm.Get("client_secret"),
"client_secret must NOT be in body on Basic-auth revocation")
}
// ── D. Wire-up — RevokeTokenWithProvider ────────────────────────────────────
// TestIssue135_RevocationUsesAssertion verifies that RevokeTokenWithProvider
// sends client_assertion (not client_secret), and that the assertion's audience
// is the tokenURL, not the revocationURL (per RFC 7523 §3).
func TestIssue135_RevocationUsesAssertion(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
const (
tokenEndpoint = "https://idp.example.com/token" // audience for assertion
clientIDVal = "revoke-client"
)
var capturedForm url.Values
// Revocation endpoint — deliberate separate URL to confirm audience != revocationURL.
revokeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
capturedForm = r.Form
w.WriteHeader(http.StatusOK)
}))
defer revokeServer.Close()
signer, err := NewClientAssertionSigner(pemBytes, "RS256", "rev-kid")
require.NoError(t, err)
oidc := &TraefikOidc{
clientID: clientIDVal,
clientAssertion: signer,
httpClient: revokeServer.Client(),
logger: GetSingletonNoOpLogger(),
}
// tokenURL drives assertion audience; revocationURL is where the POST goes.
oidc.tokenURL = tokenEndpoint
oidc.revocationURL = revokeServer.URL
err = oidc.RevokeTokenWithProvider("some-token", "refresh_token")
require.NoError(t, err)
assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
capturedForm.Get("client_assertion_type"))
assertionJWT := capturedForm.Get("client_assertion")
assert.NotEmpty(t, assertionJWT)
assert.Empty(t, capturedForm.Get("client_secret"),
"client_secret must not appear in revocation request with assertion")
// Verify the assertion audience is tokenURL (not revocationURL).
parts := strings.Split(assertionJWT, ".")
require.Len(t, parts, 3)
clms := decodeJSONPart(t, parts[1])
assert.Equal(t, tokenEndpoint, clms["aud"],
"assertion audience must be tokenURL, not revocationURL")
// Sanity-check cryptographic validity.
sigInput := parts[0] + "." + parts[1]
digest := sha256SumBytes([]byte(sigInput))
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
assert.NoError(t, rsa.VerifyPKCS1v15(&rsaKey.PublicKey, crypto.SHA256, digest, sigBytes))
}
// ── E. End-to-end via buildClientAssertionSignerFromConfig ───────────────────
// TestIssue135_BuildSignerFromInlineConfig confirms that the full config→signer
// pipeline works for an ES256 key specified inline in the Config struct.
func TestIssue135_BuildSignerFromInlineConfig(t *testing.T) {
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
pemBytes := encodeECPKCS8(t, ecKey)
cfg := &Config{
ClientAuthMethod: "private_key_jwt",
ClientAssertionPrivateKey: string(pemBytes),
ClientAssertionKeyID: "inline-ec-kid",
ClientAssertionAlg: "ES256",
}
signer, err := buildClientAssertionSignerFromConfig(cfg)
require.NoError(t, err)
require.NotNil(t, signer)
jwtStr, err := signer.Sign("https://example.com/token", "inline-client")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
hdr := decodeJSONPart(t, parts[0])
assert.Equal(t, "ES256", hdr["alg"])
assert.Equal(t, "inline-ec-kid", hdr["kid"])
// Verify the EC signature.
byteLen := (elliptic.P256().Params().BitSize + 7) / 8
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
require.Len(t, sigBytes, 2*byteLen)
r := new(big.Int).SetBytes(sigBytes[:byteLen])
s := new(big.Int).SetBytes(sigBytes[byteLen:])
sigInput := parts[0] + "." + parts[1]
digest := sha256SumBytes([]byte(sigInput))
assert.True(t, ecdsa.Verify(&ecKey.PublicKey, digest, r, s))
}
// TestIssue135_BuildSignerDefaultsToRS256 verifies that an empty
// ClientAssertionAlg defaults to RS256.
func TestIssue135_BuildSignerDefaultsToRS256(t *testing.T) {
rsaKey := genRSAKey(t, 2048)
pemBytes := encodeRSAPKCS8(t, rsaKey)
cfg := &Config{
ClientAssertionPrivateKey: string(pemBytes),
ClientAssertionKeyID: "default-alg-kid",
ClientAssertionAlg: "", // intentionally empty
}
signer, err := buildClientAssertionSignerFromConfig(cfg)
require.NoError(t, err)
jwtStr, err := signer.Sign("https://example.com/token", "default-client")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
hdr := decodeJSONPart(t, parts[0])
assert.Equal(t, "RS256", hdr["alg"], "empty alg must default to RS256")
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// genRSAKey generates an RSA key of the given bit size, failing the test on error.
func genRSAKey(t *testing.T, bits int) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, bits)
require.NoError(t, err)
return k
}
// encodeRSAPKCS8 marshals an RSA key as PKCS#8 PEM ("PRIVATE KEY").
func encodeRSAPKCS8(t *testing.T, key *rsa.PrivateKey) []byte {
t.Helper()
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
// encodeECPKCS8 marshals an EC key as PKCS#8 PEM ("PRIVATE KEY").
func encodeECPKCS8(t *testing.T, key *ecdsa.PrivateKey) []byte {
t.Helper()
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
// decodeJSONPart base64url-decodes a JWT part and parses it as a JSON object.
func decodeJSONPart(t *testing.T, b64url string) map[string]any {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(b64url)
require.NoError(t, err, "base64url decode of JWT part failed")
var m map[string]any
require.NoError(t, json.Unmarshal(raw, &m), "JSON unmarshal of JWT part failed")
return m
}
// sha256SumBytes returns the SHA-256 digest of b as a byte slice.
func sha256SumBytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
// assertValidRSAJWT signs a JWT with signer and verifies the RS256 signature
// against the given RSA public key. Used by PEM variant tests.
func assertValidRSAJWT(t *testing.T, key *rsa.PrivateKey, signer *ClientAssertionSigner, alg string) {
t.Helper()
jwtStr, err := signer.Sign("https://example.com/token", "pem-client")
require.NoError(t, err)
parts := strings.Split(jwtStr, ".")
require.Len(t, parts, 3)
hdr := decodeJSONPart(t, parts[0])
assert.Equal(t, alg, hdr["alg"])
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
sigInput := parts[0] + "." + parts[1]
digest := sha256SumBytes([]byte(sigInput))
assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes))
}