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
This commit is contained in:
2026-08-14 16:16:26 +01:00
parent 3eb11b07e9
commit 8640451877
19 changed files with 2002 additions and 508 deletions
+60 -38
View File
@@ -26,8 +26,6 @@ var (
replayCache CacheInterface
// shardedReplayCache is the new high-performance sharded cache for replay detection
shardedReplayCache *ShardedCache
// replayCacheOnce ensures the replay cache is initialized only once
replayCacheOnce sync.Once
// replayCacheCleanupWG waits for cleanup goroutine to finish
replayCacheCleanupWG sync.WaitGroup
// replayCacheCancel cancels the cleanup context
@@ -39,21 +37,23 @@ var (
// 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.
// This function uses sync.Once to ensure thread-safe single initialization.
// 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() {
replayCacheOnce.Do(func() {
// Hold mutex during initialization to synchronize with cleanup goroutine
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
// Create sharded cache with 64 shards for reduced contention
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.
@@ -61,17 +61,12 @@ func initReplayCache() {
// and properly closes the cache to ensure proper cleanup during shutdown.
func cleanupReplayCache() {
replayCacheCleanupMu.Lock()
shouldWait := replayCacheCancel != nil
if replayCacheCancel != nil {
replayCacheCancel()
replayCacheCancel = nil
}
replayCacheCleanupMu.Unlock()
// Only wait if there was a cleanup routine running
if shouldWait {
replayCacheCleanupWG.Wait()
}
replayCacheCleanupWG.Wait()
replayCacheMu.Lock()
defer replayCacheMu.Unlock()
@@ -87,8 +82,16 @@ func cleanupReplayCache() {
replayCache.Close()
replayCache = nil
}
}
replayCacheOnce = sync.Once{}
// 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.
@@ -96,9 +99,14 @@ func cleanupReplayCache() {
// - 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)
if shardedReplayCache != nil {
return shardedReplayCache.Size(), 10000
// 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
@@ -281,13 +289,12 @@ func parseJWT(tokenString string) (*JWT, error) {
return nil, fmt.Errorf("invalid JWT format: failed to decode signature: %v", err)
}
// Reuse the signature buffer if it's large enough, otherwise allocate
if cap(jwtBuf.Signature) >= n {
jwt.Signature = jwtBuf.Signature[:n:n] // Use slice trick to prevent aliasing
} else {
jwt.Signature = make([]byte, n)
copy(jwt.Signature, jwtBuf.Signature[:n])
}
// 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
}
@@ -342,12 +349,13 @@ func (j *JWT) Verify(issuerURL, expectedAudience string, skipReplayCheck ...bool
return err
}
iat, ok := claims["iat"].(float64)
if !ok {
return fmt.Errorf("missing or invalid 'iat' claim")
}
if err := verifyIssuedAt(iat); 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 {
@@ -371,6 +379,12 @@ func (j *JWT) Verify(issuerURL, expectedAudience string, skipReplayCheck ...bool
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
@@ -386,18 +400,26 @@ func (j *JWT) Verify(issuerURL, expectedAudience string, skipReplayCheck ...bool
replayCacheMu.RLock()
sc := shardedReplayCache
if sc != nil {
if !sc.SetIfAbsent(jtiValue, true, duration) {
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). Hold the
// mutex across the get+set so the record is atomic there too.
// 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()
_, exists := replayCache.Get(jtiValue)
if !exists && replayCache != nil {
replayCache.Set(jtiValue, true, duration)
rk := replayCacheKey(issuerURL, jtiValue)
exists := false
if replayCache != nil {
_, exists = replayCache.Get(rk)
if !exists {
replayCache.Set(rk, true, duration)
}
}
replayCacheMu.Unlock()
if exists {