mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-18 04:47:28 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user