perf(jwk,cache): cache parsed public keys + RLock token cache reads

Hot-path JWT verification rebuilt the public key on every call:
  jwk -> ToRSAPublicKey -> x509.MarshalPKIXPublicKey -> pem.Encode
  -> verifySignature -> pem.Decode -> x509.ParsePKIXPublicKey -> verify
Under yaegi this pinned a CPU when many concurrent dashboard panels
poll behind the middleware. The PEM round trip is pure waste.

* jwk.go: cache pre-parsed crypto.PublicKey per kid alongside the
  raw JWKSet (parallel cache entry, same 1h TTL, invalidates together).
* jwt.go: split verifySignatureWithKey from verifySignature; existing
  PEM-input entry point preserved for backchannel-logout callers.
* token_manager.go: VerifyJWTSignatureAndClaims now goes straight from
  jwks cache to verifySignatureWithKey, no PEM round trip and no
  per-request availableKids slice.
* universal_cache.go: token/JWK/session Get() takes RLock when the
  entry is unexpired, so concurrent token verifications no longer
  serialize on a single mutex. LRU semantics for general and metadata
  caches are unchanged (tests cover the strict-LRU contract there).
* mocks: MockJWKCache, EnhancedMockJWKCache, mockJWKCacheForLogout,
  staticJWKCache satisfy the extended interface.
This commit is contained in:
2026-04-30 10:14:10 +01:00
parent 2d1b04c637
commit 4d28fa01ab
7 changed files with 203 additions and 46 deletions
+37
View File
@@ -2,6 +2,7 @@ package traefikoidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -639,6 +640,26 @@ func (m *mockJWKCacheForLogout) GetJWKS(ctx context.Context, jwksURL string, htt
}, nil
}
func (m *mockJWKCacheForLogout) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
jwks, err := m.GetJWKS(ctx, jwksURL, httpClient)
if err != nil {
return nil, err
}
for i := range jwks.Keys {
k := &jwks.Keys[i]
if k.Kid != kid {
continue
}
switch k.Kty {
case "RSA":
return k.ToRSAPublicKey()
case "EC":
return k.ToECDSAPublicKey()
}
}
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
}
func (m *mockJWKCacheForLogout) Clear() {}
func (m *mockJWKCacheForLogout) Cleanup() {}
func (m *mockJWKCacheForLogout) Close() {}
@@ -755,6 +776,22 @@ func (s *staticJWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient
return s.jwks, nil
}
func (s *staticJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
for i := range s.jwks.Keys {
k := &s.jwks.Keys[i]
if k.Kid != kid {
continue
}
switch k.Kty {
case "RSA":
return k.ToRSAPublicKey()
case "EC":
return k.ToECDSAPublicKey()
}
}
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
}
func (s *staticJWKCache) Clear() {}
func (s *staticJWKCache) Cleanup() {}
func (s *staticJWKCache) Close() {}