feat(auth): support private_key_jwt and client_secret_basic (#137)

revocation endpoints, joining the existing client_secret_post default.
Both are opt-in via the new clientAuthMethod config field. Closes #135.

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

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

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

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

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

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

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

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

Config validation:
  TestIssue135_ConfigValidation — full Validate() matrix
  TestIssue135_ConfigKeyPathLoadsFile

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

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

Out of scope (deferred)
=======================
mTLS / tls_client_auth (RFC 8705) — separate change; requires per-call
http.Client with tls.Config.Certificates and conflicts with the current
pooled HTTP client architecture.
This commit is contained in:
2026-05-09 18:02:41 +01:00
committed by GitHub
parent bfd702a447
commit 1e33bb0a4d
12 changed files with 1589 additions and 12 deletions
+56 -2
View File
@@ -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