From 1e33bb0a4d2d0b1ae659773d5686c88ca43a8694 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 9 May 2026 18:02:41 +0100 Subject: [PATCH] feat(auth): support private_key_jwt and client_secret_basic (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .traefik.yml | 13 + README.md | 45 +- client_assertion.go | 295 ++++++++ docs/CONFIGURATION.md | 131 +++- docs/index.html | 37 +- examples/complete-traefik-config.yaml | 10 + helpers.go | 41 +- issue135_regression_test.go | 925 ++++++++++++++++++++++++++ main.go | 15 + settings.go | 58 +- token_manager.go | 29 +- types.go | 2 + 12 files changed, 1589 insertions(+), 12 deletions(-) create mode 100644 client_assertion.go create mode 100644 issue135_regression_test.go 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