mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
1e33bb0a4d
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.
296 lines
8.0 KiB
Go
296 lines
8.0 KiB
Go
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)
|
|
}
|
|
|
|
|
|
|