Files
traefikoidc/jwt.go
T
lukaszraczylo 8640451877 fix(auth): harden OIDC flow, token validation, session, and bearer handling
- replay entry covers exp + clock-skew window; callback GET-only (RFC 6749)
- callback rate-limit returns 429 + Retry-After; panic-recovery sets no-store
- cross-chunk integrity for chunked tokens; bounded chunked-token read scan
- HTTPError classification so 5xx/429 token-endpoint responses retry
- redaction of token/code/state in logs; per-source external auth gate
2026-08-14 16:16:26 +01:00

600 lines
20 KiB
Go

package traefikoidc
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/pool"
)
// Replay attack protection cache using sharded design for reduced lock contention.
// This cache tracks JWT IDs (jti claims) to prevent token reuse attacks.
// Under high load (500+ req/sec), the sharded design reduces contention significantly.
var (
// replayCacheMu protects access to the replay cache instance (only used for initialization)
replayCacheMu sync.RWMutex
// replayCache stores JWT IDs with expiration to prevent replay attacks (legacy interface)
replayCache CacheInterface
// shardedReplayCache is the new high-performance sharded cache for replay detection
shardedReplayCache *ShardedCache
// replayCacheCleanupWG waits for cleanup goroutine to finish
replayCacheCleanupWG sync.WaitGroup
// replayCacheCancel cancels the cleanup context
replayCacheCancel context.CancelFunc
// replayCacheCleanupMu protects cleanup operations
replayCacheCleanupMu sync.Mutex
)
// initReplayCache initializes the JWT replay protection cache with bounded size.
// Uses a sharded cache design with 64 shards for reduced lock contention under high load.
// The cache is bounded to 10,000 entries to prevent unbounded memory growth.
// Lazy creation is guarded entirely by replayCacheMu (a plain sync.Once would
// not re-arm after cleanup resets it, and the previous Once re-assignment raced
// with init under -race). cleanupReplayCache nil's the fields under the same
// lock, so this either creates anew (after a cleanup) or is a cheap no-op.
func initReplayCache() {
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
if shardedReplayCache == nil {
// Create sharded cache with 64 shards for reduced lock contention
// Under 500 req/sec, this reduces lock contention by ~64x compared to single mutex
shardedReplayCache = NewShardedCache(64, 10000)
// Also initialize legacy cache for backward compatibility
replayCache = NewCache()
replayCache.SetMaxSize(10000)
}
}
// cleanupReplayCache performs graceful shutdown of the replay cache system.
// It cancels the cleanup context, waits for background goroutines to finish,
// and properly closes the cache to ensure proper cleanup during shutdown.
func cleanupReplayCache() {
replayCacheCleanupMu.Lock()
if replayCacheCancel != nil {
replayCacheCancel()
replayCacheCancel = nil
}
replayCacheCleanupMu.Unlock()
replayCacheCleanupWG.Wait()
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
// Clear sharded cache
if shardedReplayCache != nil {
shardedReplayCache.Clear()
shardedReplayCache = nil
}
// Clear legacy cache
if replayCache != nil {
replayCache.Close()
replayCache = nil
}
}
// replayCacheKey namespaces a JTI by its issuer before it is stored in the
// process-global replay cache. JTI uniqueness is guaranteed only within a
// single issuer's namespace; many Traefik routers share this one global
// cache across different OIDC providers, so a bare jti would let two
// providers' legitimately-colliding JTIs map to the same entry (false
// replay) and silently overwrite each other (R142).
func replayCacheKey(issuer, jti string) string {
return issuer + "\x00" + jti
}
// getReplayCacheStats returns statistics about the replay cache state.
// Returns:
// - size: Current number of entries in the cache
// - maxSize: Maximum allowed entries (10,000)
func getReplayCacheStats() (size int, maxSize int) {
// Use sharded cache if available (no mutex needed due to internal sharding).
// Guard the singleton read because cleanupReplayCache nils it under the
// write lock.
replayCacheMu.RLock()
sc := shardedReplayCache
replayCacheMu.RUnlock()
if sc != nil {
return sc.Size(), 10000
}
// Fall back to legacy cache
replayCacheMu.RLock()
defer replayCacheMu.RUnlock()
if replayCache == nil {
return 0, 10000
}
return 0, 10000
}
// startReplayCacheCleanup starts a background goroutine for periodic cache maintenance.
// The goroutine runs every 5 minutes to clean expired entries and log cache statistics.
// Uses the global task registry with circuit breaker pattern to prevent duplicate tasks.
// Parameters:
// - ctx: Parent context for cancellation
// - logger: Logger for debug output (can be nil)
func startReplayCacheCleanup(_ context.Context, logger *Logger) {
registry := GetGlobalTaskRegistry()
// Define the cleanup task function
cleanupFunc := func() {
// Use mutex to safely access cache pointers - this prevents race with initReplayCache
replayCacheMu.RLock()
shardedCache := shardedReplayCache
legacyCache := replayCache
replayCacheMu.RUnlock()
// Only proceed if caches have been initialized
if shardedCache == nil && legacyCache == nil {
return
}
size, maxSize := getReplayCacheStats()
if logger != nil {
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
}
// Clean up sharded cache
if shardedCache != nil {
shardedCache.Cleanup()
}
// Also clean up legacy cache for backward compatibility
if legacyCache != nil {
legacyCache.Cleanup()
}
}
// Create or get singleton cleanup task
task, err := registry.CreateSingletonTask(
"replay-cache-cleanup",
5*time.Minute,
cleanupFunc,
logger,
&replayCacheCleanupWG,
)
if err != nil {
if logger != nil {
logger.Debugf("Replay cache cleanup task already exists or circuit breaker limit reached: %v (this is expected with multiple instances)", err)
}
return
}
// Start the task
task.Start()
if logger != nil {
logger.Debug("Started replay cache cleanup task with circuit breaker protection")
}
}
// ClockSkewToleranceFuture defines the maximum allowable clock skew for future time validation.
// Tokens are considered valid for an additional 2 minutes past their expiration time.
var ClockSkewToleranceFuture = 2 * time.Minute
// ClockSkewTolerancePast defines the maximum allowable clock skew for past time validation.
// Tokens are considered valid if issued up to 10 seconds in the future.
var ClockSkewTolerancePast = 10 * time.Second
// ClockSkewTolerance is an alias for ClockSkewToleranceFuture for backward compatibility.
var ClockSkewTolerance = ClockSkewToleranceFuture
// JWT represents a parsed JSON Web Token with its constituent parts.
// It provides a structured representation of JWT components
// for validation and processing within the OIDC middleware.
type JWT struct {
// Header contains the JWT header claims (alg, typ, kid, etc.)
Header map[string]interface{}
// Claims contains the JWT payload claims (iss, sub, aud, exp, etc.)
Claims map[string]interface{}
// Token is the original JWT token string
Token string
// Signature contains the decoded JWT signature bytes
Signature []byte
}
// parseJWT parses a JWT token string into its constituent parts.
// It decodes the base64url-encoded header, claims, and signature components
// and unmarshals the JSON data into structured maps. Uses memory pools
// for efficient memory allocation during parsing.
// Parameters:
// - tokenString: The JWT token string to parse
//
// Returns:
// - *JWT: Parsed JWT structure with header, claims, and signature
// - An error if the token format is invalid or decoding/unmarshaling fails
func parseJWT(tokenString string) (*JWT, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
}
pm := pool.Get()
jwtBuf := pm.GetJWTBuffer()
defer pm.PutJWTBuffer(jwtBuf)
jwt := &JWT{
Token: tokenString,
}
headerLen := base64.RawURLEncoding.DecodedLen(len(parts[0]))
if headerLen > cap(jwtBuf.Header) {
jwtBuf.Header = make([]byte, headerLen)
} else {
jwtBuf.Header = jwtBuf.Header[:headerLen]
}
n, err := base64.RawURLEncoding.Decode(jwtBuf.Header, []byte(parts[0]))
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err)
}
headerBytes := jwtBuf.Header[:n]
decoder := pm.GetJSONDecoder(bytes.NewReader(headerBytes))
defer pm.PutJSONDecoder(decoder)
if err := decoder.Decode(&jwt.Header); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
}
if jwt.Header == nil {
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
}
claimsLen := base64.RawURLEncoding.DecodedLen(len(parts[1]))
if claimsLen > cap(jwtBuf.Payload) {
jwtBuf.Payload = make([]byte, claimsLen)
} else {
jwtBuf.Payload = jwtBuf.Payload[:claimsLen]
}
n, err = base64.RawURLEncoding.Decode(jwtBuf.Payload, []byte(parts[1]))
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
}
claimsBytes := jwtBuf.Payload[:n]
decoder2 := pm.GetJSONDecoder(bytes.NewReader(claimsBytes))
defer pm.PutJSONDecoder(decoder2)
if err := decoder2.Decode(&jwt.Claims); err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
}
if jwt.Claims == nil {
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
}
sigLen := base64.RawURLEncoding.DecodedLen(len(parts[2]))
if sigLen > cap(jwtBuf.Signature) {
jwtBuf.Signature = make([]byte, sigLen)
} else {
jwtBuf.Signature = jwtBuf.Signature[:sigLen]
}
n, err = base64.RawURLEncoding.Decode(jwtBuf.Signature, []byte(parts[2]))
if err != nil {
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
}
// Copy the signature out of the pooled buffer. Borrowing it with
// [:(n):(n)] still shares the backing array, which the pool recycles
// and truncates on the next GetJWTBuffer — mutating this caller's
// slice after the fact (R156).
jwt.Signature = make([]byte, n)
copy(jwt.Signature, jwtBuf.Signature[:n])
return jwt, nil
}
// Verify performs comprehensive JWT token validation according to OIDC specifications.
// It validates the token signature algorithm, issuer, audience, expiration, issued-at time,
// not-before time (if present), and prevents replay attacks using JTI claims.
// Parameters:
// - issuerURL: Expected issuer URL to validate against
// - expectedAudience: Expected audience to validate against (can be clientID or custom audience)
// - skipReplayCheck: Optional parameter to skip replay attack protection
//
// Returns:
// - An error describing the first validation failure encountered
func (j *JWT) Verify(issuerURL, expectedAudience string, skipReplayCheck ...bool) error {
alg, ok := j.Header["alg"].(string)
if !ok {
return fmt.Errorf("missing 'alg' header")
}
supportedAlgs := map[string]bool{
"RS256": true, "RS384": true, "RS512": true,
"PS256": true, "PS384": true, "PS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !supportedAlgs[alg] {
return fmt.Errorf("unsupported algorithm: %s", alg)
}
claims := j.Claims
iss, ok := claims["iss"].(string)
if !ok {
return fmt.Errorf("missing 'iss' claim")
}
if err := verifyIssuer(iss, issuerURL); err != nil {
return err
}
aud, ok := claims["aud"]
if !ok {
return fmt.Errorf("missing 'aud' claim")
}
if err := verifyAudience(aud, expectedAudience); err != nil {
return err
}
exp, ok := claims["exp"].(float64)
if !ok {
return fmt.Errorf("missing or invalid 'exp' claim")
}
if err := verifyExpiration(exp); err != nil {
return err
}
// iat is OPTIONAL per RFC 7519 §4.1.6; only validate when present.
// Hard-requiring it here also rejected otherwise-valid access and
// logout tokens whose provider omits iat.
if iat, ok := claims["iat"].(float64); ok {
if err := verifyIssuedAt(iat); err != nil {
return err
}
}
if nbf, ok := claims["nbf"].(float64); ok {
if err := verifyNotBefore(nbf); err != nil {
return err
}
}
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
jtiValue, jtiOk := claims["jti"].(string)
if jtiOk && !shouldSkipReplay && jtiValue != "" {
initReplayCache()
expFloat, ok := claims["exp"].(float64)
var expTime time.Time
if ok {
expTime = time.Unix(int64(expFloat), 0)
} else {
expTime = time.Now().Add(10 * time.Minute)
}
duration := time.Until(expTime)
// A token is accepted until exp + ClockSkewToleranceFuture (see
// verifyExpiration), so the replay entry must live that long too —
// otherwise in the (exp, exp+skew] window the entry has expired
// and been swept and the same token is re-accepted as fresh
// (R179).
duration += ClockSkewToleranceFuture
if duration <= 0 {
// Expired token: no replay window worth recording, so nothing to do.
goto replayDone
}
// Use sharded cache for replay detection - no global mutex needed.
// SetIfAbsent is atomic: it holds the per-shard lock across the
// existence check and insert, closing the double-accept race where
// two concurrent requests carrying the same fresh JTI could both
// observe it absent and both be accepted. Guard the singleton read
// with replayCacheMu because cleanupReplayCache can nil it under the
// write lock.
replayCacheMu.RLock()
sc := shardedReplayCache
if sc != nil {
if !sc.SetIfAbsent(replayCacheKey(issuerURL, jtiValue), true, duration) {
replayCacheMu.RUnlock()
return fmt.Errorf("token replay detected (jti: %s)", jtiValue)
}
replayCacheMu.RUnlock()
} else {
// Fall back to legacy cache (should rarely happen). Release the
// read lock before taking the write lock: RWMutex is not
// reentrant, so Lock() here while still holding RLock()
// self-deadlocks (R156). Guard the cache for nil — it is
// cleared alongside shardedReplayCache by cleanupReplayCache.
replayCacheMu.RUnlock()
replayCacheMu.Lock()
rk := replayCacheKey(issuerURL, jtiValue)
exists := false
if replayCache != nil {
_, exists = replayCache.Get(rk)
if !exists {
replayCache.Set(rk, true, duration)
}
}
replayCacheMu.Unlock()
if exists {
return fmt.Errorf("token replay detected (jti: %s)", jtiValue)
}
}
}
replayDone:
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return fmt.Errorf("missing or empty 'sub' claim")
}
return nil
}
// verifyAudience validates the JWT audience claim against the expected client ID.
// The audience claim can be either a single string or an array of strings.
// Parameters:
// - tokenAudience: The audience claim from the JWT (string or []interface{})
// - expectedAudience: The expected audience value (typically the OAuth client ID)
//
// Returns:
// - An error if the claim type is invalid or the expected audience is not present
func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
switch aud := tokenAudience.(type) {
case string:
if aud != expectedAudience {
return fmt.Errorf("invalid audience")
}
case []interface{}:
found := false
for _, v := range aud {
if str, ok := v.(string); ok && str == expectedAudience {
found = true
break
}
}
if !found {
return fmt.Errorf("invalid audience")
}
default:
return fmt.Errorf("invalid 'aud' claim type")
}
return nil
}
// verifyIssuer validates the JWT issuer claim against the expected issuer URL.
// Parameters:
// - tokenIssuer: The issuer claim from the JWT
// - expectedIssuer: The expected issuer URL from OIDC configuration
//
// Returns:
// - An error if the issuers do not match
func verifyIssuer(tokenIssuer, expectedIssuer string) error {
if tokenIssuer != expectedIssuer {
return fmt.Errorf("invalid issuer (token: %s, expected: %s)", tokenIssuer, expectedIssuer)
}
return nil
}
// verifyTimeConstraint validates time-based JWT claims with clock skew tolerance.
// It handles both future constraints (exp) and past constraints (iat, nbf).
// Parameters:
// - unixTime: The Unix timestamp from the JWT claim
// - claimName: Name of the claim being validated (for error messages)
// - future: If true, validates against future tolerance; if false, against past tolerance
//
// Returns:
// - An error describing the failure (e.g., "token has expired", "token used before issued")
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
claimTime := time.Unix(int64(unixTime), 0)
now := time.Now()
var err error
if future {
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
if now.After(allowedExpiry) {
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
}
} else {
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
if now.Before(allowedStart) {
reason := "not yet valid"
if claimName == "iat" {
reason = "used before issued"
}
err = fmt.Errorf("token %s (%s: %v, now: %v, allowed_from: %v)", reason, claimName, claimTime.UTC(), now.UTC(), allowedStart.UTC())
}
}
return err
}
// verifyExpiration validates the JWT expiration time (exp claim) with clock skew tolerance.
// It calls verifyTimeConstraint with future=true.
func verifyExpiration(expiration float64) error {
return verifyTimeConstraint(expiration, "exp", true)
}
// verifyIssuedAt validates the JWT issued-at time (iat claim) with clock skew tolerance.
// It calls verifyTimeConstraint with future=false.
func verifyIssuedAt(issuedAt float64) error {
return verifyTimeConstraint(issuedAt, "iat", false)
}
// verifyNotBefore validates the JWT not-before time (nbf claim) with clock skew tolerance.
// It calls verifyTimeConstraint with future=false.
func verifyNotBefore(notBefore float64) error {
return verifyTimeConstraint(notBefore, "nbf", false)
}
// verifySignatureWithKey verifies a JWT signature using an already-parsed
// public key, skipping the PEM-encode/decode round trip. This is the hot
// path used by VerifyJWTSignatureAndClaims and verifyLogoutTokenSignature.
func verifySignatureWithKey(tokenString string, pubKey crypto.PublicKey, alg string) error {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return fmt.Errorf("invalid token format")
}
signedContent := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}
var hashFunc crypto.Hash
switch alg {
case "RS256", "PS256", "ES256":
hashFunc = crypto.SHA256
case "RS384", "PS384", "ES384":
hashFunc = crypto.SHA384
case "RS512", "PS512", "ES512":
hashFunc = crypto.SHA512
default:
return fmt.Errorf("unsupported algorithm: %s", alg)
}
h := hashFunc.New()
h.Write([]byte(signedContent))
hashed := h.Sum(nil)
switch pubKey := pubKey.(type) {
case *rsa.PublicKey:
// Enforce a minimum RSA modulus size (NIST SP 800-57: ≥2048 bits).
// Without this, a token signed with a weak (<2048-bit) RSA key whose
// modulus the trusted JWKS publishes is accepted as valid. Modern
// providers all use ≥2048; rejecting smaller keys is safe hardening.
if pubKey.N.BitLen() < 2048 {
return fmt.Errorf("RSA key size %d bits is below the minimum 2048 bits", pubKey.N.BitLen())
}
if strings.HasPrefix(alg, "RS") {
return rsa.VerifyPKCS1v15(pubKey, hashFunc, hashed, signature)
} else if strings.HasPrefix(alg, "PS") {
return rsa.VerifyPSS(pubKey, hashFunc, hashed, signature, nil)
} else {
return fmt.Errorf("unexpected key type for algorithm %s", alg)
}
case *ecdsa.PublicKey:
if strings.HasPrefix(alg, "ES") {
var r, s big.Int
sigLen := len(signature)
if sigLen%2 != 0 {
return fmt.Errorf("invalid ECDSA signature length")
}
r.SetBytes(signature[:sigLen/2])
s.SetBytes(signature[sigLen/2:])
if ecdsa.Verify(pubKey, hashed, &r, &s) {
return nil
} else {
return fmt.Errorf("invalid ECDSA signature")
}
} else {
return fmt.Errorf("unexpected key type for algorithm %s", alg)
}
default:
return fmt.Errorf("unsupported public key type: %T", pubKey)
}
}