diff --git a/.traefik.yml b/.traefik.yml
index f4d8e52..216e3f6 100644
--- a/.traefik.yml
+++ b/.traefik.yml
@@ -23,6 +23,19 @@ testData:
providerURL: https://accounts.google.com
clientID: 1234567890.apps.googleusercontent.com
clientSecret: your-client-secret
+ # Alternative: RFC 7523 private_key_jwt client authentication (Entra ID,
+ # Okta, Auth0, Keycloak). Replaces clientSecret with a signed JWT assertion.
+ # See README "Client authentication via private key JWT".
+ # clientAuthMethod: private_key_jwt
+ # clientAssertionKeyID: my-key-2026
+ # clientAssertionAlg: RS256 # default; or PS256/384/512, ES256/384/512
+ # # File path option:
+ # clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
+ # # Or inline PEM (PKCS#8 / PKCS#1 / SEC1):
+ # clientAssertionPrivateKey: |
+ # -----BEGIN PRIVATE KEY-----
+ # MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDexampleexample
+ # -----END PRIVATE KEY-----
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
callbackURL: /oauth2/callback
diff --git a/README.md b/README.md
index 92aafdf..61d8c03 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ More example configs in [`examples/`](examples/).
|-----------|-------------|
| `providerURL` | Issuer URL (used for OIDC discovery). |
| `clientID` | OAuth 2.0 client ID. |
-| `clientSecret` | OAuth 2.0 client secret. Supports `urn:k8s:secret:ns:name:key`. |
+| `clientSecret` | OAuth 2.0 client secret. Supports `urn:k8s:secret:ns:name:key`. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`; optional with `private_key_jwt`. |
| `sessionEncryptionKey` | Cookie encryption key, **min 32 bytes**. |
| `callbackURL` | Callback path, e.g. `/oauth2/callback`. |
@@ -133,6 +133,11 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
| `insecureSkipVerify` | `false` | **Local dev only.** Disables TLS verification, logs a security warning. |
+| `clientAuthMethod` | `client_secret_post` | Client auth method. Set `private_key_jwt` for RFC 7523 JWT assertions (Entra ID, Okta, Auth0, Keycloak). See [Client authentication via private key JWT](#client-authentication-via-private-key-jwt). |
+| `clientAssertionPrivateKey` | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. |
+| `clientAssertionKeyPath` | none | File path to PEM private key for `private_key_jwt`. |
+| `clientAssertionKeyID` | none | JWS `kid` header. Required when `clientAuthMethod=private_key_jwt`; must match the public key registered with the IdP. |
+| `clientAssertionAlg` | `RS256` | JWS alg for `private_key_jwt`. Supported: `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
| `enableBackchannelLogout` / `backchannelLogoutURL` | `false` / none | OIDC Back-Channel Logout (server-to-server). |
| `enableFrontchannelLogout` / `frontchannelLogoutURL` | `false` / none | OIDC Front-Channel Logout (iframe). |
| `redis` | disabled | See [docs/REDIS.md](docs/REDIS.md). |
@@ -213,6 +218,44 @@ caCertPEM: |
Both can be combined. An unparseable bundle fails the plugin at startup.
See [#125](https://github.com/lukaszraczylo/traefikoidc/issues/125).
+### Client authentication via private key JWT
+
+Use when your IdP enforces short-lived secrets or pushes secretless client auth
+— Microsoft Entra ID / Azure AD, Okta, Auth0, Keycloak. Instead of sending a
+static `clientSecret`, the plugin signs a short-lived JWT and submits it as
+`client_assertion` per [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523).
+
+Minimal config:
+
+```yaml
+clientAuthMethod: private_key_jwt
+clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
+clientAssertionKeyID: my-key-2026
+# clientAssertionAlg: RS256 # default; or PS256/384/512, ES256/384/512
+```
+
+Or inline:
+
+```yaml
+clientAuthMethod: private_key_jwt
+clientAssertionPrivateKey: |
+ -----BEGIN PRIVATE KEY-----
+ ...
+ -----END PRIVATE KEY-----
+clientAssertionKeyID: my-key-2026
+```
+
+Accepted PEM forms: PKCS#8 (`PRIVATE KEY`), PKCS#1 (`RSA PRIVATE KEY`), SEC1
+(`EC PRIVATE KEY`). The assertion uses `iss=sub=clientID`, `aud=tokenURL`, 60s
+lifetime, random hex `jti` per request. Sent on `/token` (auth-code + refresh)
+and `/revoke`. The `kid` must match the public key registered with the IdP.
+
+`clientSecret` becomes optional with `private_key_jwt`. Existing
+`client_secret_post` setups are unaffected. Keys are parsed once at startup —
+rotation requires a Traefik reload.
+
+See [issue #135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
+
### Environment variable names containing `API`
Traefik reserves `TRAEFIK_API_*`. User vars whose name contains `API` (e.g.
diff --git a/client_assertion.go b/client_assertion.go
new file mode 100644
index 0000000..cbfd253
--- /dev/null
+++ b/client_assertion.go
@@ -0,0 +1,295 @@
+package traefikoidc
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/sha512"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "math/big"
+ "os"
+ "time"
+)
+
+// isSupportedClientAssertionAlg reports whether alg is a recognized JWS
+// algorithm for private_key_jwt (RFC 7523 §2.2).
+func isSupportedClientAssertionAlg(alg string) bool {
+ switch alg {
+ case "RS256", "RS384", "RS512",
+ "PS256", "PS384", "PS512",
+ "ES256", "ES384", "ES512":
+ return true
+ }
+ return false
+}
+
+// ClientAssertionSigner builds and signs client_assertion JWTs (RFC 7523 §2.2).
+type ClientAssertionSigner struct {
+ key crypto.PrivateKey
+ alg string
+ kid string
+ // rand is the entropy source for jti generation and PSS/ECDSA signing.
+ // Defaults to crypto/rand.Reader when nil.
+ rand io.Reader
+ // now returns the current time. Defaults to time.Now when nil.
+ now func() time.Time
+}
+
+// NewClientAssertionSigner parses pemBytes as a private key, validates that
+// alg is consistent with the key type, and returns a ready-to-use signer.
+// kid is placed verbatim in the JWS header.
+//
+// PEM block types understood:
+// - "PRIVATE KEY" → PKCS#8 (tried first for all types)
+// - "RSA PRIVATE KEY" → PKCS#1
+// - "EC PRIVATE KEY" → SEC1
+func NewClientAssertionSigner(pemBytes []byte, alg, kid string) (*ClientAssertionSigner, error) {
+ if !isSupportedClientAssertionAlg(alg) {
+ return nil, fmt.Errorf("unsupported client assertion alg %q", alg)
+ }
+ if kid == "" {
+ return nil, fmt.Errorf("kid must not be empty")
+ }
+
+ block, _ := pem.Decode(pemBytes)
+ if block == nil {
+ return nil, fmt.Errorf("no PEM block found in private key material")
+ }
+
+ var key crypto.PrivateKey
+ var parseErr error
+
+ switch block.Type {
+ case "PRIVATE KEY":
+ key, parseErr = x509.ParsePKCS8PrivateKey(block.Bytes)
+ case "RSA PRIVATE KEY":
+ key, parseErr = x509.ParsePKCS1PrivateKey(block.Bytes)
+ case "EC PRIVATE KEY":
+ key, parseErr = x509.ParseECPrivateKey(block.Bytes)
+ default:
+ // Best-effort fallback for unknown block types.
+ key, parseErr = x509.ParsePKCS8PrivateKey(block.Bytes)
+ }
+ if parseErr != nil {
+ return nil, fmt.Errorf("failed to parse private key (block type %q): %w", block.Type, parseErr)
+ }
+
+ if err := validateAlgKeyMatch(alg, key); err != nil {
+ return nil, err
+ }
+
+ return &ClientAssertionSigner{key: key, alg: alg, kid: kid}, nil
+}
+
+// validateAlgKeyMatch returns an error when alg implies a key type that does
+// not match the actual key.
+func validateAlgKeyMatch(alg string, key crypto.PrivateKey) error {
+ switch alg[0] {
+ case 'R', 'P': // RS* or PS*
+ if _, ok := key.(*rsa.PrivateKey); !ok {
+ return fmt.Errorf("alg %q requires an RSA key, got %T", alg, key)
+ }
+ case 'E': // ES*
+ if _, ok := key.(*ecdsa.PrivateKey); !ok {
+ return fmt.Errorf("alg %q requires an EC key, got %T", alg, key)
+ }
+ }
+ return nil
+}
+
+// Sign constructs and returns a signed client_assertion JWT.
+// audience is typically the token endpoint URL (RFC 7523 §3).
+// clientID is used as both iss and sub per RFC 7523 §2.2.
+func (s *ClientAssertionSigner) Sign(audience, clientID string) (string, error) {
+ rander := s.rand
+ if rander == nil {
+ rander = rand.Reader
+ }
+ nowFn := s.now
+ if nowFn == nil {
+ nowFn = time.Now
+ }
+
+ now := nowFn()
+
+ // 16 random bytes as lowercase hex for jti uniqueness.
+ jtiBytes := make([]byte, 16)
+ if _, err := io.ReadFull(rander, jtiBytes); err != nil {
+ return "", fmt.Errorf("failed to generate jti: %w", err)
+ }
+ jti := hex.EncodeToString(jtiBytes)
+
+ header := map[string]string{
+ "alg": s.alg,
+ "typ": "JWT",
+ "kid": s.kid,
+ }
+ hdrJSON, err := json.Marshal(header)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JWT header: %w", err)
+ }
+
+ claims := map[string]any{
+ "iss": clientID,
+ "sub": clientID,
+ "aud": audience,
+ "jti": jti,
+ "iat": now.Unix(),
+ "exp": now.Add(60 * time.Second).Unix(),
+ }
+ claimsJSON, err := json.Marshal(claims)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
+ }
+
+ hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
+ claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
+ signingInput := hdrB64 + "." + claimsB64
+
+ sig, err := s.sign(rander, []byte(signingInput))
+ if err != nil {
+ return "", err
+ }
+
+ return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
+
+// sign computes raw signature bytes for signingInput per s.alg.
+// validateAlgKeyMatch in NewClientAssertionSigner guarantees the key type
+// matches s.alg, but the comma-ok asserts here keep errcheck happy and
+// surface internal misuse loudly instead of via panic.
+func (s *ClientAssertionSigner) sign(rander io.Reader, input []byte) ([]byte, error) {
+ switch s.alg {
+ case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
+ rsaKey, ok := s.key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("internal: alg %q requires *rsa.PrivateKey, got %T", s.alg, s.key)
+ }
+ hash := rsaHashForAlg(s.alg)
+ digest := hashSum(hash, input)
+ if s.alg[0] == 'R' {
+ return signRSAPKCS1v15(rander, rsaKey, hash, digest)
+ }
+ return signRSAPSS(rander, rsaKey, hash, digest)
+ case "ES256", "ES384", "ES512":
+ ecKey, ok := s.key.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("internal: alg %q requires *ecdsa.PrivateKey, got %T", s.alg, s.key)
+ }
+ hash := ecHashForAlg(s.alg)
+ digest := hashSum(hash, input)
+ return signECDSA(rander, ecKey, digest)
+ }
+ return nil, fmt.Errorf("unhandled alg %q", s.alg)
+}
+
+func rsaHashForAlg(alg string) crypto.Hash {
+ switch alg {
+ case "RS256", "PS256":
+ return crypto.SHA256
+ case "RS384", "PS384":
+ return crypto.SHA384
+ case "RS512", "PS512":
+ return crypto.SHA512
+ }
+ return 0
+}
+
+func ecHashForAlg(alg string) crypto.Hash {
+ switch alg {
+ case "ES256":
+ return crypto.SHA256
+ case "ES384":
+ return crypto.SHA384
+ case "ES512":
+ return crypto.SHA512
+ }
+ return 0
+}
+
+func hashSum(h crypto.Hash, input []byte) []byte {
+ switch h {
+ case crypto.SHA256:
+ sum := sha256.Sum256(input)
+ return sum[:]
+ case crypto.SHA384:
+ sum := sha512.Sum384(input)
+ return sum[:]
+ case crypto.SHA512:
+ sum := sha512.Sum512(input)
+ return sum[:]
+ }
+ return nil
+}
+
+func signRSAPKCS1v15(rander io.Reader, key *rsa.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) {
+ sig, err := rsa.SignPKCS1v15(rander, key, hash, digest)
+ if err != nil {
+ return nil, fmt.Errorf("RSA PKCS1v15 signing failed: %w", err)
+ }
+ return sig, nil
+}
+
+func signRSAPSS(rander io.Reader, key *rsa.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) {
+ opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: hash}
+ sig, err := rsa.SignPSS(rander, key, hash, digest, opts)
+ if err != nil {
+ return nil, fmt.Errorf("RSA PSS signing failed: %w", err)
+ }
+ return sig, nil
+}
+
+// signECDSA produces the JWS raw r||s signature (RFC 7515 App. A.3).
+// Each scalar is zero-padded to (curve.BitSize+7)/8 bytes.
+func signECDSA(rander io.Reader, key *ecdsa.PrivateKey, digest []byte) ([]byte, error) {
+ r, ss, err := ecdsa.Sign(rander, key, digest)
+ if err != nil {
+ return nil, fmt.Errorf("ECDSA signing failed: %w", err)
+ }
+ byteLen := (key.Curve.Params().BitSize + 7) / 8
+ sig := make([]byte, 2*byteLen)
+ padBigInt(sig[0:byteLen], r)
+ padBigInt(sig[byteLen:], ss)
+ return sig, nil
+}
+
+// padBigInt writes n as a fixed-width big-endian integer into buf.
+func padBigInt(buf []byte, n *big.Int) {
+ b := n.Bytes()
+ copy(buf[len(buf)-len(b):], b)
+}
+
+// buildClientAssertionSignerFromConfig loads key material and constructs a
+// ClientAssertionSigner. Called from NewWithContext when
+// ClientAuthMethod == "private_key_jwt".
+func buildClientAssertionSignerFromConfig(config *Config) (*ClientAssertionSigner, error) {
+ var pemBytes []byte
+
+ if config.ClientAssertionPrivateKey != "" {
+ pemBytes = []byte(config.ClientAssertionPrivateKey)
+ } else {
+ data, err := os.ReadFile(config.ClientAssertionKeyPath)
+ if err != nil {
+ return nil, fmt.Errorf("read clientAssertionKeyPath %q: %w", config.ClientAssertionKeyPath, err)
+ }
+ pemBytes = data
+ }
+
+ alg := config.ClientAssertionAlg
+ if alg == "" {
+ alg = "RS256"
+ }
+
+ return NewClientAssertionSigner(pemBytes, alg, config.ClientAssertionKeyID)
+}
+
+
+
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index fa516aa..6a2697b 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -5,6 +5,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
## Table of Contents
- [Required Parameters](#required-parameters)
+- [Client Authentication](#client-authentication)
- [Optional Parameters](#optional-parameters)
- [Security Options](#security-options)
- [Session Management](#session-management)
@@ -22,7 +23,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|-----------|------|-------------|---------|
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
-| `clientSecret` | string | OAuth 2.0 client secret | `your-client-secret` |
+| `clientSecret` | string | OAuth 2.0 client secret. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`. Optional when `clientAuthMethod: private_key_jwt`. | `your-client-secret` |
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
@@ -45,6 +46,129 @@ spec:
---
+## Client Authentication
+
+The middleware supports three client authentication methods at the token and
+revocation endpoints. The default is `client_secret_post` (current behavior);
+`private_key_jwt` is opt-in and backwards compatible.
+
+| Method | Default | Description |
+|--------|---------|-------------|
+| `client_secret_post` | yes | `client_id` + `client_secret` in the request body. |
+| `client_secret_basic` | no | RFC 6749 §2.3.1 — `client_id` + `client_secret` in the `Authorization: Basic` header (form-urlencoded then base64); not in the body. |
+| `private_key_jwt` | no | RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as `client_assertion`. |
+
+Select via `clientAuthMethod`:
+
+```yaml
+clientAuthMethod: private_key_jwt
+```
+
+### client_secret_post
+
+Default. The plugin sends `client_id` and `client_secret` as form parameters
+in the token / revocation request body. No additional configuration required.
+
+### private_key_jwt
+
+Asymmetric client authentication per
+[RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523). Use this when your
+IdP enforces short secret TTLs, when policy mandates secretless clients, or
+when you want to avoid distributing a shared secret to the proxy.
+
+For each token / revocation request the plugin builds a JWS with:
+
+- `iss` = `sub` = `clientID`
+- `aud` = token endpoint URL
+- `iat` = now, `exp` = now + 60s
+- `jti` = random hex per request
+- `kid` header = `clientAssertionKeyID`
+
+**Required fields:**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `clientAuthMethod` | string | `client_secret_post` | Set to `private_key_jwt`. |
+| `clientAssertionPrivateKey` | string | none | Inline PEM private key. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8, PKCS#1, and SEC1 formats accepted. |
+| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk. Mutually exclusive with `clientAssertionPrivateKey`. |
+| `clientAssertionKeyID` | string | none | `kid` header inserted in the JWS. Must match the public key registered with the IdP. |
+| `clientAssertionAlg` | string | `RS256` | One of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`. |
+
+When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
+
+**Example — inline PEM:**
+
+```yaml
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: oidc-auth
+spec:
+ plugin:
+ traefikoidc:
+ providerURL: https://idp.example.com
+ clientID: my-client-id
+ sessionEncryptionKey: your-32-byte-encryption-key-here
+ callbackURL: /oauth2/callback
+ clientAuthMethod: private_key_jwt
+ clientAssertionKeyID: key-2026-01
+ clientAssertionAlg: RS256
+ clientAssertionPrivateKey: |
+ -----BEGIN PRIVATE KEY-----
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
+ MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
+ ... (truncated) ...
+ -----END PRIVATE KEY-----
+```
+
+**Example — key on disk:**
+
+```yaml
+clientAuthMethod: private_key_jwt
+clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
+clientAssertionKeyID: key-2026-01
+clientAssertionAlg: RS256
+```
+
+**Generating an RS256 key with OpenSSL:**
+
+```bash
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
+ -out client-key.pem
+openssl rsa -in client-key.pem -pubout -out client-pub.pem
+```
+
+Register `client-pub.pem` (or its JWK form) with your IdP under the same
+`kid` you set in `clientAssertionKeyID`.
+
+**Notes:**
+
+- The private key is parsed once at plugin startup. Key rotation requires a
+ Traefik reload.
+- Assertion lifetime is fixed at 60 seconds.
+- A fresh random `jti` is generated per request.
+- The `aud` claim is the token endpoint URL (from discovery).
+- Tracking issue:
+ [#135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
+
+### client_secret_basic
+
+Per [RFC 6749 §2.3.1][rfc6749-2-3-1], the plugin sends the client credentials
+in an `Authorization: Basic` header instead of the body. Both halves
+(`client_id`, `client_secret`) are form-urlencoded individually, joined with
+a colon, then base64-encoded. Use this when your IdP requires Basic auth at
+the token endpoint and rejects credentials in the body.
+
+```yaml
+clientAuthMethod: client_secret_basic
+clientID: your-client-id
+clientSecret: your-client-secret
+```
+
+[rfc6749-2-3-1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
+
+---
+
## Optional Parameters
| Parameter | Type | Default | Description |
@@ -59,6 +183,11 @@ spec:
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
+| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
+| `clientAssertionPrivateKey` | string | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8 / PKCS#1 / SEC1. |
+| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
+| `clientAssertionKeyID` | string | none | `kid` header for `private_key_jwt` assertions. Required when `clientAuthMethod: private_key_jwt`. |
+| `clientAssertionAlg` | string | `RS256` | Signing algorithm for `private_key_jwt`. One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
### TLS Termination at Load Balancer
diff --git a/docs/index.html b/docs/index.html
index 92267d5..254de40 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -642,7 +642,7 @@ spec:
clientSecret |
- OAuth 2.0 client secret |
+ OAuth 2.0 client secret. Only required when clientAuthMethod is unset or client_secret_post/client_secret_basic. |
sessionEncryptionKey |
@@ -753,15 +753,48 @@ spec:
false |
Require RFC 7662 introspection for opaque tokens |
-
+
disableReplayDetection |
false |
Disable JTI replay detection (for multi-replica without Redis) |
+
+ clientAuthMethod |
+ client_secret_post |
+ Selects how the plugin authenticates to the token endpoint. One of client_secret_post, client_secret_basic, private_key_jwt. |
+
+
+ clientAssertionPrivateKey |
+ none |
+ Inline PEM private key used to sign client assertions for private_key_jwt. |
+
+
+ clientAssertionKeyPath |
+ none |
+ Path to a PEM private key file. Alternative to clientAssertionPrivateKey. |
+
+
+ clientAssertionKeyID |
+ none |
+ JWS kid header value. Required when clientAuthMethod is private_key_jwt. |
+
+
+ clientAssertionAlg |
+ RS256 |
+ Signing algorithm for the client assertion. One of RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384/ES512. |
+
+
+
Private Key JWT (RFC 7523)
+
Use this when your IdP (Entra ID, Okta, Auth0, Keycloak) pressures short-lived secrets, or when policy mandates secretless service-to-service authentication. The plugin signs a 60-second assertion with the configured private key and sends it as client_assertion instead of client_secret. Public-key registration on the IdP replaces shared-secret rotation. See RFC 7523 and issue #135.
+
clientAuthMethod: private_key_jwt
+clientAssertionKeyPath: /etc/traefik/oidc-client.pem
+clientAssertionKeyID: my-client-key-2026
+# clientSecret no longer required
+
Example: Google Workspace with Domain Restriction
diff --git a/examples/complete-traefik-config.yaml b/examples/complete-traefik-config.yaml
index 95c2079..939c365 100644
--- a/examples/complete-traefik-config.yaml
+++ b/examples/complete-traefik-config.yaml
@@ -101,6 +101,16 @@ http:
providerURL: "https://auth.example.com"
callbackURL: "/oauth2/callback"
+ # ----------------------------------------------------------------
+ # Optional: switch to RFC 7523 private_key_jwt client auth
+ # (Entra ID, Okta, Auth0, Keycloak). Replaces clientSecret with a
+ # signed JWT assertion. See README for details and PEM formats.
+ # ----------------------------------------------------------------
+ # clientAuthMethod: "private_key_jwt"
+ # clientAssertionKeyPath: "/etc/traefik/oidc/client-key.pem"
+ # clientAssertionKeyID: "prod-key-2026"
+ # clientAssertionAlg: "RS256" # or PS256/384/512, ES256/384/512
+
# Session Configuration
sessionEncryptionKey: "prod-encryption-key-64-chars-long-keep-it-secret-and-safe"
sessionMaxAge: 28800 # 8 hours
diff --git a/helpers.go b/helpers.go
index 300d5e5..be7d93e 100644
--- a/helpers.go
+++ b/helpers.go
@@ -107,9 +107,12 @@ type TokenResponse struct {
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant)
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
data := url.Values{
- "grant_type": {grantType},
- "client_id": {t.clientID},
- "client_secret": {t.clientSecret},
+ "grant_type": {grantType},
+ }
+ // client_id is sent in the body for every method except client_secret_basic,
+ // where it is carried in the Authorization header per RFC 6749 §2.3.1.
+ if t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
+ data.Set("client_id", t.clientID)
}
if grantType == "authorization_code" {
@@ -141,16 +144,33 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
}
}
- // Read tokenURL with RLock
+ // Read tokenURL with RLock — needed as audience for private_key_jwt (RFC 7523 §3).
t.metadataMu.RLock()
tokenURL := t.tokenURL
t.metadataMu.RUnlock()
+ useBasicAuth := false
+ if t.clientAssertion != nil {
+ assertion, err := t.clientAssertion.Sign(tokenURL, t.clientID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to sign client assertion: %w", err)
+ }
+ data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
+ data.Set("client_assertion", assertion)
+ } else if t.clientAuthMethod == "client_secret_basic" {
+ useBasicAuth = true
+ } else {
+ data.Set("client_secret", t.clientSecret)
+ }
+
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if useBasicAuth {
+ setOAuthBasicAuth(req, t.clientID, t.clientSecret)
+ }
resp, err := client.Do(req)
if err != nil {
@@ -423,6 +443,19 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
return u.String(), nil
}
+// setOAuthBasicAuth sets the Authorization header per RFC 6749 §2.3.1: the
+// client_id and client_secret are form-urlencoded individually, joined with a
+// colon, then base64-encoded. This differs from http.Request.SetBasicAuth,
+// which skips the form-urlencode step — that matters for credentials with
+// reserved characters (`:`, `@`, `+`, `%`, etc.) where the wire format would
+// otherwise diverge from what the spec mandates.
+func setOAuthBasicAuth(req *http.Request, clientID, clientSecret string) {
+ user := url.QueryEscape(clientID)
+ pass := url.QueryEscape(clientSecret)
+ auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
+ req.Header.Set("Authorization", "Basic "+auth)
+}
+
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
// This ensures that OAuth scope parameters don't contain duplicates which could
// cause issues with some authorization servers.
diff --git a/issue135_regression_test.go b/issue135_regression_test.go
new file mode 100644
index 0000000..ddcf316
--- /dev/null
+++ b/issue135_regression_test.go
@@ -0,0 +1,925 @@
+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))
+}
+
diff --git a/main.go b/main.go
index 38747e8..6682464 100644
--- a/main.go
+++ b/main.go
@@ -169,6 +169,12 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
introspectionCache: cacheManager.GetSharedIntrospectionCache(), // Cache for introspection results
clientID: config.ClientID,
clientSecret: config.ClientSecret,
+ clientAuthMethod: func() string {
+ if config.ClientAuthMethod != "" {
+ return config.ClientAuthMethod
+ }
+ return "client_secret_post"
+ }(),
audience: func() string {
if config.Audience != "" {
return config.Audience
@@ -273,6 +279,15 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
// rotates refresh tokens (Zitadel/Authentik default).
t.refreshCoordinator = NewRefreshCoordinator(DefaultRefreshCoordinatorConfig(), t.logger)
+ if config.ClientAuthMethod == "private_key_jwt" {
+ signer, err := buildClientAssertionSignerFromConfig(config)
+ if err != nil {
+ cancelFunc()
+ return nil, fmt.Errorf("failed to build client assertion signer: %w", err)
+ }
+ t.clientAssertion = signer
+ }
+
t.extractClaimsFunc = extractClaims
t.initiateAuthenticationFunc = func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
diff --git a/settings.go b/settings.go
index 1dacfe0..516e4db 100644
--- a/settings.go
+++ b/settings.go
@@ -93,6 +93,38 @@ type Config struct {
// providers. Enabling this in production is a security hole — prefer
// CACertPath/CACertPEM. Emits a loud warning at startup.
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
+
+ // ClientAuthMethod selects the OAuth 2.0 client authentication method used
+ // at the token / revocation / introspection endpoints. Supported values:
+ //
+ // - "client_secret_post" (default, current behavior): clientSecret is
+ // sent in the request body alongside client_id.
+ // - "private_key_jwt" (RFC 7523 §2.2): the plugin signs a short-lived JWT
+ // assertion with a configured private key and sends it as
+ // client_assertion. Use this when your IdP enforces short-lived secrets
+ // or mandates secretless client auth (Entra ID, Okta, Auth0, Keycloak).
+ //
+ // When set to "private_key_jwt", clientSecret may be left empty and one of
+ // clientAssertionPrivateKey / clientAssertionKeyPath must be configured.
+ ClientAuthMethod string `json:"clientAuthMethod,omitempty"`
+
+ // ClientAssertionPrivateKey is an inline PEM-encoded private key used to
+ // sign client_assertion JWTs. Mutually exclusive with
+ // ClientAssertionKeyPath. Supports PKCS#8, PKCS#1 (RSA), and SEC1 (EC).
+ ClientAssertionPrivateKey string `json:"clientAssertionPrivateKey,omitempty"`
+
+ // ClientAssertionKeyPath is a filesystem path to a PEM-encoded private key,
+ // equivalent to ClientAssertionPrivateKey but loaded from disk.
+ ClientAssertionKeyPath string `json:"clientAssertionKeyPath,omitempty"`
+
+ // ClientAssertionKeyID is the JWK key id (kid) advertised in the JWS
+ // header. Required when using private_key_jwt so the IdP can locate the
+ // matching public key registered for the client.
+ ClientAssertionKeyID string `json:"clientAssertionKeyID,omitempty"`
+
+ // ClientAssertionAlg is the JWS signing algorithm. Defaults to RS256.
+ // Supported: RS256/384/512, PS256/384/512, ES256/384/512.
+ ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
}
// loadCACertPool assembles an x509.CertPool from CACertPath and CACertPEM.
@@ -323,8 +355,30 @@ func (c *Config) Validate() error {
if c.ClientID == "" {
return fmt.Errorf("clientID is required")
}
- if c.ClientSecret == "" {
- return fmt.Errorf("clientSecret is required")
+ authMethod := c.ClientAuthMethod
+ if authMethod == "" {
+ authMethod = "client_secret_post"
+ }
+ switch authMethod {
+ case "client_secret_post", "client_secret_basic":
+ if c.ClientSecret == "" {
+ return fmt.Errorf("clientSecret is required when clientAuthMethod is %q", authMethod)
+ }
+ case "private_key_jwt":
+ if c.ClientAssertionPrivateKey == "" && c.ClientAssertionKeyPath == "" {
+ return fmt.Errorf("clientAssertionPrivateKey or clientAssertionKeyPath is required when clientAuthMethod is private_key_jwt")
+ }
+ if c.ClientAssertionPrivateKey != "" && c.ClientAssertionKeyPath != "" {
+ return fmt.Errorf("only one of clientAssertionPrivateKey or clientAssertionKeyPath may be set")
+ }
+ if c.ClientAssertionKeyID == "" {
+ return fmt.Errorf("clientAssertionKeyID is required when clientAuthMethod is private_key_jwt")
+ }
+ if c.ClientAssertionAlg != "" && !isSupportedClientAssertionAlg(c.ClientAssertionAlg) {
+ return fmt.Errorf("clientAssertionAlg %q is not supported (use RS256/384/512, PS256/384/512, or ES256/384/512)", c.ClientAssertionAlg)
+ }
+ default:
+ return fmt.Errorf("clientAuthMethod %q is not supported", authMethod)
}
// Validate session encryption key
diff --git a/token_manager.go b/token_manager.go
index 9463410..142060e 100644
--- a/token_manager.go
+++ b/token_manager.go
@@ -660,11 +660,33 @@ func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error {
}
t.logger.Debugf("Attempting to revoke token (type: %s) with provider at %s", tokenType, revocationURL)
+ // Read tokenURL with RLock — used as audience for private_key_jwt (RFC 7523 §3).
+ t.metadataMu.RLock()
+ tokenURL := t.tokenURL
+ t.metadataMu.RUnlock()
+
data := url.Values{
"token": {token},
"token_type_hint": {tokenType},
- "client_id": {t.clientID},
- "client_secret": {t.clientSecret},
+ }
+ // client_id is sent in the body for every method except client_secret_basic,
+ // where it is carried in the Authorization header per RFC 6749 §2.3.1.
+ if t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
+ data.Set("client_id", t.clientID)
+ }
+
+ useBasicAuth := false
+ if t.clientAssertion != nil {
+ assertion, err := t.clientAssertion.Sign(tokenURL, t.clientID)
+ if err != nil {
+ return fmt.Errorf("failed to sign client assertion: %w", err)
+ }
+ data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
+ data.Set("client_assertion", assertion)
+ } else if t.clientAuthMethod == "client_secret_basic" {
+ useBasicAuth = true
+ } else {
+ data.Set("client_secret", t.clientSecret)
}
req, err := http.NewRequestWithContext(context.Background(), "POST", revocationURL, strings.NewReader(data.Encode()))
@@ -674,6 +696,9 @@ func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
+ if useBasicAuth {
+ setOAuthBasicAuth(req, t.clientID, t.clientSecret)
+ }
// Send the request with circuit breaker protection if available
var resp *http.Response
diff --git a/types.go b/types.go
index 64470d2..08c00ae 100644
--- a/types.go
+++ b/types.go
@@ -119,6 +119,8 @@ type TraefikOidc struct {
audience string
clientID string
clientSecret string
+ clientAuthMethod string
+ clientAssertion *ClientAssertionSigner
registrationURL string
backchannelLogoutPath string
frontchannelLogoutPath string