Compare commits

..
77 Commits
Author SHA1 Message Date
github-actions[bot] 910cbaf92a chore(release): v1.0.63 2026-11-03 01:50:23 +00:00
lukaszraczylo 972993980e refactor(session): remove unreachable ChunkManager eviction code
ChunkManager.sessionMap is never written in this package, so the limit
and emergency eviction paths cannot run. Remove enforceSessionLimit,
findOldestSessions, CanCreateSession, EmergencyCleanup, GetSessionCount,
GetMemoryStats and globalMaxSessions, with the tests that only covered them.
The session/chunking package keeps its own copy.
2026-11-03 01:46:11 +00:00
lukaszraczylo 83da8e0bc2 fix(security): scope logout invalidation keys to the middleware instance
Back-channel logout keys for sub, sid and jti had no issuer or client
in them, in a cache shared by every instance. A logout for user u1 in
one realm logged out u1 in another, and an equal jti in two realms
looked like a replay. Keys now carry the instance prefix. An instance
without a prefix keeps the old key.
2026-11-03 01:31:35 +00:00
lukaszraczylo fd3fbbdbab fix(security): classify audience failures by sentinel error
The lenient-audience path chose itself when the verify error text
contained the word audience. The key lookup error embeds the kid from the
unverified token header. A kid of audience made a failed signature look
like an audience-only failure. verifyAudience now returns a sentinel and
the caller uses errors.Is.
2026-11-03 01:24:28 +00:00
lukaszraczylo 92c311fde1 fix(jwt): bind ECDSA curve to the algorithm and require fixed signature length
ES256, ES384 and ES512 verified against any curve and any even-length
signature. RFC 7518 fixes the curve per algorithm and the signature to
R||S at the curve's octet length. Verification now enforces both.
2026-11-03 01:21:01 +00:00
lukaszraczylo 2f0ee00e51 fix(security): scope introspection cache keys to the middleware instance
The introspection cache used the raw token as key in a shared cache.
Two instances with different providers or clients shared results. Keys
now use the same instance prefix as the verified-token cache.
2026-11-03 01:18:12 +00:00
lukaszraczylo 6e522fd212 fix(security): scope the verified-token cache to its middleware instance
The verified-token cache is one process-wide cache keyed by the raw
token. A verdict stored by one middleware instance satisfied any other
instance, so a token valid for one client or audience skipped the
issuer, audience and signature checks on another route. Each instance
now prefixes keys with its provider, client ID and audience. Dynamic
client registration updates the prefix.
2026-11-03 01:15:45 +00:00
lukaszraczylo 3ce5f428cf fix(session): keep old access token on failed store, cap incoming path, fold proto case
SetAccessToken cleared the old token before it found that no request
was available for the chunk cookies. The old token was lost.
The stored post-login path had no length bound and could bloat every
later Cookie header. It is now capped at 2048 bytes.
DetermineScheme compared X-Forwarded-Proto case-sensitively while the
cookie code did not, so HTTPS gave an http redirect_uri.
2026-11-03 01:10:36 +00:00
lukaszraczylo dc4da7b4c9 fix(security): drop client values for templated headers
A templated header skipped its Set when the render failed, came out
empty or failed sanitising. The client's own value of that header then
reached the backend. A client could forge a header such as X-Tenant by
using a token that lacks the claim. The middleware now removes the
inbound value first. Identity headers and Authorization are left alone.
2026-11-03 01:10:20 +00:00
lukaszraczylo 2cb42aa485 perf(logout): parse the ID token once for the invalidation check
With backchannel or frontchannel logout on, every authenticated request
parsed the ID token twice to read sid, sub and iat. One shared parse now
returns the same values. A test pins equality with the two separate
calls for valid, partial and malformed tokens. The gain is one parseJWT
call fewer per request. I did not measure it under yaegi.
2026-11-03 01:01:52 +00:00
lukaszraczylo 9ecd987416 fix(bearer): key the failure tracker by IPv6 /64
The bearer failure tracker keyed entries by the full source address.
One IPv6 host could create a new entry per request and grow the map for
window plus penalty seconds. It now shares one entry per /64, like the
login limiter. IPv4 keys do not change.
2026-11-03 00:59:16 +00:00
lukaszraczylo b1cf524bb4 fix(config): reject durations and sizes that overflow at startup
Several seconds settings became time.Duration with no upper bound, so
a large value wrapped negative or to zero. maxTokenAgeSeconds then
disabled the bearer age check, and a zero health check interval
panicked time.NewTicker. A circuit breaker threshold above MaxInt32
narrowed to a value that tripped on the first failure. A huge poolSize
allocated the connection channel up front. Validate now rejects these
values. Sane and default values are unchanged.
2026-11-03 00:53:12 +00:00
lukaszraczylo eec076f812 fix(resilience): recover from unhealthy and close half-open on successes
The health check promoted to healthy only when the fail counter was
above zero. The first success zeroed that counter, so a threshold of 2
or more never recovered. It now promotes on the status and the success
streak.

The breaker closed when admitted probes reached HalfOpenMaxRequests,
not when that many succeeded. A new counter tracks successes.
2026-11-03 00:46:46 +00:00
lukaszraczylo ac0fcfbe6a fix(redis): never send a zero or shortened expiry
Set and SetMany sent PSETEX with 0 for a TTL under 1ms, which Redis
rejects. They sent SETEX with truncated seconds for a fractional TTL,
so keys expired early. A shared helper now rounds up to a millisecond
and uses SETEX only for whole seconds.
2026-11-03 00:42:17 +00:00
lukaszraczylo 78db86b455 fix(security): treat trailing-dot hosts as their blocked names
validateHost and isLoopbackHost matched hostnames by exact string. The
spellings localhost. and metadata.google.internal. passed the SSRF
check. The check now drops the root dot and any IPv6 zone id first.
A bare IPv6 literal without a port no longer fails the host check.
2026-11-03 00:42:00 +00:00
lukaszraczylo 621f7993a8 fix(cache,jwt): bound grace, resize, memory cap and time claims
Grace extensions overwrote ExpiresAt, so a polled stale entry never hit
MaxGracePeriod. The bound now uses the first expiry.

SetMaxSize with a non-positive size spun the eviction loop forever. It
now clamps to 1.

One value larger than MaxMemoryBytes flushed every other entry and left
the cache over its cap. The cache now drops that value.

int64(float64) wrapped a far-future nbf or iat into the past, so the
token passed. Non-finite and out-of-range claims now fail.
2026-11-03 00:37:12 +00:00
lukaszraczylo a1b7059da1 fix(session): keep cooldown trackers and trim stale chunks by key
Cleanup evicted a refresh tracker by last attempt time alone. A tracker
could leave its cooldown early. Cleanup now also requires the cooldown
to be over.

The stale chunk trim deleted an index range derived from the chunk
count. A sparse key set kept stale high keys and failed the read. The
trim now deletes every key at or above token_total.
2026-11-03 00:15:52 +00:00
lukaszraczylo dae08b2c21 fix(refresh): reset window to zero attempts and admit one half-open probe
The window and cooldown resets set attempts to 1. The gate then counted
the same call again, so a reset window allowed one call fewer than a
fresh one. With a limit of 1 the session locked out at once. Resets now
start at 0.

The half-open breaker admitted every caller. It now admits one probe,
claimed with a compare-and-swap. A stale probe is replaced after
OpenDuration, so a lost probe cannot wedge the breaker.
2026-11-03 00:07:15 +00:00
lukaszraczylo 29a9b1f8f2 fix(ratelimit): key IPv6 sources by /64 prefix
The per-source limiter keyed buckets by the full IPv6 address. One host
holds a whole /64 and can mint 2^64 buckets. That host bypasses the
limit and grows the entries map. The limiter now shares one bucket per
/64. IPv4 keys do not change.
2026-11-03 00:02:07 +00:00
lukaszraczyloandGitHub 9ab7220828 fix(security): close confirmed findings from the 2026-10-15 hunt (#166)
Seven findings are fixed. Each fix has a regression test.

- Compare X-Forwarded-Host and the serving host case-insensitively, so a
  case variant cannot widen the session cookie Domain.
- Reject a future iat on the opaque introspection path.
- Serve the cached parsed JWKS inside the signature-refresh cooldown.
- Release the half-open circuit slot when a probe panics.
- Cap requests that wait for provider initialisation at 512. Extra
  requests receive 503 with Retry-After.
- Cap cookie deletion headers at 32 cookie names per response.
- Cap front-channel sid markers at 1000, so unauthenticated requests
  cannot evict backchannel or jti replay markers.
2026-10-16 00:47:35 +01:00
lukaszraczyloandGitHub f0af6282dc feat(templates): add toJson helper for object claims (#165)
Header templates can now emit an object or array claim as one JSON
header. Example: {{get .Claims "tenant" | toJson}}. The backend parses
the claim, so the middleware does not need to know its schema.

The helper adds no new access path. The validator treats toJson like
default and checks its arguments. A claim must be on the whitelist, and
a dump of the whole claims map is rejected. An absent or empty claim
skips the header. The JSON encoder escapes control characters.

A rendered header value over 8192 bytes is still dropped. The drop is
now logged at error level, with the header name and without the value.
Other sanitizer drops stay at debug level.

Tests cover value types, edge cases, whitelist bypass attempts, token
leaks and header injection. A new yaegi check runs the helper under the
interpreter. README, configuration docs and the plugin manifest show the
new helper.
2026-10-05 00:20:01 +01:00
lukaszraczylo 7c5a86f4e1 chore: remove dependabot config 2026-10-04 01:04:57 +01:00
lukaszraczyloandGitHub 94a8664403 perf(session): cut yaegi allocations on authenticated requests (#164)
Under yaegi, the authenticated cookie path allocated about 3.8 MB per
request. A yaegi harness measures bytes per request with a browser-like
cookie jar. With it, these changes cut bytes by 83%, allocations by 69%
and time by about 93%.

- Replace range loops over tokens with index loops. Under yaegi, ranging
  over a string allocates O(n^2) bytes.
- Pool gzip readers for combined session cookies.
- Memoize the validated ID token within one request.
- Cache content-only token validation across requests. The key is the
  token type and SHA-256, and the cache holds at most 4096 entries. The
  entry keeps the parsed exp and iat claims. The expiry and freshness
  checks still run on every request.

New tests pin the validator results, the memo and the cache behavior.
2026-09-29 11:59:31 +01:00
lukaszraczylo 74decb21c2 docs: note where streamingRefresh sends the WebSocket cookie
On a WebSocket upgrade, the refreshed session cookie arrives on the 101
response. Traefik keeps middleware headers on that response, and RFC 6455
section 4.1 has clients store cookies from it.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 17:28:01 +01:00
lukaszraczylo 7ae75d2523 docs: add #162 changelog entry and rollback cookie note
Add the #162 fix to the changelog. Document that a rollback to v1.0.25 or
earlier invalidates sessions, because those releases cannot read the
encrypted cookies from v1.0.26. Tell operators which cookies to delete.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:32:19 +01:00
lukaszraczylo 937776516d feat(middleware): refresh expired sessions on SSE/WebSocket bypass
The SSE and WebSocket bypass does not check the token expiry. A session
stays usable for streaming until the session cookie expires, also after
the IdP disables the user. The bypass now forwards the configured headers
(#162), so this gap also gives the proxy secret to such a session.

Add the streamingRefresh option. The default is false. When it is true and
the access token has expired, the bypass refreshes the session. It uses
the ID token expiry if the access token is opaque. If the IdP rejects the
refresh with invalid_grant, the request gets 401. If the IdP cannot be
reached, the request is forwarded as before.

The bypass waits for the full coordinated refresh. A shorter wait can
drop a rotated refresh token, and the next refresh then logs the user out.

applyBypassUserHeaders now takes the ResponseWriter, so a refresh can
write the new session cookie. Add a yaegi check for the invalid_grant path
through the real token endpoint and RefreshCoordinator.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:31:18 +01:00
lukaszraczylo c9844d0ff2 fix(middleware): forward configured headers on SSE/WebSocket bypass (#162)
The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not
apply the configured headers. Backends that authenticate the proxy with a
shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent
WebSocket upgrades through the normal flow, so this is a regression.

The bypass now renders the header templates through applyHeaderTemplates,
which forwardAuthorized also uses. Templates use the ID-token claims from
the session cookie. If the session has no ID token, they use the
access-token claims.

The bypass now also rejects a session that the IdP logged out through
back-channel or front-channel logout. Without this check, a logged-out
cookie can open streams with the proxy secret attached.

Add a yaegi check for the WebSocket case. Move tokenWithGroups to the
shared JWT test helpers, as FIX-41 requires.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:24:33 +01:00
lukaszraczyloGitHubcopilot-swe-agent[bot] <[email protected]>
45e99551f9 Security & correctness hardening: auth flow, cache, recovery, DCR, docs (#155)
* Audit review (rounds 1-31): fix confirmed issues, remove dead code, harden tests

Security/correctness fixes:
- fail-open token validation preserved with lenient-audience; access token that
  fails verification and has no ID token to corroborate now fails closed
- token_manager blacklist-expiry: fix impossible tautology (x>24h && x<24h)
- constant-time OAuth state compare; clear state/nonce/code_verifier on
  callback token-exchange failure to prevent replay of half-failed flows
- StopAllTasks gated on last-instance close
- background-task panic recovery; remove task-registry counter leak
- circuit breaker half-open: release probe slot; CB + per-session outcome
  recorded once per refresh operation, not once per waiter
- Redis connection pool: handle nil receive when pool closed concurrently
- FileStore: atomic temp-write + rename
- errors_recovery health-check data race -> atomic

Test-isolation / hardening:
- reset helpers for Once-pinned global cache manager and transport pool
- coverage: utils 70->93% (redirect URL security contract); fuzz targets for
  bearer parsers; concurrent ServeHTTP race tests for the shared request path

Dead-code removal (verified orphaned, build+suite green): internal/providers
(30 files, 0 importers), internal/compat, internal/features, input_validation.

Verified: govulncheck, go mod tidy, yaegi-validate, cross-compile (darwin+
linux), golangci-lint (new-from-rev clean), 3x full race+shuffle green.

* Lint: fix real int-truncation bug; document intentional gosec findings

- TransportPool configKey: write conn-count ints verbatim (strconv) instead of
  byte() truncation — a config value >=256 previously truncated and made two
  distinct configs collide on one key, silently sharing a transport with the
  wrong settings (real fix, gosec G115).
- Document design-intentional gosec findings with //nolint:gosec + rationale:
  G117 (clients_secret intentionally marshaled into stored creds JSON),
  G704 (tokenURL is the trusted discovery endpoint), G124 (delete/test cookie,
  Secure set conditionally), G118 (long-lived background refresh) and pool
  utils short-ID rune, testutil mock server redirects.

Result: full-tree golangci-lint now reports 0 issues (was ~12). Build, vet,
and the full test suite remain green.

* Fix singleton ResourceManager data race (unlocked read vs test reset)

GetResourceManager returned the process-global via an unprotected read
(singleton_resources.go:50), while resetResourceManagerForTesting and
ResetResourceManager write it under resourceManagerMutex. A live background
task (graceful-degradation health check started by one test) reading the
singleton while a shuffled test resets it was a data race (found via
-race -shuffle: Write at singleton_resources_test.go:941/942 vs Read at
autocleanup.go:554 / error_recovery.go:910).

Fix: hold resourceManagerMutex around the read, matching the sibling
GetGlobalTaskRegistry (autocleanup.go:414). Added a deterministic
concurrent regression test (GetResourceManager vs reset under -race).

Verified: 4x full -race -count=1 -shuffle=on ./... all exit 0, 0 races,
0 fails; source-order suite green; lint clean.

* Fix 6 issues surfaced by adversarial agent team (round 35)

Concurrency/lifecycle (P1, highest-value):
- singleton_resources.go: RegisterBackgroundTask now REPLACES a stopped
  task instead of reusing it. BackgroundTask.Start is sync.Once-guarded,
  so a task stopped on instance close and re-registered (Traefik
  close+recreate for the same provider) was permanently dead: IsTaskRunning
  returned false (started && !stopped) so callers retried a start that
  was a no-op. Metadata refresh, token cleanup, and health checks now
  resume across a recreate cycle.
- error_recovery.go: the shared graceful-degradation health task was
  bound to the FIRST instance's performHealthChecks (later instances'
  checks never ran) and any instance's Close stopped it for all. Added a
  global live-instance registry; the shared task runs every instance's
  checks and is stopped only when the last instance closes (mirrors the
  singleton-token-cleanup gate).

Storage/cache:
- hybrid.go recordL2Error: Store(now) then immediately compared
  time.Since(t)<1s against its own write => ALWAYS fired, so a single
  transient L2 error flipped the whole cache into fallback (full L2
  bypass -> upstream stampede). Now enters fallback only on a PRIOR
  error within the window (true "multiple errors"). Updated two tests
  that had encoded the old single-error-fallback bug.
- redis_pool.go: ConnectionPool.Put checked closed but sent on
  p.connections without holding p.mu; Close closed it under p.mu.
  Window => 'send on closed channel' panic at shutdown. Put now holds
  p.mu (the lock only Close used).

HTTP/transport:
- jwk.go fetchJWKS: added an explicit >1 MiB guard (was silent partial
  read, could yield a truncated keyset and spurious missing-kid).
- http_client_pool.go configKey: now includes every transport-behavior
  field it actually consumes (compression, HTTP2, keep-alives, timeouts,
  buffer sizes, dial) so behaviorally different configs no longer share
  one transport built with the first config's settings.

Each fix has a regression test. Verified: full source-order suite green,
golangci-lint 0 issues, 3x full -race -count=1 -shuffle=on ./... all exit 0.

* Fix round-36 issues: DCR validation, auth-method default, shared metadata task, replay false-positive

Four verified fixes from the round-36 agent team (each confirmed against source
before acting):

- settings.go: Config.Validate now skips the client-credential block when
  Dynamic Client Registration is enabled and no clientID is pre-provisioned
  (RFC 7591 provides credentials at registration). Previously the
  unconditional 'clientID is required' made DCR unreachable via New.
- dynamic_client_registration.go: default registered token_endpoint_auth_method
  to client_secret_post to match the plugin's runtime default; an enforcing
  IdP would otherwise reject the plugin's token exchange.
- utilities.go: gate the shared per-provider singleton-metadata-refresh task
  stop on lastInstance (matching singleton-token-cleanup) so one instance's
  tear down no longer kills 2h metadata refresh for a surviving sibling.
- token_manager.go: stop self-recording the JTI into the per-instance
  tokenBlacklist on every verify. On the cookie path the same valid token is
  re-presented per request; an LRU eviction of the raw-token cache (MaxSize
  1000 / 5 MiB) then turned a legitimate cache miss into a false 'token
  replay detected'. Replay tracking stays in the shared shardedReplayCache.

Regression tests: new review_r36_regression_test.go (4 tests); updated
dynamic_client_registration_test.go and security_edge_cases_test.go which had
encoded the old defaults.

Verified: full source-order suite, full -race -count=1 -shuffle=on ./...
(all packages), and golangci-lint run ./... (0 issues).

* Fix dead CircuitBreaker.ResetTimeout config (round 39)

error_recovery.go: ResetTimeout was copied into the CircuitBreaker but never
consulted by the state machine (used only for a metric), so the documented
"how long to wait before fully closing the circuit" (reset_timeout, default
30s) had no effect: Open->HalfOpen used Timeout (60s), and a single
half-open success closed the circuit immediately, losing the intended
open -> half-open -> (reset cooldown) -> closed hysteresis and letting a
stray success immediately reopen (bounce).

Now the circuit remains half-open after entering it until resetTimeout has
elapsed; only then can a successful request fully close it. Half-open
requests are still allowed as probes. Default behavior is more
conservative (up to Timeout+ResetTimeout before full close).

Tests: updated TestCircuitBreakerHalfOpenTransition to the corrected
semantics; added TestCircuitBreakerResetTimeoutEnforced (fails on the old
dead-field code, where resetTimeout was ignored).

Verified: full source-order suite, full -race -count=1 -shuffle=on ./...
(all packages), golangci-lint run ./... (0 issues).

* Apply configured conn limits in pooled transport (round 41)

http_client_pool.go SharedTransportPool.GetOrCreateTransport built the shared
http.Transport with hardcoded MaxConnsPerHost=5, MaxIdleConnsPerHost=2,
MaxIdleConns=10, IdleConnTimeout=30s while HTTPClientConfig's versions of
those fields drove configKey (cache identity) and were validated and tuned
per preset (Default: 20/10/50/30s; OIDCProvider: 50/25/100/90s). The
config was thus valid, keyed, and validated but silently ignored in the
transport used by main.go (CreatePooledHTTPClient) - dead config that left
OIDC-provider connection tuning without effect. The sibling factory path
(http_client_factory.go) already applied these values.

Now the pooled transport derives MaxConnsPerHost, MaxIdleConnsPerHost,
MaxIdleConns, and IdleConnTimeout from the config, consistent with the
factory path and with configKey.

Test: added TestSharedTransportPoolAppliesConfigLimits, which fails on the
old hardcoded code (fixed values) and passes now.

Verified: full source-order suite, full -race -count=1 -shuffle=on ./...
(all packages), golangci-lint run ./... (0 issues).

* Harden redirect-count accessors against pooled-session data race (round 44)

* Fix bearer Authorization template strip order + nonce constant-time compare (round 45)

* Sanitize claim-derived identifier on auth-bypass headers (round 48)

* R51: guarantee openid scope in buildAuthURL when filter drops it

* R53: enforce 2048-bit minimum RSA key size on verify

* R54: refresh JWKS on unknown-kid to pick up key rotation

* R55: blacklist session tokens on user logout

* R56: honor RateLimit config as sustained req rate

* R57: FIFO eviction; keep fresh replay JTIs in cache

* R58: idempotent RefreshCoordinator Shutdown; no close-close

* R59: honor backend TTL on cache repopulation

* R60: accept single-string groups/roles; fix mem over-evict

* R61: detect truncated chunked token via stored total

* R62: drain queued tasks on goroutine pool shutdown

* R63: Shutdown waits for in-flight refresh goroutines

* R64: cap metadata cache grace by configured limits

* R65: validate ES client-assertion key curve matches alg

* R66: check expiry on lenient-audience token path

* R67: apply allowedRolesAndGroups gate to SSE/WS bypass

* R68: cache expiry TOCTOU, logout open-redirect, session max-age

* R69: fix double pool-return on session-timeout path

* R70: pooled-client cookie jar, refresh Save-failure cleanup

* R71: SSE/WS allowlist, session max-age, sec headers

* R72: validate combined cookie chunk metadata

* R73: logout jti replay, expired ajax, no-value

* R74: recv handler panics, cache TTL race fix

* R75: azure unauth copy-paste, dcr guard, cookie domain

* R76: dcr singleflight, L1 no-resurrect, TLS loopback

* R77: bearer allowedUsers gate, session inUse CAS

* R78: hybrid Clear flush, reauth reacquire, pool CAS

* R79: invalidation ttl tracks maxage, empty tpl hdr

* R80: req secret for introspection, pool conn on panic

* R81: reject empty access token on callback+refresh

* R82: req discovery endpoints, single-use code retry

* R83: DCR require client_secret for confidential auth, validate redirect_uri scheme; reject callback query/fragment

* R86: atomic SetIfAbsent for replay cache; close double-accept and singleton-read race

* R87: validate backchannel logout token exp; live-refresh JWKS for logout kid-miss

* R88: make StopAllTasks cover ResourceManager singleton tasks

TaskRegistry.CreateSingletonTask delegates to ResourceManager.RegisterBackgroundTask
and stores the task in the ResourceManager's registry, not TaskRegistry.tasks.
TaskRegistry.StopAllTasks previously iterated its own (always-empty) registry,
silently leaking every process-global singleton task's ticker goroutine past the
last Close (memory-monitor, replay-cache-cleanup) — tasks kept running real
periodic work with no instance to serve.

Add ResourceManager.StopAllTasks (stops all rm-owned tasks) and have
TaskRegistry.StopAllTasks stop both its own RegisterTask-direct tasks and the
ResourceManager-owned singletons.

Regression TestR88StopAllTasksStopsResourceManagerOwnedTasks (review_r88_test.go):
fails on old code (task still running after StopAllTasks), passes on new.

* R89: normalize excluded-url paths; apply defaults to pooled HTTP clients

Two verified defects from R89 scout round:

1. Excluded-URL dot-segment traversal widening (P1, auth bypass).
   shouldBypassAuth passes req.URL.Path raw (middleware.go:54); Go's server
   leaves '..' in URL.Path (url.ParseRequestURI does not normalize), so an
   exclusion for /public also matched /public/../admin, letting a real
   sibling (/admin) reach the backend unauthenticated. pathExcluded now
   path.Clean()s the request path ('.' -> '' to preserve root semantics)
   before boundary matching. Regression TestR89ExcludedPathDotTraversal
   fails on old (all 4 traversal cases wrongly excluded), passes on new.

2. Pooled HTTP client had no zero-value defaults (P2, unbounded stall).
   CreateHTTPClient applies defaults; CreatePooledHTTPClient and
   GetOrCreateTransport did not, so a partial config yielded Timeout=0 and
   ResponseHeaderTimeout=0 (a hanging upstream stalls forever). Extracted
   applyHTTPClientDefaults (single source of truth) and applied it in all
   three construction paths; pool cache key now reflects normalized values.
   Regression TestR89PooledClientAppliesDefaults fails on old, passes on new.

R89 clean/declined: session max-age IS absolute-and-enforced-every-request
(verified, review_r68 already locks it); AllowedUserDomains fail-closed (P2
'*' not a wildcard, P3 TrimSpace — declined as config-scoped).

* R92: validate all time claims on lenient-audience access tokens

On the lenient-audience path (validateStandardTokensRS), jwt.Verify
short-circuits at the aud check before reaching exp/iat/nbf validation,
leaving every time claim unvalidated. accessTokenUnexpired previously
re-patched only 'exp' (the R66 fix), so a token with a not-yet-reached
'nbf' but a valid exp was trusted for authorization before it was valid.

accessTokenUnexpired now re-applies the full main-path time validation:
verifyExpiration, verifyIssuedAt (iat required), and verifyNotBefore when
present — all clock-skew-aware. Regression TestR92LenientPathRejectsFutureNbf
(plus valid control) fails on old code (future-nbf token accepted), passes on
new.

R92 clean/declined: bearer auth (aud enforced, full claims — clean);
refresh rotation (correctly bound to session; no local reuse detection =
design); logout cookie completeness (all chunk names cleared, no revive;
two P3 non-security notes declined).

* 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

* fix(cache): harden cache backends, resilience, recovery, and transports

- RESP -ERR no longer closes healthy conn / aborts batch; global cap replacement-aware
- global health-check pass serialized (latent data race); MemoryMonitor via ResourceManager
- token-exchange retry, open-circuit rejections counted as failures
- TLS-posture matching transport reuse (no silent InsecureSkipVerify handoff)

* fix(config,dcr): env fallbacks, DCR self-heal, per-source auth gate

- implement documented OIDC_* env fallbacks; hold metadataMu for clientAssertion write
- re-register client when client_secret expires; require secret for confidential
- add opt-in per-source external auth rate limiting (PerSourceLoginRateLimit)

* docs: complete config reference and config examples

- CONFIGURATION.md: add 11 previously-omitted optional parameters
- README: document 6 implemented-but-undocumented options
- fix example config (raw .env block made invalid YAML)

* build: pin toolchain to go1.26.6 (resolves 5 reachable stdlib CVEs)

- net/http, asn1, idna GO-2026 CVEs cleared; govulncheck now 0 findings

* fix(jwk): use time.Since for elapsed-time computation (S1012)

* test: add R-round regression and security suite

- fail-on-old regression tests for every shipped fix (R-rounds)
- fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards
- coverage-closing contract tests (health-check backend, TLS-posture reuse)
- r154 ID-token fixture built from parts to avoid false-positive secret scan

* test: mark synthetic PKCE verifier with trufflehog:ignore

Quiet the Lob detector's false positive on the fake test-only code_verifier,
matching the repo's existing // trufflehog:ignore convention in token_test.go.
Local trufflehog filesystem scan is now 0 verified / 0 unverified.

* fix(circuit-breaker): stop open-circuit rejections from re-arming the open timer (FIX-01)

ExecuteWithContext called the promoted BaseRecoveryMechanism.RecordFailure()
on every request rejected while the circuit is open. RecordFailure sets
lastFailureTime, the same field allowRequest read as the Open->HalfOpen
timer, so each rejection re-armed the timer. Under steady traffic shorter
than Timeout the breaker never left Open (the default-on token breaker,
token_resilience.go:31-36, is affected in production).

CircuitBreaker now tracks its own openedAt timestamp, set only on a
genuine Closed->Open or HalfOpen->Open transition in recordFailure.
allowRequest and IsAvailable gate on openedAt instead of lastFailureTime.
An open-circuit rejection still increments total_failures directly via
atomic.AddInt64, preserving the R180 metrics contract, but no longer
touches lastFailureTime or the open timer.

Adds TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow, which opens
the breaker (MaxFailures 1, Timeout 50ms) and sends a probe every 10ms;
it fails at head because no probe is ever admitted within 500ms.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(circuit-breaker): ignore terminal client errors in the breaker state machine (FIX-09)

After FIX-01, an ordinary fn() error still trips recordFailure(), which
reopens a half-open circuit unconditionally and holds it half-open for a
full ResetTimeout (15s/30s in production). A per-user HTTPError such as
invalid_grant from a replayed code is not a downstream-health signal, so
under a 1% user-error rate roughly 78% of half-open cycles reopened for
another ResetTimeout, extending real outages.

recordFailure() is now skipped for a terminal client error (HTTPError
4xx other than 429); the promoted RecordFailure() metrics counter still
runs, since the operation did return an error to its caller. 429 and
non-HTTPError failures (network errors, 5xx) are unaffected and still
trip the state machine.

Adds three regression tests: a half-open probe returning HTTPError 400
must stay half-open and later close on success (fails at head: reopens);
HTTPError 429 and a net.OpError must still reopen half-open (already
passing, pinned as a guard against over-broadening the exemption).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(retry): stop single-use retry from matching HTTP response bodies (FIX-14)

ExecuteSingleUseWithContext substring-matched singleUseRetryableErrors
against the raw err.Error() text. helpers.go returns a *HTTPError whose
Message carries up to 10 KiB of the token-endpoint response body once a
response is received, so an IdP 500 that happened to mention "connection
refused" in its own body was retried up to MaxAttempts times even though
the request had definitely reached the server. Re-sending a consumed
authorization code or a rotated refresh token surfaces invalid_grant or
triggers refresh-token-family revocation on rotating IdPs.

Adds isNeverSentError: an *HTTPError (a response was received) is never
retried, and the fragment match now applies only to a genuine *net.OpError
(a dial/connect failure), the concrete type net/http.Client.Do returns
before any request reaches the wire.

Adds TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse, which
fails at head (fn called 3 times instead of 1) and passes after the fix,
plus a guard that a real dial error is still retried.

Updates the existing TestRetryExecutor_SingleUse_RetriesPreSendError
(review_r82_test.go) to simulate the dial failure as a *net.OpError
instead of a bare string, matching what client.Do actually returns; the
bare-string form no longer satisfies the tightened, type-based match.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(circuit-breaker): address review for FIX-09

isTerminalClientError used errors.As(err, &httpErr) against an
interpreted *HTTPError target. Under yaegi v0.16.1 (Makefile:9) this
panics with "errors: *target must be interface or implement error",
regardless of the error's own concrete type. CircuitBreaker calls
this on every fn() error, on the default-on token-exchange/refresh
path, so every failed exchange or refresh panicked the goroutine
instead of returning its error, and the panic on the refresh path
(refresh_coordinator.go, an unrecovered goroutine) crashed the
process. Switch to a plain type assertion: the production error
reaches the breaker unwrapped, so no Unwrap chain walk is needed.

Extend cmd/yaegicheck/main.go to drive CircuitBreaker.ExecuteWithContext
under the real interpreter: a plain error (the first call shape that
panicked), a half-open 400 (must stay half-open), a closed-state 400
(must stay closed), and a 429 (must still reopen half-open). make
yaegi-validate now fails on the pre-fix code and passes after.

Also add a Closed-state regression test pinning the existing scope
choice (the terminal-client-error exemption applies to Closed as well
as HalfOpen, not only HalfOpen): a 400 leaves the breaker Closed, and a
following genuine 5xx still opens it. This is coverage-only -- the
Closed-state behavior itself already matched this before the change,
confirmed by running the new test against the pre-fix code (PASS).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(retry): address review for FIX-14

isNeverSentError used errors.As(err, &httpErr) against an interpreted
*HTTPError target. Under yaegi v0.16.1 (Makefile:9) this panics with
"errors: *target must be interface or implement error", regardless of
the error's own concrete type. ExecuteSingleUseWithContext calls this
on every fn() error, on the default-on authorization-code-exchange and
refresh path, so every failed exchange or refresh panicked instead of
returning its error. Switch to a plain type assertion: the production
error reaches here unwrapped, so no Unwrap chain walk is needed. The
errors.As(err, &opErr) match against *net.OpError is untouched -- a
compiled stdlib type, yaegi-safe.

Also narrow the *net.OpError match to Op "dial" or "proxyconnect".
isNeverSentError previously matched singleUseRetryableErrors against
ANY *net.OpError, including "read"/"write", which happen after the
connection is already established and the single-use request may
already have reached the IdP. Only a failed connect proves the request
never went out. Correct the doc comment, which claimed a *net.OpError
is always pre-wire -- it is not.

Extend cmd/yaegicheck/main.go to drive
RetryExecutor.ExecuteSingleUseWithContext under the real interpreter: a
*HTTPError whose Message happens to contain a retryable-looking
fragment (must be called once, never retried) and a real client.Do
dial failure against a closed local port (must be retried to
MaxAttempts). make yaegi-validate now fails on the pre-fix code and
passes after.

Add a regression pinning the Op-scoping fix: a *net.OpError{Op: "read"}
carrying syscall.EHOSTUNREACH is not retried by
ExecuteSingleUseWithContext (fn called once). Confirmed this fails
against the pre-fix code (fn called 3 times) before applying the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(recovery): stop gdInstances leak from TokenResilienceManager (FIX-18)

Register GracefulDegradation in gdInstances synchronously in
NewGracefulDegradation, before the health-check goroutine starts. A
Close() that ran immediately after construction could otherwise race
the async registration and delete nothing, then the later registration
would add an already-closed instance permanently.

Add Close to ErrorRecoveryManager and TokenResilienceManager.
NewTokenResilienceManager builds its own ErrorRecoveryManager (and
therefore its own GracefulDegradation), separate from the plugin
instance own errorRecoveryManager. Production Close() only ever closed
the first one, so the second leaked in gdInstances forever and the
shared health-check task last-instance stop gate never fired.

TraefikOidc.Close now calls Close on both managers.

Regression test TestFix18_ClosingBothManagersEmptiesGDInstances fails
to build at head (Close undefined on both managers) and passes after
the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(tasks): keep pending singleton task instead of orphaning it (FIX-19)

RegisterBackgroundTask replaced any existing task with
started==0 && stopped==0, including one that is merely pending its
first Start() call. CreateSingletonTask (and the two other call sites
sharing its Register-then-Start pattern) does not hold tasksMu across
that sequence, so a second concurrent caller lands in that window and
replaces a task the first caller is about to start. The first caller
then starts an object no longer reachable from the registry, and
StopAllTasks/Shutdown never reach it again.

Add a startRefused flag to BackgroundTask, set on Starts refused by
the circuit breaker or the concurrency limiter (the two early-return
paths that leave started==0 and stopped==0 permanently).
RegisterBackgroundTask now replaces an existing task only when it is
stopped or its one Start attempt was refused, keeping a task that is
simply not started yet.

review_r122_test.go pinned the old "started==0 && stopped==0 always
means replace" behavior with a fixture that force-sets those fields
directly, without ever exercising a real Start() rejection. Update the
fixture to also set startRefused=1, the precise signal that case now
carries, preserving the R122 invariant (a circuit-breaker-rejected
task is still always replaced) alongside the new one.

Regression test TestFix19_RegisterBackgroundTaskKeepsPendingTaskNoOrphan
fails at head (orphan task keeps ticking after StopAllTasks: 13 -> 25)
and passes after the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(lifecycle): re-check last-instance under shared mutex before stop (FIX-35)

Close() decided whether it was the last live instance once, early
in its shutdown sequence (unregisterLiveInstance return value), then
reused that boolean much later to gate three separate singleton
stops (singleton-token-cleanup, singleton-metadata-refresh-*, and
the general StopAllTasks). Session and cache teardown run in
between, taking real time, during which a concurrently-created new
instance (an overlapping Traefik reload) can register and adopt the
same singletons. The stale decision then stops them anyway, leaving
the surviving instance with no memory monitor, token cleanup, or
metadata refresh until the next reload.

Add liveInstanceMu, taken by registerLiveInstance,
unregisterLiveInstance and a new isLastInstanceNow. Close() now calls
isLastInstanceNow() fresh, immediately before each of the three
singleton stops, instead of reusing one early decision.

main.go now calls registerLiveInstance before New adopts the
memory-monitor singleton (previously called after), so a concurrent
Close elsewhere always sees a new instance counted before it can
decide to stop the singletons that instance depends on.

Regression test
TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose fails to
build at head (isLastInstanceNow undefined) and passes after the fix.
A throwaway scratch probe (not committed) confirmed the runtime bug
independently: reproducing the old stale-decision pattern inline
wrongly stops memory-monitor even after a new instance registered and
adopted it.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): reject CoordinateRefresh after Shutdown, correct stale ctx comments (FIX-36)

CoordinateRefresh called wg.Add(1) unconditionally, with no check
on rc.stopChan, so a request arriving during or after Shutdown still
registered and ran a new refresh operation. go doc sync.WaitGroup.Add
requires that Adds with a positive delta happening when the counter
is zero happen before a Wait; an Add racing the last Done during an
in-progress Wait can panic with WaitGroup misuse.

Add lifecycleMu (RWMutex). CoordinateRefresh now takes RLock around
its stopChan check and its wg.Add(1) call; Shutdown takes Lock()
before closing stopChan. Lock() cannot succeed while any
CoordinateRefresh call holds RLock, so every Add that will ever
happen has completed before Shutdown reaches wg.Wait().
CoordinateRefresh called after Shutdown now returns an error instead
of running.

The comment on the isNew branch claimed in-flight refreshes "are
aborted promptly via rc.ctx when stopChan closes" - rc.ctx does not
exist. An initial version of this fix added it, deriving
executeRefreshAsync timeout context from it and canceling it in
Shutdown, so Shutdown would return before a genuinely in-flight
refresh finished. That directly regressed two existing, deliberately
written tests - TestRefreshCoordinator_ShutdownWaitsForInFlight
(review_r154) and TestRefreshCoordinatorShutdownWaitsForInflight
(review_r63) - which pin that Shutdown must wait for an in-flight
refresh to finish naturally, not abandon it (avoiding a leaked
refresh goroutine). This fix keeps that contract: Shutdown still
waits via wg.Wait(), bounded only by each operation own
RefreshTimeout, and the stale comments are corrected to describe
that behavior accurately instead of a rc.ctx mechanism that does not
exist.

Regression test TestFix36_CoordinateRefreshRejectedAfterShutdown
fails at head (CoordinateRefresh after Shutdown wrongly succeeds
and runs refreshFunc) and passes after the fix. The R63/R154 tests
were re-run and confirmed still green.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(refresh): replace timing-margin sleeps with deterministic waits (FIX-22)

Four tests raced the 100ms DeduplicationCleanupDelay cleanup timer
with a 150ms fixed sleep, a 50ms margin. A late timer let the next
call join the previous operation instead of starting a new one,
under-counting circuit-breaker/rate-limit attempts on loaded CI
runners. A fifth test (internal/cache/backends) raced singleflights
own 100ms delayed-cleanup goroutine the same way.

Set DeduplicationCleanupDelay=0 on the four RefreshCoordinator tests
(TestRefreshCoordinatorIntegration CircuitBreaker and RateLimiting
subtests, TestCircuitBreakerProtection,
TestRefreshCoordinator_SuccessOnlyAfterCombinedOperations), matching
the pattern already used elsewhere in refresh_coordinator_test.go.
Delay=0 alone is not fully deterministic: close(operation.done) and
the synchronous cleanup call are two separate statements in the same
deferred closure, so a waiter woken by the channel close can run
concurrently with, and observe the map entry before, that same
goroutines next statement. Confirmed this manually: an
intermediate delay-only version (no polling) failed 1 run out of 20
under -race -shuffle=on with the exact under-counting symptom the
finding describes.

Add waitForRefreshDrain, which polls
RefreshCoordinator.inFlightRefreshes for the tokens removal instead
of sleeping a fixed margin, and use it in all four loops. For
internal/cache/backends/review_r141_test.go, poll
SingleflightCache.calls under its own mutex the same way, per the
todo acceptance criterion.

All four tests plus the r141 test now pass go test -race -count=20
-shuffle=on with no fixed sleep at the previously-flaky sites, and
keep their existing assertions.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(refresh): assert bounded Shutdown and goroutine exit (FIX-23)

TestRefreshCoordinatorShutdownStopsCleanup called Shutdown() with no
assertion, so it passed on base regardless of whether Shutdown
actually stopped the cleanup goroutine. Only the sibling
TestRefreshCoordinatorShutdownIdempotent caught anything (a panic on
double-close).

Assert Shutdown returns within a 2s deadline, then independently
observe the cleanup goroutine exit via rc.wg (the same WaitGroup
Shutdown wg.Wait() depends on, bracketing exactly cleanupRoutine).

Mutation check performed and reverted: temporarily removing the
"case <-rc.stopChan: return" branch from cleanupRoutine makes the
test fail with "Shutdown did not return within the deadline" (the
goroutine loops on the ticker forever, wg.Done() never fires).
refresh_coordinator.go is unmodified in this commit (git diff
--stat shows only review_r58_shutdown_test.go); the mutation was
applied and reverted locally to prove the pin is live before this
commit.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(recovery): address review for FIX-18

Fix the close-before-goroutine race the review reopened: delete the
duplicate async registration in startHealthCheckRoutine (only
NewGracefulDegradation registers now), and make the goroutine check
gd.stopChan under gd.mutex before creating or adopting the shared
health-check task, so a closed instance can never (re)start it.

Close() now stops the shared task by name via
GetResourceManager().StopBackgroundTask instead of reading this
instance own gd.healthCheckTask field. With two or more instances,
that field can still be nil on the last-closing instance even though
a sibling already started the shared task, which would have skipped
the stop and leaked the task.

Close every remaining GracefulDegradation NewWithContext can leak: add
a deferred cleanup that closes t.errorRecoveryManager and
t.tokenResilienceManager on any error return after they are
constructed.

Add TestFix18_CloseImmediatelyAfterNewDoesNotReRegister (pins the
exact race; fails deterministically pre-fix, green across
-count=300 -cpu=1,4,16 post-fix), TestFix18_TraefikOidcCloseEmptiesGDInstances
(drives the real TraefikOidc.Close instead of calling the managers
Close directly), and TestFix18_NewWithContextClosesManagersOnEarlyError
(pins the NewWithContext error-path leak). Make the existing
TestFix18_ClosingBothManagersEmptiesGDInstances wait for each
instance async health-check goroutine to settle before closing, via
the new gd.healthRoutineSettled helper, so it exercises the
task-already-assigned path deterministically instead of sometimes
racing the goroutine.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(lifecycle): address review for FIX-35

Close the check-then-stop window the review reopened: isLastInstanceNow
released liveInstanceMu as soon as it returned, before the actual
singleton stop ran (StopBackgroundTask / StopAllTasks, which can block
up to 5s per task). A concurrent New() could register and adopt the
very singleton in that gap, and the stop would kill it anyway.

Add stopIfLastInstance, which holds liveInstanceMu across both the
check and the stop callback, and use it at all three gated stops in
Close() (singleton-token-cleanup, singleton-metadata-refresh-*,
StopAllTasks) in place of the bare isLastInstanceNow() check.

Add TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop, which
proves the lock is genuinely held for the stop full duration (a
concurrent registerLiveInstance cannot complete while a slow stop
callback is still running). Rewrite
TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose to drive the
real (*TraefikOidc).Close via a new closeTestHook (nil in production,
invoked once in Close right after unregisterLiveInstance) instead of
re-implementing Close stop decision inline — the previous version
stayed green even with the production fix fully reverted, since it
never called Close at all.

Both new tests fail to build pre-fix (stopIfLastInstance and
closeTestHook are new symbols); green post-fix across
-count=50 -cpu=1,4,16.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): expire chunk cookies before writing replacement chunks (FIX-05)

SetRefreshToken and SetIDToken re-Get and fill the new chunk sessions
before calling the expire helper for the old ones. gorilla Registry.Get
returns the same cached *sessions.Session per cookie name for a request,
so the expire pass re-Gets the objects this call just wrote and wipes
their Values, silently dropping the replacement token.

Reorder both methods to match SetAccessToken: validate chunk count,
sizes and reassembly first (pure computation, no store.Get), then
expire the old chunks, then Get and fill the new ones. A session whose
refresh or ID token was loaded from existing chunk cookies and gets
replaced with another token that also needs chunking no longer loses
the new token.

Adds fix05_session_chunk_replace_test.go, adapted from
.claude/review-2026-09-10/repro/zz_g1_chunkwipe_test.go and
zz_g1_chunkwipe_id_test.go with helpers and test names renamed to
describe the behavior pinned.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): gate deferred pool-return on ownership generation (FIX-10)

ServeHTTP defers session.returnToPoolSafely() right after GetSession.
The invalidated-session paths deeper in the same request (backchannel/
frontchannel logout detection) can session.Clear() that exact object
and re-acquire a fresh one before ServeHTTP returns. The deferred call
stays bound to the original pointer regardless, so if a concurrent
request pops the same pooled object before this deferred call fires,
its CompareAndSwap(true, false) still succeeds against the new owner
and Reset()s a session another request is actively using (ABA on the
pool-return CAS).

Add a generation counter to SessionData, bumped once by GetSession and
newSession on every handout. ownerGeneration() reads the current
value; returnToPoolIfOwner(gen) only acts if the generation still
matches what the caller captured at acquire time, so a stale deferred
call from a previous ownership epoch is a deliberate no-op instead of
releasing a live session. ServeHTTP now captures the generation right
after GetSession and defers returnToPoolIfOwner instead of the plain
call.

This is the generation-counter alternative the finding names, chosen
over nil-ing the deferred pointer: the Clear+reacquire happens inside
processAuthorizedRequestRS/processAuthorizedRequest, separate
functions from the ServeHTTP-scope defer, so nil-ing a local variable
there cannot reach the already-bound deferred call.

Adds fix10_session_pool_aba_test.go, adapted from
.claude/review-2026-09-10/repro/zz_g1_aba_test.go with the helper
names and test renamed, and rewritten to drive the ABA sequence
deterministically (the pool handing back the same object after Clear
is unspecified sync.Pool timing, not part of the fixed contract, and
did not reproduce on every run).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): require real preflight headers for the OPTIONS auth bypass (FIX-02)

shouldBypassAuth bypassed OIDC auth for any request whose method is
OPTIONS. A genuine CORS preflight always carries both an Origin header
and an Access-Control-Request-Method header; gating on the method
alone let an unauthenticated OPTIONS on any protected path reach the
backend with no session check. Reproduced against http.FileServer:
200 with the protected file body, no cookie, no Origin.

Require both headers before treating an OPTIONS request as a
preflight bypass. A genuine preflight still bypasses unconditionally;
every other OPTIONS request now goes through the normal auth
pipeline.

Adds fix02_options_bypass_test.go covering shouldBypassAuth directly
(bare OPTIONS, Origin-only, and a genuine preflight) plus an
end-to-end ServeHTTP reproduction of the leak and its fix. Updates the
existing TestShouldBypassAuth_PassesOptions in review_r124_test.go
(touched_outside) to send a genuine preflight instead of a bare
OPTIONS, since it asserted the vulnerable bypass-on-method-alone
behavior.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): drop oversized ID token header instead of truncating (FIX-34)

forwardAuthorized truncated an ID token longer than headerTemplateMaxLen
(8192 bytes) and still set X-Auth-Request-Token to the truncated value.
A truncated JWT is structurally invalid: a downstream that verifies the
signature rejects it (fine), but one that only base64-decodes claims
without verifying reads a corrupted value.

Drop the header instead of truncating, matching the fail-closed
pattern already used for X-Forwarded-User / X-Auth-Request-User in the
same function when identifier sanitization fails.

Adds fix34_idtoken_header_test.go (oversized token drops the header;
in-budget token still forwards unmodified). Updates
review_r155_regression_test.go (touched_outside): its existing test
asserted the header was truncated to exactly headerTemplateMaxLen
bytes, which is the behavior this fix replaces; renamed to
TestR155_XAuthRequestToken_DroppedWhenOverHeaderBudget and rewritten
to assert the header is absent.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): hand next.ServeHTTP the real writer, not the tracking wrapper (FIX-06)

ServeHTTP wraps rw in *trackingWriter (rw = tw) for panic-commit
tracking and then forwards that SAME wrapped writer to every
t.next.ServeHTTP call: excluded, SSE, WebSocket, options bypass, the
default case, and forwardAuthorized (the authenticated path). Traefik
runs the plugin interpreted under yaegi v0.16.1, whose stdlib interop
layer composes a boundary wrapper only for ResponseWriter+Hijacker
crossing into compiled code, never for Flusher or the Unwrap pattern
http.ResponseController relies on. So Traefik's compiled ReverseProxy
calling http.NewResponseController(rw).Flush() for SSE gets "feature
not supported" in production, even though trackingWriter declares
Flush/Unwrap and native tests (which never cross an interpreter
boundary) pass.

Reproduced with a throwaway yaegi run probe (not committed; yaegi
v0.16.1, GOPATH/bin/yaegi) mirroring trackingWriter exactly:
  wrapped:   ResponseController.Flush err=feature not supported, recorderFlushed=false
  unwrapped: ResponseController.Flush err=<nil>,                 recorderFlushed=true
matching the finding's own reproduction.

Add downstreamWriter(rw), used at every t.next.ServeHTTP call site: it
unwraps *trackingWriter to the real writer ServeHTTP received, and
marks the trackingWriter's new calledNext field so the deferred
recover (which may run in a different function than the call site, via
forwardAuthorized) knows control passed downstream.

Handing next the real writer means wroteHeader can no longer observe
writes next makes directly (it bypasses tw entirely). The deferred
panic recovery is updated to skip its fallback 500 once calledNext is
true: guessing in that state risks appending "Internal Server Error"
to a response next already committed, which TestR155_ServeHTTP_
PanicAfterCommitKeepsBodyIntact exists to prevent and is the more
severe failure mode. A panic in the middleware's own pre-forward code
is unaffected and still gets a clean 500
(TestR179_PanicRecoverySetsNoStore). Updates review_r74_test.go
(touched_outside): its existing assertion that a downstream (next)
panic forces a 500 is no longer achievable without that same
corruption risk; rewritten to assert the recovered-but-unwritten
default (200) Go's http.Server already gives any handler that returns
without writing.

Adds fix06_downstream_writer_test.go: asserts next receives the
unwrapped writer on the SSE, WebSocket and authenticated
(forwardAuthorized) paths.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): make FIX-06 actually load and behave correctly under yaegi (FIX-06)

trackingWriter now declares Header() explicitly instead of relying on
the embedded http.ResponseWriter promotion. yaegi v0.16.1 does not
count a promoted method when it statically checks whether
*trackingWriter satisfies http.ResponseWriter, so the plugin refused
to load under yaegi at all ("missing Header method"), invisible to
every native test. Verified with a temp GOPATH symlinked to this
worktree (the shared GOPATH symlink resolves to the main checkout, not
this branch) via `yaegi run ./cmd/yaegicheck/main.go`: FAIL before
this change, OK after.

ServeHTTP's panic-recovery defer now sends a header-only 500
(Cache-Control: no-store) whenever nothing was written through the
tracking wrapper, even after control has passed to next
(tw.calledNext). WriteHeader is idempotent, so this is a no-op if next
already committed its own response; only the fallback body Write is
still gated on !calledNext, since only a body Write can corrupt an
already-committed response. Previously the whole 500 was skipped once
calledNext was true, so a downstream panic before any write produced
an empty 200 instead of a 500, telling the client a failed request
succeeded. review_r74_test.go is restored to asserting 500 (its
925d627 baseline), with comments updated for the new behavior.

Adds cmd/yaegiflushcheck, a nested Go module (its own go.mod, so it
never enters the root module's build/vendor graph) that drives the
real plugin under yaegi with a COMPILED next handler calling
http.NewResponseController(w).Flush(), reproducing the finding
end-to-end rather than only at import time. Confirmed it fails
("feature not supported") when downstreamWriter is reverted to not
unwrap the tracking writer, and passes ("err=<nil>") against the fix.
fix06_downstream_writer_test.go's dangling reference to a
never-committed fix06_yaegi_probe.go now points at this file.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): discard the response body on a forged CORS preflight (FIX-02)

The FIX-02 header check (Origin + Access-Control-Request-Method) rules
out an accidental bare OPTIONS request but not a deliberate attacker:
any non-browser client can set both headers itself, and
bypassReasonOptions still forwarded that unauthenticated request to
next with no session check. Reproduced against http.FileServer: a
forged preflight with no cookie still returned the protected file's
body.

This exceeds what the FIX-02 todo originally asked for, so flagging
for maintainer sign-off (see the fixes report's concerns) even though
it is implemented here: severity is major with a concrete exploit, and
leaving it undone seemed worse than the scope overrun.

ServeHTTP's bypassReasonOptions case now hands next an
optionsPreflightWriter instead of the raw writer. It forwards
Header()/WriteHeader() (deleting any Content-Length next sets, since
no body follows) but discards every Write(). A browser never reads a
preflight response body, so a genuine CORS preflight keeps working
unchanged (TestServeHTTP_GenuinePreflightReachesBackend still passes);
a forged one can no longer read protected content through this path.
Declares Header/Write/WriteHeader explicitly rather than relying on
the embedded http.ResponseWriter's promotion, for the same yaegi
reason as trackingWriter (FIX-06): confirmed the plugin still loads
clean under yaegi (cmd/yaegicheck) with this change.

Adds TestServeHTTP_ForgedPreflightDoesNotLeakBody, which reproduces
the finding's own repro (http.FileServer, forged Origin +
Access-Control-Request-Method, no cookie) and asserts the body never
reaches the caller.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): gate opaque-token introspection and bind it to the client (FIX-03)

buildPrincipalFromOpaqueIntrospection accepted any introspected
active=true token from the IdP, with none of the JWT-path gates: it never
checked allowOpaqueTokens, never bound the response to this client when
audience==clientID (the common default), and never consulted
isSessionInvalidated or bearerIdentifierClaim/sanitizeBearerIdentifier/
maxTokenAge. A token issued to an unrelated client at the same IdP could
authenticate as any user, and IdP-initiated logout did not revoke an opaque
bearer token.

Gate the opaque branch behind the existing allowOpaqueTokens flag (no new
config knob). When audience==clientID, require resp.ClientID==clientID or
an aud containing clientID. Apply the identifier claim, sanitization,
maxTokenAge (resp.Iat) and isSessionInvalidated (by sub; RFC 7662 carries
no sid) the same way the JWT bearer path does. Update review_r159 and
review_r166 fixtures to set allowOpaqueTokens and a matching client_id, and
document the gated contract in docs/BEARER_AUTH.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(introspection): stop treating introspection 4xx as token-revoked (FIX-13)

R156 (8640451) mapped any 400-499 status from the introspection endpoint
to a definite "token invalid" verdict, on the session path
(token_validation_rs.go) and both bearer-path introspection call sites
(bearer_auth.go). RFC 7662 s2.2 defines only a 200 response with
active=false as inactive; s2.3 defines a 401 as the plugin own client
credentials being rejected, not a statement about the token. A rotated
client_secret or an IdP 429 therefore turned every valid token into a 401,
fed the bearer per-IP penalty box, and forced a refresh storm on the
session path.

Remove the 4xx-as-invalid special case at all three sites so those errors
fall into the existing generic handling: bearerErrIntrospectionUnavailable
(503) on the bearer path, and the existing requireTokenIntrospection-gated
fail-closed / transient-fallthrough on the session path. Only a 200
response with active=false still rejects the token. Update the R156
fixture that pinned the old (now superseded) 401-forces-refresh contract,
and document the corrected 401/503 split in docs/BEARER_AUTH.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): fail closed on logout invalidation when iat is absent (FIX-24)

buildPrincipalFromBearerToken derived the backchannel-logout invalidation
createdAt from iat, falling back to time.Now() when absent. 8640451 made
iat OPTIONAL in jwt.Verify (R126), so an access token without iat now
reaches this check. The time.Now() fallback made such a token always look
newer than any logout event, so IdP-initiated logout never revoked it,
reintroducing the R98 defect the cookie path already avoids by falling
back to the zero time instead.

Fall back to the zero time (fail closed) instead of time.Now(), matching
sessionCreatedAtForInvalidation on the cookie path. Also correct the stale
comment on enforceIatAge, which claimed jwt.Verify still hard-requires iat.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): stop sweeping in-progress failure counters (FIX-28)

recordFailure map-growth sweep deleted every entry whose penaltyUntil was
before the cutoff. An entry that has not tripped the penalty box yet has a
zero penaltyUntil, which is always before any cutoff, so once the map
passed defaultBearerEntrySweepThreshold every accumulating counter was
discarded regardless of how recently it started. An attacker who floods
many distinct source IPs (or one interleaving its own failures with
sweeps) could keep any one source below the threshold forever.

Delete an entry only when it is BOTH untripped-or-expired AND outside the
counting window: penaltyUntil.Before(cutoff) AND firstFailureAt is older
than the window. A fresh, still-accumulating counter now survives a sweep;
only entries that have genuinely gone stale are reclaimed. Update the R214
memory-bound test: a fresh flood within the window is now expected to grow
past the threshold (that is the fix, not a regression), while entries that
age past the window still get swept, which the rewritten test verifies in
a second phase.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(jwt): share the time-claims contract between Verify and the lenient path (FIX-27)

accessTokenUnexpired (the lenient-audience-path re-check that runs after
jwt.Verify short-circuits at the aud check) still hard-required iat, even
though 8640451 made iat OPTIONAL in jwt.Verify itself per RFC 7519 4.1.6
(R126). The two paths had drifted onto different contracts for the same
claim: an iat-less lenient-audience access token was always forced to
refresh or re-authenticate, while the identical token with a matching
audience passed through jwt.Verify fine.

Maintainer confirmed the iat-optional contract (R126) stands. Extract the
exp/iat/nbf validation shared by both paths into one verifyTimeClaims
helper in jwt.go and use it from both jwt.Verify and accessTokenUnexpired,
so the two cannot independently drift on which time claims are mandatory
again. Both R92 fixtures (which carry iat) keep passing.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): amortize the failure-tracker map-growth sweep (FIX-28)

recordFailure swept the whole entries map on every call once the map
crossed defaultBearerEntrySweepThreshold, even when the sweep found
nothing stale to delete. A flood of distinct source IPs (attacker-
controlled) turned this into O(n) work under b.mu on every call, O(n^2)
to fill the map, while blocked() waits on the same mutex on every
bearer request.

Add nextSweepAt, which doubles after each sweep (bounded below by
defaultBearerEntrySweepThreshold), so the scan runs only when the map
has genuinely grown, amortizing its frequency to logarithmic in the
flood size instead of linear. Add sweepPasses to count scans for
tests.

Rewrite TestBearerFailureTrackerSweepBoundsMemory phase 2 to record
failures until the next amortized sweep actually fires, then assert
the aged keys are gone, rather than asserting a fixed total-size
bound that the amortized gate no longer holds to on every call. Add
TestBearerFailureTrackerSweepAmortized: 30,000 fresh distinct sources
must trigger at most 6 sweep passes (HEAD triggers on ~29,000 calls).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): close 3 opaque-introspection gaps: sid logout, iat bound, identifier claims (FIX-03)

buildPrincipalFromOpaqueIntrospection had three gaps against the JWT
bearer path it mirrors:

- isSessionInvalidated was always called with an empty sid, so a
  sid-only (front-channel) logout never revoked an opaque token, even
  when the IdP introspection response itself carried a sid. Add Sid
  to IntrospectionResponse and pass resp.Sid through.
- maxTokenAge was applied only when resp.Iat > 0, silently skipping
  the bound on a response with no iat, unlike enforceIatAge on the
  JWT path (which fails closed on a missing iat once maxAge is set,
  and New() always sets it > 0). Route the opaque-path check through
  enforceIatAge itself so the two paths cannot drift again.
- The identifier claims map was built from sub/username only, so any
  other bearerIdentifierClaim value (client_id, scope, iss, jti, aud)
  always 401d with "missing claim", even when the introspection
  response carried it.

Add regression tests for all three (sid-only logout, missing iat with
maxTokenAge set, bearerIdentifierClaim=client_id). Add iat to the
R159/R166 introspection fixtures, which otherwise now fail the
enforced age bound under makeBearerOIDC's 24h maxTokenAge default.
Document the opaque iat requirement and the sid-only logout scope in
docs/BEARER_AUTH.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(introspection): stop classifying session-path introspection errors by IdP body text (FIX-13)

validateOpaqueToken wraps introspectTokens *HTTPError for a non-200
introspection response, and HTTPError.Message embeds up to 10 KiB of
the IdPs own response body. validateStandardTokensRS substring-matched
that message for "token is not active", "revoked" or "token has
expired" before reaching the transient/fail-closed branch, so a
401/403/429 whose body happened to contain one of those words (e.g.
the IdPs OWN client-secret-revoked error, RFC 7662 s2.3) still forced
a refresh, even though only a 200 response with active=false may
reject the token.

Detect an *HTTPError via errors.As before the substring match and
route it straight to the requireTokenIntrospection/transient handling.
Only validateOpaqueTokens own generated messages (active=false,
expired, nbf — never an *HTTPError) still reach the substring
classification. Add a regression test: a 401 with "revoked" in the
body, requireTokenIntrospection=false, must not force a refresh.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): correct maxTokenAge opt-in comments (FIX-24)

The enforceIatAge and TestBearerLogoutInvalidation_NoIatFallsClosedNotOpen
comments called maxTokenAge>0 an "operator opt-in": false. New()s
config-default closure (main.go:357-362) always sets maxTokenAge > 0 —
0/unset becomes 24h — so enforceIatAge rejects every iat-less bearer
JWT in production, opt-in or not. Only a test that constructs
*TraefikOidc directly (bypassing New()) can set maxTokenAge=0.

Reword both comments to state this, and note that the zero-time
createdAt fallback for the logout-invalidation check is mostly
defense in depth given that in production: it is what protects an
iat-less token only when maxTokenAge is explicitly 0 or the object
bypasses New(), since enforceIatAge otherwise rejects the token first.

No behavior change. Separately, docs/BEARER_AUTH.md:164 and
docs/CONFIGURATION.md:288 state "0 disables the check", which
contradicts main.go:357-362 (0 becomes 24h, it is never actually
disabled) -- flagged to the lead as a pre-existing doc/code mismatch
outside this packages scope, not fixed here.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): stop deleting applied-but-slow backend writes on Set failure (FIX-04)

RedisBackend.executeWithRetry checked ctx.Err() before err==nil, so a SET
that completed after the caller gave up waiting reported as failed even
though Redis applied it. UniversalCache.Set then issued a Delete on any
backend Set error, erasing the value that write just landed.

Reorder executeWithRetry to return nil immediately on a successful
operation, before consulting ctx.Err(). Skip the Delete in
UniversalCache.Set when the error is a context deadline/timeout (the
write may have applied), and unconditionally for MonotonicMarkers caches
(blacklist, session invalidation), whose entries are revocation markers
and are never stale regardless of the failure reason.

Adds MonotonicMarkers to UniversalCacheConfig, set for the blacklist and
session-invalidation cache configs in universal_cache_singleton.go.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): add blacklist to getLocal RLock fast path (FIX-15)

The blacklist cache moved to CacheTypeBlacklist but the getLocal fast
path only gained CacheTypeIntrospection, so every blacklist lookup fell
to the exclusive c.mu.Lock(). tokenBlacklist.Get runs on the verify path
for every authenticated request, so this re-created the RLock-convoy
pattern (a single expired entry serializing every concurrent JWT verify
under yaegi) the fast path exists to prevent.

Add CacheTypeBlacklist to the fast-path case list.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): read legacy token: namespace on blacklist miss (FIX-16)

The R128 rename moved the blacklist cache backend namespace from
CacheTypeToken (token:<key>) to CacheTypeBlacklist (blacklist:<key>) with
no migration. Revocation markers written before the upgrade (TTL up to
24h) were ignored after it, and mixed-version replicas in a rolling
deploy did not share revocations, since old and new instances read and
write different namespaces.

On a blacklist miss, also read the legacy token:<key> backend entry.
Treat it as blacklisted only when the stored value deserializes to
boolean true, so a CacheTypeToken claims-map entry for the same raw
token is never misread as a revocation marker. Writes stay on the new
blacklist: namespace only.

Upgrade note: operators upgrading through this release keep denying
tokens revoked by a pre-upgrade instance for the lifetime of the
revocation marker (up to 24h). The legacy-namespace read is a one-release
compatibility shim; a future release can drop checkLegacyBlacklistMarker
and legacyBlacklistPrefix in universal_cache.go once all replicas have
rolled forward past the R128 rename.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): use PTTL and a no-expiry sentinel for Get TTL (FIX-31)

RedisBackend.Get read TTL (second precision), which maps both "no
expiry" (-1) and "under one second left" to a reported ttl of 0.
UniversalCache.Get treated any ttl<=0 as DefaultTTL, so a key on the
verge of expiring in Redis got re-cached locally for the cache's full
DefaultTTL, up to 25h for the session-invalidation cache, well past the
real expiry of the entry.

Switch Get to PTTL (millisecond precision) and return the distinct
NoExpiryTTL sentinel for a key with no associated expiry. Update
UniversalCache.Get to federate DefaultTTL only for that sentinel, and to
skip repopulating the local copy for any other non-positive ttl instead
of defaulting to DefaultTTL.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): address review for FIX-04

Replace the anonymous-interface errors.As in isTimeoutOrDeadlineError
with the compiled net.Error pattern already used in error_recovery.go.
Under yaegi v0.16.1, errors.As against `var x interface{ Timeout() bool
}` panics with "errors: *target must be interface or implement error"
when the plugin package is interpreted the way Traefik loads it (GOPATH
self-import, not `go test`). Every non-context Set error on a
non-monotonic cache panicked UniversalCache.Set in Redis mode. Verified
this session: repointing the ~/.go/src GOPATH symlink to this worktree
(make yaegi-validate otherwise silently validates the main checkout,
see the yaegi-validate-gopath-symlink memory note) reproduces the exact
panic on the old code and confirms it is gone on the new code, both via
a standalone repro script and via a new cmd/yaegicheck guard
(checkUniversalCacheSetDoesNotPanicOnBackendError) that make
yaegi-validate now runs on every call.

Add the backend-stale local-write guard the FIX-04 todo asked for.
Skipping the post-failure Delete (timeout error, or a MonotonicMarkers
cache) leaves the backend holding whatever was there before the failed
Set. Get() previously trusted that backend value unconditionally,
reopening R162 for a deadline-erroring Set that never applied, and
silently masking a newer session-invalidation logout with an older
Redis timestamp when the newer Set's write failed. UniversalCache now
tracks a per-key backend-stale mark (staleBackend, cleared by the next
successful backend Set and by item removal/eviction/Clear); Get()
prefers the live local value over the backend value for a marked key
instead of overwriting it via updateLocalCache. Corrects the
MonotonicMarkers doc comment, which claimed session-invalidation
entries are "never stale" - they are Unix timestamps compared by
value, so an older one really can be stale next to a newer one.

Add SkipAutoCleanup/Close to the FIX-04 and FIX-16 test caches that
were leaking a cleanup goroutine and ticker per test run.

Tests: TestFIX04_TimeoutSetFailureServesFreshLocalNotStale,
TestFIX04_SessionInvalidationNewerLogoutDeadlineServesFreshLocal,
TestFIX04_SessionInvalidationNewerLogoutErrorReplyServesFreshLocal
(new); existing TestFIX04_*, TestFIX16_*, TestR162_* re-run clean.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): address review for FIX-31

Correct the NoExpiryTTL godoc: it said the sentinel covers "Redis PTTL
-2/-1", contradicting the very next sentence, but Get only maps PTTL
-1 to NoExpiryTTL. PTTL -2 (and 0) map to a reported ttl of 0, same as
an about-to-expire key.

Tighten TestFIX31_GetWithRealTTLReportsPositiveDuration with a lower
bound near the set TTL. The prior assertions (ttl>0, ttl<=10s) do not
pin the millisecond-precision PTTL contract: reproduced this session
by mutating Get back to `conn.Do("TTL", ...)` while still reading the
reply as milliseconds — both FIX-31 backend tests still passed, and a
10s key reported "10ms" as its TTL. The new `ttl>9s` bound fails on
that same mutation ("10ms" is not greater than "9s") and passes on the
real PTTL-based code.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): address review for FIX-16

Reword the legacy-blacklist-namespace comment in Get(). It claimed
mixed-version replicas "share revocations" during a rolling deploy,
but the shim is one-directional: an upgraded replica still honors a
revocation a pre-upgrade replica wrote under the legacy "token:"
namespace (checkLegacyBlacklistMarker on a "blacklist:" miss), but a
pre-upgrade replica has no knowledge of "blacklist:" and never sees a
revocation an upgraded replica writes. Writes stay on the new
namespace only, unchanged.

Comment-only change; TestFIX16_* re-run clean.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/redis): drain array after nested -ERR reply (FIX-37)

readArray returned early on the first nested "-ERR" element, leaving
declared-but-unread elements on the connection. Do treats
ErrCommandReply as non-fatal and returns the connection to the pool, so
the leftover bytes could be read as the reply to the next command on
that connection (response desync).

readArray now keeps reading past a nested command-error reply, storing
it as the element value, and only aborts on a genuine IO or protocol
error.

Regression test proves a nested -ERR no longer aborts the array read
and that a subsequent reply on the same stream is not corrupted by
leftover bytes.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/redis): surface pipeline command errors in GetMany (FIX-38)

Pipeline.Execute stores a Redis command-error reply (e.g. LOADING,
WRONGTYPE) as an error value in the responses slice instead of failing
the batch. GetMany ran that value through RESPString, which always
failed on an error value, and counted the failure as a cache miss with
no log. A command error therefore looked identical to "key not found".

GetMany now recognizes an error-valued response, keeps it out of the
miss count, and returns it wrapped with the offending key. Other keys
in the same batch still populate the result map.

Regression test proves a WRONGTYPE reply for one key is surfaced as an
error, not silently dropped, while a good key in the same batch still
comes back.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* chore(cache): remove unreachable internal/cache dead code (FIX-43)

Grep confirms no non-test code imports traefikoidc/internal/cache, and
NewMemoryBackend/NewHybridBackend have no production caller anywhere
(only tests of the twins themselves). The production cache path is
UniversalCache over the root-level backends package; R64/R68/R74/R76/
R78 fixes to these twins had no production effect, and R64 duplicates
logic that already exists in universal_cache.go (unchanged).

Removed:
- internal/cache (parent package): cache.go, manager.go, typed_cache.go,
  compat.go, log_redact.go, and their tests.
- internal/cache/backends: memory.go, memory_shard.go, memory_wrapper.go,
  hybrid.go, and their tests. FIX-20 (hybrid.go asyncWg race) is
  superseded by this removal and is not fixed.

Tests that exercised the deleted MemoryCacheBackend/cacheShard/
MemoryBackend/HybridBackend internals directly are removed with them
(review_r76/78/99/124/125/139_f2/177_regression_test.go under
backends, race_regression_test.go). Tests that used NewMemoryBackend
only as a convenient CacheBackend substrate for still-alive production
code (singleflight.go, review_r141/r212 stress tests, and the root
package JWKCache tests via NewMemoryBackendForTest) now build that
substrate from a miniredis-backed RedisBackend instead. Assertions are
unchanged.

The redis.go retry classifier depended on a small case-insensitive
contains/toLower helper that had been living in hybrid.go; moved to a
new stringutil.go so the redis backend keeps working standalone.

go list ./... no longer lists internal/cache (only backends and
resilience remain); build, vet and the full race test suite are green.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/backends): delete now-unused redactKey (FIX-43)

redactKey had callers only in the deleted hybrid.go. staticcheck U1000
and a full golangci-lint run both flagged it as unused after the FIX-43
deletion. Delete it and move the package doc comment it carried onto
interface.go, replacing that file's stale singular-typo comment.

staticcheck ./... and golangci-lint run ./... (no --new-from-rev) both
report 0 issues in internal/cache/backends after this change.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/backends): treat nested nil array elements as values (FIX-37)

readArray aborted on a nested "$-1" or "*-1" element by returning
ErrNilResponse, leaving trailing array elements unread on the wire.
Do() treats ErrNilResponse as a healthy-connection signal and keeps
the connection pooled, so the next command desyncs onto the leftover
bytes. This is the same desync the nested "-ERR" fix already handles.

Store nil for a nested ErrNilResponse and keep draining the array,
mirroring how Pipeline.Execute already treats ErrNilResponse as a
non-fatal per-command value. Top-level "$-1"/"*-1" still return
ErrNilResponse unchanged.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(cache/backends): assert misses counter on GetMany command error (FIX-38)

The regression test only checked the returned error and the good key
result, not the misses counter, so the todo acceptance criterion
"does not increment misses" went unenforced.

Snapshot GetStats() before and after GetMany and assert misses is
unchanged and hits goes up by exactly one. Verified the test catches
the regression by temporarily reintroducing r.misses.Add(1) in the
resp.(error) branch: the test fails with "expected: 0 actual: 1" on
the misses assertion. Reverted the mutation; redis.go has no diff.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(test): close miniredis in NewMemoryBackendForTest (FIX-43)

NewMemoryBackendForTest started a miniredis server as a local variable
and never closed it. Callers only deferred backend.Close(), which
closes the RedisBackend pool, not the miniredis listener, so each
call leaked a listener and its goroutines for the rest of the test
binary.

NewMemoryBackendForTest now takes *testing.T and registers the
miniredis server against t.Cleanup(mr.Close), matching the pattern
already used by NewMiniredisServer and setupTestRedis in
internal/cache/backends. Updated both call sites (review_r124_test.go,
review_r130_test.go) to pass t.

New regression test in memory_backend_for_test_leak_test.go dials the
miniredis address after the owning subtest ends and asserts the
connection is refused. It fails against the pre-fix helper (dial
succeeds, listener still up) and passes after the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(cache): fix comments referencing deleted FIX-43 files (FIX-43)

Several comments still pointed at code the FIX-43 dead-code deletion
removed: sharded_cache.go and review_r129_test.go cited
internal/cache/backends/memory_shard.go, and review_r34_regression_test.go
cited a nonexistent hybrid TestRecordL2Error. interface_test.go claimed
the RedisBackend contract subtest actually runs, but setupRedisBackend
also t.Skips, so TestCacheBackendContract currently exercises neither
subtest.

Reword each comment to drop the stale references and, for
interface_test.go, state plainly that both subtests are pending
t.Skip stubs.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(jwk): bound repeat JWKS fetches after a failed refresh (FIX-30)

doLiveRefresh recorded the force-refresh cooldown only after a
successful fetch (R100), so every unknown-kid bearer request and every
backchannel-logout token hit the JWKS endpoint again during an IdP
outage, with no throttling at all.

Record a short 5s failure cooldown (jwksFailureCooldown) by backdating
the existing lastForceRefresh/lastSignatureRefresh timestamps, so a
failure blocks repeat upstream calls briefly without waiting the full
30s success cooldown before retrying once the IdP recovers.

forceJWKSRefresh previously delegated its within-cooldown branch to
GetJWKS, which itself falls back to a live fetch on a cache miss. That
silently defeated the new failure cooldown (nothing is cached after a
failure), so the branch now reads the local cache directly instead.

Update TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown (review_r100_test.go)
to pin the corrected bound: a failure records a short cooldown that
clears well before the 30s success cooldown, not zero cooldown at all -
the previous assertion (no cooldown recorded) is the exact behavior
this fix corrects.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(logout): rebuild post_logout_redirect_uri from the trusted redirect origin (FIX-07)

handleLogout sent every postLogoutRedirectURI value that does not start
with "http" (including the documented default of "/") to a branch that
set postLogoutRedirectURI = "". BuildLogoutURL then omitted the
parameter entirely, so RP-initiated logout left users stranded on the
IdP logout page under the default config plus an end_session_endpoint.

Capture the origin of redirect_url before session.Clear wipes it. That
value was persisted at authentication-initiate time (R136) and already
accepted by the IdP as this deployment redirect_uri, so its scheme and
host are safe to reuse for post_logout_redirect_uri - unlike the
request Host / X-Forwarded-Host, which the browser controls.

When no trusted origin is available (pre-R136 session, or logout
without a completed login), the parameter is still omitted, matching
the existing safe fallback. An operator-configured absolute URL keeps
taking priority over the derived origin.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(logout): log provider revocation failures and bound the calls (FIX-33)

handleLogout makes two synchronous provider revocation round trips
(access token, refresh token) and discarded both errors with "_ =",
so a failing revocation endpoint left no trace, and each call used
context.Background() with no deadline of its own, so a slow or
unreachable provider could hold the logout redirect open with no
bound of its own.

RevokeTokenWithProvider now derives its request context from a
3-second timeout (revocationRequestTimeout) instead of
context.Background(), and returns a sentinel
ErrRevocationEndpointNotConfigured for the expected "no revocation
endpoint" case. handleLogout logs any other error at error level and
still redirects the browser.

Document the logout revocation and post_logout_redirect_uri behavior
in docs/CONFIGURATION.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(auth-flow): stop logging CSRF/nonce on login initiation (FIX-29)

defaultInitiateAuthentication logged "Session saved before redirect.
CSRF: %s, Nonce: %s" and the full provider authorize URL (which also
carries state and nonce as query values) at Debug, despite the R153
commit claiming redaction of state in logs.

Drop the CSRF/nonce values from the first log line. The authorize-URL
log now goes through a new redactAuthorizeURLForLog, which calls the
existing redactCallbackURL (state) and additionally strips nonce and
code_challenge - redactCallbackURL alone was written for the callback
URL and does not know about those two authorize-URL-only values.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(auth-flow): drop Retry-After on the rate-limited callback 429 (FIX-32)

handleCallback sent Retry-After on the 429 for ErrRateLimitExceeded, but
clearOneTimeAuthState has already cleared csrf/nonce/code_verifier and
the authorization code has already been redeemed at the IdP by the
time that response is sent. A client that honors Retry-After and
retries the exact callback URL cannot succeed - it hits "CSRF token
missing in session" instead. Drop the header; the 429 status stays.

Update TestR179_CallbackRateLimitReturns429 (review_r179_regression_test.go)
to assert the corrected contract (no Retry-After) instead of the header
it previously required - the header it pinned is exactly what this fix
removes.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(replay): remove the dead JTI replay write path, make backchannel jti check atomic (FIX-17)

verifyTokenWithOpts wrote every verified JTI into the shared
shardedReplayCache, and its comment (R36) claimed this is "the store
jwt.Verify consults for genuine replay detection". Both were wrong:
jwt.Verify replay branch, the only reader of that cache, only runs
when called with skipReplayCheck=false, and the sole non-test
production caller (VerifyJWTSignatureAndClaims) always passes true.
The write was pure overhead - an exclusive replayCacheMu.Lock (via
initReplayCache) on every cache-miss verification, for a cache nothing
in production reads.

Remove the write-only block and correct the comment. jwt.Verify keeps
its replay branch intact rather than being removed too: ~15 existing
tests across several files call Verify directly with the replay check
enabled and assert on it, and rewriting that test surface is out of
this change scope - see the concern in the final report. A comment
in jwt.go now states the branch does not run on any production path.

Make the backchannel-logout jti replay check (OIDC Back-Channel Logout
1.0 section 2.5) atomic: it was a plain Get followed by a separate Set,
so two logout tokens sharing a jti could both observe "not yet
processed" and both proceed. The check now lives in
checkAndMarkLogoutJTIProcessed, serialized by a package-level mutex.

Correct four existing tests whose assertions pinned the removed
write-only behavior (review_r36_regression_test.go,
security_edge_cases_test.go TestJWTReplayAttack, main_test.go
TestAuthenticationFlowReplayDetection): each now asserts the cache is
NOT written by the production path, and where the test also wanted to
show jwt.Verify replay branch still works when called directly, it
now makes two direct Verify calls instead of relying on the removed
cross-path write.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(replay): correct four more tests pinning the removed FIX-17 write path

TestConcurrentTokenValidation, TestJTIBlacklistBehavior,
TestSessionBasedTokenRevalidation and TestEdgeCasesWithDifferentTokenTypes
(main_test.go) asserted that VerifyToken writes the JTI into the
shared shardedReplayCache, and in two cases relied on that write
happening so a single direct jwt.Verify call would then detect a
replay. FIX-17 removed that write-only path.

Each test now asserts the cache is not written by VerifyToken, and
the two direct-replay-detection cases make two direct Verify calls
(first succeeds and records the JTI itself, second is rejected)
instead of relying on the removed cross-path write.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(jwt): remove the dead replay branch from Verify (FIX-17)

Address verifier review of FIX-17.

Major: the todo's chosen option was to remove the dead jwt.Verify replay
branch, but only its write-only shardedReplayCache.Set call had been
removed; the branch itself and its comment claiming it was "live code"
stayed. Removes the replay-check block and its skipReplayCheck parameter
from JWT.Verify outright, plus the now-unused shardedReplayCache/
replayCache globals and their init/cleanup/stats helpers (jwt.go), and
the startReplayCacheCleanup call that started their background sweep
(main.go). Migrates every non-test and test call site of Verify to the
new two-argument signature, and rewrites or removes the tests whose sole
subject was the removed branch or cache (main_test.go,
security_edge_cases_test.go, review_r179_regression_test.go,
review_r156_regression_test.go, review_r119_test.go, review_r142_test.go
[deleted], review_r126_test.go, review_r36_regression_test.go,
review_r131_test.go, review_r66_lenient_expiry_test.go, audience_test.go,
coverage_boost_final_test.go). New regression coverage:
TestJWTVerify_PerformsNoReplayDetection and
TestVerifyTokenWithOpts_ProductionPathUnaffectedByReplayRemoval
(review_fix17_replay_test.go) pin that Verify performs no jti tracking at
all, and that the production verify path is unaffected.

Minor: verifyOpts.skipReplayMarking is written (bearer_auth.go) but
nothing in verifyTokenWithOpts reads it — the JTI blacklist Set it used
to gate was already removed by an earlier FIX-17 pass without updating
the comments describing it. Rewrites the comments on the field, on
verifyTokenWithOpts, on the blacklist Get, and in bearer_auth.go's
package doc to stop describing a Set that no longer exists. The field
itself is left in place and flagged as dead code needing maintainer
approval to delete, per the dead-code hygiene rule. Also fixes the
repeated "VerifyJWTSignatureAndClaims, via VerifyJWTSignatureAndClaims"
phrase in the R36 correction comment.

Minor: the backchannel-logout jti check (logout.go) uses a process-wide
mutex around Get-then-Set, not an atomic SetIfAbsent, so it is atomic
only within one process, not across Traefik replicas sharing a
Redis-backed sessionInvalidationCache. A real fix needs an atomic
primitive on the cache backend (e.g. Redis SET NX); backends.CacheBackend
has none, and adding one touches many files across internal/cache/
backends owned by unrelated work (FIX-20), which is out of this fix's
scope. Rewrites the backchannelLogoutJTIMu and
checkAndMarkLogoutJTIProcessed comments to state the single-process-only
guarantee explicitly, rather than implying full atomicity.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(logout): correct revocation timing and redirect-uri claims (FIX-33)

Address verifier review of FIX-33.

The Logout Behavior section overstated and misstated logout timing and
redirect behavior. Item 2 said the 3-second revocation timeout means a
slow provider cannot delay the redirect, but the two revocation calls
(access and refresh token) run one after the other, so together they can
delay it by up to about 6 seconds. Items 3-4 gave 'a logout request with
no completed login' as the example of a missing redirect origin, but
that case has no ID token and never reaches the oidcEndSessionURL branch
at all; the real missing-origin case is a session created before R136
started recording the redirect origin. The section also omitted that an
absolute postLogoutRedirectURI is sent unchanged. Rewrites items 2-4 to
state the actual timing and the three real conditions for the
end-session redirect, its redirect_uri handling, and when the parameter
is omitted. Removes the semicolon and the em dash per
simplified-technical-english.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(logout): make the FIX-07 redirect-origin regression table-driven (FIX-07)

Address verifier review of FIX-07.

The regression test set postLogoutRedirectURI to "", which helpers.go
sends through a separate case postLogoutRedirectURI == "" branch, not
the default: branch production actually runs (main.go turns an unset
config into the documented default '/', which goes through default:).
The old test's comment called "" the documented default, which it is
not. Deleting the trustedOrigin rebuild in default: would have kept the
old test green.

Makes the test table-driven with three cases: the production default
'/', a relative '/bye', and the pre-existing '' case, all wanting
https://app.example.com/ or https://app.example.com/bye. Verified with a
scratch mutation of the default: branch (trustedOrigin rebuild removed,
reverted before this commit): the old single-case '' test still passed,
the new '/' and '/bye' subtests failed as expected. Corrects the comment
that called '' the documented default.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(pool): remove racy nil pre-check, move test reset helper (FIX-39)

GetTransportPool read globalTransportPool outside transportPoolOnce,
racing the write inside Once.Do when two goroutines called it for the
first time concurrently. Run the whole body inside Once.Do instead, so
every caller only observes the pointer after Once establishes
happens-before.

Move resetGlobalTransportPoolForTest into a new export_test.go file so
the test-only helper no longer compiles into the plugin package that
imports internal/pool. Update TestCreateHTTPClient_Fallback to exhaust
the real singleton in place (mutate its fields) instead of swapping in
a hand-built *TransportPool, since Once.Do now always builds its own
pool after a reset and would overwrite a manually assigned one.

Regression test TestGetTransportPool_ConcurrentFirstCall pins the race:
it fails under -race at head (WARNING: DATA RACE between the write in
GetTransportPool.func1 and the read at the removed pre-check) and
passes after the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(config): add perSourceLoginRateLimit, clientAssertionKeyPath to configToMap (FIX-40)

configToMap is the single field/redaction list MarshalJSON and
MarshalYAML share (R150), meant to stop the marshalled config from
silently dropping fields. It still dropped two json-tagged Config
fields: perSourceLoginRateLimit (added in the same changeset) and
clientAssertionKeyPath. Neither is a secret, so this reintroduced the
exact drift R150 set out to eliminate.

Add both fields to configToMap. clientAssertionKeyPath is a filesystem
path, not the key content, so it needs no redaction (the content field
clientAssertionPrivateKey is already REDACTED).

TestConfigToMap_IncludesAllJSONFields reflects over every json-tagged
Config field and asserts it is a configToMap key. It fails at head
with exactly the two missing fields and passes after the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(makefile): run make test under -race (FIX-21)

Several regression pins (R44 TestSessionDataRedirectCountConcurrency,
R111 TestSessionAccessors_ConcurrentAccess, R77
TestSessionPool_ReturnToPool_ExactlyOnce) only fail when the old bug
they pin is reintroduced AND the race detector is on. make test ran
plain go test with no -race, so a regression in any of them would ship
silently. docs/TESTING.md already says to always run with -race.

Add -race to the Makefile test target.

Verified this session: reverting the R77 CAS in returnToPoolSafely
(and the inUse field back to plain bool, matching the pre-R77 shape)
back to a temporary local build, `go test -run
TestSessionPool_ReturnToPool_ExactlyOnce` passes without -race and
fails with -race:

    WARNING: DATA RACE
    Read at ... session.go:1784 (returnToPoolSafely)
    Previous write at ... session.go:1947 (Reset, called from the
    second concurrent returnToPoolSafely)
    --- FAIL: TestSessionPool_ReturnToPool_ExactlyOnce
        testing.go:1865: race detected during execution of test

The revert was local-only and is not part of this commit. Also
confirmed the shared CI workflow (go-pr.yaml@main in
lukaszraczylo/shared-actions, used by .github/workflows/pr.yaml) already
runs `go test -race -coverprofile=... -covermode=atomic ./...`, so CI
was not the gap.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(session): pin R104 no-reject-on-decompressed via the real read path (FIX-08)

TestValidateChunkingEfficiency_NoRejectOnDecompressed called
cm.validateChunkingEfficiency(big, config) as a bare statement and
asserted nothing. Go lets a statement call drop a return value, so the
test compiled and passed both before and after the R104 fix -
validateChunkingEfficiency changed from returning an error to
returning nothing in the same changeset, and a bare call compiles
either way. The test pinned nothing.

Rewrite it to drive the actual read path: build a JWT-shaped fixture
whose decompressed length exceeds MaxChunks*MaxChunkSize, gzip+base64
it the way a stored compressed cookie is shaped, and call
ChunkManager.GetToken(compressed=true, ...) - the same entrypoint
session.go uses. Assert the token reassembles unchanged with no error.

Verified this session with a temporary, uncommitted local revert of
validateChunkingEfficiency and its validateToken call site to the
pre-R104 base f728f61 shape (hard error on expectedChunks >
MaxChunks): the rewritten test fails there -

    review_r104_test.go:350: GetToken rejected a large
    legitimately-compressed token: access token would require 51
    chunks (max: 50)
    --- FAIL: TestValidateChunkingEfficiency_NoRejectOnDecompressed

and passes again once session_chunk_manager.go is restored to head.
The revert was local-only and is not part of this commit.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(ratelimit): key PerSourceLoginRateLimit on RemoteAddr, not XFF (FIX-11)

sourceIP() preferred the first X-Forwarded-For value, and
isInternalSource then trusted that attacker-controlled value as the
basis for internal classification. An external attacker sending
X-Forwarded-For: 127.0.0.1 (or any RFC 1918 address) was classified
internal and never throttled, making PerSourceLoginRateLimit a no-op
against exactly the attacker it exists to stop. Rotating arbitrary XFF
values also let an attacker dodge the per-source counter and grow the
entries map, or spoof a victim IP to exhaust that IPs budget.

clientIPForBearer (bearer_auth.go) already deliberately ignores XFF
for the same reason - a second mechanism doing the opposite of the
solved one.

Derive sourceIP from RemoteAddr only. No new config: the feature is
opt-in (default 0/off) and this restores the trust boundary it should
have shipped with.

Two existing tests relied on the old XFF-as-source behavior to
simulate distinct/internal sources and are updated to use RemoteAddr
instead, matching the corrected contract: TestR185_InternalSourceNeverThrottled
(review_r185_regression_test.go) and
TestR185_PerSourceLimiterSweepEvictsIdle (review_r213_test.go).

New regression tests (per_source_ratelimit_test.go):
TestPerSourceRateLimit_XFFSpoofDoesNotBypassThrottle drives the full
ServeHTTP callback path with an external RemoteAddr and a spoofed
X-Forwarded-For: 127.0.0.1, and TestSourceIP_IgnoresXForwardedFor pins
sourceIP() directly. Both fail at head:

    per_source_ratelimit_test.go:56: external source spoofing
    X-Forwarded-For: 127.0.0.1 was never throttled
    per_source_ratelimit_test.go:70: sourceIP = "127.0.0.1", want
    RemoteAddr-derived "203.0.113.7"

and pass after the fix.

docs/CONFIGURATION.md and README.md perSourceLoginRateLimit rows now
state the RemoteAddr-only keying.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(http): stop sharing a cookie jar across the pooled token client (FIX-12)

R70 made CreatePooledHTTPClient honor config.UseCookieJar.
TokenHTTPClientConfig and OIDCProviderHTTPClientConfig both set
UseCookieJar: true, and main.go builds one pooled tokenHTTPClient per
plugin instance from TokenHTTPClientConfig, shared by every users
token/refresh exchange. An IdP Set-Cookie on user As exchange response
was stored in that one jar and replayed on user Bs next token request
to the same host - cross-user state confusion at the IdP, with no
config change on upgrade.

Set UseCookieJar to false (the DefaultHTTPClientConfig default) in
both config constructors. Token/refresh calls are stateless
back-channel requests; the nil-client fallback in exchangeTokens
(helpers.go) already builds its own fresh jar per call, which stays
correct and is untouched. CreatePooledHTTPClients own UseCookieJar
honoring (http_client_pool.go) is unchanged and still covers any
future caller that legitimately needs a jar.

TestPooledTokenClient_DoesNotShareCookieJarAcrossRequests
(http_client_jar_test.go) drives CreatePooledHTTPClient(TokenHTTPClientConfig())
against a test IdP that sets a cookie on request 1 and asserts request
2 does not carry it. It fails at head:

    http_client_jar_test.go:31: request 2 carried leaked cookie
    idp_sess=alice from an earlier request

and passes after the fix. Two existing assertions that expected the
old (leaky) behavior are updated to match the corrected contract:
TestOIDCProviderHTTPClientConfigUnit and TestCreateTokenClientUnit
(http_client_factory_unit_test.go).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test: add newTestOIDC builder, move stubIntrospectionCache, migrate logout round files (FIX-41)

136 added root test files (71 hand-building TraefikOidc literals, 112
in total) make any change to TraefikOidc required fields a many-file
patch. Round files also depend on each others locally declared
helpers: review_r149_test.go used stubIntrospectionCache, declared in
review_r126_test.go, so deleting or renaming either file would break
the other silently at compile time.

Add newTestOIDC(t, opts...) in a new shared newtestoidc_test.go: a
builder with the common ServeHTTP-ready defaults (next handler, error
logger, closed initComplete, a real session manager, both startup
barriers past init, a plausible issuer/callback/client), customized
per test via functional options that set unexported fields directly
(same-package test file).

Move stubIntrospectionCache from review_r126_test.go into the same
shared file. review_r143_test.go and review_r149_test.go, which also
use it, need no change - Go resolves package-level identifiers by
package, not file, so moving it to a shared file removes the
round-to-round coupling without touching either caller.

Migrate the logout round files onto the builder (5 tests):
review_r55_logout_blacklist_test.go, review_r73_test.go, and
review_r87_test.go.

Verified this session that migration did not defang any of these
pins: for 3 of the 5, a temporary uncommitted local revert of the
exact behavior each test regresses made the migrated test fail, then
was restored. All 5 migrated tests, and the full root package (go
test -race), pass at head with the reverts removed.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(http): correct stale cookie-jar comment on CreatePooledHTTPClient (FIX-12)

Reword the comment above the UseCookieJar branch. It described the
jar-less pooled token/OIDC clients as a bug to fix, contradicting the
FIX-12 comments already in http_client_factory.go and inviting a
reintroduction of the cross-user jar R70 introduced. State instead
that TokenHTTPClientConfig and OIDCProviderHTTPClientConfig
deliberately leave UseCookieJar false because a pooled client is
shared across users.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(ratelimit): document the shared-proxy-IP tradeoff for perSourceLoginRateLimit

perSourceLoginRateLimit keys solely on RemoteAddr (per_source_ratelimit.go).
Behind a load balancer or CDN that is the proxy address, not the real
client: a private proxy address is exempt from throttling entirely, and
a public proxy address puts every client into one shared bucket. State
this in both docs rows, matching the tradeoff already documented in
clientIPForBearer (bearer_auth.go).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test: eliminate remaining cross-round test-helper references (FIX-41)

The prior FIX-41 pass moved only stubIntrospectionCache; 14 references
into 10 other helpers survived, 3 of them into the migrated logout
round files (review_r172_regression_test.go used newR87LogoutHarness
and r87BaseClaims from review_r87_test.go; review_r103_test.go used
mustTemplate from review_r73_test.go). Deleting or renaming any of
those round files would still have broken an unrelated file.

Move every cross-referenced helper into a shared _test.go file, by
topic: jwt_test_helpers_test.go (writeJWKS, signRSAJWTForTest,
makeJWTForTest), cache_test_helpers_test.go (NewMemoryBackendForTest,
mapCache, serializingCache), config_test_helpers_test.go
(r134ValidConfig), plugin_test_helpers_test.go (newR154TestPlugin),
logout_test_helpers_test.go (r87LogoutHarness, newR87LogoutHarness,
r87BaseClaims), and mustTemplate into the existing newtestoidc_test.go.
A go/ast analyzer built for this fix (File.Unresolved checked against
top-level decls of every other review_rNN*_test.go file) now reports 0
cross-round references, down from 20.

Also migrate TestBackchannelLogout_RejectsReplayedJTI
(review_r73_test.go) onto the newTestOIDC builder, the one migration
the prior pass missed among the three logout round files. Verified the
migrated test still catches the regression: with the jti-replay check
in logout.go temporarily disabled (reverted before this commit), it
fails with "review_r73_test.go:109: replayed logout token should be
rejected (400), got 200".

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(client-assertion): pin and document the RSA<2048 private_key_jwt rejection (FIX-42)

validateAlgKeyMatch already rejects an RS*/PS* private_key_jwt signing
key under 2048 bits (RFC 7518 3.3), added by 8640451 with no commit
mention and no CHANGELOG entry. Keep the rejection as-is.

Wrap the error with a new ErrRSAKeyTooSmall sentinel so callers can
match it with errors.Is instead of parsing the message, add a pin
test that generates a 1024-bit and a 2048-bit key and asserts the
reject/accept boundary, and document the minimum in
docs/CONFIGURATION.md and CHANGELOG.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(main): name and document dropped http endpoints under https providers (FIX-26)

validateDiscoveredEndpoint already refuses a discovered http:// endpoint
under an https providerURL (R146). Keep that pin; do not add an
opt-out flag.

Wrap the rejection in a named ErrDiscoveredEndpointSchemeDowngrade
sentinel and have updateMetadataEndpoints sanitize log an extra
SECURITY-tagged line, naming the endpoint, the dropped URL, and the
absence of an override, whenever the dropped endpoint is token,
jwks_uri, or authorization — the three a login cannot function
without. The generic per-endpoint log line stays unchanged.

Add a regression test pinning the new log line and a negative test
confirming accepted https endpoints stay silent. Document the
behaviour in docs/CONFIGURATION.md, README.md, and CHANGELOG.md.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(changelog): record the R96/R102/R105/R126 auth relaxations (FIX-25)

security_edge_cases_test.go:459 (+bearer_auth_test.go:225, custom_claims_test.go:158,
main_test.go:2325) pin four claim-validation relaxations that shipped
in 8640451 with no commit-body or CHANGELOG mention: optional iat
(R126), a stringified numeric bearer sub (R102), a malformed
groups/roles claim treated as empty (R96), and stringified numeric
items inside a groups/roles array (R105).

Each relaxation is intentional, matches a production change, and
already has a passing regression test (re-run this session to
confirm: TestMissingClaims/Missing_IssuedAt_is_optional,
TestResolveBearerIdentifier, TestCustomClaimNames_MalformedRoleClaim,
TestCustomClaimNames_MalformedGroupClaim,
TestCustomClaimNames_NonStringInRoleArray,
TestCustomClaimNames_NonStringInGroupArray). Keep all four as-is; no
code change. Record each in CHANGELOG.md so a reviewer of the
squashed commit can see the intent.

Also record the blacklist Redis namespace move (token: -> blacklist:,
FIX-16, implemented elsewhere) and its one-release legacy read, since
CHANGELOG.md is otherwise silent on it too.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(main): restrict SECURITY scheme-downgrade log to no-override endpoints (FIX-26)

sanitize logged the SECURITY line for every dropped endpoint, not only
jwks_uri, authorization and token as docs/CHANGELOG/README say. Revocation,
end_session and introspection have a config override (revocationURL,
oidcEndSessionURL, introspectionURL) that replaces the endpoint right after
sanitize runs, so the "no override; requests will fail" wording was false
for those three.

Add discoveredEndpointHasNoOverride and named constants for the three
endpoints with no config source, and gate the SECURITY line on both the
scheme-downgrade error and endpoint name. Add negative tests pinning that
end_session/revocation/introspection/registration keep only the generic
line, and that a config override still replaces a dropped endpoint. Add one
sentence to docs/CONFIGURATION.md naming which endpoints have an override.
Correct the fix26 test file header comment: the pre-existing ERROR line
already named the scheme-downgrade reason; the new SECURITY line only adds
a distinct tag and the no-override statement.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(changelog): scope the iat relaxation, drop the unbuilt FIX-16 bullet (FIX-25)

The iat bullet overstated the R126 relaxation: it is optional in
jwt.Verify only. Bearer-token auth still rejects a token with no iat
(enforceIatAge, "missing iat claim"), because maxTokenAgeSeconds always
applies and 0 falls back to a 24h default. The lenient-audience
accessTokenUnexpired path also still requires iat on this branch. State
that scope explicitly instead of implying the check is gone everywhere.

Reword the heading to match bullet 4: a numeric groups/roles array item
was silently dropped before R105, not rejected, so "previously-rejected"
alone mis-described it.

Remove the Blacklist Redis-namespace-move bullet: it describes
universal_cache.go changes from commit 4eb876b on fix/d1-universal-cache,
which this branch does not contain. Re-add it when that branch merges.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(yaegi): replace errors.As on interpreted *HTTPError in RetryExecutor

errors.As(err, &target) panics under yaegi v0.16.1 whenever the target
pointed-to type is interpreted. *HTTPError is declared in this plugin, so
RetryExecutor.isRetryableError panicked on the default-on token-exchange/
refresh retry path regardless of the concrete error type reaching it.

Add asHTTPError, a manual errors.Unwrap walk plus type assertions (both
yaegi-safe), and use it in place of both errors.As calls in
isRetryableError. Native classification stays unchanged: the walk mirrors
errors.As exactly for the fmt.Errorf(%w) chains callers actually wrap.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(yaegi): replace errors.As on interpreted HTTPError/OIDCError in internal/recovery

Same yaegi v0.16.1 hazard as error_recovery.go's RetryExecutor: this
package's own *HTTPError and *OIDCError types are interpreted whenever
the traefikoidc plugin loads under yaegi (it imports internal/recovery
transitively through internal/utils/logger_wrapper.go), so
RetryExecutor.isRetryableError's two errors.As calls here panic too.

Add asHTTPError and asOIDCError, the same manual errors.Unwrap-walk
pattern used in the top-level package, and use them in place of
errors.As. Native classification is unchanged (TestR175 stays green).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(yaegi): drop errors.As from introspection error classification

token_validation_rs.go classified validateOpaqueToken's error with
errors.As(err, &httpErr): *HTTPError is interpreted under yaegi v0.16.1,
so this panicked on every opaque-token session-path classification.

A plain type assertion is yaegi-safe but only if the value arrives
unwrapped, and an interpreted *HTTPError wrapped with fmt.Errorf(%w)
cannot be recovered by a manual errors.Unwrap walk under yaegi either
(verified this session against the pinned interpreter). So instead of
adding an unwrap-walk helper here, fix the producer:
validateOpaqueToken (token_introspection.go) now returns
introspectToken's *HTTPError unwrapped instead of wrapping it with
"token introspection failed: %w", matching the existing yaegi-safe
pattern (isTerminalClientError, isNeverSentError). The call site
switches to a plain err.(*HTTPError) assertion.

Adds native regression coverage for the unwrapped return and for the
5xx classification case (401/429 were already covered).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(yaegi): add NEW-01 regression checks to yaegicheck

Extend cmd/yaegicheck/main.go, the program make yaegi-validate runs
under the interpreter, with checks that drive every site fixed by
NEW-01: RetryExecutor.ExecuteWithContext (top-level package) with an
HTTPError 500 (retried), an HTTPError 400 (not retried), and a plain
error (no panic); the same pair for internal/recovery's RetryExecutor,
plus its OIDCError retryable/terminal classification; and a full
plugin instance driving RFC 7662 introspection end to end through the
bearer path for a 4xx and a 5xx response.

Confirmed these checks panic (errors.As at error_recovery.go:723) when
run against the pre-fix commit, and pass on the fixed worktree.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* refactor: remove symbols left unused by the review fixes

staticcheck (U1000) flagged defaultBlacklistDuration in cache_manager.go and isLastInstanceNow in singleton_resources.go. The FIX-35 lifecycle change routes every last-instance stop through stopIfLastInstance, and the blacklist TTL no longer reads the constant. Comments that named the removed function now describe the check they compare against.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(yaegi): fold the SSE flush check into yaegicheck

Add checkSSEFlushReachesNext to cmd/yaegicheck. It sends an SSE request through the excluded-URL bypass under yaegi and asserts that http.NewResponseController(w).Flush() succeeds on the writer next receives (FIX-06). Remove cmd/yaegiflushcheck, a nested module with its own go.mod (go 1.27.1) and a yaegi dependency that ran the same check. make yaegi-validate now covers it.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(changelog): record the blacklist namespace move, correct the iat scope

Add the FIX-16 entry: the blacklist cache writes under the blacklist: Redis prefix, and for one release it reads a legacy token: entry only when the stored value is boolean true. Correct the iat bullet: accessTokenUnexpired now calls verifyTimeClaims, so the lenient-audience path treats iat as optional, the same as jwt.Verify (FIX-27).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(middleware): pin FIX-02 default-deny for genuine OPTIONS preflight

Regression test for the maintainer decision on FIX-02: the released
behavior required auth for every OPTIONS request by default. Confirms
red on base code -- shouldBypassAuth currently bypasses any genuine
CORS preflight (Origin + Access-Control-Request-Method) unconditionally,
so this test currently fails with the backend reached (status=204).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* feat(settings): add AllowUnauthenticatedPreflight config option

New Config.AllowUnauthenticatedPreflight bool (json tag
allowUnauthenticatedPreflight, default false), wired through to
TraefikOidc.allowUnauthenticatedPreflight (types.go, main.go) and into
configToMap (config_marshalling.go) so it round-trips through
MarshalJSON/MarshalYAML like every other Config field. Naming and
omitempty match the sibling AllowPrivateIPAddresses option. No
behavior change yet -- middleware.go still needs to read the field
(next commit).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): gate OPTIONS bypass behind AllowUnauthenticatedPreflight

Maintainer decision for FIX-02: restore the released behavior where
every OPTIONS request needs authentication by default. shouldBypassAuth
now only treats a genuine CORS preflight (Origin +
Access-Control-Request-Method) as an auth bypass when
t.allowUnauthenticatedPreflight is true; with the flag at its default
(false), OPTIONS goes through the normal auth pipeline like any other
method. The optionsPreflightWriter body-discard hardening (forged
preflights cannot read a protected body even when the flag is on)
still applies unchanged inside the bypassReasonOptions case, since
shouldBypassAuth only returns that reason when the flag permits it.

Updates the FIX-02 tests that assumed the bypass was unconditionally
on (fix02_options_bypass_test.go, review_r124_test.go
TestShouldBypassAuth_PassesOptions) to set
allowUnauthenticatedPreflight: true, so they keep exercising the
Origin+Access-Control-Request-Method gate rather than being short-
circuited by the new flag gate. Adds
fix02b_allow_unauthenticated_preflight_test.go pinning the maintainer
decision directly: default config requires auth for a genuine
preflight (red on prior commit); with the flag on, a genuine preflight
still bypasses with its body discarded; with the flag on, a bare
OPTIONS still requires auth.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs: document allowUnauthenticatedPreflight option

Adds the new Config.AllowUnauthenticatedPreflight option to the
docs/CONFIGURATION.md security-options table and the README.md option
table, next to its sibling allowPrivateIPAddresses. .traefik.yml is not
touched: it lists only complex opt-in feature blocks (bearer-auth,
Redis) as commented examples, not the plain security-relaxing bools
like allowPrivateIPAddresses -- allowUnauthenticatedPreflight follows
that same precedent.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* style(test): fix wrong test name in FIX-02 cross-reference comment

TestShouldBypassAuth_PassesOptions referenced a
TestShouldBypassAuth_DefaultDoesNotBypassOptions that does not exist;
point it at the real default-off regression test,
TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth in
fix02b_allow_unauthenticated_preflight_test.go.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): cancel refresh context on Shutdown so it returns promptly (FIX-36)

Derive each refresh operation's timeout context in executeRefreshAsync from
a coordinator-owned context (rc.ctx) instead of context.WithTimeout(
context.Background(), RefreshTimeout). Shutdown now cancels rc.ctx before
wg.Wait(), so it no longer blocks for up to RefreshTimeout (or the
refreshFunc's own natural completion) per in-flight refresh. A waiter
blocked in CoordinateRefresh on an aborted operation gets an error, not a
hang. RefreshTimeout still caps a refresh that is not aborted by Shutdown.

The refreshFunc call itself runs in an untracked inner goroutine
(executeRefreshAsync) that cannot be aborted given its
func() (*TokenResponse, error) signature; it keeps running past Shutdown
until it returns naturally (in production, until its own HTTP client
timeout). This is documented in code comments on Shutdown and
executeRefreshAsync.

Corrects three comments that described the old contract: the goroutine-
launch comment in CoordinateRefresh, the timeout-context comment in
executeRefreshAsync, and the Shutdown comment — all previously said
Shutdown deliberately does not cancel and must wait for a genuine in-flight
refresh to finish, which is now false.

Rewrites TestRefreshCoordinatorShutdownWaitsForInflight (review_r63) and
TestRefreshCoordinator_ShutdownWaitsForInFlight (review_r154), which pinned
the old "Shutdown blocks until the refresh finishes" contract this commit
intentionally reverses; they now assert the prompt-return contract instead.

Adds TestFix36_ShutdownReturnsPromptlyDuringInFlightRefresh: starts a
refresh whose refreshFunc blocks 3s, calls Shutdown, and asserts Shutdown
returns in under 1s and the waiter gets an error. Fails on pre-fix code at
refresh_coordinator_test.go:871 ("Shutdown did not return within 1s while a
refresh was in flight"); green under go test -race.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(refresh): pin wg-tracking of the aborted refresh goroutine (FIX-36 review)

TestRefreshCoordinator_ShutdownWaitsForInFlight (review_r154) and
TestRefreshCoordinatorShutdownReleasesWaiterOnInflight (review_r63) only
checked that Shutdown returns promptly and the waiter gets an error. Both
checks also pass when the executeRefreshAsync goroutine is not tracked by
rc.wg, so an untracked-goroutine regression would go unnoticed, and the
R154 test comment claimed wg coverage the test did not assert.

Add an assertion on rc.circuitBreaker.failures right after Shutdown
returns: executeRefreshAsync calls RecordFailure() before it returns, and
the goroutine that calls it is (correctly) covered by wg.Wait(), so the
count must already be 1 by the time Shutdown, and therefore wg.Wait,
returns.

Verified with a local mutation (not committed): replacing
`defer rc.wg.Done()` inside the spawned goroutine with an immediate
`rc.wg.Done()` before `go func(){...}()` made the new assertion fail
20/20 runs for the R154 test and 12/20 for the R63 test under
-race -count=20; both pass 20/20 on the real (tracked) code.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): skip refreshFunc when rc.ctx is already canceled

executeRefreshAsync spawns an inner goroutine that calls refreshFunc
unconditionally, even when rc.ctx (and therefore refreshCtx) is already
canceled before the goroutine starts. The outer select then always takes
the refreshCtx.Done() branch and discards whatever refreshFunc returned,
but refreshFunc is the IdP refresh-token grant: calling it anyway still
spends a request against a (possibly rotating, one-time-use) refresh
token whose result nobody reads.

Check rc.ctx.Err() as the first statement in the inner goroutine and
return without calling refreshFunc when it is already canceled.

Adds TestFix36_ExecuteRefreshAsyncSkipsRefreshFuncWhenAlreadyCanceled:
cancels rc.ctx directly (isolating this from the stopChan-reject path),
then asserts refreshFunc is never called and the waiter gets an error.
Fails on pre-fix code at refresh_coordinator_test.go:926 ("refreshFunc
must not be called when rc.ctx is already canceled before the refresh
starts"); green under go test -race -count=20.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(refresh): correct stale nolint comment on the tracked refresh goroutine

The nolint reason still said the refresh "intentionally uses a background
context", describing the pre-FIX-36 behaviour. Since FIX-36,
executeRefreshAsync derives its timeout context from rc.ctx (canceled by
Shutdown), capped by RefreshTimeout, not context.Background().

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* feat(cache/redis): add atomic SetNX check-and-set to RedisBackend (FIX-17)

RedisBackend gets SetNX(ctx, key, value, ttl) (bool, error): one Redis
SET key value NX PX <ttl-ms> command instead of a separate GET then
SET, so two callers racing the same key can never both observe
"absent". Backs the UniversalCache.SetIfAbsent primitive the next
commit adds, which the backchannel-logout jti replay check
(logout.go) needs to be atomic across Traefik replicas, not just
within one process (FIX-17, R36 correction).

A nil bulk-string reply (NX condition failed, key already exists) is
reported as (false, nil), not an error: it is a valid protocol
outcome, not a backend failure, so executeWithRetry must not retry it
or surface it as err.

Regression test (miniredis-backed): a second SetNX on an already-set
key does not overwrite the first value or its TTL, and exactly one of
20 concurrent SetNX calls on the same key wins under -race.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* feat(cache): add UniversalCache.SetIfAbsent atomic check-and-set (FIX-17)

SetIfAbsent(key, value, ttl) (bool, error) stores value only if key is
not already present, replacing the TOCTOU-prone separate Get then Set
with one atomic operation (FIX-17, R36 correction).

With no distributed backend, the presence check and the insert happen
inside a single c.mu critical section (setLocalLocked, extracted from
setLocal so SetIfAbsent does not have to re-enter the lock setLocal
already holds). With a backend that implements the optional
backendSetNXer interface (RedisBackend.SetNX, previous commit),
that call alone decides the outcome, so the check is atomic across
every replica sharing that backend too. CacheBackend itself is not
widened to require SetNX: every other implementer and test double
would need a method only this caller needs, so SetIfAbsent reaches it
through a type assertion instead. A successful backend claim is then
mirrored into the local cache as a best-effort read-through.

Exposed to callers as AtomicSetIfAbsentCache (types.go), an optional
capability a CacheInterface value can satisfy, for the same reason:
CacheInterface itself is not widened, since most of its many test
doubles across the suite have no need for the atomic primitive.
CacheInterfaceWrapper (the concrete type sessionInvalidationCache
actually is at runtime) implements it by delegating to
UniversalCache.SetIfAbsent.

Regression tests: SetIfAbsent single-threaded claim/no-overwrite
semantics; exactly one of 20 concurrent same-key calls wins, both with
no backend and with a real Redis backend (miniredis); and
CacheInterfaceWrapper satisfies AtomicSetIfAbsentCache. All green
under -race.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(logout): make backchannel-logout jti check atomic via SetIfAbsent (FIX-17)

checkAndMarkLogoutJTIProcessed now type-asserts sessionInvalidationCache
to AtomicSetIfAbsentCache and, when it is available, uses SetIfAbsent
as the sole check: no separate Get then Set, and no
backchannelLogoutJTIMu. CacheInterfaceWrapper (what
sessionInvalidationCache actually is at runtime) satisfies the
interface, so this is the path production traffic takes, and it is
atomic across Traefik replicas sharing a Redis-backed cache, not just
within one process (FIX-17, R36 correction: the prior commit on this
line only made the check-and-set atomic process-locally).

backchannelLogoutJTIMu is kept as a fallback for a CacheInterface
implementation that does not provide the atomic primitive, guarding
only that case now. A SetIfAbsent backend error falls through to the
same fallback rather than rejecting an otherwise-valid logout token
outright on a transient cache failure.

Regression test: 20 concurrent backchannel-logout submissions of the
same valid logout token, through the production validateLogoutToken
path, must yield exactly one acceptance AND must reach it through
SetIfAbsent alone (asserted via call counts on an instrumented cache
double) rather than the legacy Get/Set fallback, so the test would
catch a regression to the old path even where the outcome happens to
still be correct under backchannelLogoutJTIMu. Green under
-race -count=5.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/resilience): forward SetNX through circuit-breaker and health-check wrappers

Round-2 verifier finding (major): UniversalCache.SetIfAbsent reaches a
Redis backend through the optional backendSetNXer type assertion, but
neither CircuitBreakerBackend nor HealthCheckBackend implemented SetNX,
so wrapping Redis with either (docs/REDIS.md and the example configs
recommend both true in production) made the assertion fail and every
call silently fall back to the process-local-only path. Two replicas
sharing the same wrapped Redis backend both claimed the backchannel-
logout jti and Redis never saw the key.

Add SetNX passthrough to both wrappers: forward to the wrapped backend
when it implements SetNX (through the same circuit-breaker/health
accounting as every other operation), and report the new
backends.ErrSetNXUnsupported sentinel otherwise.

Pin with three regression tests driving two UniversalCache instances
(simulated replicas) over one miniredis-backed RedisBackend wrapped by
NewCircuitBreakerBackend, NewHealthCheckBackend, and the production
wiring order HealthCheck(CircuitBreaker(raw)). All three fail on
b433f42 (second replica also claims; redis keys=[]) and pass here.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): reject SetIfAbsent when backend lacks SetNX, no silent local

Round-2 verifier finding (major, completes the previous commit):
UniversalCache.SetIfAbsent fell back to the local-only path for ANY
backend that failed the backendSetNXer type assertion, not only the
CircuitBreakerBackend/HealthCheckBackend case the previous commit
fixes at the source. Belt-and-suspenders: a distributed backend
attached to the cache now must expose SetNX (directly or through a
wrapper) or SetIfAbsent errors loudly with the new
errSetIfAbsentUnsupported sentinel, instead of quietly giving each
replica a process-local-only guarantee it never asked for.
checkAndMarkLogoutJTIProcessed already falls through to its
backchannelLogoutJTIMu-guarded Get+Set on any SetIfAbsent error, so no
logout.go behavior change is needed there beyond correcting its doc
comments to describe the corrected contract.

Pin with a fake backend that deliberately has no SetNX: two
UniversalCache instances (simulated replicas) sharing it must both
get the same sentinel error and neither may silently claim locally.
Fails on HEAD before this commit (no error returned); passes here.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(cache): pin SetIfAbsent cross-replica atomicity with two instances

Round-2 verifier finding (minor): the existing Redis-backed SetIfAbsent
test drives 20 concurrent calls through a SINGLE UniversalCache
instance, so its own c.mu already serializes them to exactly one
winner even when the c.backend.(backendSetNXer) branch is mutated
dead — the test cannot tell "one process, one mutex" from "many
replicas, one Redis". Add a test using two separate UniversalCache
instances (simulated replicas) sharing one miniredis-backed
RedisBackend: the second instance must be rejected AND the key must
be present in the shared miniredis, which two independent local maps
cannot produce.

Verified this test (and the pre-existing one) both fail under the
verifiers mutation (backendSetNXer branch forced dead) once the
previous two commits are in place: SetIfAbsent now errors instead of
silently using the local-only path, so the mutation surfaces as
"unexpected error" rather than a quiet pass.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/redis): make SetNX retry-safe when a reply is lost

Round-2 verifier finding (minor): SetNX ran SET key value NX PX inside
executeWithRetry, which blindly retries the whole operation on a
"connection"/"timeout"/"EOF" error by re-issuing the closure. That is
fine for Set (SETEX/PSETEX overwrite unconditionally, so a retry after
a lost reply just repeats the same write) but wrong for SET NX: if the
first SET NX reached Redis and applied, and only the reply was lost,
the retried SET NX sees the caller own write and reports
(false, nil) — "already claimed" — for what was actually a
first-ever claim. checkAndMarkLogoutJTIProcessed then rejected a
legitimate logout token (and every IdP redelivery) as a replay.

Add RedisConn.doTracked, reporting whether the command was already
written when it failed; Do becomes a thin wrapper over it. Give SetNX
its own retry loop: retry a failed connection acquisition (nothing
sent yet, safe), but once doTracked reports the command reached the
wire, stop and return the new backends.ErrSetNXAmbiguous sentinel
instead of guessing. checkAndMarkLogoutJTIProcessed accepts that
specific error outright rather than falling through to the
Get+Set fallback, which would see the same ambiguous write and
misreport the token as a replay.

Pin with a TCP proxy that forwards a SET NX to a real miniredis
instance but swallows its reply: RedisBackend.SetNX must report
(false, ErrSetNXAmbiguous), and the key must actually exist in
miniredis (proving the old (false, nil) was a false negative on a
first-ever claim). Also pin checkAndMarkLogoutJTIProcessed accepting
an ambiguous SetIfAbsent result without consulting Get. Both fail on
the pre-fix code (redis.go:161 gets claimed=false err=nil;
logout_test.go:89 gets a spurious replay error) and pass here.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(changelog): record the allowUnauthenticatedPreflight option (FIX-02)

OPTIONS requests need authentication by default again. The new allowUnauthenticatedPreflight option lets a genuine CORS preflight bypass authentication, with its response body discarded.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(yaegi): pin SetIfAbsent claim semantics under yaegi (FIX-17)

Add checkSetIfAbsentUnderYaegi to cmd/yaegicheck. It claims one key twice through UniversalCache.SetIfAbsent in local-only mode and through a backend that implements SetNX, and expects exactly one winner each time. A backend without SetNX must return an error instead of a local claim. This covers the optional-interface assertion in SetIfAbsent under the interpreter.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/redis): check sent before ctx.Err() in SetNX

RedisBackend.SetNX checked ctx.Err() before the sent check, so a lost
SET NX reply under the real 500ms SetIfAbsent context almost always
surfaced as context.DeadlineExceeded instead of ErrSetNXAmbiguous.
checkAndMarkLogoutJTIProcessed only special-cases the ambiguous
sentinel, so it fell through to the Get+Set fallback and rejected a
first-ever, never-before-seen logout token as a replay.

Move the sent check ahead of ctx.Err() so ErrSetNXAmbiguous is
reported whenever the command reached the wire, regardless of ctx
state, and keep it returned unwrapped for == comparison under yaegi.

Add a regression test that drives UniversalCache.SetIfAbsent and
checkAndMarkLogoutJTIProcessed through miniredis behind a proxy that
drops the SET NX reply, using a deterministic pre-call delay to avoid
racing the real 500ms timing window.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(main): cover AllowUnauthenticatedPreflight constructor wiring

FIX-02: main.go NewWithContext wires
Config.AllowUnauthenticatedPreflight into
TraefikOidc.allowUnauthenticatedPreflight, but every existing
middleware test builds *TraefikOidc as a struct literal and sets the
field directly, so deleting that wiring line failed no test.

Add a pair of tests that build the plugin through NewWithContext: one
with the flag enabled, asserting a genuine CORS preflight reaches next
and the response body is discarded; one with the default config,
asserting the same request is redirected into the login flow instead.
Confirmed with a mutation (temporarily removing the wiring line) that
the enabled case fails while the default case still passes, then
restored the line.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): drop unused nolint:gosec on tracked refresh goroutine

FIX-36: nolintlint (allow-unused: false) reports the //nolint:gosec
directive on the tracked refresh goroutine call as unused — gosec
never flagged this line, so the suppression served no purpose.

Remove the directive suffix and keep the explanatory comment, which
still documents why the goroutine detaches from the request ctx.

nolintlint is not in .golangci.yml enable list in this worktree, so
plain golangci-lint run ./... does not surface this finding either
before or after the change; verified with --enable nolintlint instead
that the report is gone and gosec genuinely does not fire on this
line. The plain run ./... command shows the same 6 pre-existing,
unrelated findings before and after (2x gosec G117 in
internal/dcrstorage, 2x gosec G710 in internal/testutil/servers,
1x gosec G124 in internal/testutil/suite.go, 1x unparam in
token_manager.go) — left untouched, out of scope.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* refactor(token): remove the dead verifyOpts parameter

FIX-17 removed the only reader of verifyOpts.skipReplayMarking, so golangci-lint (unparam) reported the opts parameter of verifyTokenWithOpts as unused. Remove the verifyOpts type, its dead field, its only write in buildPrincipalFromBearerToken and the parameter, and update the comments that described it. Behaviour does not change. The function keeps its name to avoid churn in the many comments that refer to it.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(helpers): move round-file-declared cross-file JWT/GD test helpers into shared files

makeTestJWT (review_r92_test.go) was consumed by
access_token_unexpired_iat_optional_test.go and resetGdInstancesForTest
(review_r34_regression_test.go) was consumed by fix18_gd_lifecycle_test.go,
reintroducing the hidden cross-round-file dependency FIX-41 removed:
deleting or folding either round file would silently break its consumer.

Move makeTestJWT into jwt_test_helpers_test.go (alongside the other shared
JWT builders) and resetGdInstancesForTest into a new
gd_test_helpers_test.go, unchanged in behaviour.

Add TestRoundFilesDependOnlyOnSharedHelpers, a static AST-based regression
pin that parses every _test.go file and fails if any non-Test helper
declared in a review_rNN*_test.go round file is referenced from another
file, catching this class of fragility again.

Closes the remaining partial from FIX-41.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): drop Content-Length on implicit-200 preflight writes

optionsPreflightWriter only stripped a stale Content-Length inside its
own WriteHeader. A next handler that sets Content-Length and calls
Write with no explicit WriteHeader (the implicit-200 idiom) left the
real header in place while the body stayed discarded, so the client
saw a non-zero Content-Length with an empty body and treated the
preflight response as truncated.

Write now commits an implicit 200 through WriteHeader before
discarding the bytes, so Content-Length is stripped either way.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): repanic ErrAbortHandler, drop guessed post-next 500

ServeHTTP recovered http.ErrAbortHandler like any other panic and
returned normally, so net/http finished the response as if it were
complete: a reverse-proxied body truncated mid-copy (httputil panics
with this sentinel) reached the client as a clean 200 instead of an
aborted connection. Re-panic it first, before any logging or write,
so the outer net/http recovery aborts the connection the same way it
would with no plugin in front of it.

Separately: once t.next.ServeHTTP has been called it holds the real
ResponseWriter (FIX-06), not the tracking wrapper, so wroteHeader can
no longer prove nothing was sent. The old code still guessed and sent
WriteHeader(500) unconditionally, which is a superfluous call net/http
logs whenever next already committed a response. The recovery now
logs and returns without writing once next has been reached, instead
of guessing. Documented trade-off: a panic in next before it writes
anything no longer gets an explicit 500 from this middleware.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(main): gate the SECURITY log on whether an override is set

discoveredEndpointHasNoOverride was static: it always answered false
for revocation, end_session and introspection, regardless of whether
the operator had actually configured revocationURL/oidcEndSessionURL/
introspectionURL. With no override set, a scheme-downgrade drop of one
of these three is exactly as terminal as a drop of jwks_uri,
authorization or token, yet it never logged the SECURITY line, and the
comment plus docs/CONFIGURATION.md claimed nothing fails for them.

discoveredEndpointHasNoOverride now checks the matching config field
and fires the SECURITY line whenever it is empty. registration is
unaffected: its override is read independently of this discovery
step. Corrects docs/CONFIGURATION.md to match.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(yaegicheck): fail NEW-01 introspection checks on recovered panic

checkNew01IntrospectionBearerStatus only failed when rw.Code == 0,
which httptest.NewRecorder never leaves (it starts at 200), so a
reintroduced errors.As panic on the bearer path -- recovered by
ServeHTTP as a 500 -- still printed PASS. Require the exact expected
status (503, bearerErrIntrospectionUnavailable) instead.

The bearer path also shares introspectToken with the SESSION path
(token_validation_rs.go, the NEW-01 fix site) but never reaches it, so
no check exercised the classification commit d7686c3 actually fixed.
Adds checkNew01OpaqueSessionIntrospectionClassification: builds an
authenticated cookie session holding an opaque access token via a
second SessionManager sharing cfgs encryption key and cookie
parameters (the plugins own session manager is an unexported field,
unreachable from this package, but the cookie codec key is a pure
function of those inputs), then drives it through ServeHTTP against a
5xx introspection response and requires the expected 302
re-authentication redirect.

Verified both checks fail under yaegi v0.16.1 with a temporarily
reintroduced errors.As at their respective sites (bearer_auth.go
buildPrincipalFromOpaqueIntrospection, token_validation_rs.go
validateStandardTokensRS), then reverted.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* ci(pr): run cmd/yaegicheck under yaegi v0.16.1 on every PR

The 15 yaegi-only regression checks in cmd/yaegicheck (errors.As on an
interpreted target, SSE Flush across the interpreter boundary, ...)
only ran locally via `make yaegi-validate`. The shared go-pr.yaml
workflow has no yaegi step, so a PR reintroducing an errors.As on an
interpreted type -- the exact shape of the NEW-01 production break --
passed CI.

Adds a sibling yaegi-validate job: checkout, setup-go, install yaegi
pinned to v0.16.1 (the version Traefik v3.7.1 vendors, matching
Makefile YAEGI_VERSION), symlink the checkout into GOPATH so yaegi
resolves the plugins own import path, then run cmd/yaegicheck.
contents:read only and a 10-minute timeout; no non-actions/* action is
used, so nothing needs SHA pinning. The existing pr-checks job is
unchanged.

Validated: python3 -c "import yaml" parses the file, and the shell
steps ran end to end against a fresh private GOPATH (go install +
symlink + yaegi run, exit 0).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(middleware): repanic instead of swallowing a post-next panic

Once t.next has been called, the deferred recover silently returned
after logging, answering every post-next panic with a false clean
response: an empty 200 for a pre-write panic, and a truncated-but-
"complete" 200 for a mid-body panic. That is the same defect class
the ErrAbortHandler branch above it already exists to prevent.

Re-panic the original value instead, once calledNext is true. This
writes no second WriteHeader and wraps nothing, so it reaches
net/http's own top-level recovery (or, in production, Traefik's
compiled recovery middleware), which can tell whether a response was
already committed and react correctly from outside this plugin.

Update the three regression tests that pinned the old return-normally
behavior (TestServeHTTP_RecoversHandlerPanic,
TestServeHTTP_PanicAfterNextCommitted_NoSuperfluousWriteHeader,
TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact) to recover around
ServeHTTP and assert the original panic value comes back out, with the
writer holding only next's own writes.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(middleware): fix stale trackingWriter doc comment on calledNext

The comment said the panic handler still sends a header-only 500 once
calledNext is true. That stopped being true with the repanic fix: the
recover now re-panics the original value and writes nothing.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(yaegi): pin ErrAbortHandler abort behavior through a compiled next

r == http.ErrAbortHandler only matches a panic raised by compiled
code under yaegi v0.16.1 -- exactly the real production shape, since
only this plugin runs interpreted and every next handler downstream
of it is compiled. Add a yaegicheck that drives that shape for real: a
front-facing server running the interpreted plugin, next a compiled
httputil.ReverseProxy, upstream writes a partial chunk, flushes, then
Hijacks and closes the raw connection. The client must see a non-nil
read error, never a clean 200 with a truncated body.

Amend the comment on the ErrAbortHandler branch: the re-panicked
value is not identical to the sentinel once it crosses back out of
the interpreter, so net/http and Traefik log a panic line even though
the connection still aborts correctly.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(yaegicheck): require an actual introspection hit for NEW-01 session check

checkNew01OpaqueSessionIntrospectionClassification passed on a bare
302 alone. An unauthenticated request with no session cookie at all
also redirects with 302, without ever calling
introspectToken/validateOpaqueToken (the d7686c3 fix site), so a
future regression there could leave the check green.

Count requests that reach the introspection endpoint with an atomic
counter and require at least one, alongside the existing 302 check.

Verified: with the cookie deliberately withheld (simulating the
second SessionManager cookie no longer decoding), the check now FAILS
with "expected at least one request to reach the introspection
endpoint ... got 0" instead of silently printing PASS.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(main): cover revocation and introspection in the override-replace test

TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint only
pinned end_session. A regression that stopped applying
configRevocationURL or configIntrospectionURL specifically left every
test green.

Verified: with discoveredEndpointHasNoOverride hardcoded to always
report no override for revocation/introspection (ignoring the
configured override), the prior test suite still passed. The new
table-driven version, run against the same mutation, fails for both
revocation and introspection; reverted, all three subtests pass.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(config): correct registration override wording for scheme-downgrade drops

The prior wording implied an override always covers registration. It
does not: RegisterClient falls back to the discovered
registrationURL whenever dynamicClientRegistration.registrationEndpoint
is unset (main.go), so an unset override plus a scheme-downgrade drop
breaks Dynamic Client Registration with only the generic ERROR line --
the same unset-override case this fix already made loud for
revocation, end_session and introspection.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): make pool-return ownership check and release atomic

returnToPoolIfOwner checked sd.generation.Load() != gen and then called
returnToPoolSafely, whose inUse.CompareAndSwap(true, false) is a second,
independent atomic. GetSession/newSession hand an object out with
inUse.Store(true) before generation.Add(1), so a stale deferred return
from a previous owner could land its generation check in that window,
then have its CAS succeed against a concurrently re-acquired object,
releasing (and Reset()ing) a session another request still owns.

Replace inUse+generation as the ownership gate with a single atomic
word, sessionOwner: 0 means free, any nonzero value is the current
owner's generation. A handout claims it with Store(newGeneration); a
release claims it back with one CompareAndSwap(gen, 0). generation
itself never resets and is never reused, so once a release zeroes
sessionOwner, no later stale CompareAndSwap naming an older generation
can ever match again, regardless of timing against a new owner's own
writes.

inUse stays as a best-effort, observational field for callers that only
need a snapshot (token_manager.go's refreshToken abort check); it is no
longer part of the ownership decision. ReturnToPool now delegates to
returnToPoolSafely so both unconditional-release call sites share one
gate instead of racing two independent CAS mechanisms.

Adds a stress regression test that races the real returnToPoolIfOwner
against a goroutine performing the exact writes GetSession/newSession
make on a handout of a freshly-freed object. A cruder version of the
same race (matching the old two-field design, where a release never
touched generation) reproduced the bug in 362/400000 iterations against
the unfixed code; the fixed code shows 0 hits deterministically across
repeated -race runs.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(session): pin ServeHTTP's generation-gated pool-return wiring

The only existing regression for FIX-10 (fix10_session_pool_aba_test.go)
calls returnToPoolIfOwner directly and never drives ServeHTTP, where the
actual fix lives (middleware.go:700-701:
'defer session.returnToPoolIfOwner(sessionGen)' instead of a plain
'defer session.returnToPoolSafely()'). Reverting those two lines
compiles and leaves the whole root suite green (re-review finding at
session.go:1825).

Add a test that drives a real ServeHTTP call through the
Clear-and-reacquire branch (processAuthorizedRequestRS's backchannel/
front-channel-logout invalidation check). A small test-only hook,
sessionClearReleaseHook (nil outside tests, called at the end of
Clear()'s existing pool-return defer), lets the test pause the
invalidated request's goroutine the instant its session is freed and
deterministically steal that exact object with a direct GetSession
call, mark it as a different user's live session, then let the
invalidated request run to completion including its outer ServeHTTP
defer.

Verified the test fails (5/5 runs) against a temporary, uncommitted
revert of middleware.go:700-701 to 'defer session.returnToPoolSafely()'
(inUse=false: the stale call released the concurrent owner's session),
and passes (including under -race) against the unmodified wiring.
middleware.go itself is untouched by this commit.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): make the ServeHTTP pool-owner wiring test deterministic

The initial version of TestServeHTTP_InvalidatedSessionReacquireDoesNotStealConcurrentSession
was flaky in two independent ways, both found by stress-running it:

1. Stealing the freed session object via a second, real GetSession call
   (even from the same goroutine, immediately after the Put, with
   GOMAXPROCS(1) and GC disabled) missed the just-freed object in roughly
   a quarter of runs -- sync.Pool's exact reuse behavior here is an
   unspecified implementation detail. Fixed by having the hook swap the
   SessionManager's pool for a fresh, empty one the instant it captures
   the freed pointer, so request A's own reacquire can only allocate a
   new object, never collide with the stolen one.

2. The test asserted on the stolen object's user identifier
   (SetUserIdentifier/GetUserIdentifier), which lives in the gorilla
   session's Values map. gorilla/sessions caches a decoded session on the
   *http.Request's context keyed by cookie name; request A's own reacquire
   reads the SAME request, so it aliases the SAME underlying mainSession
   the stolen SessionData points to, even once it is a distinct SessionData
   struct. Request A's own re-auth bookkeeping then legitimately (and
   correctly) mutates that shared map, which looked like corruption but
   had nothing to do with the pool-ownership bug being tested. Fixed by
   asserting only on the SessionData struct's own ownership fields
   (inUse/sessionOwner via ownerGeneration), which ns's aliased
   mainSession cannot touch.

Verified: 80/80 passes against the correct wiring and 20/20 failures
against a temporary, uncommitted revert of middleware.go:700-701 to
'defer session.returnToPoolSafely()', both under -race. middleware.go
itself remains untouched.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): simulate the real handout sequence in the FIX-10 ABA test

The stale-return simulation wrote inUse and generation but left
sessionOwner at 0. returnToPoolIfOwner gates on sessionOwner, so the
stale return CAS mismatched because the object looked already-free
(owner 0), not because a real new owner held it. The cleanup call at
the end of the test was therefore a no-op: it never released the
object back to the pool.

Set sessionOwner to the freshly bumped generation before inUse, the
same order GetSession/newSession use on every real handout
(session.go:1076-1078, 1108-1110). Verified against the current
session.go: before this change the cleanup left inUse true and
activeSessions unchanged; after it, inUse goes false and
activeSessions decrements by one, as a real release does.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(session): skip the pool-owner race test below GOMAXPROCS 2, fix rate claim

TestSessionPoolReturnIfOwner_ConcurrentHandoutRace only catches the
regression statistically: it needs two OS threads to interleave a
stale returnToPoolIfOwner with a concurrent handout. At GOMAXPROCS=1
it passes with 0 hits even against the unfixed two-field gate, so a
single-CPU runner would report green for the wrong reason. Skip the
test below GOMAXPROCS 2 instead.

The comment also claimed a rate "consistent with" a different,
unrelated experiment (about 3 hits per 1.2M), which does not hold up:
reverting returnToPoolIfOwner/returnToPoolSafely to the pre-fix
two-field gate and running this exact test gives 76-89 hits per
400000 on this 16-core machine, roughly 100x that figure. Replace the
claim with the rate actually measured for this test against that
mutation, plus the 0-hits-at-GOMAXPROCS=1 control that motivates the
skip.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(bearer): check local revocation blacklist and nbf on opaque path

buildPrincipalFromOpaqueIntrospection never read t.tokenBlacklist, so a
captured opaque bearer token kept authenticating after logout for as
long as the introspection endpoint still reported it active (RevokeToken
blacklists locally; provider-side revocation is best-effort and silent
by default). Reject the raw token and, when the introspection response
carries one, its jti (gated on disableReplayDetection), the same way
verifyTokenWithOpts gates the JWT path. Also enforce nbf, which
validateOpaqueToken already applies on the session path and the JWT
path applies through verifyTimeClaims, but the opaque bearer path
skipped.

Regression tests: RevokeToken-blacklisted raw token, blacklisted jti
echoed in the introspection response, and a future nbf all now 401;
each failed on the base code before this fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(bearer): correct maxTokenAgeSeconds:0 opt-out claim

main.go maps every maxTokenAgeSeconds <= 0 (unset or an operator setting
0) to a 24h default; there is no config value that disables the iat-age
check. The FIX-24 comment and BEARER_AUTH.md both said 0 could disable
it and be used as an opt-out for an iat-less opaque introspection
response. State plainly that 0 means the 24h default and that a
response without iat is rejected once the bound is in effect, which it
always is.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(bearer): rewrite stale JTI replay marking claim

FIX-17 removed the last writer of the JTI replay cache: no request path
Sets a JTI any more, so the header comment and BEARER_AUTH.md row that
described a Set/Get split ("skips the Set but the Get stays active")
were stale. Revocation does not depend on that cache: RevokeToken
blacklists the raw token and a JWTs jti, and both the JWT and (as of
the previous commit) the opaque-introspection bearer path check that
blacklist before accepting a token.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* chore(cache): remove dead ShardedCache

FIX-17 removed jwt.gos replay cache, ShardedCaches last production
caller. Nothing outside its own tests referenced NewShardedCache or
ShardedCache after that (verified with grep across the package and a
clean go build). Delete sharded_cache.go and the test functions that
existed only to exercise it:

- review_r57_shard_eviction_test.go, review_r86_test.go: deleted whole
  files, entirely ShardedCache-specific.
- cache_test.go, cache_bench_test.go, review_r129_test.go,
  review_r153_regression_test.go: deleted only the ShardedCache-specific
  functions/benchmarks, keeping unrelated tests in the same file.
  BenchmarkShardedVsGlobalMutex is dropped whole since its purpose was
  comparing against ShardedCache.

Approved dead-code removal per review decision; go build and the full
test suite are clean after the removal.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(bearer): scope JTI replay claim to access tokens

bearer_auth.go header and BEARER_AUTH.md JTI-replay row said no path
writes a JTI to a replay cache at all. Backchannel logout still writes
logout-token JTIs via checkAndMarkLogoutJTIProcessed (made atomic by
FIX-17, not removed). Limit the claim to access-token JTIs and name the
one JTI replay check left.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(bearer): correct stale JTI Set/Get replay claim in README

README said the bearer path skips the JTI Set but keeps the Get active,
contradicting the rewritten BEARER_AUTH.md and bearer_auth.go, both of
which say no access-token JTI Set exists on this path at all. Replace
with a bullet matching the corrected wording: revocation goes through
the raw-token (and JWT jti) blacklist, not a JTI-replay Get.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(config): correct maxTokenAgeSeconds opt-out claim

CONFIGURATION.md said maxTokenAgeSeconds:0 disables the age check.
main.go maps any value <= 0 to a 24h default; no value disables the
check. BEARER_AUTH.md already carries the corrected wording; this was
the sibling copy the earlier fix missed.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(bearer): correct nbf clock-skew-tolerance claim in comment

Comment on the opaque-introspection nbf check said it was the same
check the JWT path applies through verifyTimeClaims. It is not: the JWT
path's verifyNotBefore allows 10s of ClockSkewTolerancePast, this check
allows none. It does match validateOpaqueToken's strict comparison and
this function's own strict exp check. Comment only, no behaviour
change.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(bearer): assert opaque-token rejects return 401 not 503

TestBearerOpaqueIntrospection_RevokedTokenRejected,
_BlacklistedJtiRejected and _NbfFutureRejected only checked bErr != nil.
A mutant that swaps those three gates to
newBearerError(bearerErrIntrospectionUnavailable, ...) (mapped to 503,
and skipped by the per-IP failure tracker) still passed bErr == nil.
Add a kind == bearerErrTokenInactive check to each so the tests pin the
401 status, not just any error.

Verified against the described mutant before reverting it: all three
tests failed with 'got kind 6' (bearerErrIntrospectionUnavailable);
reverting the mutant restores a clean pass.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(bearer): pin disableReplayDetection skips jti blacklist check

The jti-blacklist gate at bearer_auth.go:928 guards itself with
!t.disableReplayDetection, but no test exercised that guard. Add
TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled:
a blacklisted jti on an otherwise-valid introspection response must be
accepted when disableReplayDetection is true.

Verified against a mutant removing the guard before reverting it: the
new test failed with 'token replay detected (jti blacklisted)'
(bearer_opaque_binding_test.go:337); reverting the mutant restores a
clean pass across all TestBearerOpaqueIntrospection_* tests.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache/redis): treat a Redis command-error reply as definitive in SetNX

RedisBackend.SetNX reported ErrSetNXAmbiguous for any post-write read
failure, including a RESP -ERROR reply (READONLY, OOM, MISCONF). That
reply proves Redis rejected the command, unlike a genuinely lost reply
after a timeout/EOF/reset. Reporting it as ambiguous let
checkAndMarkLogoutJTIProcessed accept a logout jti-claim outright
instead of falling to its local Get+Set fallback, so a captured
backchannel-logout token could be replayed forever while Redis
rejects writes (e.g. a demoted read-only replica).

Check errors.Is(doErr, ErrCommandReply) before the sent-based
ambiguity check and return it as a plain error. Add a proxy-backed
regression test in internal/cache/backends pinning SetNX itself, and
an end-to-end test wiring a real RedisBackend behind a proxy that
always answers SET-family commands with -READONLY, confirming
checkAndMarkLogoutJTIProcessed now rejects the second delivery of the
same jti.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): let a newer backend value win over a stale-marked local one

FIX-04s backend-stale mark let Get serve the local value over EVERY
later backend value for a marked key, assuming the backend entry
could only be older (a same-replica write that may have landed after
a timeout). That is false across replicas: another replica can write
a genuinely newer value to the same key while this one is still
marked stale, e.g. a later cross-replica backchannel logout. The
session-invalidation cache (MonotonicMarkers, 25h local TTL) could
then ignore a newer logout from another replica for up to 25h.

Add backendValueIsNewer: when both the local and backend values
decode to a comparable timestamp, Get now serves whichever is not
older, so a genuinely newer backend value wins. Values it cannot
compare (e.g. the blacklist caches bool markers) keep the original
FIX-04 behavior of always preferring local. This also keeps the
original protection intact: an applied-but-timed-out Set is still not
clobbered by an older backend value, because local is newer in that
case too.

Add two-replica regression tests (a shared fake backend, no real
Redis needed) for both the plain and MonotonicMarkers cases.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* perf(cache): bound the legacy blacklist-namespace lookup cost

checkLegacyBlacklistMarker ran an extra Redis round trip (PING+GET,
plus PTTL when the key existed) on every single blacklist Get miss,
which is nearly every blacklist check, since most tokens are never
revoked. It also fully JSON-decoded whatever it found there, even a
CacheTypeToken claims map cached under the same raw token, just to
learn the value was not the boolean marker.

Compare the raw legacy value against the exact bytes serialize(true)
produces before decoding anything, and remember a miss (or a
non-bool legacy hit) locally per key for legacyBlacklistMissCacheTTL
(30s), so a repeated check for the same key skips the legacy round
trip until the window elapses. A genuine legacy marker already in
Redis is still honored once the window is up. Prune expired entries
opportunistically from cleanup(), which already runs on a timer for
every cache including the blacklist one.

Add miniredis CommandCount-based regression tests: the fix reduces a
repeated miss from 4 Redis commands to 2, and separate tests pin that
a real legacy marker is still found and a cached claims map is still
never misread as one.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* chore(cache): remove dead memory/hybrid backend config

FIX-43 deleted MemoryBackend and HybridBackend, but config.go still
declared BackendTypeMemory, BackendTypeHybrid, TypeMemory, TypeHybrid,
DefaultConfig (Type: memory) and DefaultHybridConfig — config for
backends with no implementation left. Confirmed no production caller
of backends.DefaultConfig or backends.DefaultHybridConfig outside
their own now-deleted tests (servers.DefaultConfig in
internal/testutil/servers is an unrelated function).

Remove both functions and the memory/hybrid constants; keep
BackendTypeRedis/TypeRedis, the only backend now implemented. Delete
config_test.go, which exercised only DefaultHybridConfig.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(cache): run the backend contract suite against a real RedisBackend

TestCacheBackendContract was a no-op: setupMemoryBackend and
setupRedisBackend were both t.Skip stubs, so no CacheBackend
implementation ever ran the 13 shared contract tests. MemoryBackend
no longer exists (FIX-43), so drop that subtest and wire
setupRedisBackend to a real miniredis-backed RedisBackend, matching
the pattern redis_test.go and singleflight_test.go already use.

miniredis only expires a key when its own simulated clock is moved
forward (FastForward) - it never expires anything on real elapsed
time, so testTTLExpirations time.Sleep never actually exercised
expiry against it. Thread an advanceClock func through
runContractTests/testTTLExpiration and pass mr.FastForward for it, so
that subtest genuinely proves expiry rather than passing by coincidence.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): bound the backend-stale mark in time for values FIX-04 cannot compare

markBackendStale/backendStaleLocalValue let Get serve the local value over
every later backend value, for as long as the local entry stayed live,
whenever backendValueIsNewer cannot decode the value into a comparable
number (a string, a map, a struct). The DCR client-credentials cache
(a JSON string, TTL up to 100*365 days) hit this on every replica whose
write timed out: a stale credential could pin over a value a different
replica legitimately wrote, for the entry's whole TTL.

staleBackend now records when each key was marked (map[string]time.Time),
and backendStaleLocalValue treats a mark older than the new
backendStaleMarkTTL (5s, well above the 500ms Set timeout) as expired,
deleting it so the current backend value is trusted again. The numeric
newer-wins comparison for MonotonicMarkers caches is unchanged.

Two regression tests pin both directions for a non-numeric (string) value
across two replicas sharing one backend: within the bound an older
backend write does not clobber the surviving local value, and after the
bound a newer value written by another replica is served.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): gate the backend-stale numeric comparison on MonotonicMarkers

backendValueIsNewer's "larger number wins" comparison ran for every
cache, not only MonotonicMarkers ones. For a non-monotonic numeric cache
a larger number is not necessarily a newer write, so an older, larger
backend value could clobber a fresh, smaller local write that survived a
timed-out Set — the exact clobber FIX-04 exists to prevent.

Get now gates the comparison behind c.config.MonotonicMarkers. Every
other cache recovers from a stale mark via the time bound added in the
previous commit instead of by numeric order.

Rebuilt TestR4_BackendStale_CrossReplicaNewerValue_PrefersNewerBackend on
the time bound instead of numeric order, since its cache never set
MonotonicMarkers, and added a regression test pinning the direction it
used to get backwards (an old, larger backend value must not clobber a
fresh, smaller local write on a non-monotonic cache).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* perf(cache): cap legacyBlacklistMissUntil at the cache's MaxSize

legacyBlacklistMissUntil had no size cap of its own. Its keys came from
the raw bearer token, looked up before signature verification, so a
caller who can send unique tokens fully controlled how many distinct
entries piled up. Pruning ran only from cleanup()'s 5-minute tick, so
each unique key stayed in memory for up to ~5.5 minutes regardless of
volume — unbounded memory a caller could grow at will.

recordLegacyBlacklistMiss now caps the map at c.config.MaxSize (already
>0 for every cache), pruning expired entries inline when the cap is hit
before deciding whether to record. If it is still full afterward, the
miss is simply not recorded — the only cost is one extra legacy lookup
next time, exactly what every check paid before this bound existed. The
map is also keyed by a sha256 digest of the lookup key instead of the raw
string, so an entry's size no longer grows with token length.

Regression test drives a small-MaxSize blacklist cache through 4x its
cap in unique misses and asserts the map never exceeds the cap, and that
a genuine legacy marker is still honored while the map sits at capacity.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): honour the pre-#117 legacy blacklist marker encoding

checkLegacyBlacklistMarker's byte compare recognized only the current
serialize() encoding (0x01 type-marker byte + json.Marshal(true)). Code
before #117 (commit 775de2a; every tag up to and including v0.8.17) wrote
plain json.Marshal(true) with no type-marker byte at all - 4 bytes,
'true'. deserialize's legacy no-marker fallback still accepts that
shape, and the OLD checkLegacyBlacklistMarker (which deserialized instead
of comparing bytes) honored it, so a Redis-mode upgrade straight from
v0.8.17 or earlier could silently drop revocations written up to the
blacklist's TTL before the upgrade.

Also accept legacyBlacklistTrueMarker with its type-marker byte stripped.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(lifecycle): fix order-dependent baseline and global-state leaks in FIX-35/18/19 tests

TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose registered a live
instance without capturing a liveInstanceCount baseline first. A live
instance left registered by an earlier test in the same run already makes
the pre-FIX-35 stale "was last" decision false on its own, so the test
cannot tell a reverted production fix from a working one under -shuffle.
Save/reset/restore the counter via a new resetLiveInstanceCountForTest
return value and t.Cleanup instead. Reproduced with a temporary leaked
instance ahead of the test plus a manual revert of the FIX-35 decision in
Close(): the test passed either way before this change, now fails as
expected. TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop is
updated to the same save/restore pattern.

TestFix18_CloseImmediatelyAfterNewDoesNotReRegister called the shared
resetGdInstancesForTest helper, which wipes the process-global gdInstances
registry with no restore, discarding any GracefulDegradation instances
other tests left registered. Add snapshotAndClearGdInstancesForTest, which
saves the prior set and restores it via t.Cleanup.

TestFix19_RegisterBackgroundTaskKeepsPendingTaskNoOrphan called
rm.StopAllTasks() on the process-global ResourceManager, which stops every
registered singleton task (memory-monitor, singleton-token-cleanup, etc.),
not just the one this test created. Replace it with
StopBackgroundTask(name) so the test only touches its own task.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(recovery): treat HTTP 408 as transient in isTerminalClientError

isTerminalClientError excluded only 429 from the terminal-4xx breaker
exemption, so a burst of token-endpoint 408 Request Timeout responses
never opened the refresh circuit breaker. token_validation_rs.go (FIX-13),
bearer_auth.go, and internal/recovery already classify 408 as a transient
service-health signal, not a per-request rejection. Exclude 408 alongside
429.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* refactor(recovery): move healthRoutineSettled out of production code

GracefulDegradation.healthRoutineSettled was added to error_recovery.go
(production) but its only caller is fix18_gd_lifecycle_test.go, and its
own doc comment says "Tests use this". FIX-39 established the pattern of
keeping test-only helpers out of the production binary
(internal/pool/export_test.go). Move the method into
fix18_gd_lifecycle_test.go next to its sole caller,
waitForHealthRoutineSettled.

No behavior change: verified via go build (production code compiles with
the method removed) and the FIX-18 test suite, unchanged.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(refresh): remove fixed-sleep dedup race in TestRefreshRateLimiting

TestRefreshRateLimiting used the default 100ms DeduplicationCleanupDelay
and a 150ms sleep to outrun it before starting the next iteration -- the
same fixed-sleep-vs-cleanup-timer race FIX-22 removed from its siblings
(TestCircuitBreakerProtection, issue67, review_refresh_cb), but not from
this test.

Reproduced deterministically by temporarily setting
DeduplicationCleanupDelay above the 150ms sleep: the next CoordinateRefresh
call then joins the still-registered previous operation instead of
starting fresh, so attempts is miscounted (5 instead of 3) and cooldown
never triggers. Fix: DeduplicationCleanupDelay=0 plus waitForRefreshDrain
polling the in-flight map, matching the sibling tests.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): bound Shutdown's abandonment of in-flight refreshes to a drain cap

FIX-36 made Shutdown cancel rc.ctx immediately, aborting every in-flight
refresh unconditionally -- including one whose refreshFunc call had
already reached the IdP and would complete on its own. Discarding that
result loses the token response, and for a rotating IdP (Zitadel,
Authentik), consumes a one-time-use refresh token that gets thrown away,
forcing the next refresh into invalid_grant and a re-login.

DECIDED: Shutdown now waits for in-flight refreshes to drain naturally, up
to a new shutdownRefreshDrainTimeout (5s) constant. A refresh that
completes within the cap delivers its result to its waiters as normal;
Shutdown only cancels rc.ctx (abandoning whatever is still running) once
the cap elapses, so Shutdown itself never blocks longer than the cap.
CoordinateRefresh's stopChan check (still gated by lifecycleMu, still
ahead of wg.Add) is unchanged: a call arriving after Shutdown starts is
still rejected immediately, without waiting for the drain cap.

Reproduced with a probe mirroring the finding's evidence (refreshFunc
completes 300ms after Shutdown starts): the waiter got "refresh
coordinator is shutting down: context canceled" instead of its tokens on
unfixed code. Added as
TestFix36_ShutdownDeliversResultForRefreshCompletingWithinDrainCap
(fails on base at refresh_coordinator_test.go:905) and
TestFix36_ShutdownReturnsNearCapWhenRefreshExceedsIt (fails on base:
Shutdown returned after 4.666us instead of waiting out the cap), plus a
rejected-during-drain coverage test. Rewrote
TestFix36_ShutdownReturnsPromptlyDuringInFlightRefresh into these two,
since its "always cancel immediately" assertions directly contradicted
the new contract, and its refreshFunc's fixed 3s time.Sleep left a
goroutine running past the test -- both replacements block on a channel
released via t.Cleanup instead.

Updated review_r63's TestRefreshCoordinatorShutdownReleasesWaiterOnInflight
and review_r154's TestRefreshCoordinator_ShutdownWaitsForInFlight to the
same drain-cap contract: their refreshFunc never completes on its own, so
they now assert Shutdown gives up near the cap (not immediately) and the
waiter still gets an error, while keeping their original wg-tracking
assertion (circuit-breaker failure recorded before Shutdown returns).

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(refresh): gate the pre-start refresh on stopChan, not only rc.ctx

Shutdown closes stopChan synchronously but only cancels rc.ctx after
shutdownRefreshDrainTimeout (or once every in-flight refresh drains).
executeRefreshAsyncs inner goroutine checked only rc.ctx.Err() before
calling refreshFunc, so an operation whose wg.Add ran just before
Shutdown started, but whose goroutine reached that check afterward,
still spent the IdP grant for nothing during the whole drain window.

Add a stopChan gate ahead of the rc.ctx check so a refresh not yet
started is skipped and its waiter gets the shutdown error immediately.
Also make Shutdown cancel rc.ctx unconditionally before it returns (not
only on the drain-cap path), so a clean Shutdown with nothing in flight
still leaves rc.ctx canceled instead of dead. Update the stale FIX-36
comments describing the old cancel-immediately contract.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(refresh): wait on stopChan instead of fixed sleeps in two Fix36 tests

TestFix36_CoordinateRefreshRejectedAfterShutdownStillImmediate slept
20ms hoping Shutdown had closed stopChan by then; a late-scheduled
Shutdown would let the second CoordinateRefresh get wrongly accepted.
TestFix36_ShutdownDeliversResultForRefreshCompletingWithinDrainCap
slept 50ms before releasing the refresh; if Shutdown had not started
yet, the refresh would finish before Shutdown ran, passing even on the
reverted cancel-immediately code (a false negative).

Both now block on <-rc.stopChan (with a 2s failure timeout) so the
release/second-call always happens strictly after Shutdown has begun.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(lifecycle): remove duplicated doc paragraph in FIX-35 test

be3ac67 duplicated the TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose
doc paragraph word for word. Delete the copy and keep the baseline
paragraph that follows it.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(lifecycle): restore process-global singleton state in fix18/fix35 tests

TestFix18_TraefikOidcCloseEmptiesGDInstances made its synthetic
instance the last live one, so Close() ran stopIfLastInstance and
stopped singleton-token-cleanup and (via StopAllTasks) every other
process-global singleton, including memory-monitor, with nothing to
restart them. TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose
unconditionally reset the global memory monitor on entry and on
cleanup, discarding whatever state an earlier test in the same binary
left running.

Fix18: register a sentinel live instance so this test is never the
last one, leaving Close()s last-instance stop path unreached.
Fix35: capture whether memory-monitor was running before this tests
own reset, and restart it in a cleanup (registered first, so it runs
last) when it was.

Verified with a throwaway probe (started both singletons, ran both
tests via t.Run, asserted running-state after): failed at HEAD with
singleton-token-cleanup=false/memory-monitor=false after fix18 and
memory-monitor still false after fix35; passes with this change.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(lifecycle): surface StopBackgroundTask errors in FIX-19 test

_ = rm.StopBackgroundTask(name) discarded the "task not registered"
error StopBackgroundTask returns on a failed lookup, which would let
the test fail later with a misleading "orphan escaped registry"
message instead of pointing at the real cause. Fail the test directly
with the actual error, and fix the stale StopAllTasks wording in the
neighboring failure message to name the call this test actually makes.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* docs(changelog): record the round-4 re-review fixes

Record the behaviour changes users can notice: opaque bearer tokens with all three bearer flags, the first post-upgrade logout for older sessions, the 5-second refresh drain on shutdown, and panics after forwarding that now abort the connection. Add a Fixed section for the six round-4 fixes and note that the legacy blacklist check runs at most once per key in each 30 seconds.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(cache): keep the backend-stale mark for MonotonicMarkers caches

The round-4 time bound on the backend-stale mark also expired it for MonotonicMarkers caches. When a replica could not write a newer session-invalidation marker to Redis, it fell back to the older backend marker after 5 seconds and stopped enforcing the newer logout, which is the case FIX-04 exists for. Exempt MonotonicMarkers caches from the bound. Get still lets a newer marker from another replica win through the newer-wins compare. The regression test fails before this change (Get returns 1000) and passes after it.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* chore(git): ignore the .claude directory

Local Claude Code state (todos, review artifacts, agent worktrees) lives under .claude/ and must not be committed. Replace the narrower .claude/*.out rule with the whole directory.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(make): fail make lint on golangci-lint findings

The lint recipe "golangci-lint run ./... || echo ..." turned every lint failure into exit 0, so make lint always reported success. Use an explicit if/else: run golangci-lint when it is installed and return its exit status, otherwise print the skip message. TestMakeLintPropagatesGolangciLintResult runs the target with a fake golangci-lint and fails on the old recipe.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix: avoid narrowing int conversions flagged by gosec G115

reserveConnectionSlot and the half-open gate narrowed an int limit to int32, so a configured value above MaxInt32 wrapped negative and refused every connection or probe. Compare in int64 instead. BuildSessionName used rune(0+index) for any index below 10, so a negative index produced a non-digit; limit the fast path to 0..9. Regression tests fail on the old code.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* chore(gosec): annotate intentional findings with #nosec

GitHub code scanning runs standalone gosec, which ignores golangci //nolint:gosec directives and honors only #nosec. Replace the directive with a #nosec Gxxx comment and a reason at the nine intentional sites: persisted DCR client_secret (G117), operator-enabled insecureSkipVerify (G402), the conditional-Secure deletion cookie and a test request cookie (G124), and the test mock IdP redirects (G710). golangci-lint also honors #nosec, so it stays at 0 issues.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* ci: build with Go 1.26

staticcheck@latest (v0.8.1) and govulncheck@latest (v1.8.0) now require Go 1.26. The shared workflow runs them with GOTOOLCHAIN=local on the pinned 1.25.x, so the Static Analysis and Security Scan jobs failed to install them. Pin 1.26.x in pr.yaml (both jobs) and release.yml, matching toolchain go1.26.6 in go.mod.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(recovery): stop health routine restarting task after last Close

GracefulDegradation.Close closed stopChan without gd.mutex. A health
routine that passed its stopChan check could then reach the task registry
after the last Close stopped the shared task. RegisterBackgroundTask
replaces a stopped task, so the routine started a task that no live
instance can stop. This made TestSharedHealthTaskSurvivesOtherInstanceClose
flaky when an earlier test left the shared task running.

Close now closes stopChan under gd.mutex, so an in-flight routine finishes
before the instance unregisters. The lock is released before the task
stop, because BackgroundTask.Stop waits for a pass that takes gd.mutex.

TestSharedHealthTaskNotRestartedAfterLastClose parks the routine after its
check and fails on the old code in 20 of 20 runs.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(recovery): keep shared health pass out of health-check subtests

TestGracefulDegradationHealthChecks calls performHealthChecks directly.
The shared health-check task also runs the same registered checks in the
background, so the race detector reported a write to healthCheckCalled.
The background pass could also clear the degraded state before the first
assertion. The subtests failed 18 of 1000 runs alone under -race.

The two subtests now hold globalHealthCheckMu, so no background pass runs
during their assertions. The unlock is deferred after Close, so it runs
first and Close never waits on a blocked pass.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(tls): drop the deprecated PreferServerCipherSuites field

crypto/tls ignores Config.PreferServerCipherSuites since Go 1.18, and staticcheck v0.8.1 (SA1019), which CI now installs, fails the Static Analysis job on it. Remove the field at its three sites and the stale #nosec comment beside one of them. Behaviour does not change.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test(cleanup): wait for the processed-task metric in TestWorkerPool_GetMetrics

executeTask increments tasksProcessed in a deferred call that runs after the task returns, so the task wg.Done() can fire first, and wg.Wait() returned while the metric still read 1. CI failed on this once. Poll the counter with a 2 second deadline before asserting. With a 20ms delay injected before the increment, the old test fails in 3 of 3 runs and the new test passes.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(recovery): serialize GracefulDegradation creation with the last-instance stop

GracefulDegradation.Close removed the instance from gdInstances, decided it
was the last one, and released the lock before stopping the shared
health-check task. A GracefulDegradation created in that gap registered at
once, and its health routine adopted the still-running task. The stop then
killed the task that the new, live instance relies on, and nothing
restarted it.

Add gdLifecycleMu. NewGracefulDegradation registers under it, and Close holds
it across the last-instance check and the stop. A new instance now either
counts toward the check or registers after the stop, and its routine then
replaces the stopped task. The health-check pass never takes this lock, so
the stop can still wait for an in-progress pass.

TestSharedHealthTaskSurvivesCreateDuringLastClose pauses Close between the
check and the stop through a nil-by-default test seam
(beforeSharedTaskStop). With the seam on the old Close logic, it fails in 10
of 10 runs. With this change, it passes in 20 of 20 runs under -race.

Production impact today is limited: no production code registers a health
check, and degraded state expires through RecoveryTimeout, not the task.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* fix(session): delete stale cookies at the configured cookiePath

Session cookies are written at the configured cookiePath
(EnhanceSessionSecurity), but CleanupOldCookies wrote its deletion cookies
at a hardcoded "/". A browser deletes a cookie only when name, domain and
path all match, so the cleanup removed nothing when cookiePath was set.

Add SessionManager.effectiveCookiePath (the configured cookiePath, or "/")
and use it for cookie writes, for the deletion cookies, and in
getSessionOptions, so the three sites cannot drift. The cleanup deletes
only at the configured path. Cookies written at "/" before an operator set
cookiePath stay until they expire.

TestCleanupOldCookies_UsesConfiguredCookiePath fails on the old code
(deletion Path "/", want "/app") and passes after the fix.

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

* test: close leaked graceful-degradation managers and guard against leaks

Eleven root tests created an ErrorRecoveryManager or TokenResilienceManager,
or built a manager with a GracefulDegradation, and never closed it. Each one
left an instance registered and the shared health-check task running for
every later test in the process. That made lifecycle tests that assume a
clean start flaky (TestSharedHealthTaskSurvivesOtherInstanceClose).

The leakers were found by running each of the 1156 root tests alone, followed
by a check of gdInstances and the task state. Each leaker now registers
t.Cleanup(<manager>.Close).

TestMain now fails the package when tests leave a GracefulDegradation
unclosed or the shared task running, after a 2-second settle window for
plugin instances that close asynchronously. On a leaking test it reports
"tests leaked 1 unclosed GracefulDegradation instance(s)".

Claude-Session: https://claude.ai/code/session_01KwtV5ecajCrftcf8TkfPad

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-28 12:55:10 +01:00
Alex SedovandGitHub d3012d5169 feat: add RFC 8707 resource indicator via resource config (#161)
* feat: add RFC 8707 resource indicator via resource config

`Config.Resource` is an alternative to `audience` for IdPs that implement
RFC 8707. `buildAuthURL` sends it as the `resource` parameter on the
authorization request, before `extraAuthParams` so an operator-configured
`resource` key there cannot override or duplicate it. `exchangeTokens`
repeats it on both the `authorization_code` and `refresh_token` grants
(RFC 8707 §2.2), so a refreshed token keeps the audience binding of the
original grant.

Only one resource is accepted. RFC 8707 permits a list, but no provider
that supports the parameter handles a list usefully: one rejects any
request naming more than one resource with HTTP 400 `invalid_target`;
node-oidc-provider accepts several and issues an opaque token bound to
none of them.

`Config.Validate()` requires an absolute URI of at most 256 characters
with no fragment, wildcard, or control characters. `http`/`https` values
go through `isValidSecureURL`, so HTTPS is required outside loopback;
other schemes (`urn:`) pass as-is.

When `resource` is set and `audience` is not, `audience` defaults to the
resource for access-token validation; `strictAudienceValidation` applies
the same way whichever of the two is set. That default is validation-only.
A new `TraefikOidc.explicitAudience` mirrors `Config.Audience` verbatim,
and `buildAuthURL` keys the non-standard `audience` request parameter (the
Auth0 custom-API pattern) on it rather than on `audience`, so a
resource-derived default is never sent as an unsolicited `audience`
parameter alongside `resource` — a combination that misbehaves against
providers such as Ory Hydra, which give `audience` a different meaning.
`audience_test.go` and `url_helpers_ultra_test.go` set `audience` on the
struct directly, bypassing `New()`, and now set `explicitAudience` too.

docs/CONFIGURATION.md carries only the reference-table row and a short
pointer; the new docs/RFC8707.md holds the request behaviour, the
single-resource rationale, the `resource`/`audience` relationship, and a
provider-support table from interop testing: node-oidc-provider supports
it; Zitadel accepts and silently ignores it (`oidc.AuthRequest` has no
`resource` field, confirmed against a live instance); Authentik returns
`invalid_target`; Keycloak has no support through 26.6; Ory Hydra/Fosite
uses its own `audience` parameter; Azure AD/Entra ID v2 is reported to
reject `resource` combined with v2-style `scope`; Auth0 and AWS Cognito
claims are unverified. README.md, docs/index.html, and .traefik.yml each
point at docs/RFC8707.md instead of repeating it, and the comments on
`Config.Resource`, in main.go, and in url_helpers.go defer to it as well.

rfc8707_resource_test.go covers the authorization-URL parameter (set,
unset, query-bearing value percent-encoded rather than merged,
`extraAuthParams` unable to override), both grants sending `resource` in
the form body, the `Config.Validate()` rules table, audience defaulting
through `New()`, and a regression pair: a resource-derived audience
default is not sent as `audience`, while an explicitly configured one
still is.

* fix: accept Resource in place of Audience for bearer-auth validation

`NewWithContext` rejected `EnableBearerAuth=true` whenever
`config.Audience` was empty, even though `t.audience` already falls back
to `config.Resource` in that case. The check now fails only when both
`Audience` and `Resource` are empty, and the error names both fields.
`TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience` covers the
`Resource`-only configuration.

`docs/RFC8707.md` links the `audience` parameter note to
`docs/AUTH0_AUDIENCE_GUIDE.md` instead of restating it, and drops the
provider table's GitHub row and the interop-testing paragraph, which are
covered elsewhere.
2026-09-10 14:49:53 +01:00
Martin FreyandGitHub 61e60733a5 fix: workaround for yaegi type checking using reflection (#159) 2026-09-02 16:42:47 +01:00
Martin FreyandGitHub 7e44725edc fix: avoid nil dereference in Close (#157) 2026-08-28 00:47:30 +01:00
15dba0e8d3 feat: add cookiePath config to scope session cookies to subpath (#143)
Fixes #122.

Co-authored-by: Hermes Agent <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-19 17:49:52 +01:00
lukaszraczylo f728f61777 fix(config): wire introspectionURL override and repair dead endpoint overrides (#152)
Issue #152 asks for a manual introspection endpoint because some IdPs
omit introspection_endpoint from discovery. Analysis found the two
documented overrides revocationURL and oidcEndSessionURL were dead
code: the 1b49e13 rebuild dropped their constructor wiring.

Add the IntrospectionURL config field and wire all three overrides.
The overrides apply inside updateMetadataEndpoints, so they survive
every metadata refresh. A manual value always wins over discovery.
The constructor also seeds the live fields, so a failed discovery
does not lose operator configuration.

Manual values skip the poisoned-discovery defences on purpose. They
sit at the same trust tier as providerURL and isValidSecureURL gates
them at config time.

Add issue152_regression_test.go: 8 tests pin precedence, refresh
survival, cold-start seeding, end-to-end introspection over HTTP,
and the validation contract.
2026-08-09 01:59:47 +01:00
lukaszraczylo 78b6f193b3 fix(security): allow loopback hosts for loopback providerURL (#154)
Issue #154: a providerURL that points at a localhost IdP caused an
infinite redirect loop. validateHost rejected loopback hosts
unconditionally, so buildAuthURL returned an empty string.

Derive allowLoopbackHosts from a loopback providerURL at construction
time. Discovery validation already used this derivation, so the two
validation layers now agree. This adds no new config surface.

Cloud metadata hosts (169.254.169.254, metadata.google.internal),
link-local, and unspecified addresses stay blocked unconditionally.
Non-loopback providers keep identical behaviour.

Add issue154_regression_test.go: 8 tests pin the new allowance, the
default block, and the security envelope.
2026-08-09 00:47:25 +01:00
edb7174cc8 docs: pin documented plugin version to v1.0.29 (#153)
The install snippet in the README still pinned v0.7.10 and the example
configs v0.8.0, so anyone following the docs installed a release far
behind the current v1.0.29.

Co-authored-by: Markus Renschler <[email protected]>
2026-07-31 10:04:47 +01:00
lukaszraczylo e51bd9cdbe fix(catalog): analyzer-valid testData and explicit New() error return
The plugin catalog analyzer failed v1.0.28 with "invalid handler
type: <nil>" (#151). Two defects combined.

The analyzer feeds .traefik.yml testData to New() verbatim. The header
values used quadruple braces on a false premise: no configuration path
collapses them. Template validation rejected them, so New() failed. The
testData now carries plain, valid templates.

New() also returned failures through a multi-value tail call
(return NewWithContext(...)). Yaegi v0.16.1 zeroes both results of that
statement on the error path. The analyzer therefore saw (nil, nil) and
reported the opaque handler-type message instead of the real validation
error. New() now splits the return explicitly. Construction failures
surface as real error messages under yaegi and in Traefik logs.

The regression tests pin both fixes. README and CONFIGURATION.md now
give correct escaping guidance: plain double braces everywhere; file
provider users escape with a raw string. Quadruple braces do not work
anywhere.

Fixes #151.
2026-07-22 10:22:35 +01:00
lukaszraczyloandGitHub f320d0a316 fix(settings): repair #149 + AST-based header template validation + allowedClaims (#150)
* fix(settings): allow documented range + static header templates (#149)

Upgrading past the release where NewWithContext began calling
config.Validate() (546ceb9) broke `headers` configs that worked in 0.8.25:
New() returned a nil handler and Traefik logged the opaque "invalid handler
type: <nil>". Two header-validation checks were stricter than the documented,
legitimate usage:

- validateTemplateSecure rejected the documented multi-valued-claim join
  "{{range $i,$e := .Claims.groups}}...{{end}}" as a dangerous {{range}},
  even though README/CONFIGURATION show exactly that for groups/roles.
- Config.Validate required every header value to contain {{ }}, rejecting
  static/literal values such as a shared proxy secret.

Fixes:
- Allow static/literal header values (reject only CR/LF/NUL header-injection
  chars); flag unbalanced {{ }} explicitly.
- Allow {{range}} only when its clause targets .Claims.<field>; a range over
  the bare .Claims map stays blocked and a decoy {{.Claims.x}} elsewhere no
  longer launders it. Generalize the claims whitelist scan to .Claims. so a
  claim reached via range/with/default is whitelisted identically to a direct
  {{.Claims.x}} reference.
- normalizeTemplateDelimiters now collapses ALL whitespace inside {{ }}
  (newline/CR/FF too), closing a newline-obfuscation bypass ({{\nrange,
  {{\nindex) found in review.
- Log the concrete validation reason at construction so operators see WHY the
  middleware failed to build instead of only "invalid handler type: <nil>".

Adds issue149_regression_test.go: pins the exact issue config, the documented
groups template rendering "admin,users" past the runtime sanitizer, static and
delimiter edge cases, and the security boundary (whole-Claims-map dump via
range, non-whitelisted claims, call/define/template/printf, and
newline-obfuscated actions all rejected).

* fix(settings): AST-based header template validation, close whitelist bypasses

Follow-up hardening to the #149 work. An adversarial review showed the
substring-based validateTemplateSecure could not actually bound what a header
template reads: get/index with arbitrary keys, {{with .Claims}} field access,
and a whitelisted-decoy + bare {{.}}/{{$}}/{{.Claims}} render all leaked the
whole root (every raw token) or the whole Claims map past validation. The core
problem is structural — the meaning of "." depends on scope, which substrings
cannot see.

Replace the substring validator with an AST walk (text/template/parse):
- Parse with only get/default known; every other function (printf, call, index,
  js, urlquery, ...) then fails to parse and is rejected — no blocklist.
- Reject {{define}}/{{block}} (they produce extra trees).
- Walk the tree tracking whether "." is the root. Permit ONLY: literal text;
  the four root token fields (AccessToken, IdToken, IDToken, RefreshToken);
  whitelisted .Claims.<field>; range/with over a whitelisted .Claims.<field>
  ("." inside the body is the already-whitelisted element); get on .Claims with
  a whitelisted literal key; default over safe values; {{if}} control flow and
  $-scoped range/with/decl variables. Everything else — bare {{.}} / {{$}}
  (whole-root dump), bare {{.Claims}} (whole-map dump), non-whitelisted claims,
  $.Claims root reach — is rejected.

Also closes the get/default exfil and the with-over-bare-map vectors, and fixes
ID-token forwarding: templateData now carries both IDToken and IdToken and the
validator accepts {{.IdToken}} (documented) and {{.IDToken}}; previously the
documented spelling rendered empty and the working one was rejected.

Runtime `get` additionally enforces the claims whitelist on its key, and
safeClaimsFields is promoted to a package-level single source of truth shared by
the validator and the get helper. The header template funcMap is extracted to
headerTemplateFuncMap() so tests exercise the exact production map. Removes the
now-dead normalizeTemplateDelimiters/regex (the parser handles whitespace).

Extends issue149_regression_test.go: renders through the real funcMap to prove
get cannot leak; asserts the whole-root/whole-map/decoy/var-launder/index
vectors are rejected while every documented pattern (groups/roles join,
with .Claims.email, get .Claims "internal_role", default, both IdToken
spellings) still validates and renders.

* feat(settings): allowedClaims to extend the header-template claim whitelist

The AST validator and the runtime `get` only permit a fixed set of claim fields
in header value templates. Providers issue custom claims (e.g. employee_id) that
aren't built in, and there was no supported way to forward them — the previous
`get` escape hatch was closed by the security hardening.

Add `allowedClaims []string` to Config. Listed names extend the effective
whitelist for BOTH static validation and the runtime `get` (a single
claimsWhitelist() = built-in safeClaimsFields + AllowedClaims, so the two never
drift). Built-in claims need not be listed; unlisted claims stay rejected.

  allowedClaims:
    - employee_id
  headers:
    - name: X-Employee-Id
      value: "{{`{{.Claims.employee_id}}`}}"

Implementation: the whitelist is threaded through the validator (now a small
templateValidator struct carrying the allowed set) and headerTemplateFuncMap
instead of the package global, so it is per-config with no shared mutable state.

Docs: README and docs/CONFIGURATION document the whitelist, the validation
behaviour, and allowedClaims (grounded in safeClaimsFields).

Tests: TestIssue149_AllowedClaimsExtendsWhitelist pins that a custom claim is
rejected by default and validates + renders once listed, via both direct access
and get, while an unlisted claim stays rejected. A regression assertion that
coupled to the old validator's "dangerous" wording now checks the stable
"failed security validation" wrapper (the templates are still rejected).
2026-07-21 13:04:39 +01:00
lukaszraczylo 48a19e04f8 fix(settings): tolerate whitespace in header template delimiters (#148)
validateTemplateSecure matched safe functions with literal strings.Contains
("{{get "), so a space after {{ ("{{ get") failed the whitelist and
Config.Validate() rejected the config, surfacing as Traefik's opaque
"invalid handler type: <nil>". Go's text/template treats {{ get and {{get
identically, so the validator was stricter than the engine.

Normalize inner-delimiter whitespace before matching. This also closes a
security gap where a leading space let dangerous patterns ("{{ range",
"{{ call") evade detection.

Adds unit + regression coverage.
2026-06-26 11:49:31 +01:00
lukaszraczyloandGitHub 546ceb949c security: remediate audit findings (ranks 1–16 + 22 Lows) + yaegi load validation (#144)
* fix(security): encrypt session cookies + fail closed on invalid config

Batch 1 of security audit remediation (ranks 1, 2, 6).

- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
  and build the gorilla cookie store with both, so session cookies are now
  encrypted, not merely signed. The single-key store previously left OIDC
  access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
  changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
  instead of silently substituting a public hardcoded encryption key for
  empty/short keys (which allowed session forgery). The yaegi analyzer
  passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
  only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
  tests in security_audit_fixes_test.go.

Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).

* fix(security): validate discovered OIDC endpoints + pin introspection host

Batch 2 of security audit remediation (ranks 3, 4).

- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
  taken from the provider discovery document (jwks_uri, token, authorization,
  revocation, end_session, introspection, registration). Blocks link-local
  (cloud metadata 169.254.169.254), multicast, unspecified and private
  addresses (unless allowPrivateIPAddresses); blocks loopback unless the
  configured providerURL is itself loopback (dev/test). Cross-domain JWKS
  hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
  blanks any that fail (fail closed downstream). The introspection endpoint
  carries the client secret via HTTP Basic, so it is additionally pinned to
  the providerURL host to stop a poisoned discovery document exfiltrating the
  secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.

* fix(security): close open redirects + anchor excluded-URL matching

Batch 3 of security audit remediation (ranks 5, 14, 15).

- auth_flow.go: run the stored incoming path through normalizeLogoutPath
  before using it as the post-login redirect, so //evil.com and /\evil.com
  payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
  (exact, sub-path "/", or file extension "."), so excluding "/public" no
  longer also bypasses auth on "/publicsecret"; "/favicon" still matches
  "/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
  CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
  endpoint is host-relative, never an absolute URL derived from the
  client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
  add regression tests.

* fix(security): reject unverified Azure tokens; fix transport TLS reuse

Batch 4 of security audit remediation (ranks 7, 11).

- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
  cryptographically verified no longer returns "authenticated" when there is
  no ID token to corroborate it; it refreshes (if possible) or forces
  re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
  lock before mutating refCount (fixes a data race) and only reuses a
  transport whose TLS settings (CA pool + InsecureSkipVerify) match the
  caller's, never one with a different trust store; if none matches it returns
  nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.

* fix(security): stop logging templated header values (token leak)

Batch 5 of security audit remediation (rank 16).

middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.

* fix(security): cache-key collision, cache-config divergence, fleet cleanup

Batch 6 of security audit remediation (ranks 9, 10, 12).

- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
  full token instead of the first 32 chars (which are only the base64url JWT
  header). Distinct tokens sharing alg+kid no longer collide and get
  mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
  shared across plugin instances; it now logs a loud warning when a later
  instance requests a different explicit Redis backend that is silently
  ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
  instance count; the shared singleton-token-cleanup task is stopped only when
  the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
  no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.

* fix(security): bound introspection cache + cookie lifetime to config

Batch 7 of security audit remediation (ranks 8, 13).

- token_introspection.go: when requireTokenIntrospection is enabled, cap the
  positive introspection-result cache at 30s (instead of 5m) so a token
  revoked at the provider stops passing within ~30s, matching the operator's
  near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
  so the cookie codec's cryptographic timestamp validity is no longer fixed at
  gorilla's 30-day default; a stolen cookie is valid only for the configured
  session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.

* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)

Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).

- universal_cache.go: updateLocalCache updates an existing key in place instead
  of orphaning its LRU element and double-counting currentSize/currentMemory
  (rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
  with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
  buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
  key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
  verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
  the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
  map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
  resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.

* fix(security): header sanitization, issuer pinning, fail-closed paths

Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).

- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
  auth path before injecting them into downstream headers. Drop group/role and
  identifier values containing control chars, bidi-override runes, or the
  , ; = delimiters (a comma would inject phantom entries into X-User-Groups);
  reject control/bidi/over-length in rendered templated header output (but
  permit , ; = in free-form values such as a bearer token). The bearer path
  already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
  provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
  discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
  closed on a missing or mismatched introspection audience; aud parsed as
  string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
  rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
  successful introspection fails closed (re-auth) instead of authenticating
  (ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
  sanitization distinction and the fail-closed paths.

* chore(security): remove unwired dead code with latent footguns

Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.

- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
  Valid=true with NO signature verification — a severe footgun if ever wired
  (rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
  that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
  uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
  (Update/Read/DeleteClientRegistration) that dereferenced an attacker-
  influenced RegistrationClientURI with the registration token attached and no
  HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
  credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.

* chore: add Makefile with yaegi load validation

No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.

- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
  excluded from `go build ./...` (avoids VCS-stamping a main binary, which
  fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
  targets; `make check` runs vet + tests + yaegi-validate.

Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.

* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1

Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.

Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.

Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.

* docs: align README/CONFIGURATION with branch behavior changes

- excludedURLs: documented as segment/extension-boundary matching (was
  "prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
  rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
  AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
  upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
  callbackURL, non-HTTPS remote providerURL) now fails closed at startup.

* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check

CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
  was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
  tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.

The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.

* fix(test): stabilize flaky TestWorkerPool_TaskPanic

tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
2026-05-30 14:10:32 +01:00
lukaszraczylo f75b2f20e0 fix: resolve cache eviction lock-up and migrate telemetry [patch-release]
universal_cache: stop the write-lock convoy / 100%-CPU spin (observed via pprof: one ServeHTTP goroutine holding c.mu.Lock for hours while 119 requests queued). The per-request populate path (updateLocalCache) PushFronted a duplicate LRU node + overwrote items[key] without removing the prior node; once eviction deleted the key, orphan nodes at Back() were never removable and the eviction loop spun forever under the write lock. Replace the entry in place (mirroring setLocal) and harden evictOldest with a forward-progress guard. Adds universal_cache_orphan_test.go.

telemetry: delete the hand-rolled client; call oss-telemetry v0.2.3 (vendored, Yaegi-safe) directly from New(), once per process via sync.Once.

version: add version.go + workflow-prepare.sh so the release semver is stamped into source at build time (the value cannot be resolved at runtime under Yaegi). dev/source builds keep the 0.0.0-dev sentinel and emit no telemetry.
2026-05-30 13:22:03 +01:00
paiking1andGitHub cf6ed1da55 feat: feat: add extraAuthParams (extra authorization request parameters) (#139)
Adds optional extraAuthParams map[string]string config.

Extra params are appended to the authorization request but can never
override plugin-managed params (client_id, state, nonce, etc.).
2026-05-27 21:41:09 +01:00
lukaszraczylo f821b8829b fix: remove write-lock convoy in getLocal + fix mutateState CAS bug
UniversalCache.getLocal(): when a cached token expires, the RLock fast
path (line 385-398) previously fell through to c.mu.Lock() (write lock).
Under Yaegi, the write-lock holder takes 10-100ms for LRU manipulation,
and Go's RWMutex writer-priority blocks ALL new RLock callers. A single
expired-token event turned every concurrent request from read-parallel
into write-serialized — the convoy that produced the 737-goroutine
pileup at 0x400275a608 (pprof captured at /tmp/traefik-spike-1779663149).

Fix: return (nil, false) immediately on expiry for Token/JWK/Session
cache types. The periodic cleanup goroutine handles eviction. Write lock
is never taken on the read path for these cache types.

refreshAttemptTracker.mutateState(): the CAS loop used
t.state.CompareAndSwap(t.state.Load(), next) — a second Load that can
see a different value from a concurrent writer, silently overwriting
their update. Fixed to CompareAndSwap(cur, next) using the snapshot we
computed the mutation from.
2026-05-25 00:06:47 +01:00
lukaszraczylo 5f9c574f95 refactor: delete dead non-RS validators; tests use RS variants
After v1.0.20 the non-RS validation chain had no production callers —
middleware.ServeHTTP dispatched exclusively through isUserAuthenticatedRS.
The orphaned functions stayed reachable only from a handful of test
files and risked silent logic drift against their RS counterparts.

Deleted from production code (~440 LOC):
  - auth_flow.go:        isUserAuthenticated
  - token_manager.go:    validateAzureTokens
  - token_manager.go:    validateGoogleTokens
  - token_manager.go:    validateStandardTokens
  - token_manager.go:    validateTokenExpiry
  - removed now-unused encoding/base64 and encoding/json imports
    from token_manager.go (only the deleted validateStandardTokens
    needed them; the RS variant in token_validation_rs.go keeps its
    own imports).

Added (3 LOC):
  - token_validation_rs.go: validateGoogleTokensRS (trivial delegator,
    parity with the deleted non-RS variant so isUserAuthenticatedRS
    can dispatch cleanly).

Tests ported (10 call sites across 3 files):
  - audience_test.go:                ts.tOidc.validateStandardTokens
  - azure_oidc_test.go:              tOidc.validateAzureTokens,
                                     ts.tOidc.validateGoogleTokens,
                                     ts.tOidc.validateAzureTokens,
                                     ts.tOidc.isUserAuthenticated
  - issue134_followup_graph_test.go: oidc.validateAzureTokens (4x)

Each ported site now constructs a *requestState from its existing
*SessionData via (&requestState{}).captureSession(session) and calls
the *RS variant. Same data, different read source.

Net diff: -440 LOC production, ~+25 LOC tests, +3 LOC stub.
Production now has a single source of truth for token validation;
no parallel implementations to keep in sync.

All tests pass with -race; golangci-lint clean.
2026-05-23 13:04:26 +01:00
lukaszraczylo 7c6f09fb20 feat(middleware): RS-aware token validators (kill ~21 RLocks/request)
Adds token_validation_rs.go with requestState-aware variants of the
token validation path:

  isUserAuthenticatedRS(rs) -> dispatches by provider
    validateStandardTokensRS(rs) -> standard path (eliminates 17 RLocks)
    validateAzureTokensRS(rs)    -> Azure path     (eliminates 10 RLocks)
    validateGoogleTokensRS(rs)   -> delegates to standard
  validateTokenExpiryRS(rs, tok) -> shared expiry check (eliminates 4 RLocks)

middleware.ServeHTTP now calls isUserAuthenticatedRS(rs) on the hot
path. The pre-v1.0.20 non-RS variants are kept untouched for tests
and any future caller that doesn't have a captured snapshot.

Why
---
The standard validation path read SessionData via session.GetX() 17
times, with GetRefreshToken alone called 11 times (every "return
'needs refresh'" branch re-reads it). Each call acquires
sd.sessionMutex.RLock(). Under Yaegi each RLock costs ~1-5ms of
interpreter dispatch. The captured snapshot already lives on rs, so
the RS variants substitute direct struct field reads.

Per-request cost on the hot authenticated path
----------------------------------------------
  ServeHTTP enters:
    + 1 RLock to populate rs (was 0)
  Validation path:
    Standard: was 17 RLocks, now 0
    Azure:    was 10 RLocks, now 0
  processAuthorizedRequestRS:
    was 4-6 GetX calls, now 0 (already in v1.0.19)

Net: ~22-27 fewer Yaegi-dispatched RLock acquisitions per authenticated
request on the hot path.

Caveats
-------
* Refresh / expired / callback paths still use the non-RS validators
  because they can mutate session state between validation and use.
* The RS variants are by-design line-for-line equivalents of the
  originals. If logic in the originals changes, the RS variants need
  matching updates. This is acceptable for now; a future refactor
  could collapse them once the non-RS callers are gone.

All tests pass with -race; golangci-lint clean.
2026-05-23 12:38:42 +01:00
lukaszraczylo 68e1c4319c feat(middleware): per-request context object (requestState)
Adds requeststate.go and threads a *requestState through the
ServeHTTP -> processAuthorizedRequestRS -> forwardAuthorized path.
rs is allocated once at the top of ServeHTTP, populates SessionData
field snapshots under a SINGLE sd.sessionMutex.RLock, and caches the
MetadataSnapshot. Downstream handlers read the cached fields instead
of calling session.GetX() / t.metadataSnap() repeatedly.

Why
---
Under Yaegi each method dispatch (including RWMutex.RLock) costs
~1-5ms of interpreter overhead. SessionData getters each take an
RLock on sd.sessionMutex; the previous hot path called 5-7 of them
per request (GetAuthenticated, GetAccessToken, GetIDToken,
GetRefreshToken, GetUserIdentifier, plus the same set again inside
processAuthorizedRequest). With one batched RLock + cached fields,
that drops to a single RLock for the whole handler chain.

This is scoped — not a wholesale architectural refactor:

* requestState is per-request (alloc at ServeHTTP entry, dropped on
  return). It is NOT a shared cache and never escapes the request.
* The original processAuthorizedRequest is kept unchanged for any
  callers we don't migrate this round (bearer path, callback
  handlers, expired-token handlers). New code path is the RS-aware
  processAuthorizedRequestRS, which middleware.ServeHTTP now uses for
  the happy authenticated-and-not-needing-refresh case.
* Cross-request caches (tokenCache, JWKCache, sessionEntries,
  sessionInvalidationCache) are unchanged. rs is additive, not a
  replacement.

What this does NOT change
-------------------------
* The refresh path still calls session.GetX() in middleware.go
  (handleExpiredToken, refreshToken, defaultInitiateAuthentication)
  because those flows can mutate session state and a stale rs would
  be wrong.
* validateStandardTokens still has its own session.GetX() calls.
  Deep plumbing into the token-verification path is a follow-up.
* No semantic changes to authentication, refresh, or session
  lifecycle — only the read path is optimised.

All tests pass with -race; golangci-lint clean.
2026-05-23 12:22:51 +01:00
lukaszraczylo 17e3f8ef62 fix: snapshot patterns for refresh-tracker and metadata URLs
Two related lock-free snapshot refactors addressing the remaining
post-v1.0.16 code-review findings.

1. refreshAttemptTracker: per-field atomic.Load/Store -> atomic.Value
   snapshot of *attemptState (refresh_coordinator.go).

   Previously each tracker held five independently-atomic fields. The
   cooldown-exit reset wrote cooldownEndNano = 0 first, then separately
   stored attempts = 1 and windowStartNano = now. A concurrent
   isInCooldown call could observe cooldownEndNano = 0 (reset just
   completed) with attempts still at MaxRefreshAttempts, immediately
   triggering a fresh cooldown — a benign double-trigger race that
   nonetheless meant the state machine had observable intermediate
   states.

   New design: state is a *attemptState (immutable) published via
   atomic.Value. All transitions (record/success/failure/window-reset/
   cooldown-enter/cooldown-exit) go through mutateState, which runs a
   CAS loop: load current snapshot -> construct fresh snapshot ->
   CompareAndSwap. Either the entire new state publishes or none of
   it does — no intermediate visibility, no cross-field race.

   Under Yaegi this collapses 3-5 per-field atomic dispatches into one
   atomic.Value.Load on the read path. Write paths pay an extra
   allocation for the new snapshot but avoid the cross-field hazard.

2. MetadataSnapshot: hot-path readers use atomic.Value instead of
   metadataMu.RLock (middleware.go, types.go, main.go, utilities.go).

   middleware.ServeHTTP previously took metadataMu.RLock on every
   non-bypass request to read the single field issuerURL. Under Yaegi
   each RLock acquisition costs 1-5ms of interpreter dispatch.
   updateMetadataEndpoints now also publishes an immutable
   *MetadataSnapshot via atomic.Value; the hot-path reader loads it
   in one op via t.metadataSnap(). Falls back to the legacy
   metadataMu.RLock pattern when the snapshot is unpublished (some
   test setups initialize the struct fields directly without going
   through updateMetadataEndpoints).

   Less-frequent callers (helpers, logout, token_introspection) still
   take metadataMu.RLock and are unchanged. The snapshot strictly
   subsets the metadataMu-protected fields, so those readers see
   identical data.

Note on atomic.Pointer[T]: this would have been the cleaner type but
yaegi v0.16.1's stdlib (used by traefik:v3.7.1) exposes only the
legacy unsafe.Pointer-based atomic primitives — no generic Pointer[T].
atomic.Value provides the same semantics via interface{} + type assert.

All tests pass with -race; golangci-lint clean.
2026-05-23 11:31:51 +01:00
lukaszraczylo 827926bc3a fix(refresh-coordinator): trim per-request mutex/map ops
Three related changes addressing post-v1.0.15 code-review findings and
the user's observation that we have been "throwing maps around" — under
Yaegi every sync.Map / atomic / mutex dispatch costs ~1-5ms of
interpreter overhead, so the number of dispatches per request matters
as much as whether they are lock-free.

1. Remove cleanupTimers map + cleanupTimerMu sync.Mutex.

   scheduleDelayedCleanup previously tracked every pending timer in a
   map guarded by a mutex so a duplicate scheduling could cancel the
   prior timer. That "shouldn't happen" path was the only consumer of
   the map, but the mutex fired on every successful refresh
   completion — another per-request Yaegi-dispatched lock.
   performCleanup is already idempotent (LoadAndDelete on the sync.Map),
   so a duplicate firing is at worst a no-op second call. Dropped the
   map entirely; time.AfterFunc callback now calls performCleanup
   directly.

   Net: -1 sync.Mutex, -1 map field, -2 Lock/Unlock pairs per refresh
   completion. Shutdown simplified — no need to enumerate-and-stop
   timers since the callbacks no longer need teardown.

2. Reorder applyLeaderGates: cooldown check BEFORE recordRefreshAttempt.

   Previously incremented the attempt counter and then checked cooldown.
   Under burst load (many concurrent leaders with different token hashes
   but the same session) every goroutine could increment past
   MaxRefreshAttempts before any one of them observed the threshold,
   so the gate fired too late — same thundering-herd shape that drove
   v1.0.14 into the ground. Reordering makes the gate authoritative:
   only attempts that pass the gate are recorded.

   Semantic change: with MaxRefreshAttempts=N, exactly N attempts now
   run to completion before the (N+1)th is denied. Previously the Nth
   was denied as it tried to record (off-by-one stricter). Test
   assertion updated to N (was N-1).

3. Fix getOrCreateOperation MaxConcurrentRefreshes overshoot.

   The previous CAS-loop allowed a transient overshoot of up to N-1
   leaders when several goroutines all observed `current < max` in the
   same scheduling slice before any one of them succeeded their CAS —
   visible to readers as currentInFlightRefreshes > MaxConcurrentRefreshes
   for a brief window.

   Replaced with the ticket-and-return pattern: increment optimistically,
   decrement if we overshot. Strictly bounded: only the goroutine that
   produces max+1 sees max+1 as committed; the rest decrement back
   immediately. No CAS retry loop needed.

What was NOT done in this commit, and why:

* metadataMu.RLock cached via atomic snapshot — code-reviewer flagged
  this at severity 7 (3 RLocks per request: middleware.go:213,
  token_manager.go:349, token_manager.go:408). The clean fix is an
  atomic.Pointer[*MetadataSnapshot], but generic atomic.Pointer[T] is
  NOT exposed by yaegi v0.16.1's stdlib (only legacy unsafe.Pointer
  primitives). atomic.Value would work but requires a snapshot-struct
  refactor across ~15 call sites (helpers/logout/token_introspection/
  token_manager/main/middleware). Deferred to a focused future PR.

* isInCooldown multi-field reset race — the cooldown-reset CAS wins
  on cooldownEndNano, then separately stores attempts/consecutiveFailures/
  windowStartNano. A concurrent isInCooldown can briefly see the
  pre-reset attempts value and trigger a fresh cooldown. Semantic glitch
  (double-cooldown), not a correctness disaster. Fix is a single atomic
  pointer swap of an immutable snapshot — same atomic.Pointer constraint
  as above. Deferred.

All tests pass with -race; golangci-lint clean.
2026-05-23 11:23:16 +01:00
lukaszraczylo abbfdb02a7 fix(jwk): replace JWKCache.mutex with singleflight pattern
JWKCache.GetJWKS previously held a sync.RWMutex.Lock() across the entire
HTTP round-trip to the IdP's JWKS endpoint (jwk.go:93). On a cold cache
(cold pod, JWK rotation, transient network blip) every concurrent
request piled up on this single global write-lock. Under Yaegi each
Lock() acquisition costs 10-50ms of interpreter dispatch — same
architectural shape as the bugs v1.0.14 and v1.0.15 already fixed,
just one that hadn't surfaced as the dominant bottleneck yet.

Code-review post-spike #2 flagged this at confidence 9/10 as the next
likely death-spiral on pod cold-start.

Change replaces the lock with a sync.Map-based singleflight: the first
caller for a given JWKS URL performs the fetch; concurrent callers
attach to the same *jwksFetch and wait on its done channel for the
result. Cold-cache cost is now O(1) HTTP fetch regardless of how many
goroutines are waiting, and no Yaegi-dispatched lock is held during the
fetch itself.

Correctness:
- LoadOrStore winner does the work; losers wait on a done channel.
- Done channel close is in a defer, so panics in fetchJWKS still
  unblock waiters.
- Map entry is removed in the same defer, so a fresh failed fetch can
  be retried by the next request without waiting for any stale entry.
- ctx.Done() unblocks waiters independently of the leader's progress.
- Re-checks the cache after winning LoadOrStore, since another fetch
  may have populated the cache between the initial miss and the win.

Cleanup: also removes a stray yaegi-extract output file
(github_com-lukaszraczylo-traefikoidc.go) that was accidentally
committed during local yaegi compatibility testing.

All tests pass with -race; golangci-lint clean.
2026-05-23 11:05:24 +01:00
lukaszraczylo 72e2b682bb fix: eliminate per-request global mutexes in Yaegi hot paths
The v1.0.14 fix replaced one contended sync.RWMutex (RefreshCoordinator.
refreshMutex) with sync.Map. Production showed the same death-spiral
signature recurring ~2 hours later — same shape, different mutex:
65 goroutines stuck on a sync.(*RWMutex).Lock at one address, pod
pinned at 1000m CPU, identical Yaegi runCfg/reflect.Value.Call stack
pattern. The mutex was RefreshCoordinator.attemptsMutex.

Generalising: under Yaegi (interpreted Go for traefik plugins), any
per-request global mutex acquisition is a latent serialization point.
reflect.Value.Call dispatch on a held lock turns a microsecond
critical section into a multi-millisecond one, and on a GOMAXPROCS=1
pod the queue is unbounded.

This commit removes every per-request global mutex on the hot path:

1. RefreshCoordinator.attemptsMutex (sync.RWMutex)
   sessionRefreshAttempts: map -> sync.Map.
   refreshAttemptTracker: all fields atomic (int32, int64 UnixNano,
   cooldownEndNano == 0 as the not-in-cooldown sentinel, replacing
   the inCooldown bool).
   isInCooldown / recordRefreshAttempt / recordRefreshSuccess /
   recordRefreshFailure all become lock-free. Cooldown entry uses
   CompareAndSwapInt64 so only one goroutine logs the transition.

2. RefreshCircuitBreaker.mutex (sync.RWMutex)
   lastFailureTime / lastSuccessTime -> atomic.Int64 UnixNano.
   state and failures already atomic.
   AllowRequest / RecordSuccess / RecordFailure now pure atomic ops.

3. TraefikOidc.firstRequestMutex (sync.Mutex)
   firstRequestReceived bool -> firstRequestStarted int32.
   metadataRefreshStarted bool -> metadataRefreshStartedAtomic int32.
   ServeHTTP bootstrap path uses CompareAndSwapInt32 — fires once,
   zero steady-state cost. Previously the mutex was acquired on
   every non-health request forever.

4. TraefikOidc.metadataRetryMutex (sync.Mutex)
   lastMetadataRetryTime time.Time -> lastMetadataRetryNano int64.
   The 30-second retry throttle is now a CAS on lastMetadataRetryNano.

cleanupStaleEntries iterates via sync.Map.Range; eviction is a
CompareAndDelete by pointer identity so a tracker freshly re-used by
a concurrent caller is not lost.

Empirical evidence (3 specialist-agent analysis of the v1.0.14 spike,
profiles in /tmp/traefik-spike-1779511683/):
  * mutex profile: 97% delay in sync.(*Mutex).Unlock via
    HTTPHandlerSwitcher -> accesslog -> metrics -> backoff.RetryNotify
  * 65 stuck goroutines at one RWMutex address (0x40022eb648),
    identical Yaegi CFG pointer, all on rc.attemptsMutex via
    recordRefreshAttempt + isInCooldown
  * traffic driver: long-lived in-cluster Go-http-client doing
    ~5.4 req/s POST embeddings via OIDC cookie session → same
    sessionID → contention all funnels to one tracker entry

Yaegi support for sync/atomic confirmed at
github.com/traefik/[email protected].1/stdlib/go1_22_sync_atomic.go:
AddInt32/Int64, LoadInt32/Int64, StoreInt32/Int64,
CompareAndSwapInt32/Int64 all exposed via reflect.ValueOf. Yaegi
dispatches each call through reflect.Value.Call to the COMPILED
atomic.* function, which executes a single hardware CAS/LOCK-XADD
instruction. Each atomic op still pays Yaegi dispatch cost but
cannot block — no queueing, no death spiral.

Trade-off acknowledged: v1.0.15 issues ~6-8 atomic/sync.Map ops per
leader-path request vs the 4 mutex ops of v1.0.14. Under low
contention this is a modest CPU bump. Under high contention it's
an unbounded → bounded transformation. Net win.

All tests pass with -race; golangci-lint clean.
2026-05-23 10:47:21 +01:00
lukaszraczylo ae4ccaa89d fix(refresh-coordinator): replace global RWMutex with sync.Map
Under Yaegi, the RefreshCoordinator.refreshMutex was held for tens of
milliseconds per request because every operation inside the critical
section (map access, isInCooldown, recordRefreshAttempt,
isUnderMemoryPressure, atomic ops, struct allocation) is dispatched
through reflect.Value.Call with full arg boxing/unboxing.

Concurrent refreshes on the same coordinator serialized into a queue
that grew without bound. Live capture in production (3 Grafana
dashboards left open) showed:
  * 63 goroutines stuck on rc.refreshMutex.Lock() for 1-11 minutes
  * pod pinned at 1000m CPU (GOMAXPROCS=1)
  * 5.15M allocs/sec, 0.45 RPS effective throughput
  * yaegi.call.func9 accounting for 92.66% of cumulative allocs
  * mutex profile dominated by sync.(*Mutex).Unlock via the request chain

Change inFlightRefreshes from map[string]*refreshOperation+RWMutex to
sync.Map and rewrite getOrCreateOperation to:
  1. Speculatively allocate the candidate operation.
  2. Atomically LoadOrStore by tokenHash. Joiners take the existing
     operation; leader takes the new one. No global lock acquired.
  3. Leader runs rate-limit / cooldown / memory-pressure gates AFTER
     the atomic store. Joiners share the leader's outcome via op.done.
  4. Reserve the concurrent-refresh slot via CompareAndSwap so the
     count cannot overshoot in absence of the old serializing lock.
  5. On any gate failure the leader calls failCandidate, which deletes
     the entry from sync.Map, records the error on op.result and closes
     op.done so any joiner that snuck in returns the same error.

performCleanup becomes a single sync.Map.LoadAndDelete, eliminating
the lock entirely on the cleanup path.

Net effect: critical section is no longer Yaegi-interpreted; it
collapses to atomic instructions on a sharded sync.Map. Refresh
contention disappears even under Yaegi.

All tests pass with -race; golangci-lint clean.
2026-05-23 02:34:49 +01:00
lukaszraczylo 984fd1c08f docs: add Telemetry section linking to oss-telemetry opt-out docs
Discloses the single anonymous adoption ping sent on first plugin
instantiation. Points users to the upstream README section for the
disclosure pattern and to the local telemetry.go for the inline
implementation.
2026-05-21 04:07:19 +01:00
lukaszraczylo 99bdd23986 feat: anonymous usage telemetry via inline oss-telemetry
Adds a yaegi-safe inline telemetry helper that fires a single
fire-and-forget ping at plugin load. Helps track adoption and version
spread. No persistent identifiers are collected.

Implementation notes:
- inline (no external dep) so Traefik plugin loader does not need to
  resolve a new vendored module
- stdlib-only, no generics, no range-over-int — verified to load under
  yaegi 0.16.x (full plugin import + CreateConfig/New symbol lookup OK)
- avoids `switch{case A,B,C:}` blocks where some yaegi releases
  mis-evaluate comma-separated case lists
- sync.Once guards against amplified pings on Traefik dynamic config
  reloads (which re-instantiate the middleware)

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  TRAEFIKOIDC_DISABLE_TELEMETRY=1
2026-05-21 03:20:36 +01:00
lukaszraczyloandGitHub a548665edb feat: opt-in M2M bearer-token authentication (supersedes #93) (#140)
* docs: bearer-token auth design spec

* docs: harden bearer-auth spec with security review findings

* feat(bearer): opt-in M2M bearer-token authentication

Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine
clients. Replaces and supersedes the broken approach in PR #93
(synthetic-session that omitted user_identifier and skipped ID-token
rejection / replay-protection-semantics / kid-pinning / etc.).

Design

  Two auth entrypoints feed one shared post-auth pipeline:

    cookie path  ─┐
                  ├── forwardAuthorized(rw, req, *principal)
    bearer path  ─┘    (roles/groups, header injection, security
                        headers, cookie strip, forward)

  buildPrincipalFromSession and buildPrincipalFromBearerToken produce
  the same `principal` value type. forwardAuthorized is session-agnostic
  and runs the existing post-auth work; processAuthorizedRequest now
  wraps it with the session-specific concerns (backchannel-logout,
  dirty/Save). The cookie path's behaviour is byte-identical to before
  this PR; the existing test suite passes unmodified.

Security hardening baked into the bearer path

  - Audience MANDATORY. Startup fails when EnableBearerAuth=true and
    Audience is empty.
  - BearerIdentifierClaim defaults to "sub"; "email" is rejected at
    startup to avoid the unverified-email spoofing footgun. Cookie
    path's UserIdentifierClaim is unaffected and still defaults to
    "email".
  - ID tokens explicitly rejected via the existing detectTokenType
    helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID
    heuristics); belt-and-braces nonce/token_use=id rejection on top.
  - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE
    JWKS fetch, blocking alg=none and alg=HS* probes from amplifying
    into upstream calls.
  - kid length capped at 256 bytes and charset-restricted before JWKS
    fetch, blocking pathological-kid JWKS amplification.
  - Multi-audience tokens require azp == clientID.
  - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock-
    manipulation and forever-token abuse.
  - Identifier sanitization: length cap, control-char + bidi-override
    + delimiter (, ; =) rejection.
  - Per-IP failure throttle: configurable threshold/window/penalty;
    returns 429 + Retry-After. Limits offline-guessing-style attacks
    and protects the shared rate-limiter / JWKS endpoint.
  - JTI replay marking suppressed via new internal verifyOpts
    {skipReplayMarking} so the same bearer can be reused until exp;
    the blacklist Get stays active so RevokeToken still terminates a
    bearer token immediately. The existing exported VerifyToken
    interface is unchanged so all mocks continue to work.
  - Cookie wins by default when both bearer and cookie are present
    (safer against browser/extension/proxy bearer injection).
    Operator can flip via BearerOverridesCookie.
  - Authorization header stripped on forward by default; also stripped
    on excluded URLs so the token can't leak into health/metrics
    downstream logs.
  - Optional RFC 7662 introspection via existing
    requireTokenIntrospection. Introspection-endpoint failure returns
    503 (distinguishes infra from token rejection).
  - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure
    reason is logged at debug; raw tokens are never logged.

Implementation

  - principal.go: pure-data principal type and buildPrincipalFromSession.
  - bearer_auth.go: alg/kid pin, classifier, identifier sanitization,
    multi-aud azp gate, iat age check, per-IP failure tracker,
    handleBearerRequest, buildPrincipalFromBearerToken.
  - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts
    that accepts internal-only verifyOpts. Existing callers, the
    TokenVerifier interface, and all mocks unchanged.
  - middleware.go: extracted forwardAuthorized from
    processAuthorizedRequest; wired bearer detection after init wait
    + after bypass; excluded-URL Authorization strip when bearer
    enabled.
  - settings.go: ten new config fields with defaults applied in
    CreateConfig.
  - main.go: startup validation for audience + identifier-claim
    guard; bearer failure tracker init.

Tests

  - bearer_auth_test.go: table-driven helper tests for every new
    component (parseBearerJOSEHeader, sanitizeBearerIdentifier,
    resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge,
    bearerFailureTracker, detectBearerToken). Integration tests
    through ServeHTTP covering happy path, ID-token rejection,
    alg=none rejection, oversized kid, multi-aud with/without azp,
    iat-too-old, bidi identifier, replay (100x reuse), 429 throttle
    trip, excluded-URL strip, roles gate, cookie-wins precedence,
    BearerOverridesCookie, oversized token, malformed JWT,
    feature-off pass-through. Startup validation for audience-
    required and email-identifier-rejected.
  - All existing tests pass unmodified (cookie-path regression).
  - go vet clean. golangci-lint clean (0 issues). Race detector
    clean on bearer tests.

Documentation

  - README.md: bearer auth section with security highlights and
    config snippet; doc link in the index.
  - .traefik.yml: commented config block exposing every bearer knob.
  - docs/CONFIGURATION.md: new subsection with full parameter table.
  - docs/BEARER_AUTH.md: threat model, hardening matrix, failure
    response table, operational guidance, known follow-ups.
  - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md:
    design spec + security-review hardening history.

* fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging)

CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the
in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug.
Cache callers (token cache, blacklist, introspection cache) pass raw
access / refresh / id tokens as cache keys, so any debug-enabled
deployment would write them to log streams.

Pre-existing issue. CodeQL started flagging it on this PR because the
new bearer-auth path adds a data-flow source (req.Header.Get("Authorization"))
that reaches the existing logging sinks via the same cache. The cookie
path had the same risk but wasn't tracked as taint by CodeQL.

Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the
bearer-auth logger uses for principal identifiers (spec §13). Doesn't
change cache semantics — same key still produces the same hash, so
debug correlation across log lines is preserved without exposing the
raw value.

Touches both affected packages:
  - internal/cache/cache.go (2 sites: Set + LRU eviction)
  - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback)

New helper `redactKey` colocated with each package (unexported,
package-local) keeps the change blast radius narrow. Tests green; lint
clean.

* docs(bearer): how to obtain bearer tokens from the OIDC provider

Adds a section walking operators through the OAuth 2.0 client_credentials
flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523),
with a worked Auth0-shape curl example, a per-provider quick reference
(Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational
notes (token TTL, caching, JWKS rotation, revocation, scope vs audience,
secret hygiene), and a three-line validation loop.

Most common operator confusion: "I enabled the feature but tokens get
401'd" — almost always missing or wrong audience. The new section makes
the audience-matching requirement loud, with per-provider parameter
names so people don't have to dig through IdP docs.

Locations:
  - docs/BEARER_AUTH.md  — full section under "Quick start"
  - README.md            — short snippet + deep link
2026-05-18 17:35:37 +01:00
lukaszraczyloandGitHub 8c5df82dcf fix(azure): treat Microsoft proprietary access tokens as opaque (#134) (#138)
Followup to issue #134 — two reporters returned saying that even with the
JWKS caching fix in v1.0.7/v1.0.8, every request emitted:

  ERROR: TraefikOidcPlugin: UNKNOWN token verification failed:
    signature verification failed: crypto/rsa: verification error
  ERROR: TraefikOidcPlugin: DIAGNOSTIC: Signature verification failed for
    kid=<kid>, alg=RS256: crypto/rsa: verification error

Root cause: when an Azure tenant is configured without a custom API
resource, Microsoft issues access tokens for Microsoft Graph (or Azure
Mgmt). These tokens carry a `nonce` value in the JWT *header*; the bytes
that get signed contain SHA256(nonce), while the wire token ships the
original nonce. Any standard JWS verifier rejects the signature, which is
exactly Microsoft's intent — they document the format as proprietary and
tell client apps not to validate it
(https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
"you can't validate tokens for Microsoft Graph according to these rules
due to their proprietary format").

validateAzureTokens was nonetheless attempting JWT verification on every
JWT-shaped access token, then silently falling back to the ID token when
verification failed. Auth still worked end-to-end, but every request
spammed two error log lines.

Two-layer defense:

* validateAzureTokens now detects the proprietary-nonce header before
  calling verifyToken on the access token. When detected, the token is
  treated as opaque (matching the existing branch for non-JWT tokens) and
  validation proceeds via the ID token, exactly as Microsoft prescribes.

* VerifyJWTSignatureAndClaims downgrades the DIAGNOSTIC error log to
  debug for tokens carrying the same proprietary marker, in case any
  path outside validateAzureTokens reaches it.

Authorization still hinges on a separately-verifiable ID token — the
confused-deputy guard from CWE-441 is preserved (and explicitly tested).
2026-05-11 17:31:37 +01:00
lukaszraczyloandGitHub aa96e9dbee Add sponsorship
Just in case you appreciate this project, feel generous and want to sponsor my caffeine addiction.
2026-05-10 21:25:26 +01:00
lukaszraczyloandGitHub 1e33bb0a4d feat(auth): support private_key_jwt and client_secret_basic (#137)
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.
2026-05-09 18:02:41 +01:00
lukaszraczyloandGitHub bfd702a447 fix(jwk): keep parsed JWKS in local cache only (#134) (#136)
Under yaegi (Traefik's plugin runtime) json.Marshal exposes unexported
struct fields with an X-prefixed name. parsedJWKS{ keys map[string]
crypto.PublicKey } therefore round-tripped through Redis as
{"Xkeys":{"<kid>":{"N":<huge>,"E":65537}}} — *rsa.PublicKey.N is a
*big.Int that marshals to a JSON number hundreds of digits long. On
read, json.Unmarshal into interface{} parses numbers as float64, which
cannot represent that range:

  Failed to deserialize value for key .../discovery/v2.0/keys:parsed:
  json: cannot unmarshal number 2251513...
    into Go value of type float64

Auth still worked (the JWKCache rebuilt the keys in memory on every
miss) but the error log spammed every request.

Two structural problems were behind it:

* parsedJWKS holds crypto.PublicKey interface values that aren't
  meaningfully JSON-serializable. Even on compiled Go (where the
  unexported field marshals to {}), the post-roundtrip type assertion
  v.(*parsedJWKS) silently failed and the cache was useless.
* The same pattern applied to *JWKSet — the struct shape survived JSON
  but the type assertion still failed, defeating the cache for every
  call that went through Redis.

Both keys now use the new UniversalCache.SetLocal/GetLocal pair, which
skips the configured distributed backend entirely. JWK rotation is rare
and a per-replica HTTP fetch on cold cache is cheap, so cross-replica
coherence buys nothing for these entries.

Stale Redis entries written by previous versions are simply ignored —
the new code never reads under those keys, and Redis TTL retires them.

Includes regression coverage for the Azure round-trip, the
poisoned-stale-data scenario, and the SetLocal/GetLocal isolation
contract.

patch-release
2026-05-08 13:35:23 +01:00
lukaszraczylo 68c150eba4 fix(cache/redis): honor enableTLS for Redis backend (#133)
The redis.enableTLS / redis.tlsSkipVerify settings were accepted by the
config layer but silently dropped before reaching the connection pool, so
the plugin always dialed Redis in plaintext. This blocked TLS-only Redis
deployments such as AWS ElastiCache with in-transit encryption.

- Add EnableTLS, TLSSkipVerify, TLSServerName to backends.Config and
  PoolConfig and forward them through universal_cache_singleton ->
  backends.Config -> PoolConfig.
- In the connection pool, dial via tls.Dialer.DialContext (TLS 1.2
  minimum) with SNI defaulting to the host part of the configured
  Address when TLSServerName is empty, so ElastiCache cluster endpoints
  validate out of the box. Plain dial path now also propagates ctx.
- Add regression tests covering successful TLS negotiation with skip-
  verify, rejection of self-signed certs without skip-verify, rejection
  of plain TCP servers when EnableTLS=true, and unaffected plaintext
  behavior.
- Document maxRefreshTokenAgeSeconds (added in 1b6c861) and the implicit
  SSE / WebSocket auth bypass (added in 684a990) in README.md,
  docs/CONFIGURATION.md and docs/index.html.
- Add the missing redis.tlsSkipVerify row to docs/index.html and clarify
  the redis.enableTLS description.

patch-release
2026-05-07 12:24:13 +01:00
lukaszraczylo 9cbca4c4fb fix(refresh): honor userIdentifierClaim in token refresh path (#132)
patch-release

The refresh path in token_manager.go hardcoded the "email" claim when
extracting the user identifier from a refreshed ID token, ignoring the
configured userIdentifierClaim. Keycloak users without an email claim
(using sub or another identifier) were kicked out on refresh even
though their initial login worked.

The callback path (auth_flow.go:226-239) already honored
userIdentifierClaim with "sub" fallback; PR #100 (commit a316a98)
added that support but missed the refresh path.

Mirror the callback logic in refreshToken so both paths behave the same.

Cleanup: rename Get/SetEmail to Get/SetUserIdentifier on SessionData
to match the actual semantics. The slot already stored the configured
identifier (email, sub, oid, upn, preferred_username), only the API
name was misleading. Storage key "email" → "user_identifier" and
combinedSessionPayload field E (json:"e") → Ui (json:"ui").

Compat note: existing user sessions invalidate on upgrade — every active
user re-authenticates once after deploying this change.
2026-05-07 09:21:41 +01:00
lukaszraczylo 684a990f59 fix: reduce yaegi CPU footprint + require auth on SSE/WebSocket bypass
minor-release

Behaviour changes (potentially breaking for operators relying on the prior
unauthenticated SSE bypass):

* SSE (`Accept: text/event-stream`) and WebSocket upgrade requests now
  return 401 when no authenticated session is present. Previously the
  bypass forwarded unconditionally, which let any caller reach the
  backend by setting the right header. Excluded URLs are unchanged.
  Operators relying on unauthenticated SSE/WS access must move the path
  into ExcludedURLs.

Performance fixes (target: long-running dashboards like Grafana / ArgoCD
where many panels poll concurrently while the page stays open):

* Stop honouring isTestMode() for the singleton-token-cleanup interval
  under yaegi (the Traefik plugin runtime). In production the plugin was
  running a 20 Hz no-op cleanup ticker because runtime.Compiler ==
  "yaegi" tripped the test-mode branch.
* processAuthorizedRequest now resolves ID-token claims at most once per
  request via SessionData.GetIDTokenClaims (already cached on the
  session) and reuses them for both groups/roles extraction and
  header-template rendering. Previously every authenticated request
  parsed the JWT twice.
* Added extractGroupsAndRolesFromClaims to drive groups/roles off
  pre-parsed claims; extractGroupsAndRoles still works for tests.
* Removed the unconditional session.MarkDirty() in the header-templates
  branch. Templates only mutate request headers, not session state, so
  the prior MarkDirty was re-encrypting and rewriting all session
  cookies on every authenticated request that used header templates.

Other:

* Added isWebSocketUpgrade (RFC 6455 handshake detection — Connection:
  Upgrade + Upgrade: websocket, tolerant of multi-token Connection
  headers and case).
* Renamed applySSEUserHeaders -> applyBypassUserHeaders; it now returns
  bool so the dispatcher can reject unauthenticated SSE/WS with 401.
* Added tests for SSE and WS bypass covering both the auth-rejection
  path and the authenticated forward path.
2026-05-02 03:12:20 +01:00
lukaszraczyloandGitHub 1b6c8616fd fix(refresh): coalesce refresh-token grants + bound goroutines + cache hot path (target v0.8.27) (#131)
* fix(refresh): wire RefreshCoordinator into the live refresh path

The RefreshCoordinator existed but was never instantiated. The actual
refresh path used only session.refreshMutex, which is per-SessionData
instance - and SessionData is pulled from a sync.Pool per request -
so concurrent requests sharing a refresh token had ZERO coordination.

Symptom: when access_token expired (e.g. 5min Zitadel default), every
in-flight request from a polling client (Grafana panels) entered the
refresh path simultaneously and POSTed the same refresh_token to the
IdP. With refresh-token rotation enabled (Zitadel/Authentik default),
only one grant succeeded; the rest got invalid_grant and each cleared
the entire session. Subsequent requests then thrashed in re-auth loops.

This commit:
- adds refreshCoordinator field on TraefikOidc
- instantiates it in NewWithContext with DefaultRefreshCoordinatorConfig
- shuts it down in Close() under shutdownOnce
- routes refreshToken() through the coordinator via coordinatedTokenRefresh,
  which collapses concurrent grants to a single upstream call per
  refresh_token hash
- exports refreshCoordinatorSessionID for both internal hashing and the
  middleware-level wireup so dedup keys stay aligned

Behavioural notes:
- nil-coordinator fallback preserves existing tests that build TraefikOidc
  literals without going through the constructor
- followers receive the same TokenResponse/error as the leader, so no
  per-instance code paths change
- existing TestGetNewTokenWithRefreshToken_Concurrency still passes
  because it hits GetNewTokenWithRefreshToken directly, below the
  coordinator boundary

Tests:
- refresh_coordinator_wireup_test.go: 50 concurrent refreshes coalesce
  to <=2 upstream calls; distinct tokens still run in parallel; nil
  coordinator falls back cleanly

* perf(cache): bound L1 backfill goroutines in HybridBackend

Get() and GetMany() previously spawned a goroutine per L2 hit to write
the value through to L1. Under sustained polling traffic (e.g. a Grafana
dashboard refreshing every 30s with N panels) this minted thousands of
goroutines, each running in Yaegi - directly contributing to the
~1000% CPU spike that pairs with the refresh-token herd.

Replace the per-hit goroutines with a single l1BackfillWorker fed by
l1BackfillBuffer, mirroring the existing asyncWriteBuffer/asyncWriteWorker
pattern for L2 writes. Buffer overflow drops the backfill (counted via
l1BackfillDrops) - a dropped backfill just means the next L2 hit for
that key re-queues it, which is safe.

Tests:
- TestHybridBackend_L1BackfillBounded: 1000 distinct L2 hits keep
  goroutine count within +20 of baseline (pre-fix it grew by ~1000)
- TestHybridBackend_L1BackfillFullDrops: drops are accounted for when
  the buffer is saturated and the worker is stopped

* feat(refresh): implement isRefreshTokenExpired heuristic

Replace the placeholder `return false` with a real check based on the
issued_at timestamp that SetRefreshToken already stamps into the session.
Gated by a new MaxRefreshTokenAgeSeconds config field (default 21600 =
6h, matching the existing comment). 0 disables the check.

This wires the previously-dead refreshTokenExpired branch in middleware.go,
which short-circuits AJAX requests with a 401 instead of letting them
hammer the IdP for a refresh token that's almost certainly stale - the
classic Grafana-after-long-pause failure mode.

Behaviour:
- maxRefreshTokenAge=0 disables the check (preserves prior behaviour)
- legacy sessions without issued_at still attempt one refresh; the IdP
  remains the source of truth on first try
- nil-receiver and nil-session guards keep test code that builds
  TraefikOidc literals safe

Tests:
- TestIsRefreshTokenExpired_DisabledWhenAgeZero
- TestIsRefreshTokenExpired_LegacySessionWithoutTimestamp
- TestIsRefreshTokenExpired_WithinWindow
- TestIsRefreshTokenExpired_BeyondWindow
- TestIsRefreshTokenExpired_NilGuards

* perf(token): skip parseJWT on cache hit in VerifyToken

The token cache fast-return existed but ran AFTER parseJWT, so every
validation paid for base64 + JSON unmarshal even on a hit. Under bursty
traffic (e.g. 10+ concurrent panel requests on every Grafana dashboard
refresh, each calling validateStandardTokens which verifies BOTH the
access token and the ID token), this is two redundant parses per
request multiplied by the panel count.

Move the cache lookup ahead of parseJWT. On a hit the function returns
nil immediately. On a miss the original flow runs unchanged.

Also nil-guard t.tokenCache to keep partial-literal test instances safe
(matches the same pattern we already use for tokenBlacklist).

Tests:
- TestVerifyToken_CacheHitSkipsParse: cache pre-populated with claims
  for a token whose body would fail parseJWT - returns nil iff the
  fast-path bypasses the parse
- TestVerifyToken_CacheMissStillParses: a syntactically valid but
  unsigned token still errors past parseJWT on cache miss

* feat(refresh): cross-replica refresh-grant dedup via shared cache

The in-process RefreshCoordinator added in 9f96d8c already collapses
concurrent refresh-token grants on a single Traefik replica. With the
plugin's existing Redis (Dragonfly) cache infrastructure available, we
can extend that dedup across replicas: if pod A refreshes a token at
T+0 and pod B receives a request for the same session at T+1, pod B
should reuse pod A's result rather than POSTing the now-rotated refresh
token to the IdP.

Implementation:
- Add a refreshResultCache to UniversalCacheManager (memory-only when
  Redis is disabled, Redis-backed in production via the existing
  hybrid/Redis-only mode selection)
- Expose it through CacheManager.GetSharedRefreshResultCache and on the
  TraefikOidc struct as refreshResultCache (CacheInterface)
- Inside the closure passed to RefreshCoordinator.CoordinateRefresh,
  consult the cache first; on hit return immediately, on miss exchange
  with the IdP and populate the cache for peers
- 5s TTL: long enough for siblings to observe, short enough that a
  rotated refresh token cannot be re-supplied after the IdP has moved on
- Errors are intentionally NOT cached - peers must always be able to
  retry on their own

Pragmatic choice: optimistic cache rather than a hard distributed lock.
- A hard lock (SET NX + poll) doubles Redis RTT and risks dead-locks
  if a Traefik pod dies mid-grant.
- The user's BGP+Local externalTrafficPolicy already pins ingress for
  a session to one node in steady state, so cross-pod racing is rare.
- This optimistic path catches the rare failover case without adding
  failure modes.

Tests:
- TestCoordinatedTokenRefresh_CrossReplicaCacheHit: pre-populated cache
  short-circuits the upstream call entirely (0 IdP calls)
- TestCoordinatedTokenRefresh_PopulatesCrossReplicaCache: leader stores
  a successful result for peers to find
- TestCoordinatedTokenRefresh_ErrorIsNotCached: invalid_grant must not
  poison the dedup cache - peers must retry independently
2026-04-30 18:52:39 +01:00
lukaszraczylo 4d28fa01ab 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.
2026-04-30 10:14:10 +01:00
lukaszraczyloandGitHub 2d1b04c637 review fixes apr 2026 (#130)
* Multiple fixes

- refresh coordinator dedup + memory pressure wire
- middleware sse consolidation + timer leak + claim cache
- universal cache sync backfill + isDebug gate
- lazy background task race
- memory monitor stw cached + refresh() api

* fix(auth): suppress OIDC redirects on non-navigation requests

- [x] Add isNonNavigationRequest using Sec-Fetch-Mode and Accept headers
- [x] Add comprehensive TestIsNonNavigationRequest
- [x] Update ServeHTTP to 401 non-navigation and AJAX requests

Fixes #129

* feat(config): add custom CA and insecure skip verify for OIDC TLS

- [x] Add CACertPath, CACertPEM, InsecureSkipVerify to Config
- [x] Implement loadCACertPool for CA bundle loading
- [x] Update HTTPClientConfig with RootCAs and InsecureSkipVerify
- [x] Apply CA pool and skip verify to pooled HTTP clients
- [x] Enhance configKey to distinguish TLS configs
- [x] Add comprehensive ca_cert_test.go

Fixes #125

* feat(oidc): add custom CA certificate support for private OIDC providers

- [x] Add caCertPath, caCertPEM, insecureSkipVerify config options
- [x] Update traefik.yml with new OIDC client config fields
- [x] Add configuration schema descriptions for new options
- [x] Update README table and add Custom CA Certificates section

* Fix the documentation.

* test(redis): add oversized argument rejection test

- [x] Add TestRedisConn_RejectOversizedArgumentBytes
- [x] Import strings package

* Dependencies cleanup
2026-04-19 10:12:00 +01:00
lukaszraczyloandGitHub ccbb98b9dd fix-issue-122 (#128) 2026-03-04 00:23:30 +00:00
Serhii VasylievandGitHub 1362cc0dac Improve debug logging around callback URL matching (#126)
* Add debug logging around callback URL matching in ServeHTTP

The callback URL comparison at the core of OIDC flow had zero logging,
making it extremely difficult to diagnose redirect loop issues caused
by misconfigured callbackURL (e.g., full URL vs path-only).

Every other path comparison in ServeHTTP already logs debug info
(logout, backchannel, frontchannel, excluded URLs), but the callback
URL check was completely silent.

Added debug logs that show:
- The values being compared (request path vs configured callback)
- Whether the match succeeded or failed
- Configured redirURLPath during initialization

This would have immediately revealed the root cause of issue #1
where callbackURL was set as a full URL but compared against
req.URL.Path which only contains the path component.

Closes #3

* improve-callback-url-logging: Add init-time logging for callbackURL config
2026-02-23 10:36:37 +00:00
249dcad1b3 fix: prevent deadlock in SessionData.Clear method (#114)
Move mutex unlock before calling Save() to prevent potential deadlock
when Save() method needs to acquire the same mutex.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <[email protected]>
2026-02-16 15:02:33 +00:00
lukaszraczyloandGitHub de4b4d7258 fix(cache): remove sync.Pool for Yaegi compatibility (#121)
- [x] Remove sync.Pool implementation that causes reflection panics
- [x] Replace pool-based NewRESPWriter with direct instantiation
- [x] Replace pool-based NewRESPReader with direct instantiation
- [x] Convert Release() methods to no-ops for API compatibility
- [x] Add documentation explaining sync.Pool removal for Yaegi
- [x] Remove "sync" import

Resolves #120
2026-01-19 17:52:31 +00:00
lukaszraczyloandGitHub 9d52f1b018 feat(core): refactor linters config and improve code quality (#119)
- [x] Reorganize golangci-lint configuration with documented disable reasons
- [x] Simplify errcheck and revive linter rules with targeted exclusions
- [x] Pre-compile regex patterns in input_validation.go for performance
- [x] Fix type assertions in memory_shard.go and resp.go with safety checks
- [x] Replace string comparison with EqualFold for case-insensitive matching
- [x] Fix loop variable captures in jwk.go and logout.go
- [x] Change high goroutine log level from Info to Debug in autocleanup.go
- [x] Replace deprecated "cancelled" spelling with "canceled" throughout
- [x] Add nolint annotations for intentional unused parameters
- [x] Improve comment formatting for deprecated functions
- [x] Fix comment spelling: "marshalling" → "marshaling"
- [x] Refactor provider warnings formatting in internal/providers/warnings.go
- [x] Simplify metrics summary building in internal/recovery/metrics.go
- [x] Pre-allocate slice in error_recovery.go GetDegradedServices
- [x] Refactor context cancellation checks in redis.go
2026-01-15 10:40:49 +00:00
lukaszraczyloandGitHub 57724918fe fix 116 (#118)
* Fix cache serialisation

* fix(cache): add integer overflow protection for serialization

- [x] Add maxCacheEntrySize constant (64 MiB) to prevent memory overflow
- [x] Validate byte slice size before adding marker byte
- [x] Validate JSON-serialized data size before marker addition
- [x] Add comprehensive overflow protection test cases

* docs: add security fix documentation for integer overflow protection

* test: fix goroutine tests to use mock OIDC servers

The TestContextAwareGoroutineManagement tests were making real HTTP
calls to hardcoded URLs like https://example.com, causing failures
in CI when those requests timeout or return HTTP errors.

Changes:
- Added createMockOIDCServer() helper function using httptest
- Updated GoroutineCleanupOnContextCancel to use mock server
- Updated NoGoroutineLeakOnMultipleInstances to use 3 mock servers
- Updated SingletonTasksAcrossInstances to use mock servers array

This prevents network calls and makes tests more reliable and faster.

Fixes test failures in GitHub Actions CI.
2026-01-08 22:50:46 +00:00
lukaszraczyloandGitHub 775de2ada1 Fix cache serialisation (#117)
* Fix cache serialisation

* fix(cache): add integer overflow protection for serialization

- [x] Add maxCacheEntrySize constant (64 MiB) to prevent memory overflow
- [x] Validate byte slice size before adding marker byte
- [x] Validate JSON-serialized data size before marker addition
- [x] Add comprehensive overflow protection test cases
2026-01-08 22:06:19 +00:00
lukaszraczyloandGitHub 7816e05c98 fix issue with logout url (#112)
* fix(logout): handle logout requests before OIDC initialization

- [x] Add debug logging to logout handler entry point
- [x] Move logout path check before OIDC initialization to enable logout when provider unavailable
- [x] Move excluded URL and SSE checks before initialization wait
- [x] Add debug logging for initialization wait to diagnose hanging requests
- [x] Add test for logout functionality without OIDC provider availability

* feat(logout): implement OIDC backchannel and front-channel logout

- [x] Add logout token validation and backchannel logout handler
- [x] Add front-channel logout handler with iframe support
- [x] Implement session invalidation cache for distributed deployments
- [x] Add comprehensive logout token claim verification (issuer, audience, events, iat, sid/sub)
- [x] Integrate session invalidation checks into authorization flow
- [x] Add configuration options for enabling backchannel/front-channel logout
- [x] Add extensive test coverage for logout flows and edge cases
- [x] Update documentation with logout configuration examples
- [x] Add middleware routing for logout endpoints
- [x] Extend cache manager with session invalidation cache support

Resolves #110

* fixup! feat(logout): implement OIDC backchannel and front-channel logout

* fixup! Merge branch 'main' into fix-issue-with-logout-url
2026-01-04 01:59:50 +00:00
Dominik ChillaandGitHub 8bf7998150 Fix for Hashicorp Vault - accept opaque access tokens with dot-characters (#113) 2026-01-02 16:42:22 +00:00
22c4323fcb fix: set X-Forwarded-User header for SSE requests from existing session (#111)
Co-authored-by: muffin <[email protected]>
2026-01-02 02:50:11 +00:00
lukaszraczyloandGitHub 06b219d1f8 feat(dcr): Add Redis storage support for multi-replica deployments (#109)
- [x] Add file and Redis storage backends for DCR credentials
- [x] Implement storage abstraction with FileStore and RedisStore
- [x] Add factory function for automatic backend selection (auto/file/redis)
- [x] Integrate DCR credentials cache into UniversalCacheManager
- [x] Add comprehensive tests for storage backends and factory
- [x] Update configuration schema with storage backend options
- [x] Update documentation with multi-replica deployment guidance
- [x] Add Redis key prefix configuration for credential isolation
2025-12-31 12:52:39 +00:00
lukaszraczyloandGitHub 413e4a1b7d LRU + cache conflicts prevention. (#104)
* LRU + cache conflicts prevention.

* Bugfix universalCache flooding ( issue #105 )

  1. Traefik cancels the context for old plugin instances
  2. Each plugin's Close() method is called
  3. The CacheInterfaceWrapper.Close() was calling cache.Close() on the shared singleton caches
  4. Each Close() triggered Clear() which logged "Cleared all items" at INFO level
2025-12-24 18:54:39 +00:00
lukaszraczylo 69e0d98c67 fixup! Add signing of the plugin on release. 2025-12-24 12:33:33 +00:00
lukaszraczylo 6d893df12b Add signing of the plugin on release. 2025-12-15 00:38:35 +00:00
508 changed files with 56021 additions and 29676 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: lukaszraczylo
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: https://monzo.me/lukaszraczylo
-52
View File
@@ -1,52 +0,0 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "github-actions"
reviewers:
- "lukaszraczylo"
# Maintain Go module dependencies
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
include: "scope"
labels:
- "dependencies"
- "go"
reviewers:
- "lukaszraczylo"
# Group patch updates together
groups:
patch-updates:
patterns:
- "*"
update-types:
- "patch"
minor-updates:
patterns:
- "*"
update-types:
- "minor"
# Ignore certain dependencies if needed
ignore:
# Example: ignore specific versions
# - dependency-name: "github.com/example/package"
# versions: ["1.x", "2.x"]
+38 -1
View File
@@ -18,6 +18,43 @@ jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.24.11"
go-version: "1.26.x"
coverage-threshold: 70
secrets: inherit
yaegi-validate:
name: Yaegi interpreter compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.26.x"
# Pinned to the yaegi version Traefik v3.7.1 vendors (Makefile
# YAEGI_VERSION), so this tests the real interpreter a release runs
# under, not a newer one that may tolerate more.
- name: Install yaegi
run: go install github.com/traefik/yaegi/cmd/[email protected]
# yaegi resolves the plugin's own import path
# (github.com/lukaszraczylo/traefikoidc) through GOPATH/src, same as
# `make yaegi-validate` does locally.
- name: Symlink checkout into GOPATH
run: |
mkdir -p "$(go env GOPATH)/src/github.com/lukaszraczylo"
ln -sfn "$GITHUB_WORKSPACE" "$(go env GOPATH)/src/github.com/lukaszraczylo/traefikoidc"
# Runs cmd/yaegicheck under the real interpreter: imports the plugin
# package, exercises CreateConfig + New, and drives every yaegi-only
# regression check (errors.As-on-an-interpreted-target panics,
# SSE Flush across the interpreter boundary, etc.) that native
# `go build`/`go test` cannot see.
- name: Run yaegi regression checks
run: DO_NOT_TRACK=1 GOFLAGS=-mod=vendor yaegi run ./cmd/yaegicheck/main.go
+3 -1
View File
@@ -11,11 +11,13 @@ on:
workflow_dispatch:
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.24.11"
go-version: "1.26.x"
secrets: inherit
+2 -1
View File
@@ -1,3 +1,4 @@
docker/
.claude/*.out
.claude/
*.test
.leann/
+49 -32
View File
@@ -14,21 +14,22 @@ linters:
- gosec
- misspell
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- unconvert
- unparam
- whitespace
disable:
- exhaustive
- funlen
- gocognit
- gocyclo # Disabled: OAuth/OIDC flows are inherently complex
- goprintffuncname # Disabled: naming convention is project-specific
- lll
- mnd
- testpackage
- whitespace # Disabled: style preference about newlines
- wsl
settings:
dupl:
@@ -47,29 +48,13 @@ linters:
- fmt.Fprintln
goconst:
min-len: 3
min-occurrences: 10 # Increased to reduce noise for standard OAuth2/OIDC strings
min-occurrences: 15 # Increased to reduce noise for standard OAuth2/OIDC strings and common patterns like "true"
ignore-tests: true
gocritic:
# Using default enabled checks in v2
enabled-checks:
- appendCombine
- boolExprSimplify
- builtinShadow
- commentedOutCode
- emptyFallthrough
- equalFold
- hexLiteral
- indexAlloc
- initClause
- methodExprCall
- nestingReduce
- rangeExprCopy
- rangeValCopy
- stringXbytes
- typeAssertChain
- typeUnparen
- unlabelStmt
- yodaStyleExpr
# Disable style-only checks that add noise
disabled-checks:
- ifElseChain # Style preference, switch not always clearer
- elseif # Style preference
gocyclo:
min-complexity: 30 # OAuth/OIDC flows are inherently complex; set higher for Yaegi compatibility
gosec:
@@ -106,23 +91,23 @@ linters:
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
# - name: exported # Disabled: too noisy, not all exported functions need comments
# - name: if-return # Disabled: style preference
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
# - name: var-naming # Disabled: too strict for legacy code (IP vs Ip)
# - name: var-declaration # Disabled: explicit zero values can be clearer
# - name: package-comments # Disabled: handled by other tools
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
# - name: indent-error-flow # Disabled: style preference
- name: errorf
- name: empty-block
# - name: empty-block # Disabled: sometimes empty blocks are intentional
- name: superfluous-else
- name: unused-parameter
# - name: unused-parameter # Disabled: test callbacks and interface implementations often have required unused params
- name: unreachable-code
- name: redefines-builtin-id
# - name: redefines-builtin-id # Disabled: min/max helpers are common before Go 1.21
unparam:
check-exported: false
staticcheck:
@@ -132,8 +117,15 @@ linters:
- -QF1003 # Tagged switch - style preference, may affect Yaegi
- -QF1007 # Merge conditional assignment - style preference
- -QF1008 # Remove embedded field - may break Yaegi compatibility
- -QF1011 # Omit type from declaration - style preference
- -QF1012 # Use fmt.Fprintf - style preference
- -SA9003 # Empty branch - sometimes intentional for future work
- -ST1000 # Package comment format - not required for all packages
- -ST1003 # Package name format - allowed for test packages
- -ST1016 # Receiver name consistency - legacy code
- -ST1020 # Comment format for methods - style preference
- -ST1021 # Comment format for types - style preference
- -ST1023 # Omit type from declaration - style preference
exclusions:
generated: lax
rules:
@@ -144,18 +136,43 @@ linters:
- goconst
- gocyclo
- gosec
- govet
- ineffassign
- noctx
- prealloc
- unparam
- revive
- gocritic
path: _test\.go
- linters:
- dupl
- gocyclo
- govet
- noctx
- prealloc
- unparam
- revive
- gocritic
path: test.*\.go
- linters:
- gocritic
- unused
- errcheck
- revive
path: mocks.*\.go
- linters:
- errcheck
- revive
- gocritic
- govet
- unparam
path: internal/testutil/
- linters:
- govet
- unparam
- noctx
- prealloc
path: integration/
- linters:
- gosec
text: 'G404:'
+11
View File
@@ -47,3 +47,14 @@ release:
name_template: "v{{ .Version }}"
draft: false
prerelease: auto
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
+98 -1609
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
# Changelog
All notable changes to this project are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **`allowUnauthenticatedPreflight` (default `false`).** Every `OPTIONS`
request needs authentication by default, as in the last release. Set the
option to `true` to let a genuine CORS preflight reach the backend
without a session. Only an `OPTIONS` request that carries both `Origin`
and `Access-Control-Request-Method` bypasses authentication, and the
plugin discards the backend's response body for it, because a browser
never reads a preflight body (`settings.go`, `middleware.go`, FIX-02).
See the option table in [CONFIGURATION.md](docs/CONFIGURATION.md).
- **`streamingRefresh` (default `false`).** SSE and WebSocket requests do
not check the token expiry, so a session stays usable for streaming until
the session cookie expires. Set the option to `true` to refresh an expired
token on these requests. If the IdP rejects the refresh with
`invalid_grant`, the request gets `401`. If the IdP cannot be reached, the
request is forwarded as before. The request waits for the full refresh, so
a rotated refresh token is never lost (`middleware.go`).
### Changed
- **Opaque bearer tokens when all three bearer flags are set.** A deployment
that sets `enableBearerAuth`, `requireTokenIntrospection` and
`allowOpaqueTokens` now accepts opaque bearer tokens after RFC 7662
introspection. The last release rejected every non-JWT bearer token. The
plugin binds an accepted opaque token to the client through `client_id` or
`aud`. It also applies the identifier, token-age, logout, revocation and
`nbf` checks (`bearer_auth.go`).
- **Sessions created before this upgrade can omit `post_logout_redirect_uri`
on their first logout.** The plugin now builds that parameter only from the
redirect-URI origin recorded at login, never from the logout request's
host. Older sessions have no recorded origin, so those users stay on the
IdP's logout page once (`helpers.go`).
- **Shutdown waits up to 5 seconds for a token refresh in progress.** On
shutdown, for example a Traefik reload, the refresh coordinator lets a
refresh that already reached the IdP deliver its tokens, then returns
(`shutdownRefreshDrainTimeout`, `refresh_coordinator.go`). A refresh that
has not started does not start after shutdown.
- **A panic after the request reaches the backend now aborts the
connection.** The plugin re-panics it, for example the reverse proxy's
`http.ErrAbortHandler` when an upstream drops the connection mid-body. Go's
HTTP server then aborts the connection. Before, the plugin recovered the
panic, so a truncated response could reach the client as a complete 200. A
panic before the request reaches the backend still returns 500
(`middleware.go`).
- **private_key_jwt now rejects RSA signing keys under 2048 bits.**
`validateAlgKeyMatch` stops `New()` with an error when a configured
`private_key_jwt` RS\*/PS\* key is smaller than 2048 bits (RFC 7518 §3.3).
A smaller key signs locally, but a conformant IdP rejects it at token
exchange with `invalid_client`, so the plugin now fails at construction
instead of at every runtime exchange. There is no override: deployments
using an RSA key under 2048 bits must generate a 2048-bit-or-larger key
before upgrading. The rejection returns a matchable `ErrRSAKeyTooSmall`
sentinel (`client_assertion.go`).
- **A discovered `http://` endpoint is now dropped when `providerURL` is
`https://`.** `validateDiscoveredEndpoint` refuses to accept an
authorization, token, jwks_uri, revocation, end_session, introspection, or
registration endpoint served over plaintext HTTP when the operator
configured an HTTPS provider (R146): using it as-is would send a client
secret or token over an unauthenticated channel. There is no configuration
flag to disable this check. A dropped endpoint logs an `ERROR` line; a
dropped `token`, `jwks_uri`, or `authorization` endpoint — the three
every login needs — additionally logs a `SECURITY:`-prefixed line naming
the endpoint, the dropped URL, and the fact that there is no override, so
the failure is diagnosable instead of surfacing only as a broken login.
See [Discovered Endpoint
Validation](docs/CONFIGURATION.md#discovered-endpoint-validation)
(`url_helpers.go`, `main.go`).
- **Blacklist entries in Redis use the `blacklist:` key prefix.** The
blacklist cache now has its own cache type (`CacheTypeBlacklist`), so its
Redis keys move from the shared `token:` prefix to `blacklist:` (R128).
A cached token entry and a revocation marker for the same raw token can
no longer overwrite each other. For one release, a blacklist miss also
reads the legacy `token:<key>` entry and treats it as revoked only when
the stored value is boolean `true` (`checkLegacyBlacklistMarker` in
`universal_cache.go`, FIX-16). The fallback works in one direction only:
during a rolling deploy, a replica that runs the previous version does
not see revocations that an upgraded replica writes under `blacklist:`.
A blacklist miss checks the legacy `token:` entry at most once per key in
each 30 seconds, so the fallback adds little Redis traffic.
- **Claim validation accepts four previously-rejected or silently-dropped
claim shapes.** Each change below is intentional, matches a production
change, and is pinned by an existing regression test.
- `iat` (issued-at) is optional in `jwt.Verify`, per RFC 7519 §4.1.6. A
token that omits it is validated on `iss`/`aud`/`exp`/`nbf` alone,
instead of being rejected (`jwt.go`, R126). The lenient-audience
access-token path (`accessTokenUnexpired` in `token_validation_rs.go`)
applies the same rule, because both paths call `verifyTimeClaims`
(`jwt.go`, FIX-27). Bearer-token auth still rejects a token with no
`iat`: it returns `"missing iat claim"` (`enforceIatAge` in
`bearer_auth.go`), because `maxTokenAgeSeconds` always applies — `0`
falls back to a 24h default (`main.go`).
- A numeric `sub` (bearer-auth identifier) claim is stringified instead of
rejected, so an IdP that emits a numeric subject still authenticates
(`bearer_auth.go` `resolveBearerIdentifier`, R102).
- A `groups`/`roles` claim that is neither a string nor an array (for
example a bare number or `null`) is treated as an empty list instead of
failing claim extraction outright, so a malformed claim on one side does
not suppress a valid claim on the other (`token_manager.go`
`extractGroupsAndRolesFromClaims`, R96).
- A numeric element inside a `groups`/`roles` array is stringified into
the list instead of being silently dropped, so a numeric group ID
survives extraction and can match an `allowedRolesAndGroups` entry
(`token_manager.go` `stringListFromClaim`, `utilities.go`
`claimScalarString`, R105).
### Fixed
- SSE and WebSocket requests now get the configured `headers`, as normal
requests do. The last release sent only `X-Forwarded-User` on these
requests, so a backend that checks a shared-secret header answered `401`
on WebSocket upgrades. The same requests now also honor back-channel and
front-channel logout (`middleware.go`, #162).
- The opaque bearer path rejects a token that `RevokeToken` added to the
blacklist, for example at logout. It also rejects an opaque token whose
`nbf` is in the future (`bearer_auth.go`).
- The backchannel-logout replay check treats a Redis error reply such as
`READONLY`, `OOM` or `MISCONF` as a failure, not as an unknown outcome. A
replayed logout token is now rejected while Redis refuses writes
(`internal/cache/backends/redis.go`).
- After a failed cache write, a replica no longer ignores a newer value that
another replica wrote, such as a later logout. The preference for the local
value expires after 5 seconds (`backendStaleMarkTTL`, `universal_cache.go`).
- The token circuit breaker treats HTTP 408 from the token endpoint as a
transient failure, the same as 429 (`error_recovery.go`).
- With `allowUnauthenticatedPreflight` enabled, a preflight response no longer
carries a stale `Content-Length` when the backend writes a body without
calling `WriteHeader` (`middleware.go`).
- A dropped `http://` revocation, end-session or introspection endpoint logs
the `SECURITY:` line when its override (`revocationURL`, `oidcEndSessionURL`
or `introspectionURL`) is not configured (`main.go`).
+61
View File
@@ -0,0 +1,61 @@
# traefikoidc — Makefile
# Run `make help` for available targets.
GO ?= go
GOPATH := $(shell $(GO) env GOPATH)
# Pin to the yaegi version bundled by the deployed Traefik so yaegi-validate
# tests the real interpreter, not a newer one that may support more. Traefik
# v3.7.1 vendors yaegi v0.16.1 (Go ~1.22 stdlib surface). Bump when Traefik is.
YAEGI_VERSION ?= v0.16.1
TEST_TIMEOUT ?= 480s
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -hE '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
.PHONY: build
build: ## Compile all packages (native toolchain)
$(GO) build ./...
.PHONY: fmt
fmt: ## Format sources with gofmt
gofmt -w $$(git ls-files '*.go' | grep -v '^vendor/')
.PHONY: vet
vet: ## Run go vet
$(GO) vet ./...
.PHONY: lint
lint: ## Run golangci-lint if available
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; else echo "golangci-lint not installed; skipping"; fi
.PHONY: staticcheck
staticcheck: ## Run staticcheck (matches the CI "Static Analysis" job; catches U1000 unused, etc.)
@command -v staticcheck >/dev/null 2>&1 || { echo ">> installing staticcheck"; $(GO) install honnef.co/go/tools/cmd/staticcheck@latest; }
@GOFLAGS=-buildvcs=false $$(command -v staticcheck || echo "$(GOPATH)/bin/staticcheck") ./...
.PHONY: test
test: ## Run the test suite (race detector on: several regression pins, e.g. R44/R77/R111, only fail under -race)
$(GO) test -race ./... -count=1 -timeout $(TEST_TIMEOUT)
.PHONY: vendor
vendor: ## Refresh and vendor dependencies
$(GO) mod tidy && $(GO) mod vendor
# yaegi-validate interprets the plugin under the yaegi interpreter the same way
# Traefik loads it. Native `go build`/`go test` use the standard compiler and do
# NOT catch yaegi-only incompatibilities (unsupported stdlib symbols, reflection
# edge cases). This target does. Importing the package forces yaegi to interpret
# every file in it plus its vendored deps; CreateConfig + New exercise the
# instantiation path. Pin YAEGI_VERSION to match Traefik's bundled yaegi if you
# need exact parity.
.PHONY: yaegi-validate
yaegi-validate: ## Verify the plugin loads under Traefik's yaegi interpreter
@command -v yaegi >/dev/null 2>&1 || { echo ">> installing yaegi@$(YAEGI_VERSION)"; $(GO) install github.com/traefik/yaegi/cmd/yaegi@$(YAEGI_VERSION); }
@echo ">> interpreting plugin under yaegi (as Traefik does)"
@DO_NOT_TRACK=1 GOFLAGS=-mod=vendor $$(command -v yaegi || echo "$(GOPATH)/bin/yaegi") run ./cmd/yaegicheck/main.go
.PHONY: check
check: vet staticcheck test yaegi-validate ## vet + staticcheck + tests + yaegi load validation
+458 -1932
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# Security Fix: Integer Overflow Protection in Cache Serialization
## Summary
Fixed **High severity** integer overflow vulnerability identified by GitHub Advanced Security in PR #117.
## Vulnerability
**Locations**: `universal_cache.go` lines 789 and 811
- `result := make([]byte, len(bytes)+1)` - Raw bytes path
- `result := make([]byte, len(jsonData)+1)` - JSON encoding path
**Risk**: Potential integer overflow when allocating memory for very large cache entries.
## Fix Applied
1. **Added size limit constant**:
```go
maxCacheEntrySize = 64 * 1024 * 1024 // 64 MiB
```
2. **Size validation before allocation**:
- Validates entry size doesn't exceed limit
- Validates adding marker byte won't overflow
- Returns descriptive error messages
3. **Comprehensive test coverage**:
- Oversized byte slices (>64 MiB)
- Exact max size edge case
- Safe sizes (normal operation)
- Large JSON data structures
## Verification
✅ All tests pass with race detection
✅ No security issues (golangci-lint, gosec)
✅ 76.3% test coverage maintained
## Impact
- No breaking changes
- Negligible performance overhead
- Prevents potential buffer overflows
- Predictable memory usage
---
**Date**: January 8, 2026
**Severity**: High → Resolved
@@ -0,0 +1,49 @@
package traefikoidc
import (
"testing"
"time"
)
// FIX-27 regression test.
//
// accessTokenUnexpired (the lenient-audience-path time-claims re-check)
// returned false whenever iat was absent, even though 8640451 made iat
// OPTIONAL in jwt.Verify itself (R126: "Hard-requiring it here also
// rejected otherwise-valid access ... tokens whose provider omits iat").
// The two paths had drifted onto different contracts for the same claim.
// A lenient (wrong-audience) access token without iat, in a session with
// no ID token to corroborate it, was always forced to refresh or
// re-authenticate, while the identical token with a matching audience
// authenticated fine via the main jwt.Verify path.
//
// Fail-on-old: a syntactically valid, unexpired token with no iat is
// rejected by accessTokenUnexpired (returns false).
func TestAccessTokenUnexpired_IatOptional(t *testing.T) {
tm := &TraefikOidc{}
now := time.Now()
noIat := makeTestJWT(t, map[string]interface{}{
"exp": float64(now.Add(time.Hour).Unix()),
// intentionally no "iat", matching jwt.Verify's R126 contract
})
if !tm.accessTokenUnexpired(noIat) {
t.Error("a token with a valid exp and no iat must be accepted, matching jwt.Verify's iat-optional contract (R126/FIX-27)")
}
}
// TestAccessTokenUnexpired_IatStillBoundedWhenPresent is the positive
// control: an iat far in the future (used-before-issued) must still be
// rejected when iat IS present — the fix makes iat optional, not ignored.
func TestAccessTokenUnexpired_IatStillBoundedWhenPresent(t *testing.T) {
tm := &TraefikOidc{}
now := time.Now()
futureIat := makeTestJWT(t, map[string]interface{}{
"exp": float64(now.Add(time.Hour).Unix()),
"iat": float64(now.Add(time.Hour).Unix()), // far in the future
})
if tm.accessTokenUnexpired(futureIat) {
t.Error("a present iat far in the future (used-before-issued) must still be rejected")
}
}
+24
View File
@@ -0,0 +1,24 @@
package traefikoidc
import (
"errors"
"fmt"
"testing"
)
// Only a real audience mismatch may select the lenient-audience path; an
// error text that merely embeds the attacker-chosen kid must not.
func TestAudienceErrorClassifiedBySentinel(t *testing.T) {
if !errors.Is(fmt.Errorf("standard claim verification failed: %w", verifyAudience("x", "y")), errInvalidAudience) {
t.Fatal("audience mismatch not recognized through wrapping")
}
if errors.Is(verifyAudience("y", "y"), errInvalidAudience) {
t.Fatal("matching audience classified as mismatch")
}
for _, kid := range []string{"audience", "invalid audience"} {
err := fmt.Errorf("failed to get public key: no matching public key found for kid: %s", kid)
if errors.Is(err, errInvalidAudience) {
t.Errorf("kid %q fooled the audience classifier", kid)
}
}
}
+9 -21
View File
@@ -288,6 +288,10 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
customAudience := "https://my-api.example.com"
ts.tOidc.audience = customAudience
// buildAuthURL sources the outbound `audience` param from
// explicitAudience, not audience (see url_helpers.go); an explicitly
// configured custom audience must set both to behave like production.
ts.tOidc.explicitAudience = customAudience
// Create ID token with aud = client_id (OIDC standard)
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
@@ -322,16 +326,12 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
}
// Verify ID token validates against client_id
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
}
// Verify access token validates against custom audience
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err != nil {
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
@@ -395,8 +395,6 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
}
// Verify ID token validates
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed: %v", err)
@@ -404,8 +402,6 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
// Access token won't have client_id in aud, so it will fail validation
// This is expected for scenario 2 - the session validation relies on ID token
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err == nil {
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
@@ -456,8 +452,6 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
opaqueAccessToken := "opaque_access_token_random_string_12345"
// Verify ID token validates
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token validation failed: %v", err)
@@ -484,7 +478,8 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
session.SetAccessToken(opaqueAccessToken)
session.SetIDToken(idToken)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated || needsRefresh || expired {
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
authenticated, needsRefresh, expired)
@@ -519,8 +514,6 @@ func TestAuth0AudienceArrayValidation(t *testing.T) {
}
// Should validate successfully - custom audience is in the array
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err != nil {
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
@@ -553,8 +546,6 @@ func TestAuth0MismatchedAudience(t *testing.T) {
}
// Should fail validation - audience doesn't match
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(accessToken)
if err == nil {
t.Error("Access token with wrong audience should fail validation")
@@ -623,7 +614,8 @@ func TestAuth0Scenario2StrictMode(t *testing.T) {
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
// In strict mode, this should FAIL (no fallback to ID token)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if authenticated {
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
}
@@ -663,8 +655,6 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
}
// Should validate successfully - ID tokens are checked against client_id
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(idToken)
if err != nil {
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
@@ -686,8 +676,6 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
}
// Should fail - ID tokens must have client_id as audience
cleanupReplayCache()
initReplayCache()
err = ts.tOidc.VerifyToken(wrongIDToken)
if err == nil {
t.Error("ID token with custom audience (not client_id) should fail validation")
@@ -1491,7 +1479,7 @@ func TestAudienceEndToEndScenario(t *testing.T) {
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("Failed to set authenticated: %v", err)
}
session.SetEmail("[email protected]")
session.SetUserIdentifier("[email protected]")
session.SetIDToken(validJWT)
session.SetAccessToken(validJWT)
+271 -48
View File
@@ -1,13 +1,37 @@
package traefikoidc
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
"time"
)
// redactAuthorizeURLForLog removes the remaining security-sensitive query
// values from the provider authorize URL before it is logged. It builds on
// redactCallbackURL (url_helpers.go, R153), which strips "code" and
// "state" but was written for the callback URL and does not know about
// the nonce or PKCE code_challenge the authorize URL also carries — so
// logging redactCallbackURL(authURL) alone would still leak the nonce
// (FIX-29 correction of the R153 claim).
func redactAuthorizeURLForLog(rawURL string) string {
redacted := redactCallbackURL(rawURL)
u, err := url.Parse(redacted)
if err != nil {
return redacted
}
q := u.Query()
for _, k := range []string{"nonce", "code_challenge"} {
if q.Get(k) != "" {
q.Set(k, "[REDACTED]")
}
}
u.RawQuery = q.Encode()
return u.String()
}
// validateRedirectCount checks if redirect limit is exceeded and handles the error
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
const maxRedirects = 5
@@ -15,6 +39,16 @@ func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.Respon
if redirectCount >= maxRedirects {
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
session.ResetRedirectCount()
// Persist the reset so the NEXT request starts at 0 and can
// attempt auth again. Without Save, the reset only touched the
// in-memory session value (dirty flag set but never flushed): the
// cookie still carried the old count, so every subsequent
// request re-observed redirect_count==max and the user was
// hard-locked on 508 for the whole session with no recovery
// (R123).
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session after redirect reset: %v", err)
}
t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected)
return fmt.Errorf("redirect limit exceeded")
}
@@ -44,7 +78,7 @@ func (t *TraefikOidc) generatePKCEParameters() (string, string, error) {
func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrfToken, nonce, codeVerifier, incomingPath string) {
// Clear all existing session data
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication state on new flow
session.SetEmail("")
session.SetUserIdentifier("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetIDToken("")
@@ -69,15 +103,45 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
// - req: The HTTP request initiating authentication.
// - session: The session data to prepare for authentication.
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
//
// rateLimitPerSourceAuth applies the (optional) per-external-source auth
// throttle at an OIDC gate. Returns true if the request was already
// answered with 429 and the caller must return. No-op when the limiter is
// disabled (PerSourceLoginRateLimit <= 0) or the source is internal.
func (t *TraefikOidc) rateLimitPerSourceAuth(rw http.ResponseWriter, req *http.Request) bool {
if t.perSourceLimiter == nil {
return false
}
if t.perSourceLimiter.allow(req) {
return false
}
rw.Header().Set("Retry-After", "1")
t.sendErrorResponse(rw, req, "Too many authentication attempts, please try again shortly", http.StatusTooManyRequests)
return true
}
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
// Per-external-source throttle on login initiation: a single host
// flooding the route with auth starts shouldn't be able to spin up an
// unbounded number of IdP round-trips or wear the shared token-origin
// bucket down for everyone (R185).
if t.rateLimitPerSourceAuth(rw, req) {
return
}
// Check and handle redirect limits
if err := t.validateRedirectCount(session, rw, req); err != nil {
return
}
csrfToken := uuid.NewString()
csrfToken, err := newUUIDv4()
if err != nil {
t.logger.Errorf("Failed to generate CSRF token: %v", err)
http.Error(rw, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
nonce, err := generateNonce()
if err != nil {
t.logger.Errorf("Failed to generate nonce: %v", err)
@@ -93,9 +157,42 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
return
}
// Build the provider authorize URL BEFORE mutating the session. A URL-
// build failure must not clear a valid existing session, and must not
// emit an empty-Location redirect (browsers reload the current page,
// re-entering this flow and bumping the redirect count until the hard
// 508). Fail closed with an explicit status instead (R142).
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
if authURL == "" {
t.logger.Errorf("Failed to build provider authorize URL; aborting login flow")
http.Error(rw, "Failed to build provider authorize URL", http.StatusInternalServerError)
return
}
// When dynamic client registration is enabled but has not yet produced a
// client_id (registration pending or failed), forging an authorize URL
// with client_id="" would send the user to the provider on a broken
// flow. Fail with a clear status instead of redirecting. This guard
// must run BEFORE prepareSessionForAuthentication re-seeds the session:
// re-seeding (and the save that follows) would otherwise mutate and
// persist the session even though we are about to answer 503 with no
// redirect — discarding any existing session state for no reason (R163).
// Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137).
clientID, _, _, _, _ := t.clientCredentials()
if t.dcrConfig != nil && t.dcrConfig.Enabled && clientID == "" {
t.logger.Errorf("OIDC dynamic client registration has not produced a client_id; refusing to redirect to provider")
http.Error(rw, "Identity provider registration pending", http.StatusServiceUnavailable)
return
}
// Clear existing session data and set new authentication state
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
// Persist the redirect_uri so the callback reuses the exact value sent
// to the IdP, instead of rebuilding it from the (client-affectable)
// callback request's X-Forwarded-Host (R136).
session.SetRedirectURL(redirectURL)
session.MarkDirty()
if err := session.Save(req, rw); err != nil {
@@ -104,12 +201,17 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
return
}
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
csrfToken, nonce)
// CSRF/state and nonce are single-use security credentials; do not log
// them in the clear (FIX-29 correction of the R153 redaction claim).
t.logger.Debug("Session saved before redirect")
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
t.logger.Debugf("Redirecting user to OIDC provider: %s", redactAuthorizeURLForLog(authURL))
// 302s into the IdP must not be heuristically cacheable (RFC 7234):
// a stale, cached redirect could replay the pre-re-authentication URL.
// Every other auth response sets no-store; these redirects were missed
// (R147).
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, authURL, http.StatusFound)
}
@@ -120,7 +222,31 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
// - rw: The HTTP response writer.
// - req: The callback request containing authorization code and state.
// - redirectURL: The fully qualified callback URL (used in the token exchange request).
//
// clearOneTimeAuthState clears the one-time CSRF/nonce/PKCE-verifier after a
// callback that consumed the authorization code but then failed, and persists
// the cleared state. Without it the failed session stays in a pending-auth
// state and a retried callback would re-exchange a fresh code before failing
// again. Mirrors the cleanup on the exchange-failure / empty-access-token
// paths (R134).
func (t *TraefikOidc) clearOneTimeAuthState(session *SessionData, req *http.Request, rw http.ResponseWriter) {
session.SetCSRF("")
session.SetNonce("")
session.SetCodeVerifier("")
if saveErr := session.Save(req, rw); saveErr != nil {
t.logger.Errorf("Failed to save session after callback failure: %v", saveErr)
}
}
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
// Per-external-source throttle on the authorization-code callback: this
// is where the one-time code exchange (server round-trip + token
// cache write) happens, so a single source can't brute-force
// repeated code exchanges or drown the shared limiter (R185).
if t.rateLimitPerSourceAuth(rw, req) {
return
}
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Session error during callback: %v", err)
@@ -129,15 +255,28 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
}
defer session.returnToPoolSafely()
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
t.logger.Debugf("Handling callback, URL: %s", redactCallbackURL(req.URL.String()))
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description")
if errorDescription == "" {
errorDescription = req.URL.Query().Get("error")
}
t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
// error / error_description are attacker-controlled query params.
// Sanitize before logging and before echoing back to the client:
// an embedded newline would inject a forged line into (especially
// structured) logs, and unbounded length would bloat the log and
// the error response (R145).
sanitizeErr := func(s string) string {
s = strings.NewReplacer("\n", " ", "\r", " ").Replace(s)
const maxErrLen = 512
if len(s) > maxErrLen {
s = s[:maxErrLen]
}
return s
}
t.logger.Errorf("Authentication error from provider during callback: %s - %s", sanitizeErr(req.URL.Query().Get("error")), sanitizeErr(errorDescription))
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", sanitizeErr(errorDescription)), http.StatusBadRequest)
return
}
@@ -151,7 +290,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
csrfToken := session.GetCSRF()
if csrfToken == "" {
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
session.GetAuthenticated(), req.URL.String())
session.GetAuthenticated(), redactCallbackURL(req.URL.String()))
cookie, err := req.Cookie("_oidc_raczylo_m")
if err != nil {
@@ -164,7 +303,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return
}
if state != csrfToken {
if !constantTimeStringCompare(state, csrfToken) {
t.logger.Error("State parameter does not match CSRF token in session during callback")
t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
return
@@ -178,16 +317,55 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
}
codeVerifier := session.GetCodeVerifier()
if t.enablePKCE && codeVerifier == "" {
t.logger.Error("PKCE is enabled but code verifier is missing from session during callback")
t.sendErrorResponse(rw, req, "Authentication failed: PKCE verifier missing", http.StatusBadRequest)
return
}
// Use the redirect_uri persisted at initiate time (must match what was
// sent to the IdP's authorize endpoint). Fall back to the rebuilt one
// only for pre-existing sessions that predate R136 (R136).
if stored := session.GetRedirectURL(); stored != "" {
redirectURL = stored
}
tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
if err != nil {
t.logger.Errorf("Failed to exchange code for token during callback: %v", err)
// Invalidate the one-time auth state and persist it so a
// partially-failed flow cannot be continued: the authorization code
// may still be unconsumed at the provider, so a replayed callback
// must re-authenticate rather than revalidate a stale
// state/nonce/code_verifier. Mirrors the cleanup on success.
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
return
}
if tokenResponse.AccessToken == "" {
t.logger.Errorf("Token endpoint returned no access token during callback")
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Token endpoint returned no access token", http.StatusInternalServerError)
return
}
if err = t.verifyToken(tokenResponse.IDToken); err != nil {
t.logger.Errorf("Failed to verify id_token during callback: %v", err)
t.clearOneTimeAuthState(session, req, rw)
if errors.Is(err, ErrRateLimitExceeded) {
// Rate-limit exhaustion must be a 429, not a generic 500: a
// single source's burst currently degrades every user's
// callback to "server error" (R179). No Retry-After: by this
// point clearOneTimeAuthState has already destroyed the
// csrf/nonce/code_verifier and the authorization code has
// already been redeemed at the IdP, so a client that honors
// Retry-After and retries this exact callback URL is
// guaranteed to hit "CSRF token missing in session" instead
// of a successful retry (FIX-32). The user must restart login.
t.sendErrorResponse(rw, req, "Authentication failed: Too many requests, please retry", http.StatusTooManyRequests)
return
}
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
return
}
@@ -195,6 +373,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
if err != nil {
t.logger.Errorf("Failed to extract claims during callback: %v", err)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
return
}
@@ -202,41 +381,57 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
nonceClaim, ok := claims["nonce"].(string)
if !ok || nonceClaim == "" {
t.logger.Error("Nonce claim missing in id_token during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusBadRequest)
return
}
sessionNonce := session.GetNonce()
if sessionNonce == "" {
t.logger.Error("Nonce not found in session during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusBadRequest)
return
}
if nonceClaim != sessionNonce {
if !constantTimeStringCompare(nonceClaim, sessionNonce) {
t.logger.Error("Nonce claim does not match session nonce during callback")
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusBadRequest)
return
}
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
userIdentifier, _ := claimScalarString(claims[t.userIdentifierClaim])
if userIdentifier == "" {
// Try "sub" as fallback since it's required by OIDC spec
if t.userIdentifierClaim != "sub" {
userIdentifier, _ = claims["sub"].(string)
userIdentifier, _ = claimScalarString(claims["sub"])
}
if userIdentifier == "" {
t.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", t.userIdentifierClaim)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
return
}
t.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", t.userIdentifierClaim)
}
// Validate user authorization. When the identity is an email, require the
// IdP not to have explicitly flagged it as unverified — an unverified
// address must not satisfy an email/domain allowlist (see
// emailIdentityPermitted).
if !emailIdentityPermitted(userIdentifier, claims) {
t.logger.Errorf("User email not verified during callback, rejecting email-based authorization: %s", userIdentifier)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Email not verified", http.StatusForbidden)
return
}
// Validate user authorization
if !t.isAllowedUser(userIdentifier) {
t.logger.Errorf("User not authorized during callback: %s", userIdentifier)
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
return
}
@@ -246,7 +441,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
return
}
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
session.SetUserIdentifier(userIdentifier)
session.SetIDToken(tokenResponse.IDToken)
session.SetAccessToken(tokenResponse.AccessToken)
session.SetRefreshToken(tokenResponse.RefreshToken)
@@ -259,7 +454,10 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
redirectPath := "/"
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
redirectPath = incomingPath
// Neutralize open-redirect payloads (e.g. //evil.com, /\evil.com) stored
// from the original request target before using it as the post-login
// redirect target. normalizeLogoutPath forces a host-relative path.
redirectPath = normalizeLogoutPath(incomingPath)
}
session.SetIncomingPath("")
@@ -270,6 +468,8 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
}
t.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
// no-store so the post-auth redirect isn't cached and replayed (R147).
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, redirectPath, http.StatusFound)
}
@@ -286,7 +486,7 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
session.SetIDToken("")
session.SetAccessToken("")
session.SetRefreshToken("")
session.SetEmail("")
session.SetUserIdentifier("")
// Clear CSRF tokens to prevent replay attacks
session.SetCSRF("")
session.SetNonce("")
@@ -301,28 +501,6 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
}
// isUserAuthenticated determines the authentication status and refresh requirements.
// It delegates to provider-specific validation methods that handle different token types
// and expiration behaviors.
// Parameters:
// - session: The session data containing authentication tokens.
//
// Returns:
// - authenticated (bool): True if the user has valid tokens.
// - needsRefresh (bool): True if tokens are valid but nearing expiration.
// - expired (bool): True if the session is unauthenticated, the token is missing,
// or the token verification failed for reasons other than nearing/actual expiration.
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) {
if t.isAzureProvider() {
return t.validateAzureTokens(session)
} else if t.isGoogleProvider() {
return t.validateGoogleTokens(session)
}
// Auth0 and other providers can now use standard validation
// which handles opaque tokens generically
return t.validateStandardTokens(session)
}
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
xhr := req.Header.Get("X-Requested-With")
@@ -334,9 +512,54 @@ func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
strings.Contains(accept, "application/json")
}
// isRefreshTokenExpired checks if refresh token is likely expired (older than 6 hours)
// isNonNavigationRequest reports whether the request is a browser
// sub-resource (script, image, stylesheet, fetch, serviceWorker) rather than
// a top-level HTML navigation. Non-navigation requests MUST NOT trigger an
// OIDC redirect flow: several sub-resource loads happening in parallel would
// each call defaultInitiateAuthentication, each overwriting the session's
// CSRF/nonce, breaking the eventual callback (issue #129).
//
// Detection prefers Sec-Fetch-Mode, which all modern browsers send
// (Chrome/Edge/Firefox/Safari). For older or non-browser clients we fall
// back to Accept: if Accept is present and does not list text/html, treat
// it as a sub-resource. An empty/missing Accept is assumed to be navigation
// (safer to redirect than 401 on an ambiguous request).
func (t *TraefikOidc) isNonNavigationRequest(req *http.Request) bool {
if mode := req.Header.Get("Sec-Fetch-Mode"); mode != "" {
return mode != "navigate"
}
accept := req.Header.Get("Accept")
if accept == "" || accept == "*/*" {
return false
}
return !strings.Contains(accept, "text/html")
}
// isRefreshTokenExpired checks whether the stored refresh token is likely
// past its useful lifetime, using the cookie-side issued_at timestamp set by
// SetRefreshToken. IdPs do not expose RT TTL on the wire, so this is a
// conservative heuristic gated by t.maxRefreshTokenAge (default 6h, set via
// MaxRefreshTokenAgeSeconds; 0 disables the check).
//
// The point of this check is to short-circuit the refresh path BEFORE the
// thundering herd hits the IdP for a token the provider has almost certainly
// revoked. Together with the RefreshCoordinator wireup, it keeps Grafana-
// style polling clients from looping on invalid_grant after a long pause.
func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool {
// This is a heuristic check - actual implementation would depend on
// the specific provider and token metadata
return false // Placeholder implementation
if t == nil || session == nil {
return false
}
if t.maxRefreshTokenAge <= 0 {
return false
}
issuedAt := session.GetRefreshTokenIssuedAt()
if issuedAt.IsZero() {
// No timestamp recorded (legacy session pre-dating the issued_at
// field). Don't force a re-auth - attempt refresh once and let the
// IdP be the source of truth.
return false
}
return time.Since(issuedAt) > t.maxRefreshTokenAge
}
+92 -6
View File
@@ -192,7 +192,7 @@ func (s *AuthFlowBehaviourSuite) TestPrepareSessionForAuthentication() {
// Pre-populate session with old data
_ = session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetUserIdentifier("[email protected]")
session.SetAccessToken("old-access-token-with-many-characters")
session.SetRefreshToken("old-refresh-token-with-many-characters")
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.signature")
@@ -207,7 +207,7 @@ func (s *AuthFlowBehaviourSuite) TestPrepareSessionForAuthentication() {
// Verify old data is cleared
s.False(session.GetAuthenticated())
s.Empty(session.GetEmail())
s.Empty(session.GetUserIdentifier())
// Verify new data is set
s.Equal(csrfToken, session.GetCSRF())
@@ -305,6 +305,90 @@ func (s *AuthFlowBehaviourSuite) TestIsAjaxRequest() {
}
}
// TestIsNonNavigationRequest verifies browser sub-resource detection used to
// suppress OIDC redirects on parallel static-asset loads (issue #129).
func (s *AuthFlowBehaviourSuite) TestIsNonNavigationRequest() {
testCases := []struct {
headers map[string]string
name string
expectNonNavigation bool
}{
{
name: "Sec-Fetch-Mode navigate",
headers: map[string]string{"Sec-Fetch-Mode": "navigate"},
expectNonNavigation: false,
},
{
name: "Sec-Fetch-Mode no-cors",
headers: map[string]string{"Sec-Fetch-Mode": "no-cors"},
expectNonNavigation: true,
},
{
name: "Sec-Fetch-Mode cors",
headers: map[string]string{"Sec-Fetch-Mode": "cors"},
expectNonNavigation: true,
},
{
name: "Sec-Fetch-Mode same-origin (fetch in page)",
headers: map[string]string{"Sec-Fetch-Mode": "same-origin"},
expectNonNavigation: true,
},
{
name: "Accept text/html (fallback)",
headers: map[string]string{"Accept": "text/html,application/xhtml+xml"},
expectNonNavigation: false,
},
{
name: "Accept image/png (fallback)",
headers: map[string]string{"Accept": "image/png,image/*;q=0.8"},
expectNonNavigation: true,
},
{
name: "Accept application/javascript (fallback)",
headers: map[string]string{"Accept": "application/javascript"},
expectNonNavigation: true,
},
{
name: "Accept */* treated as navigation",
headers: map[string]string{"Accept": "*/*"},
expectNonNavigation: false,
},
{
name: "No Accept header assumed navigation",
headers: map[string]string{},
expectNonNavigation: false,
},
{
name: "Sec-Fetch-Mode beats Accept (navigate wins)",
headers: map[string]string{
"Sec-Fetch-Mode": "navigate",
"Accept": "application/javascript",
},
expectNonNavigation: false,
},
{
name: "Sec-Fetch-Mode beats Accept (no-cors wins)",
headers: map[string]string{
"Sec-Fetch-Mode": "no-cors",
"Accept": "text/html",
},
expectNonNavigation: true,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
req := httptest.NewRequest(http.MethodGet, "/_static/asset.js", nil)
for key, value := range tc.headers {
req.Header.Set(key, value)
}
result := s.tOidc.isNonNavigationRequest(req)
s.Equal(tc.expectNonNavigation, result)
})
}
}
// TestHandleCallback_MissingState tests callback with missing state parameter
func (s *AuthFlowBehaviourSuite) TestHandleCallback_MissingState() {
sessionManager, err := NewSessionManager(
@@ -627,7 +711,7 @@ func (s *AuthFlowBehaviourSuite) TestHandleExpiredToken() {
session, err := sessionManager.GetSession(req)
s.Require().NoError(err)
_ = session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetUserIdentifier("[email protected]")
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.signature")
session.mainSession.Values["redirect_count"] = 3
@@ -636,7 +720,7 @@ func (s *AuthFlowBehaviourSuite) TestHandleExpiredToken() {
// Session should be cleared
s.False(session.GetAuthenticated())
s.Empty(session.GetEmail())
s.Empty(session.GetUserIdentifier())
s.Empty(session.GetIDToken())
// Redirect count should be reset to 0 and then incremented by defaultInitiateAuthentication
@@ -873,8 +957,10 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() {
// Call handleCallback
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
// Should return internal server error due to nonce mismatch
s.Equal(http.StatusInternalServerError, rw2.Code)
// R161: nonce mismatch is a client-side authorization-request
// integrity failure (same class as state/CSRF mismatch), so it returns
// 400, not 500.
s.Equal(http.StatusBadRequest, rw2.Code)
}
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
+122 -27
View File
@@ -28,6 +28,12 @@ type BackgroundTask struct {
stopped int32 // 1 = stopped, 0 = not stopped
started int32 // 1 = started, 0 = not started
doneClosed int32 // 1 = doneChan closed, 0 = not closed
// startRefused is set when Start's circuit-breaker or concurrency-limit
// early-return fires (FIX-19). started stays 0 and stopped stays 0 in
// that case, which is indistinguishable from "registered but Start has
// not been called yet" — RegisterBackgroundTask needs the distinction to
// know this task's slot is actually free to reuse.
startRefused int32 // 1 = a Start call was refused, 0 = never refused
}
// NewBackgroundTask creates a new background task with the specified configuration.
@@ -46,6 +52,12 @@ func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), log
if len(wg) > 0 {
externalWG = wg[0]
}
// run() uses time.NewTicker(interval), which panics for d <= 0 inside
// the started goroutine (autocleanup.go:186), crashing the whole
// process on a misconfigured interval. Normalize here (R149).
if interval <= 0 {
interval = time.Minute
}
return &BackgroundTask{
name: name,
interval: interval,
@@ -81,6 +93,7 @@ func (bt *BackgroundTask) Start() {
if bt.logger != nil {
bt.logger.Debugf("Cannot start task %s: %v (circuit breaker protection working as expected)", bt.name, err)
}
atomic.StoreInt32(&bt.startRefused, 1)
// Close doneChan since the task won't run
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
close(bt.doneChan)
@@ -88,8 +101,21 @@ func (bt *BackgroundTask) Start() {
return
}
// Reserve the task slot immediately when starting
registry.cb.OnTaskStart(bt.name)
// Reserve the task slot immediately when starting. OnTaskStart
// is the authoritative concurrency gate (R165): it returns false
// if maxConcurrent is already reached, in which case this task
// must not launch run() (its slot was not taken).
if !registry.cb.OnTaskStart(bt.name) {
if bt.logger != nil {
bt.logger.Debugf("Cannot start task %s: concurrency limit reached, slot not acquired", bt.name)
}
atomic.StoreInt32(&bt.startRefused, 1)
// Close doneChan since the task won't run
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
close(bt.doneChan)
}
return
}
atomic.StoreInt32(&bt.started, 1)
bt.internalWG.Add(1)
@@ -114,6 +140,14 @@ func (bt *BackgroundTask) Stop() {
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
close(bt.doneChan)
}
// Also close stopChan (R117): a concurrent Start may be in-flight
// and about to launch run(). Signaling stopChan lets it exit at
// the next stop check instead of leaking the goroutine and its
// circuit-breaker slot (taken by OnTaskStart but never released).
func() {
defer func() { _ = recover() }() // safe if already closed
close(bt.stopChan)
}()
return
}
@@ -134,18 +168,37 @@ func (bt *BackgroundTask) Stop() {
// This avoids the race condition with WaitGroup
select {
case <-bt.doneChan:
// Normal completion
// Normal completion: the goroutine has returned, so it is safe
// to wait on the WaitGroup.
bt.internalWG.Wait()
case <-time.After(5 * time.Second):
// The task goroutine is still running (its taskFunc is blocked or
// long-lived). Do NOT wait on the WaitGroup here — that would
// block Stop() (and shutdown) for as long as the task runs.
// Return now; the goroutine is reaped when it completes.
if bt.logger != nil {
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
}
}
// Wait for the internal WaitGroup synchronously after doneChan signals
bt.internalWG.Wait()
})
}
// safeRunTask executes the task function, recovering from any panic so that a
// single panicking run does not permanently kill the background goroutine and its
// periodic loop. Under yaegi (where this plugin runs) unexpected panics are more
// likely, and a silently-dead background task (metadata refresh, autocleanup,
// memory monitor) would otherwise stop forever with no log or restart.
func (bt *BackgroundTask) safeRunTask() {
defer func() {
if r := recover(); r != nil {
if bt.logger != nil {
bt.logger.Errorf("Background task %s panicked (recovered): %v", bt.name, r)
}
}
}()
bt.taskFunc()
}
// run is the main loop for the background task.
// It executes the task function immediately, then periodically
// until the stop signal is received.
@@ -187,7 +240,7 @@ func (bt *BackgroundTask) run() {
}
return
default:
bt.taskFunc()
bt.safeRunTask()
}
for {
@@ -206,7 +259,7 @@ func (bt *BackgroundTask) run() {
}
return
default:
bt.taskFunc()
bt.safeRunTask()
}
case <-bt.stopChan:
if bt.logger != nil {
@@ -329,9 +382,30 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
}
}
// OnTaskStart records a task starting execution
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
atomic.AddInt32(&cb.concurrentTasks, 1)
// OnTaskStart records a task starting execution. It atomically reserves a
// concurrency slot and returns whether one was available. The reservation
// is fundamental (R165): CanCreateTask's limit check is only advisory —
// it is also called by RegisterTask without starting the task — so a
// check-then-act TOCTOU meant two concurrently starting tasks could both
// read current < limit and then both start, letting live tasks exceed
// maxConcurrent and defeating the DoS backstop. OnTaskStart is the
// single commit point, so it enforces maxConcurrent here via a CAS loop:
// only maxConcurrent tasks can hold slots at once. BackgroundTask.Start
// aborts (without launching run) when this returns false.
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
max := atomic.LoadInt32(&cb.maxConcurrent)
for {
current := atomic.LoadInt32(&cb.concurrentTasks)
if current >= max {
if cb.logger != nil {
cb.logger.Debug("Concurrent task limit reached, refusing slot for: %s", taskName)
}
return false
}
if atomic.CompareAndSwapInt32(&cb.concurrentTasks, current, current+1) {
break
}
}
cb.tasksMu.Lock()
cb.activeTasks[taskName] = struct{}{}
cb.tasksMu.Unlock()
@@ -342,6 +416,7 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
atomic.LoadInt32(&cb.concurrentTasks), taskName)
}
return true
}
// OnTaskComplete records a task completing execution
@@ -463,10 +538,13 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error {
tr.mu.Lock()
defer tr.mu.Unlock()
// Task execution tracking is now handled in the run() method
tr.tasks[name] = task
tr.cb.OnTaskSuccess(name)
// Execution tracking (the circuit-breaker concurrent count) is handled by
// Start()/run(): Start() reserves a slot via OnTaskStart and run() releases
// it via OnTaskComplete. Do NOT also OnTaskSuccess here — that would
// double-count every registered task (+1 on register, +1 on Start, -1 on
// stop), leaking one slot per task and eventually blocking all background
// task creation at the concurrency limit.
if tr.logger != nil {
tr.logger.Debug("Registered background task: %s", name)
@@ -499,9 +577,18 @@ func (tr *TaskRegistry) GetTask(name string) (*BackgroundTask, bool) {
return task, exists
}
// StopAllTasks stops all registered background tasks
// StopAllTasks stops all registered background tasks.
//
// Tasks can live in two places: those registered directly via
// TaskRegistry.RegisterTask (stored in tr.tasks) and process-global
// singletons created via TaskRegistry.CreateSingletonTask, which delegate to
// GetResourceManager().RegisterBackgroundTask and store the task in the
// ResourceManager's registry (see CreateSingletonTask below) — NOT in
// tr.tasks. Stop both so neither a direct task nor a singleton task leaks its
// ticker goroutine past the last Close.
func (tr *TaskRegistry) StopAllTasks() {
// First, copy the tasks map to avoid deadlock with GetTaskCount()
// First, stop tasks registered directly in this registry.
// Copy the tasks map to avoid deadlock with GetTaskCount().
tr.mu.Lock()
tasksCopy := make(map[string]*BackgroundTask, len(tr.tasks))
for name, task := range tr.tasks {
@@ -511,13 +598,15 @@ func (tr *TaskRegistry) StopAllTasks() {
tr.tasks = make(map[string]*BackgroundTask)
tr.mu.Unlock()
// Now stop all tasks without holding the lock
for name, task := range tasksCopy {
for _, task := range tasksCopy {
task.Stop()
if tr.logger != nil {
tr.logger.Debug("Stopped background task during shutdown: %s", name)
}
}
// Then stop process-global singleton tasks, which live in the
// ResourceManager's registry, not in tr.tasks (otherwise the singleton
// task teardown would be a silent no-op and leak every singleton task's
// ticker goroutine past the last Close).
GetResourceManager().StopAllTasks()
}
// GetTaskCount returns the number of active tasks
@@ -599,8 +688,9 @@ func GetGlobalTaskMemoryMonitor(logger *Logger) *TaskMemoryMonitor {
return globalTaskMemoryMonitor
}
// NewTaskMemoryMonitor creates a new memory monitor for task registry
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior
// NewTaskMemoryMonitor creates a new memory monitor for task registry.
//
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior.
func NewTaskMemoryMonitor(logger *Logger, registry *TaskRegistry) *TaskMemoryMonitor {
return GetGlobalTaskMemoryMonitor(logger)
}
@@ -712,7 +802,7 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
// Check for goroutine leaks (arbitrary threshold)
if stats.Goroutines > 100 {
mm.logger.Infof("High goroutine count detected: %d", stats.Goroutines)
mm.logger.Debugf("High goroutine count detected: %d", stats.Goroutines)
}
// Check for heap growth without corresponding GC activity
@@ -720,9 +810,14 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
historyLen := len(mm.statsHistory)
if historyLen >= 2 {
prev := mm.statsHistory[historyLen-2]
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
// Guard against div-by-zero (prev.HeapAlloc == 0) which would make
// heapGrowth == +Inf and fire a spurious "memory leak" alert for a
// first-ever measurement (R103).
if prev.HeapAlloc > 0 {
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
}
}
}
mm.mu.RUnlock()
+17 -13
View File
@@ -262,16 +262,17 @@ func TestAzureOIDCRegression(t *testing.T) {
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test that CSRF is preserved during Azure validation failures
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
// Should not be authenticated due to validation failure
// Should not be authenticated due to validation failure, and with no
// refresh token and no tokens to probe, should not claim an expiry
// state (matches the standard path: unauthenticated, no refresh).
if authenticated {
t.Error("Should not be authenticated when token validation fails")
}
// Should be marked as expired since no tokens work
if !expired && !needsRefresh {
t.Error("Should be marked as needing refresh or expired when validation fails")
if needsRefresh || expired {
t.Error("With no refresh token and no tokens to probe, should be (false,false,false)")
}
// Verify CSRF token is still preserved in session
@@ -453,7 +454,8 @@ func TestValidateGoogleTokens(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.validateGoogleTokens(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.validateGoogleTokensRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -637,7 +639,8 @@ func TestIsUserAuthenticated(t *testing.T) {
defer func() { ts.tOidc.issuerURL = originalIssuer }()
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.isUserAuthenticated(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.isUserAuthenticatedRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -688,7 +691,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
return session
},
expectedAuth: false,
expectedRefresh: true,
expectedRefresh: false,
expectedExpired: false,
description: "Unauthenticated Azure session without refresh token",
},
@@ -722,10 +725,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
return session
},
expectedAuth: true,
expectedAuth: false,
expectedRefresh: false,
expectedExpired: false,
description: "Azure session with opaque access token",
expectedExpired: true,
description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
},
{
name: "AuthenticatedWithBothTokensInvalid",
@@ -762,7 +765,8 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.validateAzureTokensRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
+23 -14
View File
@@ -29,8 +29,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
pressure := monitor.GetMemoryPressure()
assert.Equal(t, MemoryPressureNone, pressure)
// Collect stats to populate lastStats
monitor.GetCurrentStats()
// Explicitly sample to populate lastStats; GetCurrentStats is now a
// cached read and no longer forces a runtime.ReadMemStats.
monitor.Refresh()
// Now should return a valid pressure level
pressure = monitor.GetMemoryPressure()
@@ -46,11 +47,13 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Start monitoring should not panic
// Start monitoring should not panic. Interval is clamped to the
// minimum (30s); we rely on Refresh() when we need a synchronous
// sample instead of waiting for a tick.
assert.NotPanics(t, func() {
ctx := context.Background()
monitor.StartMonitoring(ctx, 100*time.Millisecond)
time.Sleep(GetTestDuration(50 * time.Millisecond))
monitor.StartMonitoring(ctx, 0)
monitor.Refresh()
})
// Clean up
@@ -117,6 +120,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
// Refresh forces a synchronous sample; GetCurrentStats is a cached
// read, so we sample first to guarantee fresh data.
monitor.Refresh()
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
assert.Greater(t, stats.HeapAllocBytes, uint64(0))
@@ -450,12 +456,12 @@ func TestMemoryMonitorIntegration(t *testing.T) {
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
defer monitor.StopMonitoring()
// Start monitoring
// Start monitoring. The interval is clamped to the minimum (30s) so
// the ticker won't fire during the test; drive the sample manually via
// Refresh() instead.
ctx := context.Background()
monitor.StartMonitoring(ctx, 50*time.Millisecond)
// Wait for at least one check
time.Sleep(GetTestDuration(150 * time.Millisecond))
monitor.StartMonitoring(ctx, 0)
monitor.Refresh()
// Get pressure (should be a valid pressure level)
pressure := monitor.GetMemoryPressure()
@@ -488,6 +494,7 @@ func TestMemoryStatsCollection(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
monitor.Refresh()
stats := monitor.GetCurrentStats()
assert.NotNil(t, stats)
@@ -501,6 +508,7 @@ func TestMemoryStatsCollection(t *testing.T) {
thresholds := DefaultMemoryAlertThresholds()
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
monitor.Refresh()
stats := monitor.GetCurrentStats()
// Should calculate and include pressure level
@@ -521,13 +529,14 @@ func TestMemoryStatsCollection(t *testing.T) {
// Allocate some memory
_ = make([]byte, 1024*1024) // 1MB
// Get stats before GC
beforeStats := monitor.GetCurrentStats()
// Get stats before GC (explicit Refresh so we have a fresh pre-GC
// snapshot to compare against, not the constructor baseline).
beforeStats := monitor.Refresh()
// Trigger GC
// Trigger GC (internally Refresh()es before and after)
monitor.TriggerGC()
// Get stats after GC
// Get stats after GC from cache (TriggerGC already refreshed it)
afterStats := monitor.GetCurrentStats()
// After GC should have different stats
+1100
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
package traefikoidc
import "testing"
// FuzzParseBearerJOSEHeader verifies the attacker-controlled JWT header parser
// never panics on arbitrary input (no slice OOB, no unbounded alloc, no
// JSON-engine surprise) regardless of token contents.
func FuzzParseBearerJOSEHeader(f *testing.F) {
seeds := []string{
"",
"...",
"abc.def.ghi",
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEifQ.sig.sig", // alg=RS256 kid=kid1
"::",
"snapshot=",
"a,b.zzz",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, token string) {
parseBearerJOSEHeader(token) // must not panic
})
}
// FuzzSanitizeBearerIdentifier verifies the identifier sanitizer never panics on
// arbitrary strings and always returns either a clean value or a *bearerError.
func FuzzSanitizeBearerIdentifier(f *testing.F) {
seeds := []string{
"",
"alice",
"user,group=admin",
"a\u202eb\u202c",
"\t \n",
"verylongidentifier-padding-padding-padding-padding",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, raw string) {
id, berr := sanitizeBearerIdentifier(raw, 256)
if berr == nil && id == "" {
t.Fatalf("sanitize ok but empty identifier for %q", raw)
}
})
}
+886
View File
@@ -0,0 +1,886 @@
package traefikoidc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
)
// =============================================================================
// Helper builders
// =============================================================================
// makeBearerJWT constructs a JWT with explicit header + claims for tests.
// Signature is opaque (b64("signature")) — bearer tests don't exercise the
// real cryptographic verifier; verification is bypassed via tokenCache pre-
// seed so the bearer pipeline under test sees a "verified" token.
func makeBearerJWT(t *testing.T, header, claims map[string]interface{}) string {
t.Helper()
hb, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal header: %v", err)
}
cb, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
return fmt.Sprintf("%s.%s.%s",
base64.RawURLEncoding.EncodeToString(hb),
base64.RawURLEncoding.EncodeToString(cb),
base64.RawURLEncoding.EncodeToString([]byte("signature")),
)
}
// defaultBearerHeader produces the standard RS256+kid header used in tests.
func defaultBearerHeader() map[string]interface{} {
return map[string]interface{}{"alg": "RS256", "kid": "test-kid"}
}
// defaultBearerClaims produces a baseline access-token claim set. Tests
// shallow-clone and override fields as needed.
func defaultBearerClaims() map[string]interface{} {
return map[string]interface{}{
"iss": "https://issuer.example.com",
"aud": "https://api.example.com",
"sub": "service-account-1",
"scope": "api:read api:write",
"exp": float64(time.Now().Add(time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
}
}
// makeBearerOIDC constructs a TraefikOidc wired for bearer auth tests. The
// real verifyTokenWithOpts pipeline is short-circuited via tokenCache pre-
// seed: any token Set into t.tokenCache returns nil from VerifyToken,
// letting tests exercise the post-verify bearer logic (classifier, identifier,
// throttle, header forwarding) without standing up JWKs.
func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
t.Helper()
sm := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: sm,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://issuer.example.com",
audience: "https://api.example.com",
clientID: "https://api.example.com",
tokenCache: NewTokenCache(),
excludedURLs: map[string]struct{}{"/favicon.ico": {}},
allowedRolesAndGroups: map[string]struct{}{},
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
ctx: context.Background(),
enableBearerAuth: true,
stripAuthorizationHeader: true,
bearerEmitWWWAuthenticate: true,
bearerOverridesCookie: false,
bearerIdentifierClaim: "sub",
maxIdentifierLength: 256,
maxTokenAge: 24 * time.Hour,
bearerFailureThreshold: 20,
bearerFailureWindow: 60 * time.Second,
bearerFailurePenalty: 60 * time.Second,
bearerFailureTracker: newBearerFailureTracker(20, 60*time.Second, 60*time.Second),
}
oidc.extractClaimsFunc = extractClaims
close(oidc.initComplete)
return oidc
}
// seedVerified pre-populates the tokenCache so verifyTokenWithOpts short-
// circuits to nil for the given token. Mirrors the production fast-return
// path at token_manager.go for previously-verified tokens.
func seedVerified(t *testing.T, oidc *TraefikOidc, token string, claims map[string]interface{}) {
t.Helper()
if oidc.tokenCache == nil {
oidc.tokenCache = NewTokenCache()
}
oidc.tokenCache.Set(token, claims, time.Hour)
}
// =============================================================================
// Unit tests — small helpers
// =============================================================================
func TestDetectBearerToken(t *testing.T) {
t.Parallel()
cases := []struct {
name string
header string
want string
ok bool
}{
{"missing header", "", "", false},
{"basic auth", "Basic abc", "", false},
{"bearer with token", "Bearer abc.def.ghi", "abc.def.ghi", true},
{"lowercase bearer", "bearer abc.def.ghi", "abc.def.ghi", true},
{"mixed case", "BeArEr abc.def.ghi", "abc.def.ghi", true},
{"empty token after prefix", "Bearer ", "", false},
{"bearer no space", "Bearerabc", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
got, ok := detectBearerToken(req)
if ok != tc.ok || got != tc.want {
t.Fatalf("got=(%q, %v), want=(%q, %v)", got, ok, tc.want, tc.ok)
}
})
}
}
func TestParseBearerJOSEHeader(t *testing.T) {
t.Parallel()
mk := func(t *testing.T, h map[string]interface{}) string {
return makeBearerJWT(t, h, map[string]interface{}{"sub": "x"})
}
cases := []struct {
header map[string]interface{}
name string
wantErr bool
}{
{name: "valid RS256", header: map[string]interface{}{"alg": "RS256", "kid": "k1"}, wantErr: false},
{name: "valid ES512", header: map[string]interface{}{"alg": "ES512", "kid": "abc-_.="}, wantErr: false},
{name: "alg=none rejected", header: map[string]interface{}{"alg": "none", "kid": "k1"}, wantErr: true},
{name: "alg=HS256 rejected", header: map[string]interface{}{"alg": "HS256", "kid": "k1"}, wantErr: true},
{name: "missing kid", header: map[string]interface{}{"alg": "RS256"}, wantErr: true},
{name: "kid too long", header: map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}, wantErr: true},
{name: "kid bad chars", header: map[string]interface{}{"alg": "RS256", "kid": "evil/../etc/passwd"}, wantErr: true},
{name: "kid with space", header: map[string]interface{}{"alg": "RS256", "kid": "key one"}, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
token := mk(t, tc.header)
err := parseBearerJOSEHeader(token)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestSanitiseBearerIdentifier(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
wantErr bool
}{
{"normal sub", "service-account-1", "service-account-1", false},
{"email-like", "[email protected]", "[email protected]", false},
{"trim whitespace", " abc ", "abc", false},
{"empty", "", "", true},
{"only whitespace", " ", "", true},
{"control char (newline)", "alice\nbob", "", true},
{"control char (CR)", "alice\rbob", "", true},
{"control char (NUL)", "alice\x00bob", "", true},
{"bidi override", "alice\u202ebob", "", true},
{"bidi isolate", "alice\u2066bob", "", true},
{"comma delimiter", "alice,bob", "", true},
{"semicolon delimiter", "alice;bob", "", true},
{"equals delimiter", "alice=bob", "", true},
{"over length", strings.Repeat("a", 257), "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := sanitizeBearerIdentifier(tc.in, 256)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Fatalf("got=%q want=%q", got, tc.want)
}
})
}
}
func TestResolveBearerIdentifier(t *testing.T) {
t.Parallel()
cases := []struct {
claims map[string]interface{}
name string
claim string
want string
wantErr bool
}{
{name: "default sub", claims: map[string]interface{}{"sub": "abc"}, claim: "", want: "abc"},
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
{name: "numeric claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", want: "123"}, // R102: scalar numbers stringified, not rejected
{name: "object claim", claims: map[string]interface{}{"sub": map[string]interface{}{"a": 1}}, claim: "sub", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveBearerIdentifier(tc.claims, tc.claim)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Fatalf("got=%q want=%q", got, tc.want)
}
})
}
}
func TestEnforceMultiAudienceAzp(t *testing.T) {
t.Parallel()
const cid = "https://api.example.com"
cases := []struct {
claims map[string]interface{}
name string
wantErr bool
}{
{name: "single string aud", claims: map[string]interface{}{"aud": "x"}, wantErr: false},
{name: "single element array", claims: map[string]interface{}{"aud": []interface{}{"x"}}, wantErr: false},
{name: "multi-aud with matching azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": cid}, wantErr: false},
{name: "multi-aud missing azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}}, wantErr: true},
{name: "multi-aud empty azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": ""}, wantErr: true},
{name: "multi-aud wrong azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": "other"}, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := enforceMultiAudienceAzp(tc.claims, cid)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestEnforceIatAge(t *testing.T) {
t.Parallel()
now := time.Now()
cases := []struct {
name string
iat float64
maxAge time.Duration
wantErr bool
}{
{name: "fresh", iat: float64(now.Unix()), maxAge: time.Hour, wantErr: false},
{name: "23h59m old, max 24h", iat: float64(now.Add(-23*time.Hour - 59*time.Minute).Unix()), maxAge: 24 * time.Hour, wantErr: false},
{name: "25h old, max 24h", iat: float64(now.Add(-25 * time.Hour).Unix()), maxAge: 24 * time.Hour, wantErr: true},
{name: "1970 token", iat: float64(0), maxAge: 24 * time.Hour, wantErr: true},
{name: "maxAge disabled (0)", iat: float64(0), maxAge: 0, wantErr: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := enforceIatAge(map[string]interface{}{"iat": tc.iat}, tc.maxAge)
if (err != nil) != tc.wantErr {
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestBearerFailureTracker(t *testing.T) {
t.Parallel()
tr := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
const ip = "10.0.0.1"
// Below threshold: not blocked.
for i := 0; i < 2; i++ {
tr.recordFailure(ip)
if b, _ := tr.blocked(ip); b {
t.Fatalf("blocked too early after %d failures", i+1)
}
}
// Threshold reached: blocked.
tr.recordFailure(ip)
if b, retry := tr.blocked(ip); !b || retry <= 0 {
t.Fatalf("expected blocked with positive retry, got=%v retry=%v", b, retry)
}
// A success while a penalty is active must NOT wipe the in-effect lockout
// (otherwise a single success could clear an attacker's penalty).
tr.recordSuccess(ip)
if b, _ := tr.blocked(ip); !b {
t.Fatalf("expected still blocked after success while penalty active")
}
// Other IPs are unaffected.
if b, _ := tr.blocked("10.0.0.2"); b {
t.Fatalf("unrelated IP should not be blocked")
}
// With an expired penalty, a success resets the counter so a subsequent
// sub-threshold failure does not immediately re-block.
tr2 := newBearerFailureTracker(3, 60*time.Second, 1*time.Millisecond)
const ip2 = "10.0.0.3"
for i := 0; i < 3; i++ {
tr2.recordFailure(ip2)
}
time.Sleep(5 * time.Millisecond) // let the short penalty expire
if b, _ := tr2.blocked(ip2); b {
t.Fatalf("expected unblocked after penalty expiry")
}
tr2.recordSuccess(ip2) // resets count since penalty has passed
tr2.recordFailure(ip2) // single failure, well below threshold
if b, _ := tr2.blocked(ip2); b {
t.Fatalf("expected unblocked: counter should have reset after success")
}
}
// =============================================================================
// Integration tests — full ServeHTTP via the bearer pipeline
// =============================================================================
func TestServeHTTP_Bearer_HappyPath(t *testing.T) {
t.Parallel()
var nextCalled atomic.Bool
var capturedHeaders http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
capturedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled.Load() {
t.Fatalf("expected next handler to run; got status=%d body=%q", rw.Code, rw.Body.String())
}
if rw.Code != http.StatusOK {
t.Fatalf("status=%d, want 200", rw.Code)
}
if got := capturedHeaders.Get("X-Forwarded-User"); got != "service-account-1" {
t.Fatalf("X-Forwarded-User=%q, want service-account-1", got)
}
if got := capturedHeaders.Get("Authorization"); got != "" {
t.Fatalf("Authorization should be stripped, got=%q", got)
}
}
func TestServeHTTP_Bearer_StripAuthDisabled(t *testing.T) {
t.Parallel()
var capturedAuth string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.stripAuthorizationHeader = false
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !strings.HasPrefix(capturedAuth, "Bearer ") {
t.Fatalf("expected Authorization to be forwarded, got=%q", capturedAuth)
}
}
func TestServeHTTP_Bearer_RejectIDToken(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for ID token rejection")
})
oidc := makeBearerOIDC(t, next)
// ID-token shape: nonce claim present and no scope. detectTokenType
// returns true.
claims := map[string]interface{}{
"iss": "https://issuer.example.com",
"aud": "https://api.example.com",
"sub": "user-1",
"nonce": "n-0S6_WzA2Mj",
"exp": float64(time.Now().Add(time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
}
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
if wa := rw.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) {
t.Fatalf("expected WWW-Authenticate invalid_token, got=%q", wa)
}
}
func TestServeHTTP_Bearer_AlgNoneRejected(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for alg=none")
})
oidc := makeBearerOIDC(t, next)
header := map[string]interface{}{"alg": "none", "kid": "k1"}
claims := defaultBearerClaims()
token := makeBearerJWT(t, header, claims)
// Even if we pre-seeded the cache, the early alg pin runs FIRST.
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_KidTooLongRejected(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for oversized kid")
})
oidc := makeBearerOIDC(t, next)
header := map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}
claims := defaultBearerClaims()
token := makeBearerJWT(t, header, claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_MultiAudRequiresAzp(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for multi-aud without azp")
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
delete(claims, "azp")
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_MultiAudWithAzpAccepted(t *testing.T) {
t.Parallel()
var nextCalled atomic.Bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
claims["azp"] = oidc.clientID
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusOK || !nextCalled.Load() {
t.Fatalf("expected 200 + next called; got status=%d called=%v", rw.Code, nextCalled.Load())
}
}
func TestServeHTTP_Bearer_IatTooOldRejected(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for old iat")
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
claims["iat"] = float64(time.Now().Add(-25 * time.Hour).Unix())
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_IdentifierWithBidiRejected(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for bidi identifier")
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
claims["sub"] = "alice\u202ebob"
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_ReplayRegression(t *testing.T) {
t.Parallel()
var successCount atomic.Int32
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
successCount.Add(1)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
claims["jti"] = "regression-jti"
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
for i := 0; i < 100; i++ {
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("iteration %d: status=%d, want 200", i, rw.Code)
}
}
if successCount.Load() != 100 {
t.Fatalf("successCount=%d, want 100", successCount.Load())
}
}
func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run during throttle test")
})
oidc := makeBearerOIDC(t, next)
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
// Send malformed bearers from the same RemoteAddr until threshold trips.
send := func() *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/work", nil)
req.RemoteAddr = "10.0.0.5:1234"
req.Header.Set("Authorization", "Bearer not-a-jwt")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
return rw
}
for i := 0; i < 3; i++ {
rw := send()
if rw.Code != http.StatusUnauthorized {
t.Fatalf("pre-throttle iteration %d: status=%d, want 401", i, rw.Code)
}
}
// 4th request: throttled.
rw := send()
if rw.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 after threshold, got %d", rw.Code)
}
if ra := rw.Header().Get("Retry-After"); ra == "" {
t.Fatalf("expected Retry-After header on 429")
}
}
func TestServeHTTP_Bearer_Introspection503_NotThrottleFailure(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run: introspection 503 should reject request")
})
oidc := makeBearerOIDC(t, next)
oidc.requireTokenIntrospection = true
// Unreachable introspection endpoint -> every request returns 503.
oidc.introspectionURL = "http://127.0.0.1:1/"
oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
send := func() *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/work", nil)
req.RemoteAddr = "10.0.0.7:1234"
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
return rw
}
// Four requests during an introspection outage. Each is a 503 and the
// per-IP failure count must stay at 0 so the threshold (3) never trips.
// On OLD code each 503 was counted as a throttle failure, so the 4th
// request would come back 429 (penalty box) instead of 503.
for i := 0; i < 4; i++ {
rw := send()
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("iteration %d: status=%d, want %d", i, rw.Code, http.StatusServiceUnavailable)
}
}
}
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
t.Parallel()
var capturedAuth string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.excludedURLs = map[string]struct{}{"/favicon.ico": {}}
req := httptest.NewRequest("GET", "/favicon.ico", nil)
req.Header.Set("Authorization", "Bearer abc.def.ghi")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("excluded path should pass; got %d", rw.Code)
}
if capturedAuth != "" {
t.Fatalf("Authorization must be stripped on excluded paths, got=%q", capturedAuth)
}
}
func TestServeHTTP_Bearer_RolesGate(t *testing.T) {
t.Parallel()
cases := []struct {
name string
rolesClaim []interface{}
want int
}{
{name: "matching role", rolesClaim: []interface{}{"admin"}, want: http.StatusOK},
{name: "no matching role", rolesClaim: []interface{}{"viewer"}, want: http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.allowedRolesAndGroups = map[string]struct{}{"admin": {}}
oidc.roleClaimName = "roles"
claims := defaultBearerClaims()
claims["roles"] = tc.rolesClaim
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != tc.want {
t.Fatalf("status=%d, want %d", rw.Code, tc.want)
}
})
}
}
func TestServeHTTP_Bearer_CookieWinsByDefault(t *testing.T) {
t.Parallel()
// Both cookie and bearer present: cookie path runs (which will redirect
// to /authorize since the cookie is empty/unauthenticated).
var nextCalled atomic.Bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
prefix := oidc.sessionManager.GetCookiePrefix()
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
// Cookie path consumed the request; bearer was ignored. Since the
// cookie is empty, the cookie path will either 302 to /authorize or
// return 401 — in either case, next must NOT be called.
if nextCalled.Load() {
t.Fatalf("next must not be called when bearer is ignored due to cookie precedence")
}
}
func TestServeHTTP_Bearer_BearerOverridesCookie(t *testing.T) {
t.Parallel()
var nextCalled atomic.Bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.bearerOverridesCookie = true
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
prefix := oidc.sessionManager.GetCookiePrefix()
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled.Load() || rw.Code != http.StatusOK {
t.Fatalf("expected bearer to win with override; status=%d called=%v", rw.Code, nextCalled.Load())
}
}
func TestServeHTTP_Bearer_OversizedToken(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for oversized token")
})
oidc := makeBearerOIDC(t, next)
huge := strings.Repeat("a", AccessTokenConfig.MaxLength+1)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+huge)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_MalformedJWT(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run for malformed JWT")
})
oidc := makeBearerOIDC(t, next)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer not.jwt") // 1 dot
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status=%d, want 401", rw.Code)
}
}
func TestServeHTTP_Bearer_FeatureOffPassesThrough(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Should not be reached: cookie path runs and (with no session)
// will redirect or 401. We assert no panic / next not called.
t.Fatalf("next must not run when bearer is off and no valid session exists")
})
oidc := makeBearerOIDC(t, next)
oidc.enableBearerAuth = false
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
req := httptest.NewRequest("GET", "/api/work", nil)
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
// Expect non-200: either 302 to /authorize or 401. The point is the
// bearer pipeline didn't run.
if rw.Code == http.StatusOK {
t.Fatalf("expected non-200 when bearer is off; got %d", rw.Code)
}
}
// =============================================================================
// Startup validation tests
// =============================================================================
func TestStartupValidation_BearerRequiresAudience(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.ProviderURL = "https://issuer.example.com"
cfg.ClientID = "id"
cfg.ClientSecret = "secret"
cfg.CallbackURL = "/oauth/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
cfg.EnableBearerAuth = true
cfg.Audience = ""
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
if err == nil || !strings.Contains(err.Error(), "requires Audience") {
t.Fatalf("expected audience-required error, got %v", err)
}
}
func TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.ProviderURL = "https://issuer.example.com"
cfg.ClientID = "id"
cfg.ClientSecret = "secret"
cfg.CallbackURL = "/oauth/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
cfg.EnableBearerAuth = true
cfg.Audience = ""
cfg.Resource = "https://api.example.com"
mw, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
if err != nil {
t.Fatalf("expected Resource to satisfy the bearer-auth audience requirement, got %v", err)
}
defer mw.(*TraefikOidc).Close()
}
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.ProviderURL = "https://issuer.example.com"
cfg.ClientID = "id"
cfg.ClientSecret = "secret"
cfg.CallbackURL = "/oauth/callback"
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
cfg.EnableBearerAuth = true
cfg.Audience = "https://api.example.com"
cfg.BearerIdentifierClaim = "email"
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
if err == nil || !strings.Contains(err.Error(), "bearerIdentifierClaim=\"email\"") {
t.Fatalf("expected email-identifier rejection, got %v", err)
}
}
// =============================================================================
// Principal invariants
// =============================================================================
func TestBuildPrincipalFromSession_NoIdentifier(t *testing.T) {
t.Parallel()
oidc := &TraefikOidc{logger: NewLogger("error")}
if p := oidc.buildPrincipalFromSession(nil); p != nil {
t.Fatalf("nil session must produce nil principal")
}
}
+24
View File
@@ -0,0 +1,24 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
)
// Addresses in one IPv6 /64 must share one failure-tracker key.
func TestClientIPForBearerCollapsesIPv6Prefix(t *testing.T) {
key := func(addr string) string {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = addr
return clientIPForBearer(r)
}
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:2:ffff::9]:2"); a != b {
t.Fatalf("same /64 got different keys: %q vs %q", a, b)
}
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:3::1]:1"); a == b {
t.Fatalf("different /64 shared key %q", a)
}
if got := key("192.0.2.7:80"); got != "192.0.2.7" {
t.Fatalf("ipv4 key = %q", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
package traefikoidc
import (
"fmt"
"testing"
"time"
)
// FIX-28 regression tests.
//
// R117's map-growth sweep in recordFailure deleted every entry whose
// penaltyUntil was before the cutoff. An entry that has not tripped the
// penalty box yet has a ZERO penaltyUntil, which is always before any
// cutoff — so once the map passed defaultBearerEntrySweepThreshold, every
// accumulating (not-yet-tripped) counter was discarded regardless of how
// recently it started, no matter how recent firstFailureAt was. A source
// whose failures interleave with sweeps could never reach the threshold.
// TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters guards the
// exact scenario from the finding: threshold 3, 2 failures for a target
// IP, then 1025 other IPs failing once each (crossing the sweep
// threshold), then a 3rd failure for the target IP. The target's count
// must survive the sweep and trip the penalty box.
// Fail-on-old: the sweep discards the target's in-progress counter, so the
// 3rd failure restarts it at count=1 and blocked() reports false.
func TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters(t *testing.T) {
b := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
b.recordFailure("target-ip")
b.recordFailure("target-ip")
// Push the map past defaultBearerEntrySweepThreshold with distinct,
// equally-fresh IPs so recordFailure's sweep branch runs.
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
b.recordFailure(fmt.Sprintf("other-ip-%d", i))
}
b.recordFailure("target-ip")
blocked, _ := b.blocked("target-ip")
if !blocked {
t.Fatal("a source whose failures are recent (within the window) must not lose its count to the map-growth sweep, and must trip the penalty box on its 3rd failure")
}
}
// TestBearerFailureTracker_SweepRemovesTrulyStaleEntries is the positive
// control: an entry that is BOTH untripped AND outside the window (a
// source that failed once, long ago, and never returned) must still be
// swept once the map grows past the threshold — otherwise the fix would
// defeat the sweep's original memory-bound purpose entirely.
func TestBearerFailureTracker_SweepRemovesTrulyStaleEntries(t *testing.T) {
b := newBearerFailureTracker(20, 60*time.Second, 60*time.Second)
b.mu.Lock()
b.entries["stale-ip"] = &bearerFailureEntry{
firstFailureAt: time.Now().Add(-time.Hour), // well outside the 60s window
count: 1,
// penaltyUntil left zero: never tripped.
}
b.mu.Unlock()
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
b.recordFailure(fmt.Sprintf("filler-ip-%d", i))
}
b.mu.Lock()
_, stillPresent := b.entries["stale-ip"]
b.mu.Unlock()
if stillPresent {
t.Fatal("an untripped entry whose firstFailureAt is outside the window must still be swept once the map grows past the threshold")
}
}
+100
View File
@@ -0,0 +1,100 @@
package traefikoidc
import (
"testing"
"time"
)
// FIX-24 regression test.
//
// buildPrincipalFromBearerToken derived the IdP-logout-invalidation
// "createdAt" from the token's iat claim, falling back to time.Now() when
// iat was absent. Since 8640451 made iat OPTIONAL in jwt.Verify (R126), an
// access token without iat now reaches this check. time.Now() makes such a
// token always look newer than any logout event (isSessionInvalidated only
// matches when invalidationTime >= createdAt), so IdP-initiated
// (backchannel) logout never revokes it — reintroducing the R98 defect the
// cookie path already fixed by falling back to zero time instead.
//
// Fail-on-old: a bearer token without iat, for a sid invalidated by
// backchannel logout, still authenticates (200) after the logout.
func TestBearerLogoutInvalidation_NoIatFallsClosedNotOpen(t *testing.T) {
cache := NewCache()
defer cache.Close()
oidc := makeBearerOIDC(t, nil)
oidc.sessionInvalidationCache = cache
// Disable the unrelated maxTokenAge/enforceIatAge bound (a DIFFERENT
// check that also rejects a missing iat, and that New() always turns on
// in production — 0/unset becomes 24h, main.go:357-362 — this override
// only exists because this test constructs *TraefikOidc directly,
// bypassing New()) so this test isolates the isSessionInvalidated
// createdAt fallback under test. That fallback is what actually matters
// once maxTokenAge is 0: with maxTokenAge > 0 (the production default),
// enforceIatAge rejects an iat-less token before this logout check is
// even reached.
oidc.maxTokenAge = 0
claims := defaultBearerClaims()
delete(claims, "iat") // provider omits iat (now legal per R126)
claims["sub"] = "user-1"
claims["sid"] = "session-1"
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
// Backchannel logout invalidates the session a full hour before this
// request (set directly rather than via invalidateSession's real-clock
// Unix() call, so the comparison is deterministic regardless of
// test-execution timing rather than depending on two time.Now() calls
// landing in the same second). The buggy time.Now() fallback for
// createdAt makes the token look newer than this invalidation
// (invalidationTime.Before(createdAt) == true -> NOT invalidated); the
// fix's zero-time fallback is never after any invalidation timestamp.
invalidatedAt := time.Now().Add(-time.Hour).Unix()
cache.Set(oidc.buildSessionInvalidationKey("sid", "session-1"), invalidatedAt, time.Hour)
_, bErr := oidc.buildPrincipalFromBearerToken(token)
if bErr == nil {
t.Fatal("a bearer token without iat, whose sid was invalidated by backchannel logout, must be rejected (fail closed), not authenticated as if issued after the logout")
}
}
// TestBearerLogoutInvalidation_WithIatStillHonored is the positive control:
// a token WITH iat issued before the logout must still be invalidated (the
// existing R146 behavior), and one issued after must still pass.
func TestBearerLogoutInvalidation_WithIatStillHonored(t *testing.T) {
cache := NewCache()
defer cache.Close()
oidc := makeBearerOIDC(t, nil)
oidc.sessionInvalidationCache = cache
if err := oidc.invalidateSession("session-2", ""); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
// Issued well before the logout above.
staleClaims := defaultBearerClaims()
staleClaims["sub"] = "user-2"
staleClaims["sid"] = "session-2"
staleClaims["iat"] = float64(time.Now().Add(-time.Hour).Unix())
staleToken := makeBearerJWT(t, defaultBearerHeader(), staleClaims)
seedVerified(t, oidc, staleToken, staleClaims)
if _, bErr := oidc.buildPrincipalFromBearerToken(staleToken); bErr == nil {
t.Fatal("a token issued before the logout must still be invalidated")
}
// Freshly issued after the logout, for an unrelated sid.
freshClaims := defaultBearerClaims()
freshClaims["sub"] = "user-3"
freshClaims["sid"] = "session-3"
freshClaims["iat"] = float64(time.Now().Unix())
freshToken := makeBearerJWT(t, defaultBearerHeader(), freshClaims)
seedVerified(t, oidc, freshToken, freshClaims)
if _, bErr := oidc.buildPrincipalFromBearerToken(freshToken); bErr != nil {
t.Fatalf("a freshly issued token for an uninvalidated sid must pass, got: %v", bErr)
}
}
+390
View File
@@ -0,0 +1,390 @@
package traefikoidc
import (
"testing"
"time"
)
// FIX-03 regression tests.
//
// R159 sent every opaque (non-JWT) bearer token to
// buildPrincipalFromOpaqueIntrospection whenever requireTokenIntrospection
// was enabled, with none of the JWT-path gates: allowOpaqueTokens was never
// checked, the introspected client_id/audience was never bound to this
// client when the operator left audience==clientID (the common default),
// and IdP-initiated logout (isSessionInvalidated) was never consulted. Any
// introspected active=true token from the same IdP therefore authenticated,
// including one issued to a completely different client.
// TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens guards the
// allowOpaqueTokens gate: with it left at its default (false), an opaque
// bearer token must be rejected even when requireTokenIntrospection is on
// and the introspection endpoint reports the token active.
func TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens(t *testing.T) {
active := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: active},
requireTokenIntrospection: true,
allowOpaqueTokens: false, // default
clientID: "my-client",
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("opaque bearer token must be rejected when allowOpaqueTokens is false, even with requireTokenIntrospection on")
}
}
// TestBearerOpaqueIntrospection_RejectsForeignClient guards the client
// binding: when audience==clientID (no distinct API audience configured),
// an introspected token whose client_id names another client, and whose
// aud does not contain this client either, must be rejected.
func TestBearerOpaqueIntrospection_RejectsForeignClient(t *testing.T) {
foreign := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "other-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: foreign},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected token issued to a different client_id must be rejected")
}
}
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID is the positive
// control: client_id matching this client must still authenticate.
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID(t *testing.T) {
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
}
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("a token issued to this client_id must be accepted, got: %v", bErr)
}
if p.Identifier != "user-1" {
t.Fatalf("expected identifier %q, got %q", "user-1", p.Identifier)
}
}
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud is the positive
// control for providers that omit client_id from the introspection
// response but carry it in aud instead (RFC 7662 leaves client_id
// optional).
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud(t *testing.T) {
own := &IntrospectionResponse{Active: true, Sub: "user-1", Aud: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("a token whose aud contains this client_id must be accepted, got: %v", bErr)
}
}
// TestBearerOpaqueIntrospection_InvalidatedSessionRejected guards the
// IdP-initiated-logout check: a subject invalidated via backchannel logout
// must be rejected even though the introspected token itself reports
// active=true. The introspection response carries no sid (RFC 7662 does
// not define one), so this checks invalidation by sub, mirroring the JWT
// bearer path's isSessionInvalidated(sid, sub, createdAt) call with an
// empty sid.
func TestBearerOpaqueIntrospection_InvalidatedSessionRejected(t *testing.T) {
cache := NewCache()
defer cache.Close()
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
sessionInvalidationCache: cache,
}
if err := tObj.invalidateSession("", "user-1"); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected token for a subject invalidated by backchannel logout must be rejected")
}
}
// TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted is the positive
// control: a subject with no invalidation entry must still authenticate.
func TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted(t *testing.T) {
cache := NewCache()
defer cache.Close()
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
sessionInvalidationCache: cache,
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("a subject with no invalidation entry must authenticate, got: %v", bErr)
}
}
// TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim guards that the
// identifier is resolved through bearerIdentifierClaim + sanitizeBearerIdentifier
// like the JWT bearer path, instead of always preferring sub.
func TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim(t *testing.T) {
own := &IntrospectionResponse{Active: true, Sub: "user-1", Username: "svc-account", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
bearerIdentifierClaim: "username",
}
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("unexpected error: %v", bErr)
}
if p.Identifier != "svc-account" {
t.Fatalf("expected identifier from configured claim %q, got %q", "username", p.Identifier)
}
}
// TestBearerOpaqueIntrospection_MaxTokenAgeEnforced guards that maxTokenAge
// bounds the introspected token's iat like enforceIatAge does for the JWT
// path, when the AS returns one.
func TestBearerOpaqueIntrospection_MaxTokenAgeEnforced(t *testing.T) {
stale := &IntrospectionResponse{
Active: true,
Sub: "user-1",
ClientID: "my-client",
Iat: time.Now().Add(-2 * time.Hour).Unix(),
}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: stale},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
maxTokenAge: time.Hour,
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected token whose iat is older than maxTokenAge must be rejected")
}
}
// TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge guards
// that an opaque token whose introspection response carries no iat is
// bound by maxTokenAge exactly like enforceIatAge binds the JWT path: when
// maxTokenAge > 0 (New() always sets it so — 0/unset becomes 24h,
// main.go's config-default closure), a missing iat gives nothing to bound
// the token's age against, so it must fail closed rather than bypass the
// age check entirely.
func TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge(t *testing.T) {
noIat := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"} // Iat left zero
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: noIat},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
maxTokenAge: 24 * time.Hour,
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected opaque token with no iat must be rejected when maxTokenAge is set, matching enforceIatAge's fail-closed contract on the JWT path")
}
}
// TestBearerOpaqueIntrospection_SidOnlyLogoutRejected guards front-channel
// (sid-only) logout reaching an opaque bearer token. isSessionInvalidated
// used to be called with a hardcoded empty sid, so a logout recorded only
// by sid (front-channel logout always records this way; so does a
// back-channel logout token carrying only sid) never revoked an opaque
// token even when the introspection response itself carried a sid.
func TestBearerOpaqueIntrospection_SidOnlyLogoutRejected(t *testing.T) {
cache := NewCache()
defer cache.Close()
withSid := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Sid: "session-1"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: withSid},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
sessionInvalidationCache: cache,
}
if err := tObj.invalidateSession("session-1", ""); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected token whose sid was invalidated by a sid-only (front-channel) logout must be rejected, when the introspection response carries that sid")
}
}
// TestBearerOpaqueIntrospection_RevokedTokenRejected guards the local
// revocation blacklist: the JWT bearer path rejects a raw token that
// RevokeToken blacklisted (verifyTokenWithOpts, token_manager.go:71-75), and
// handleLogout calls RevokeToken on the session's access token specifically
// so "a token captured before logout ... cannot be reused" (helpers.go).
// buildPrincipalFromOpaqueIntrospection never consulted t.tokenBlacklist, so
// a captured opaque bearer token kept authenticating as the victim after
// logout for as long as the IdP still reported it active.
func TestBearerOpaqueIntrospection_RevokedTokenRejected(t *testing.T) {
blacklist := NewCache()
defer blacklist.Close()
active := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: active},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
}
defer tObj.tokenCache.Close()
const token = "opaque-victim-token"
tObj.RevokeToken(token)
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection(token)
if bErr == nil {
t.Fatal("an opaque bearer token blacklisted by RevokeToken (logout) must be rejected, even though introspection still reports it active")
}
if bErr.kind != bearerErrTokenInactive {
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
}
}
// TestBearerOpaqueIntrospection_BlacklistedJtiRejected guards the jti side
// of the same gate: when the introspection response carries a jti and that
// jti is blacklisted (e.g. the IdP echoes the same jti it minted for a JWT
// RevokeToken already blacklisted by jti), the opaque path must reject it
// too, unless disableReplayDetection is set — mirroring the JWT path's
// tokenCache-hit jti check (token_manager.go:90-96).
func TestBearerOpaqueIntrospection_BlacklistedJtiRejected(t *testing.T) {
blacklist := NewCache()
defer blacklist.Close()
blacklist.Set("revoked-jti-1", true, time.Hour)
withJti := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client", Jti: "revoked-jti-1"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: withJti},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
tokenBlacklist: blacklist,
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an opaque bearer token whose introspection jti is blacklisted must be rejected")
}
if bErr.kind != bearerErrTokenInactive {
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
}
}
// TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled
// guards the disableReplayDetection escape hatch on the jti-blacklist gate
// (bearer_auth.go:928): an operator who has turned off replay/JTI tracking
// must not be surprised by this gate rejecting an otherwise-valid token
// whose jti happens to collide with a blacklisted one. The raw token itself
// is NOT blacklisted here — only its jti is — so this isolates the jti
// branch from the raw-token blacklist check covered by
// _RevokedTokenRejected above.
func TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled(t *testing.T) {
blacklist := NewCache()
defer blacklist.Close()
blacklist.Set("revoked-jti-1", true, time.Hour)
withJti := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Jti: "revoked-jti-1"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: withJti},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
tokenBlacklist: blacklist,
disableReplayDetection: true,
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("a blacklisted jti must be ignored when disableReplayDetection is true, got: %v", bErr)
}
}
// TestBearerOpaqueIntrospection_NbfFutureRejected guards the not-before
// check the JWT path applies through verifyTimeClaims and the session path
// applies through validateOpaqueToken (token_introspection.go:258-263): an
// introspection response reporting a future nbf must be rejected on the
// bearer path too.
func TestBearerOpaqueIntrospection_NbfFutureRejected(t *testing.T) {
notYetValid := &IntrospectionResponse{
Active: true,
Sub: "user-1",
ClientID: "my-client",
Nbf: time.Now().Add(time.Hour).Unix(),
}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: notYetValid},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
}
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr == nil {
t.Fatal("an introspected token with nbf in the future must be rejected")
}
if bErr.kind != bearerErrTokenInactive {
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
}
}
// TestBearerOpaqueIntrospection_UsesClientIDClaim guards that
// bearerIdentifierClaim can resolve against any IntrospectionResponse
// member, not just sub/username — client_id, scope, iss, jti and aud must
// all be available too.
func TestBearerOpaqueIntrospection_UsesClientIDClaim(t *testing.T) {
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
tObj := &TraefikOidc{
logger: newNoOpLogger(),
introspectionCache: &stubIntrospectionCache{v: own},
requireTokenIntrospection: true,
allowOpaqueTokens: true,
clientID: "my-client",
bearerIdentifierClaim: "client_id",
}
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
if bErr != nil {
t.Fatalf("unexpected error: %v", bErr)
}
if p.Identifier != "my-client" {
t.Fatalf("expected identifier from configured claim %q, got %q", "client_id", p.Identifier)
}
}
+137
View File
@@ -0,0 +1,137 @@
package traefikoidc
import (
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// testCertPEM returns a valid PEM-encoded certificate harvested from an
// httptest.NewTLSServer. Using httptest keeps the test free of any
// handwritten static cert that could expire.
func testCertPEM(t *testing.T) string {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
t.Cleanup(srv.Close)
cert := srv.Certificate()
if cert == nil {
t.Fatal("httptest.NewTLSServer did not expose a certificate")
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
func TestLoadCACertPool_Empty(t *testing.T) {
cfg := &Config{}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool != nil {
t.Errorf("expected nil pool when no CA source configured, got %v", pool)
}
}
func TestLoadCACertPool_InlinePEM(t *testing.T) {
cfg := &Config{CACertPEM: testCertPEM(t)}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool for valid CACertPEM")
}
}
func TestLoadCACertPool_InlinePEM_Garbage(t *testing.T) {
cfg := &Config{CACertPEM: "not a pem"}
pool, err := cfg.loadCACertPool()
if err == nil {
t.Fatal("expected error for garbage CACertPEM, got nil")
}
if pool != nil {
t.Errorf("expected nil pool on error, got %v", pool)
}
if !strings.Contains(err.Error(), "caCertPEM") {
t.Errorf("error should name the failing field, got: %v", err)
}
}
func TestLoadCACertPool_FilePath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ca.pem")
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
t.Fatalf("writing temp PEM: %v", err)
}
cfg := &Config{CACertPath: path}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool for valid CACertPath")
}
}
func TestLoadCACertPool_FilePath_Missing(t *testing.T) {
cfg := &Config{CACertPath: "/does/not/exist/ca.pem"}
pool, err := cfg.loadCACertPool()
if err == nil {
t.Fatal("expected error for missing CACertPath, got nil")
}
if pool != nil {
t.Errorf("expected nil pool on error, got %v", pool)
}
}
func TestLoadCACertPool_Combined(t *testing.T) {
// Both inline and file sources populated — certificates from both should
// be accepted into the same pool.
dir := t.TempDir()
path := filepath.Join(dir, "ca.pem")
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
t.Fatalf("writing temp PEM: %v", err)
}
cfg := &Config{CACertPath: path, CACertPEM: testCertPEM(t)}
pool, err := cfg.loadCACertPool()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pool == nil {
t.Fatal("expected non-nil pool when both sources set")
}
}
func TestSharedTransportPool_ConfigKeyDistinguishesCAAndSkipVerify(t *testing.T) {
p := GetGlobalTransportPool()
cfgSystem := DefaultHTTPClientConfig()
cfgSkip := DefaultHTTPClientConfig()
cfgSkip.InsecureSkipVerify = true
cfgCustomCA := DefaultHTTPClientConfig()
pool, err := (&Config{CACertPEM: testCertPEM(t)}).loadCACertPool()
if err != nil {
t.Fatalf("loadCACertPool: %v", err)
}
cfgCustomCA.RootCAs = pool
keys := map[string]string{
"system": p.configKey(cfgSystem),
"skip": p.configKey(cfgSkip),
"customCA": p.configKey(cfgCustomCA),
}
seen := make(map[string]string, len(keys))
for name, key := range keys {
if dup, ok := seen[key]; ok {
t.Errorf("configKey collision: %s and %s share key %q", name, dup, key)
}
seen[key] = name
}
}
-79
View File
@@ -2,7 +2,6 @@ package traefikoidc
import (
"fmt"
"sync"
"testing"
"time"
)
@@ -161,81 +160,3 @@ func BenchmarkLRUStrategy_EstimateSize(b *testing.B) {
strategy.EstimateSize(item)
}
}
// =============================================================================
// SHARDED CACHE BENCHMARKS
// =============================================================================
func BenchmarkShardedCache(b *testing.B) {
b.Run("Set", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
})
b.Run("Get", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
for i := 0; i < 10000; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get(fmt.Sprintf("key-%d", i%10000))
}
})
b.Run("ParallelSetGet", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, i, 5*time.Minute)
cache.Get(key)
i++
}
})
})
}
// BenchmarkShardedVsGlobalMutex compares sharded cache with global mutex approach
func BenchmarkShardedVsGlobalMutex(b *testing.B) {
b.Run("ShardedCache64", func(b *testing.B) {
cache := NewShardedCache(64, 100000)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
if !cache.Exists(key) {
cache.Set(key, true, 5*time.Minute)
}
i++
}
})
})
b.Run("GlobalMutexCache", func(b *testing.B) {
var mu sync.RWMutex
data := make(map[string]bool)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("jti-%d", i%10000)
mu.RLock()
_, exists := data[key]
mu.RUnlock()
if !exists {
mu.Lock()
data[key] = true
mu.Unlock()
}
i++
}
})
})
}
+63 -15
View File
@@ -5,10 +5,6 @@ import (
"time"
)
const (
defaultBlacklistDuration = 24 * time.Hour
)
// CacheManager manages all caching components using the universal cache
type CacheManager struct {
manager *UniversalCacheManager
@@ -16,19 +12,23 @@ type CacheManager struct {
}
var (
globalCacheManagerInstance *CacheManager
cacheManagerInitOnce sync.Once
globalCacheManagerInstance *CacheManager
cacheManagerInitOnce sync.Once
cacheManagerActiveFingerprint string
)
// GetGlobalCacheManager returns a singleton CacheManager instance
// Deprecated: Use GetGlobalCacheManagerWithConfig instead
// GetGlobalCacheManager returns a singleton CacheManager instance.
//
// Deprecated: Use GetGlobalCacheManagerWithConfig instead.
func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
return GetGlobalCacheManagerWithConfig(wg, nil)
}
// GetGlobalCacheManagerWithConfig returns a singleton CacheManager instance with optional Redis configuration
func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheManager {
fp := redisFingerprint(config)
cacheManagerInitOnce.Do(func() {
cacheManagerActiveFingerprint = fp
var redisConfig *RedisConfig
var logger *Logger
@@ -54,14 +54,32 @@ func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheM
manager: GetUniversalCacheManagerWithConfig(logger, redisConfig),
}
})
// Warn loudly if a later instance asks for a DIFFERENT explicit Redis
// backend than the one that won initialization: the cache manager is a
// process-global singleton shared across plugin instances (yaegi), so this
// instance's divergent configuration is silently ignored, which would
// otherwise collapse cache/state isolation between routes (rank 9).
if fp != "" && cacheManagerActiveFingerprint != "" && fp != cacheManagerActiveFingerprint {
NewLogger(config.LogLevel).Errorf("cache manager already initialized with Redis backend %q; this instance's Redis backend %q is IGNORED (process-global singleton). Use a single consistent cache configuration across all routes.", cacheManagerActiveFingerprint, fp)
}
return globalCacheManagerInstance
}
// redisFingerprint returns a stable identifier for an explicitly-enabled Redis
// backend (address + key prefix), or "" when Redis is not explicitly enabled.
// Used to detect divergent cache configurations across plugin instances.
func redisFingerprint(config *Config) string {
if config == nil || config.Redis == nil || !config.Redis.Enabled {
return ""
}
return config.Redis.Address + "|" + config.Redis.KeyPrefix
}
// GetSharedTokenBlacklist returns the shared token blacklist cache
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache(), managed: true}
}
// GetSharedTokenCache returns the shared token cache
@@ -85,7 +103,7 @@ func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &JWKCache{cache: cm.manager.GetJWKCache()}
return &JWKCache{cache: cm.manager.GetJWKCache(), lastForceRefresh: make(map[string]time.Time)}
}
// GetSharedIntrospectionCache returns the shared token introspection cache
@@ -93,7 +111,7 @@ func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache(), managed: true}
}
// GetSharedTokenTypeCache returns the shared token type cache
@@ -101,7 +119,23 @@ func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache()}
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache(), managed: true}
}
// GetSharedSessionInvalidationCache returns the shared session invalidation cache
// for backchannel and front-channel logout (IdP-initiated logout)
func (cm *CacheManager) GetSharedSessionInvalidationCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetSessionInvalidationCache(), managed: true}
}
// GetSharedRefreshResultCache returns the short-lived refresh-result cache used
// by the refresh path to coalesce grants across Traefik replicas via Redis.
func (cm *CacheManager) GetSharedRefreshResultCache() CacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &CacheInterfaceWrapper{cache: cm.manager.GetRefreshResultCache(), managed: true}
}
// Close gracefully shuts down all cache components
@@ -121,7 +155,8 @@ func CleanupGlobalCacheManager() error {
// CacheInterfaceWrapper wraps UniversalCache to implement CacheInterface
type CacheInterfaceWrapper struct {
cache *UniversalCache
cache *UniversalCache
managed bool // If true, cache is managed globally and Close() is a no-op
}
// Set stores a value
@@ -134,6 +169,13 @@ func (c *CacheInterfaceWrapper) Get(key string) (interface{}, bool) {
return c.cache.Get(key)
}
// SetIfAbsent atomically stores a value only if the key is not already
// present. See UniversalCache.SetIfAbsent and AtomicSetIfAbsentCache
// (FIX-17).
func (c *CacheInterfaceWrapper) SetIfAbsent(key string, value interface{}, ttl time.Duration) (bool, error) {
return c.cache.SetIfAbsent(key, value, ttl)
}
// Delete removes a key
func (c *CacheInterfaceWrapper) Delete(key string) {
c.cache.Delete(key)
@@ -149,9 +191,15 @@ func (c *CacheInterfaceWrapper) Cleanup() {
c.cache.Cleanup()
}
// Close shuts down the cache
// Close shuts down the cache if it's not managed globally.
// For managed caches (from UniversalCacheManager), this is a no-op to prevent log flooding
// when multiple plugin instances are closed during Traefik configuration reloads.
func (c *CacheInterfaceWrapper) Close() {
// Close the underlying cache to stop goroutines
if c.managed {
// Cache is managed globally by UniversalCacheManager, so we don't close it here.
return
}
// Standalone cache - close it properly to stop cleanup goroutines
if c.cache != nil {
_ = c.cache.Close() // Safe to ignore: closing cache is best-effort during shutdown
}
+158 -308
View File
@@ -178,6 +178,11 @@ func TestCacheInterfaceWrapper_Clear(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
// The shared token blacklist is process-global across tests; under shuffled
// execution a prior test may have left items behind. Clear first so the
// size assertions are stable regardless of test order.
cache.Clear()
cache.Set("key1", "value1", time.Hour)
cache.Set("key2", "value2", time.Hour)
@@ -219,6 +224,159 @@ func TestCacheInterfaceWrapper_Close(t *testing.T) {
nilWrapper.Close()
}
// TestCacheInterfaceWrapper_ManagedClose_Regression tests that managed cache wrappers
// don't close the underlying cache when Close() is called. This is a regression test
// for issue #105 where multiple plugin instances closing shared caches caused log flooding.
func TestCacheInterfaceWrapper_ManagedClose_Regression(t *testing.T) {
cm := getTestCacheManager(t)
// Get a managed cache wrapper
cache := cm.GetSharedTokenBlacklist()
wrapper, ok := cache.(*CacheInterfaceWrapper)
if !ok {
t.Fatal("Expected CacheInterfaceWrapper")
}
// Verify it's marked as managed
if !wrapper.managed {
t.Error("Expected shared cache wrapper to be marked as managed")
}
// Set some data before Close
cache.Set("test-key", "test-value", time.Hour)
// Close the wrapper (should be a no-op for managed caches)
wrapper.Close()
// Verify the cache is still operational after Close
value, found := cache.Get("test-key")
if !found {
t.Error("Expected cache to still work after Close() on managed wrapper")
}
if value != "test-value" {
t.Errorf("Expected 'test-value', got %v", value)
}
// Can still set new values
cache.Set("new-key", "new-value", time.Hour)
newValue, found := cache.Get("new-key")
if !found || newValue != "new-value" {
t.Error("Expected to be able to set new values after Close() on managed wrapper")
}
}
// TestCacheInterfaceWrapper_StandaloneClose tests that standalone cache wrappers
// properly close the underlying cache when Close() is called.
func TestCacheInterfaceWrapper_StandaloneClose(t *testing.T) {
// Create a standalone cache (not from the global cache manager)
standaloneCache := NewCache()
wrapper, ok := standaloneCache.(*CacheInterfaceWrapper)
if !ok {
t.Fatal("Expected CacheInterfaceWrapper")
}
// Verify it's NOT marked as managed
if wrapper.managed {
t.Error("Expected standalone cache wrapper to NOT be marked as managed")
}
// Set some data
standaloneCache.Set("test-key", "test-value", time.Hour)
// Get baseline goroutine count
baselineGoroutines := runtime.NumGoroutine()
// Close the wrapper (should actually close the underlying cache)
wrapper.Close()
// Give cleanup goroutine time to stop
time.Sleep(100 * time.Millisecond)
// Goroutine count should decrease (cleanup routine stopped)
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > baselineGoroutines {
// This is acceptable - other tests might have started goroutines
t.Logf("Goroutine count: baseline=%d, final=%d", baselineGoroutines, finalGoroutines)
}
}
// TestCacheInterfaceWrapper_MultipleInstancesClose_Regression tests that multiple
// plugin instances can close their cache wrappers without affecting shared caches.
// This is a regression test for issue #105.
func TestCacheInterfaceWrapper_MultipleInstancesClose_Regression(t *testing.T) {
cm := getTestCacheManager(t)
// Simulate multiple plugin instances getting cache references
instances := make([]*CacheInterfaceWrapper, 5)
for i := 0; i < 5; i++ {
cache := cm.GetSharedTokenBlacklist()
wrapper, ok := cache.(*CacheInterfaceWrapper)
if !ok {
t.Fatal("Expected CacheInterfaceWrapper")
}
instances[i] = wrapper
// Each instance might set some data
cache.Set(fmt.Sprintf("instance-%d-key", i), fmt.Sprintf("value-%d", i), time.Hour)
}
// Close all instances (simulating plugin shutdown/reload)
for _, wrapper := range instances {
wrapper.Close()
}
// The shared cache should still work after all instances closed their wrappers
newCache := cm.GetSharedTokenBlacklist()
// Data set by earlier instances should still be accessible
for i := 0; i < 5; i++ {
key := fmt.Sprintf("instance-%d-key", i)
value, found := newCache.Get(key)
if !found {
t.Errorf("Expected data from instance %d to still be accessible", i)
}
expectedValue := fmt.Sprintf("value-%d", i)
if value != expectedValue {
t.Errorf("Expected '%s', got '%v'", expectedValue, value)
}
}
// Should be able to add new data
newCache.Set("after-close-key", "after-close-value", time.Hour)
value, found := newCache.Get("after-close-key")
if !found || value != "after-close-value" {
t.Error("Expected to be able to use cache after all wrapper Close() calls")
}
}
// TestAllSharedCachesMarkedAsManaged verifies all shared cache getters
// return managed wrappers to prevent the log flooding issue.
func TestAllSharedCachesMarkedAsManaged(t *testing.T) {
cm := getTestCacheManager(t)
tests := []struct {
name string
cache CacheInterface
}{
{"TokenBlacklist", cm.GetSharedTokenBlacklist()},
{"IntrospectionCache", cm.GetSharedIntrospectionCache()},
{"TokenTypeCache", cm.GetSharedTokenTypeCache()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wrapper, ok := tt.cache.(*CacheInterfaceWrapper)
if !ok {
t.Fatalf("Expected CacheInterfaceWrapper for %s", tt.name)
}
if !wrapper.managed {
t.Errorf("%s cache wrapper should be marked as managed", tt.name)
}
})
}
}
func TestCacheInterfaceWrapper_GetStats(t *testing.T) {
cm := getTestCacheManager(t)
cache := cm.GetSharedTokenBlacklist()
@@ -312,314 +470,6 @@ func TestConcurrentCacheAccess(t *testing.T) {
wg.Wait()
}
// =============================================================================
// SHARDED CACHE TESTS
// =============================================================================
func TestShardedCacheBasicOperations(t *testing.T) {
t.Run("SetAndGet", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
cache.Set("key2", 42, 5*time.Minute)
cache.Set("key3", true, 5*time.Minute)
val1, ok := cache.Get("key1")
if !ok || val1 != "value1" {
t.Errorf("Expected 'value1', got %v, ok=%v", val1, ok)
}
val2, ok := cache.Get("key2")
if !ok || val2 != 42 {
t.Errorf("Expected 42, got %v, ok=%v", val2, ok)
}
val3, ok := cache.Get("key3")
if !ok || val3 != true {
t.Errorf("Expected true, got %v, ok=%v", val3, ok)
}
})
t.Run("GetNonExistent", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
val, ok := cache.Get("nonexistent")
if ok || val != nil {
t.Errorf("Expected nil/false for nonexistent key, got %v/%v", val, ok)
}
})
t.Run("Delete", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
cache.Delete("key1")
val, ok := cache.Get("key1")
if ok || val != nil {
t.Errorf("Expected nil/false after delete, got %v/%v", val, ok)
}
})
t.Run("Exists", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 5*time.Minute)
if !cache.Exists("key1") {
t.Error("Expected Exists to return true for existing key")
}
if cache.Exists("nonexistent") {
t.Error("Expected Exists to return false for nonexistent key")
}
})
t.Run("Size", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
if cache.Size() != 0 {
t.Errorf("Expected size 0, got %d", cache.Size())
}
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
if cache.Size() != 100 {
t.Errorf("Expected size 100, got %d", cache.Size())
}
})
t.Run("Clear", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
cache.Clear()
if cache.Size() != 0 {
t.Errorf("Expected size 0 after clear, got %d", cache.Size())
}
})
}
func TestShardedCacheExpiration(t *testing.T) {
t.Run("ItemExpires", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("key1", "value1", 50*time.Millisecond)
if !cache.Exists("key1") {
t.Error("Item should exist immediately after set")
}
time.Sleep(100 * time.Millisecond)
if cache.Exists("key1") {
t.Error("Item should have expired")
}
})
t.Run("CleanupRemovesExpired", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("expired%d", i), i, 10*time.Millisecond)
}
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
}
time.Sleep(50 * time.Millisecond)
cache.Cleanup()
for i := 0; i < 50; i++ {
if cache.Exists(fmt.Sprintf("expired%d", i)) {
t.Errorf("Expired item %d should not exist after cleanup", i)
}
}
for i := 0; i < 50; i++ {
if !cache.Exists(fmt.Sprintf("valid%d", i)) {
t.Errorf("Valid item %d should still exist after cleanup", i)
}
}
})
t.Run("ZeroTTLNeverExpires", func(t *testing.T) {
cache := NewShardedCache(16, 1000)
cache.Set("permanent", "value", 0)
time.Sleep(10 * time.Millisecond)
if !cache.Exists("permanent") {
t.Error("Item with 0 TTL should never expire")
}
})
}
func TestShardedCacheConcurrency(t *testing.T) {
t.Run("ConcurrentSetGet", func(t *testing.T) {
cache := NewShardedCache(64, 10000)
const numGoroutines = 100
const numOperations = 1000
var wg sync.WaitGroup
var errors int32
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Set(key, j, 5*time.Minute)
}
}(i)
}
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Get(key)
}
}(i)
}
wg.Wait()
if atomic.LoadInt32(&errors) > 0 {
t.Errorf("Encountered %d errors during concurrent access", errors)
}
})
t.Run("ConcurrentMixedOperations", func(t *testing.T) {
cache := NewShardedCache(64, 10000)
const numGoroutines = 50
const numOperations = 500
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d", j%100)
switch j % 4 {
case 0:
cache.Set(key, j, 5*time.Minute)
case 1:
cache.Get(key)
case 2:
cache.Exists(key)
case 3:
cache.Delete(key)
}
}
}(i)
}
wg.Wait()
})
t.Run("NoConcurrentPanics", func(t *testing.T) {
cache := NewShardedCache(32, 5000)
const numGoroutines = 100
var wg sync.WaitGroup
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
t.Errorf("Panic in goroutine %d: %v", id, r)
}
}()
for j := 0; j < 100; j++ {
cache.Set(fmt.Sprintf("k%d", j), j, time.Millisecond)
cache.Get(fmt.Sprintf("k%d", j))
cache.Cleanup()
}
}(i)
}
wg.Wait()
})
}
func TestShardedCacheEviction(t *testing.T) {
t.Run("EvictsWhenFull", func(t *testing.T) {
cache := NewShardedCache(4, 100)
for i := 0; i < 600; i++ {
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
}
size := cache.Size()
if size >= 600 {
t.Errorf("Expected eviction to reduce size below 600, got %d", size)
}
t.Logf("Cache size after adding 600 items: %d", size)
})
t.Run("EvictsExpiredFirst", func(t *testing.T) {
cache := NewShardedCache(4, 100)
for i := 0; i < 50; i++ {
cache.Set(fmt.Sprintf("expired%d", i), i, 1*time.Millisecond)
}
time.Sleep(10 * time.Millisecond)
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
}
validCount := 0
for i := 0; i < 100; i++ {
if cache.Exists(fmt.Sprintf("valid%d", i)) {
validCount++
}
}
if validCount < 80 {
t.Errorf("Expected at least 80 valid items, got %d", validCount)
}
})
}
func TestShardedCacheShardDistribution(t *testing.T) {
t.Run("EvenDistribution", func(t *testing.T) {
cache := NewShardedCache(16, 16000)
for i := 0; i < 10000; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
}
stats := cache.ShardStats()
average := 10000 / 16
for i, count := range stats {
if count > average*3 || count < average/3 {
t.Errorf("Shard %d has uneven distribution: %d items (expected ~%d)", i, count, average)
}
}
})
}
// =============================================================================
// CACHE COMPATIBILITY TESTS
// =============================================================================
+93
View File
@@ -0,0 +1,93 @@
package traefikoidc
// Shared CacheInterface / cache-backend test doubles for round-regression
// tests.
//
// Moved here for FIX-41: NewMemoryBackendForTest, mapCache/newMapCache and
// serializingCache were each declared inside a single round file (review_r124,
// review_r146, review_r159) but consumed by other round files, so deleting or
// renaming the declaring file silently broke the consumer. Every
// review_rNN*_test.go file must depend only on shared helpers like this one,
// never on another round file.
import (
"encoding/json"
"github.com/alicebob/miniredis/v2"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
)
// NewMemoryBackendForTest returns a cache backend backed by an in-process
// miniredis instance, for white-box tests that need a real CacheBackend
// without depending on a live Redis server. internal/cache's in-memory
// backend was removed as unreachable dead code (FIX-43); RedisBackend
// against miniredis is the project's standard lightweight substitute.
//
// The miniredis server is registered against t.Cleanup so it (and its
// listener goroutine) is closed when t ends. Callers still defer
// backend.Close() to close the RedisBackend connection pool.
func NewMemoryBackendForTest(t *testing.T) (backends.CacheBackend, error) {
t.Helper()
mr, err := miniredis.Run()
if err != nil {
return nil, err
}
t.Cleanup(mr.Close)
return backends.NewRedisBackend(backends.DefaultRedisConfig(mr.Addr()))
}
// mapCache is a minimal thread-safe CacheInterface for seeding caches in
// bearer-invalidation tests.
type mapCache struct {
mu sync.Mutex
m map[string]any
}
func newMapCache() *mapCache { return &mapCache{m: map[string]any{}} }
func (c *mapCache) Set(key string, value any, ttl time.Duration) {
c.mu.Lock()
c.m[key] = value
c.mu.Unlock()
}
func (c *mapCache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[key]
return v, ok
}
func (c *mapCache) Delete(key string) { c.mu.Lock(); delete(c.m, key); c.mu.Unlock() }
func (c *mapCache) SetMaxSize(size int) {}
func (c *mapCache) Size() int { return 0 }
func (c *mapCache) Clear() {}
func (c *mapCache) Cleanup() {}
func (c *mapCache) Close() {}
func (c *mapCache) GetStats() map[string]any { return nil }
// serializingCache simulates a UniversalCache backed by a serializing
// store (e.g. Redis): Set JSON-round-trips the value, so Get returns a
// generic map, never the original concrete type. This is exactly what
// production sees on the distributed path.
type serializingCache struct {
m map[string]interface{}
}
func (c *serializingCache) Set(key string, value any, ttl time.Duration) {
b, _ := json.Marshal(value)
var v interface{}
_ = json.Unmarshal(b, &v)
c.m[key] = v
}
func (c *serializingCache) Get(key string) (any, bool) {
v, ok := c.m[key]
return v, ok
}
func (c *serializingCache) Delete(key string) { delete(c.m, key) }
func (c *serializingCache) SetMaxSize(int) {}
func (c *serializingCache) Size() int { return len(c.m) }
func (c *serializingCache) Clear() { c.m = map[string]interface{}{} }
func (c *serializingCache) Cleanup() {}
func (c *serializingCache) Close() {}
func (c *serializingCache) GetStats() map[string]any { return nil }
+94
View File
@@ -0,0 +1,94 @@
package traefikoidc
import (
"strings"
"testing"
)
// These tests pin the token-content validators' results, including for
// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where
// ranging over a string allocates O(n²)) without changing behavior.
func TestDetectRepeatedCharactersPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"short token skipped", "aaaaaaaaa", ""},
{"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""},
{"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""},
{"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
{"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
// Rune counts are divided by the byte length, so a multi-byte rune
// cannot reach the 70% threshold. Pinned as-is.
{"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""},
{"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"},
{"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateJWTFormatPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""},
{"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""},
{"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateJWTFormat(tc.token, "access")
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateTokenSanitizationPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""},
{"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""},
{"control char byte offset", "abcé\x01def", "control character at position 5"},
{"delete char", "abcdef\x7fgh", "control character at position 6"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateTokenSanitization(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the
// pre-commit secret scanner.
func jwtLike(parts ...string) string { return strings.Join(parts, ".") }
func assertPinnedErr(t *testing.T, err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want containing %q", err, want)
}
}
+339
View File
@@ -0,0 +1,339 @@
package traefikoidc
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"os"
"time"
)
// ErrRSAKeyTooSmall is returned (wrapped) by validateAlgKeyMatch when a
// private_key_jwt RS*/PS* signing key is smaller than the RFC 7518 §3.3
// minimum of 2048 bits. A named sentinel lets callers detect this specific
// rejection with errors.Is instead of matching the formatted message text
// (FIX-42; see CHANGELOG.md).
var ErrRSAKeyTooSmall = errors.New("RSA key too small for RS*/PS* signing (RFC 7518 §3.3 requires >= 2048 bits)")
// 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*
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return fmt.Errorf("alg %q requires an RSA key, got %T", alg, key)
}
// RFC 7518 §3.3: RS*/PS* MUST use a key of 2048 bits or larger.
// A smaller key signs locally but conformant IdPs (Entra, Okta,
// Keycloak) reject it, so every exchange/refresh/revocation fails
// invalid_client at runtime — better to fail at construction
// (R135).
if rsaKey.N.BitLen() < 2048 {
return fmt.Errorf("alg %q requires an RSA key of at least 2048 bits, got %d: %w", alg, rsaKey.N.BitLen(), ErrRSAKeyTooSmall)
}
case 'E': // ES*
ecKey, ok := key.(*ecdsa.PrivateKey)
if !ok {
return fmt.Errorf("alg %q requires an EC key, got %T", alg, key)
}
// ES* maps 1:1 to a NIST curve (ES256→P-256, ES384→P-384,
// ES512→P-521). A key on the wrong curve would otherwise pass this
// check yet always produce a signature the IdP rejects.
if want := ecdsaAlgorithmCurve(alg); want != nil && ecKey.Curve != want {
return fmt.Errorf("alg %q requires a key on curve %s, got %s", alg, want.Params().Name, ecKey.Curve.Params().Name)
}
}
return nil
}
// ecdsaAlgorithmCurve returns the NIST curve required by an ES* algorithm, or
// nil for any non-ES algorithm.
func ecdsaAlgorithmCurve(alg string) elliptic.Curve {
switch alg {
case "ES256":
return elliptic.P256()
case "ES384":
return elliptic.P384()
case "ES512":
return elliptic.P521()
}
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()
// RFC 7523 §3: aud must be the token (or introspection/revocation)
// endpoint URL. An empty audience yields a client_assertion with
// "aud":"" that the IdP will reject, so fail fast with a clear
// error rather than minting a useless token (R150).
if audience == "" {
return "", fmt.Errorf("client assertion audience must not be empty")
}
// 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)
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
package traefikoidc
import (
"sync"
"testing"
)
func TestDecompressCombinedPayloadReusesReaders(t *testing.T) {
var readers sync.Pool
first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress first: %v", err)
}
second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress second: %v", err)
}
cases := []struct {
name string
input string
wantUser string
wantErr bool
}{
{"first payload", first, "[email protected]", false},
{"corrupt payload rejected", "bm90LWd6aXA=", "", true},
{"second payload after corrupt one", second, "[email protected]", false},
{"first payload again", first, "[email protected]", false},
}
for _, tc := range cases {
got, err := decompressCombinedPayload(tc.input, &readers)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error", tc.name)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got.Ui != tc.wantUser {
t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package traefikoidc
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
// TestConcurrentServeHTTP_SharedInstance drives the full request path
// (bootstraps, auth-bypass evaluation, session lookup, redirect) across many
// goroutines against ONE shared plugin instance. Traefik runs the middleware
// concurrently for every request on a router, so this is the production
// concurrency shape. Holds under -race only if the request path touches
// shared mutable state via atomics/locks, never plain field writes.
func TestConcurrentServeHTTP_SharedInstance(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
clientID: "test-client",
audience: "test-client",
authURL: "https://provider.example.com/auth",
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
metadataRefreshStartedAtomic: 0,
}
close(oidc.initComplete)
const goroutines = 40
const perGoroutine = 5
var wg sync.WaitGroup
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
req := httptest.NewRequest("GET", fmt.Sprintf("/api/g%d/", g), nil)
w := httptest.NewRecorder()
oidc.ServeHTTP(w, req)
// Unauthenticated request must bounce to the IdP (3xx), not
// forward to the backend (200) or error.
if w.Code < 300 || w.Code >= 400 {
t.Errorf("expected auth redirect (3xx), got %d", w.Code)
}
}
}()
}
wg.Wait()
}
// TestConcurrentServeHTTP_ExcludedBypass drives the auth-bypass path
// concurrently (shouldBypassAuth read-path) on a shared instance.
func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
authURL: "https://provider.example.com/auth",
}
close(oidc.initComplete)
const goroutines = 40
const perGoroutine = 5
var wg sync.WaitGroup
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
oidc.ServeHTTP(w, req)
}
}()
}
wg.Wait()
}
+103 -44
View File
@@ -7,50 +7,32 @@ import (
// REDACTED is the placeholder value for sensitive information
const REDACTED = "[REDACTED]"
// MarshalJSON implements custom JSON marshalling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalJSON() ([]byte, error) {
// Build a map manually to avoid type alias issues with yaegi
result := make(map[string]interface{})
// Copy public fields
result["providerURL"] = c.ProviderURL
result["clientID"] = c.ClientID
result["callbackURL"] = c.CallbackURL
result["logoutURL"] = c.LogoutURL
result["postLogoutRedirectURI"] = c.PostLogoutRedirectURI
result["scopes"] = c.Scopes
result["forceHTTPS"] = c.ForceHTTPS
result["logLevel"] = c.LogLevel
result["rateLimit"] = c.RateLimit
result["excludedURLs"] = c.ExcludedURLs
result["allowedUserDomains"] = c.AllowedUserDomains
result["allowedUsers"] = c.AllowedUsers
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
// Redact sensitive fields
result["clientSecret"] = REDACTED
result["sessionEncryptionKey"] = REDACTED
// Handle Redis config
if c.Redis != nil {
redisMap := make(map[string]interface{})
redisMap["enabled"] = c.Redis.Enabled
redisMap["address"] = c.Redis.Address
redisMap["password"] = REDACTED
redisMap["db"] = c.Redis.DB
redisMap["poolSize"] = c.Redis.PoolSize
redisMap["cacheMode"] = c.Redis.CacheMode
result["redis"] = redisMap
// marshalDCR builds the JSON/YAML-safe representation of the dynamic
// client registration config, redacting the initial access token.
func marshalDCR(d *DynamicClientRegistrationConfig) map[string]interface{} {
if d == nil {
return nil
}
return json.Marshal(result)
m := make(map[string]interface{})
m["enabled"] = d.Enabled
m["persistCredentials"] = d.PersistCredentials
m["storageBackend"] = d.StorageBackend
m["initialAccessToken"] = REDACTED
m["registrationEndpoint"] = d.RegistrationEndpoint
m["credentialsFile"] = d.CredentialsFile
m["redisKeyPrefix"] = d.RedisKeyPrefix
return m
}
// MarshalYAML implements custom YAML marshalling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalYAML() (interface{}, error) {
// Build a map manually to avoid type alias issues with yaegi
// configToMap builds the JSON/YAML-safe map representation of Config. Both
// MarshalJSON and MarshalYAML share it so the (hand-maintained for yaegi
// compatibility) field/redaction list cannot drift between the two
// encoders — previously the list silently dropped entire config areas
// (dynamicClientRegistration, securityHeaders, audience, overrideScopes,
// enablePKCE, allowOpaqueTokens, requireTokenIntrospection,
// strictAudienceValidation, extraAuthParams, client assertion settings)
// and never redacted their embedded secrets (R150).
func (c Config) configToMap() map[string]interface{} {
result := make(map[string]interface{})
// Copy public fields
@@ -68,11 +50,66 @@ func (c Config) MarshalYAML() (interface{}, error) {
result["allowedUsers"] = c.AllowedUsers
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
// Redact sensitive fields
// Fields that the legacy hand-written list dropped (R150): important
// non-secret config that must survive any JSON/YAML round-trip.
result["audience"] = c.Audience
result["resource"] = c.Resource
result["cookiePrefix"] = c.CookiePrefix
result["cookieDomain"] = c.CookieDomain
result["userIdentifierClaim"] = c.UserIdentifierClaim
result["groupClaimName"] = c.GroupClaimName
result["roleClaimName"] = c.RoleClaimName
result["oidcEndSessionURL"] = c.OIDCEndSessionURL
result["revocationURL"] = c.RevocationURL
result["introspectionURL"] = c.IntrospectionURL
result["headers"] = c.Headers
result["allowedClaims"] = c.AllowedClaims
result["extraAuthParams"] = c.ExtraAuthParams
result["refreshGracePeriodSeconds"] = c.RefreshGracePeriodSeconds
result["maxRefreshTokenAgeSeconds"] = c.MaxRefreshTokenAgeSeconds
result["sessionMaxAge"] = c.SessionMaxAge
result["overrideScopes"] = c.OverrideScopes
result["disableReplayDetection"] = c.DisableReplayDetection
result["requireTokenIntrospection"] = c.RequireTokenIntrospection
result["allowOpaqueTokens"] = c.AllowOpaqueTokens
result["strictAudienceValidation"] = c.StrictAudienceValidation
result["enablePKCE"] = c.EnablePKCE
result["allowPrivateIPAddresses"] = c.AllowPrivateIPAddresses
result["allowUnauthenticatedPreflight"] = c.AllowUnauthenticatedPreflight
result["streamingRefresh"] = c.StreamingRefresh
result["minimalHeaders"] = c.MinimalHeaders
result["stripAuthCookies"] = c.StripAuthCookies
result["cookiePath"] = c.CookiePath
result["enableBackchannelLogout"] = c.EnableBackchannelLogout
result["enableFrontchannelLogout"] = c.EnableFrontchannelLogout
result["backchannelLogoutURL"] = c.BackchannelLogoutURL
result["frontchannelLogoutURL"] = c.FrontchannelLogoutURL
result["caCertPath"] = c.CACertPath
result["caCertPEM"] = c.CACertPEM
result["insecureSkipVerify"] = c.InsecureSkipVerify
result["clientAuthMethod"] = c.ClientAuthMethod
result["clientAssertionKeyID"] = c.ClientAssertionKeyID
result["clientAssertionAlg"] = c.ClientAssertionAlg
result["clientAssertionKeyPath"] = c.ClientAssertionKeyPath
result["perSourceLoginRateLimit"] = c.PerSourceLoginRateLimit
result["enableBearerAuth"] = c.EnableBearerAuth
result["bearerIdentifierClaim"] = c.BearerIdentifierClaim
result["stripAuthorizationHeader"] = c.StripAuthorizationHeader
result["bearerEmitWWWAuthenticate"] = c.BearerEmitWWWAuthenticate
result["bearerOverridesCookie"] = c.BearerOverridesCookie
result["maxTokenAgeSeconds"] = c.MaxTokenAgeSeconds
result["maxIdentifierLength"] = c.MaxIdentifierLength
result["bearerFailureThreshold"] = c.BearerFailureThreshold
result["bearerFailureWindowSeconds"] = c.BearerFailureWindowSeconds
result["bearerFailurePenaltySeconds"] = c.BearerFailurePenaltySeconds
// Redact sensitive fields (existing secrets plus the previously-leaked
// client assertion private key, R150).
result["clientSecret"] = REDACTED
result["sessionEncryptionKey"] = REDACTED
result["clientAssertionPrivateKey"] = REDACTED
// Handle Redis config
// Handle Redis config (redacts password)
if c.Redis != nil {
redisMap := make(map[string]interface{})
redisMap["enabled"] = c.Redis.Enabled
@@ -84,7 +121,29 @@ func (c Config) MarshalYAML() (interface{}, error) {
result["redis"] = redisMap
}
return result, nil
// Handle dynamic client registration (redacts the initial access token)
if c.DynamicClientRegistration != nil {
result["dynamicClientRegistration"] = marshalDCR(c.DynamicClientRegistration)
}
// Handle security headers
if c.SecurityHeaders != nil {
result["securityHeaders"] = c.SecurityHeaders
}
return result
}
// MarshalJSON implements custom JSON marshaling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalJSON() ([]byte, error) {
return json.Marshal(c.configToMap())
}
// MarshalYAML implements custom YAML marshaling to redact sensitive fields
// Rewritten without type aliases for yaegi compatibility
func (c Config) MarshalYAML() (interface{}, error) {
return c.configToMap(), nil
}
// MarshalJSON for RedisConfig to redact sensitive fields
+44
View File
@@ -0,0 +1,44 @@
package traefikoidc
import (
"reflect"
"strings"
"testing"
)
// TestConfigToMap_IncludesAllJSONFields pins FIX-40: configToMap is the
// single field/redaction list shared by MarshalJSON and MarshalYAML (R150),
// so every json-tagged Config field must appear as a configToMap key or the
// marshaled/logged config silently drops it again. Fields tagged
// `json:"-"` are intentionally excluded from marshaling and are skipped.
func TestConfigToMap_IncludesAllJSONFields(t *testing.T) {
// Non-nil pointer fields so their conditional branches in configToMap
// run and populate a map key, matching how a real, non-empty Config is
// built.
cfg := Config{
Redis: &RedisConfig{},
DynamicClientRegistration: &DynamicClientRegistrationConfig{},
SecurityHeaders: &SecurityHeadersConfig{},
}
result := cfg.configToMap()
typ := reflect.TypeOf(Config{})
var missing []string
for i := 0; i < typ.NumField(); i++ {
tag := typ.Field(i).Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
continue
}
if _, ok := result[name]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("configToMap is missing json-tagged Config fields: %v", missing)
}
}
+21
View File
@@ -0,0 +1,21 @@
package traefikoidc
// Shared Config-fixture helpers for round-regression tests.
//
// Moved here for FIX-41: r134ValidConfig was declared inside review_r134_test.go
// but consumed by review_r135_test.go, so deleting or renaming the declaring
// file silently broke the consumer. Every review_rNN*_test.go file must
// depend only on shared helpers like this one, never on another round file.
// r134ValidConfig returns a minimal Config that passes Validate(), for tests
// that mutate one field and assert the resulting accept/reject outcome.
func r134ValidConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
CallbackURL: "/callback",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "0123456789abcdef0123456789abcdef",
RateLimit: CreateConfig().RateLimit,
}
}
+67
View File
@@ -0,0 +1,67 @@
package traefikoidc
import (
"strings"
"testing"
"time"
)
func TestContentValidCache(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
t.Cleanup(cm.Shutdown)
now := time.Now()
valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())})
badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl")
t.Run("valid token passes twice and is cached", func(t *testing.T) {
for i := 0; i < 2; i++ {
if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil {
t.Fatalf("pass %d: %v", i, res.Error)
}
}
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok {
t.Fatal("valid token was not cached")
}
})
t.Run("cache is per token type", func(t *testing.T) {
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok {
t.Fatal("access-token entry leaked into the ID-token key")
}
})
t.Run("time checks still run on a cache hit", func(t *testing.T) {
cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat))
res := cm.validateToken(futureIat, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error)
}
})
t.Run("cache hit uses the stored claims", func(t *testing.T) {
other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
future := now.Add(time.Hour)
cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future})
res := cm.validateToken(other, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error)
}
})
t.Run("invalid content never cached", func(t *testing.T) {
if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil {
t.Fatal("control character accepted")
}
if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok {
t.Fatal("invalid token was cached")
}
})
t.Run("cache stays bounded", func(t *testing.T) {
for i := 0; i < contentValidCacheMax+10; i++ {
cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{})
}
cm.contentMu.Lock()
n := len(cm.contentValid)
cm.contentMu.Unlock()
if n > contentValidCacheMax {
t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax)
}
})
}
+55
View File
@@ -0,0 +1,55 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestCleanupOldCookies_UsesConfiguredCookiePath pins the fix for cookie
// cleanup under cookiePath. Session cookies are written at the configured
// cookiePath (EnhanceSessionSecurity), but CleanupOldCookies wrote its
// deletion cookies at a hardcoded "/". A browser deletes a cookie only when
// name, domain and path all match, so the cleanup removed nothing when
// cookiePath was set.
func TestCleanupOldCookies_UsesConfiguredCookiePath(t *testing.T) {
cases := []struct {
name string
cookiePath string
wantPath string
}{
{name: "configured path", cookiePath: "/app", wantPath: "/app"},
{name: "default path", cookiePath: "", wantPath: "/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "app.example.com", "testpref", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sm.Shutdown()
sm.cookiePath = tc.cookiePath
req := httptest.NewRequest(http.MethodGet, "/app/page", nil)
req.Host = "app.example.com"
req.AddCookie(&http.Cookie{Name: "testpref_sess", Value: "x"})
rw := httptest.NewRecorder()
sm.CleanupOldCookies(rw, req)
deleted := rw.Result().Cookies()
if len(deleted) == 0 {
t.Fatal("expected deletion cookies for the other domains")
}
for _, c := range deleted {
if c.Path != tc.wantPath {
t.Errorf("deletion cookie %s for domain %s has Path %q, want %q", c.Name, c.Domain, c.Path, tc.wantPath)
}
}
})
}
}
+44 -173
View File
@@ -41,24 +41,6 @@ func TestLRUStrategy_OnRemove_CoverageBoost(t *testing.T) {
strategy.OnRemove("")
}
// =============================================================================
// JWT REPLAY CACHE TESTS
// =============================================================================
func TestGetReplayCacheStats_CoverageBoost(t *testing.T) {
// Test the function - it should return valid stats
size, maxSize := getReplayCacheStats()
if maxSize != 10000 {
t.Errorf("Expected maxSize to be 10000, got %d", maxSize)
}
// Size should be >= 0
if size < 0 {
t.Errorf("Expected size to be >= 0, got %d", size)
}
}
// =============================================================================
// PROFILING MANAGER TESTS
// =============================================================================
@@ -278,82 +260,6 @@ func TestHTTPClientProfiler_Methods_CoverageBoost(t *testing.T) {
}
}
// =============================================================================
// SECURITY MONITORING TESTS
// =============================================================================
func TestSecurityMonitor_StopCleanupRoutine_CoverageBoost(t *testing.T) {
logger := NewLogger("info")
config := SecurityMonitorConfig{
MaxFailuresPerIP: 5,
FailureWindowMinutes: 15,
BlockDurationMinutes: 30,
RapidFailureThreshold: 3,
CleanupIntervalMinutes: 60,
RetentionHours: 24,
EnablePatternDetection: true,
EnableDetailedLogging: false,
LogSuspiciousOnly: false,
}
sm := NewSecurityMonitor(config, logger)
if sm == nil {
t.Fatal("Expected non-nil SecurityMonitor")
}
// Start cleanup routine first (lowercase method)
sm.startCleanupRoutine()
// Give it a moment to start
time.Sleep(50 * time.Millisecond)
// Stop cleanup routine (public method)
sm.StopCleanupRoutine()
// Stop again should be safe
sm.StopCleanupRoutine()
}
func TestSecurityMonitor_MultipleHandlers_CoverageBoost(t *testing.T) {
logger := NewLogger("info")
config := SecurityMonitorConfig{
MaxFailuresPerIP: 5,
FailureWindowMinutes: 15,
BlockDurationMinutes: 30,
RapidFailureThreshold: 3,
CleanupIntervalMinutes: 60,
RetentionHours: 24,
}
sm := NewSecurityMonitor(config, logger)
// Create handler
handler := &LoggingSecurityEventHandler{logger: logger}
// Register handler using AddEventHandler
sm.AddEventHandler(handler)
// Record a failure to trigger events
sm.RecordAuthenticationFailure("192.168.1.100", "test-agent", "/test", "test_failure", nil)
}
func TestLoggingSecurityEventHandler_HandleSecurityEvent_AllSeverities_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
handler := &LoggingSecurityEventHandler{logger: logger}
// Severity is a string in this implementation
events := []SecurityEvent{
{Type: "test", Severity: "low", Message: "low severity"},
{Type: "test", Severity: "medium", Message: "medium severity"},
{Type: "test", Severity: "high", Message: "high severity"},
{Type: "test", Severity: "critical", Message: "critical severity"},
}
for _, event := range events {
handler.HandleSecurityEvent(event)
}
}
// =============================================================================
// SESSION MANAGER TESTS
// =============================================================================
@@ -826,11 +732,53 @@ func TestValidateTemplateSecure_CoverageBoost(t *testing.T) {
template: "{{.Unknown}}",
shouldError: true,
},
// Issue #148: whitespace after {{ and before }} must be tolerated
{
name: "get with leading space (issue #148)",
template: "{{ get .Claims \"email\" }}",
shouldError: false,
},
{
name: "get with multiple leading spaces",
template: "{{ get .Claims \"email\"}}",
shouldError: false,
},
{
name: "default with spaces",
template: "{{ default \"unknown\" .Claims.email }}",
shouldError: false,
},
{
name: "claims with spaces",
template: "{{ .Claims.email }}",
shouldError: false,
},
{
name: "access token with spaces",
template: "{{ .AccessToken }}",
shouldError: false,
},
// Issue #148: space must NOT let dangerous patterns bypass detection
{
name: "range with leading space stays blocked",
template: "{{ range .Items }}{{.}}{{end}}",
shouldError: true,
},
{
name: "call with leading space stays blocked",
template: "{{ call .Func }}",
shouldError: true,
},
{
name: "disallowed claim with spaces stays blocked",
template: "{{ .Claims.password }}",
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTemplateSecure(tt.template)
err := validateTemplateSecure(tt.template, nil)
if tt.shouldError && err == nil {
t.Errorf("Expected error for template: %s", tt.template)
}
@@ -973,82 +921,6 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
// CHUNK MANAGER TESTS
// =============================================================================
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetSessionCount
count := cm.GetSessionCount()
if count != 0 {
t.Errorf("Expected 0 sessions, got %d", count)
}
}
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetMemoryStats
stats := cm.GetMemoryStats()
if stats == nil {
t.Fatal("Expected non-nil stats")
}
// Verify expected keys exist
if _, ok := stats["active_sessions"]; !ok {
t.Error("Expected active_sessions key in stats")
}
if _, ok := stats["max_sessions"]; !ok {
t.Error("Expected max_sessions key in stats")
}
if _, ok := stats["bytes_allocated"]; !ok {
t.Error("Expected bytes_allocated key in stats")
}
}
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test CanCreateSession - should be true initially
canCreate, err := cm.CanCreateSession()
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !canCreate {
t.Error("Expected CanCreateSession to return true when empty")
}
}
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test EmergencyCleanup - should not panic on empty session map
cm.EmergencyCleanup()
// Verify no sessions exist
if cm.GetSessionCount() != 0 {
t.Error("Expected 0 sessions after cleanup")
}
}
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
@@ -1060,8 +932,7 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
// Test CleanupExpiredSessions - should not panic on empty session map
cm.CleanupExpiredSessions()
// Verify no sessions exist
if cm.GetSessionCount() != 0 {
if len(cm.sessionMap) != 0 {
t.Error("Expected 0 sessions after cleanup")
}
}
+4 -4
View File
@@ -31,7 +31,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
session.SetCSRF(csrfToken)
session.SetNonce("test-nonce")
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetUserIdentifier("[email protected]")
session.SetAccessToken("old-access-token")
session.SetRefreshToken("old-refresh-token")
session.SetIDToken("old-id-token")
@@ -61,7 +61,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
// Now perform selective clearing (as done in the fix)
session2.SetAuthenticated(false)
session2.SetEmail("")
session2.SetUserIdentifier("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
@@ -303,7 +303,7 @@ func TestRegressionLoginLoop(t *testing.T) {
// Set initial session data
session.SetAuthenticated(true)
session.SetEmail("[email protected]")
session.SetUserIdentifier("[email protected]")
session.SetAccessToken("old-token")
session.SetCSRF("existing-csrf")
@@ -325,7 +325,7 @@ func TestRegressionLoginLoop(t *testing.T) {
// OLD BEHAVIOR: session.Clear() would have been called here, losing CSRF
// NEW BEHAVIOR: Selective clearing
session2.SetAuthenticated(false)
session2.SetEmail("")
session2.SetUserIdentifier("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
+22 -24
View File
@@ -151,9 +151,11 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
// Configure custom claim names
ts.tOidc.roleClaimName = "custom_roles"
// Create token with malformed role claim (not an array)
// Create token with malformed role claim (non-string, non-array scalar).
// A single-string role claim is now accepted as a 1-element list, so
// "malformed" here means a scalar that is neither.
claims := map[string]interface{}{
"custom_roles": "this-should-be-an-array",
"custom_roles": 12345,
}
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
@@ -161,15 +163,12 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err)
}
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
if err == nil {
t.Error("Expected error for malformed role claim, got nil")
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err != nil {
t.Fatalf("Expected no error for malformed role claim, got %v", err)
}
// Check error message contains the custom claim name
expectedError := "custom_roles claim is not an array"
if err.Error() != expectedError {
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
if len(groups) != 0 || len(roles) != 0 {
t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles)
}
}
@@ -191,15 +190,12 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err)
}
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
if err == nil {
t.Error("Expected error for malformed group claim, got nil")
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err != nil {
t.Fatalf("Expected no error for malformed group claim, got %v", err)
}
// Check error message contains the custom claim name
expectedError := "custom_groups claim is not an array"
if err.Error() != expectedError {
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
if len(groups) != 0 || len(roles) != 0 {
t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles)
}
}
@@ -328,9 +324,10 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err)
}
// Should only extract string elements
if !stringSliceEqual(roles, []string{"role1", "role2"}) {
t.Errorf("Expected roles [role1 role2], got %v", roles)
// String elements kept, numeric scalars stringified (R105, matching R102
// numeric-tolerance), only non-scalars (here: bool) dropped.
if !stringSliceEqual(roles, []string{"role1", "12345", "role2"}) {
t.Errorf("Expected roles [role1 12345 role2], got %v", roles)
}
}
@@ -357,8 +354,9 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err)
}
// Should only extract string elements
if !stringSliceEqual(groups, []string{"group1", "group2"}) {
t.Errorf("Expected groups [group1 group2], got %v", groups)
// String elements kept, numeric scalars stringified (R105); only
// non-scalars (here: nil) dropped.
if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) {
t.Errorf("Expected groups [group1 group2 3.14], got %v", groups)
}
}
+290
View File
@@ -0,0 +1,290 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"context"
"fmt"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/dcrstorage"
)
// DCRStorageBackend represents the type of storage backend for DCR credentials.
// Alias for internal package type for backward compatibility.
type DCRStorageBackend = dcrstorage.StorageBackend
const (
// DCRStorageBackendFile uses file-based storage (default for backward compatibility)
DCRStorageBackendFile DCRStorageBackend = dcrstorage.StorageBackendFile
// DCRStorageBackendRedis uses Redis for distributed storage
DCRStorageBackendRedis DCRStorageBackend = dcrstorage.StorageBackendRedis
// DCRStorageBackendAuto automatically selects Redis if available, otherwise file
DCRStorageBackendAuto DCRStorageBackend = dcrstorage.StorageBackendAuto
)
// DCRCredentialsStore defines the interface for storing DCR credentials.
// This abstraction allows different storage backends (file, Redis) to be used
// for persisting OIDC Dynamic Client Registration credentials across nodes.
type DCRCredentialsStore interface {
// Save stores the client registration response for a provider
// The providerURL is used as a key to support multi-tenant scenarios
Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error
// Load retrieves stored credentials for a provider
// Returns nil, nil if no credentials exist (not an error)
Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error)
// Delete removes stored credentials for a provider
Delete(ctx context.Context, providerURL string) error
// Exists checks if credentials exist for a provider
Exists(ctx context.Context, providerURL string) (bool, error)
}
// loggerAdapter adapts our Logger to the dcrstorage.Logger interface
type loggerAdapter struct {
logger *Logger
}
func (l *loggerAdapter) Debug(msg string) { l.logger.Debug("%s", msg) }
func (l *loggerAdapter) Debugf(format string, args ...any) { l.logger.Debugf(format, args...) }
func (l *loggerAdapter) Info(msg string) { l.logger.Info("%s", msg) }
func (l *loggerAdapter) Infof(format string, args ...any) { l.logger.Infof(format, args...) }
func (l *loggerAdapter) Error(msg string) { l.logger.Error("%s", msg) }
func (l *loggerAdapter) Errorf(format string, args ...any) { l.logger.Errorf(format, args...) }
// cacheAdapter adapts UniversalCache to dcrstorage.Cache interface
type cacheAdapter struct {
cache *UniversalCache
}
func (c *cacheAdapter) Get(key string) (any, bool) {
return c.cache.Get(key)
}
func (c *cacheAdapter) Set(key string, value any, ttl time.Duration) error {
return c.cache.Set(key, value, ttl)
}
func (c *cacheAdapter) Delete(key string) {
c.cache.Delete(key)
}
// fileStoreWrapper wraps dcrstorage.FileStore to implement DCRCredentialsStore
type fileStoreWrapper struct {
inner *dcrstorage.FileStore
}
func (w *fileStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
innerCreds := convertCredsToInternal(creds)
return w.inner.Save(ctx, providerURL, innerCreds)
}
func (w *fileStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
innerCreds, err := w.inner.Load(ctx, providerURL)
if err != nil || innerCreds == nil {
return nil, err
}
return convertCredsFromInternal(innerCreds), nil
}
func (w *fileStoreWrapper) Delete(ctx context.Context, providerURL string) error {
return w.inner.Delete(ctx, providerURL)
}
func (w *fileStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
return w.inner.Exists(ctx, providerURL)
}
// basePath returns the base path used for storing credentials (for backward compatibility in tests)
func (w *fileStoreWrapper) basePath() string {
return w.inner.BasePath()
}
// getFilePath returns the file path for storing credentials for a specific provider (for backward compatibility in tests)
func (w *fileStoreWrapper) getFilePath(providerURL string) string {
return w.inner.GetFilePath(providerURL)
}
// redisStoreWrapper wraps dcrstorage.RedisStore to implement DCRCredentialsStore
type redisStoreWrapper struct {
inner *dcrstorage.RedisStore
}
func (w *redisStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
innerCreds := convertCredsToInternal(creds)
return w.inner.Save(ctx, providerURL, innerCreds)
}
func (w *redisStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
innerCreds, err := w.inner.Load(ctx, providerURL)
if err != nil || innerCreds == nil {
return nil, err
}
return convertCredsFromInternal(innerCreds), nil
}
func (w *redisStoreWrapper) Delete(ctx context.Context, providerURL string) error {
return w.inner.Delete(ctx, providerURL)
}
func (w *redisStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
return w.inner.Exists(ctx, providerURL)
}
// FileCredentialsStore implements DCRCredentialsStore using file-based storage.
// This is the default storage backend for backward compatibility with existing deployments.
type FileCredentialsStore = fileStoreWrapper
// RedisCredentialsStore implements DCRCredentialsStore using Redis-backed cache.
// This storage backend enables sharing DCR credentials across multiple Traefik instances.
type RedisCredentialsStore = redisStoreWrapper
// NewFileCredentialsStore creates a new file-based credentials store.
// If basePath is empty, defaults to /tmp/oidc-client-credentials.json
func NewFileCredentialsStore(basePath string, logger *Logger) *FileCredentialsStore {
var dcrLogger dcrstorage.Logger
if logger != nil {
dcrLogger = &loggerAdapter{logger: logger}
}
inner := dcrstorage.NewFileStore(basePath, dcrLogger)
return &fileStoreWrapper{inner: inner}
}
// NewRedisCredentialsStore creates a new Redis-backed credentials store.
// The cache should be configured with a Redis backend for distributed storage.
// If keyPrefix is empty, defaults to "dcr:creds:"
func NewRedisCredentialsStore(cache *UniversalCache, keyPrefix string, logger *Logger) *RedisCredentialsStore {
var dcrLogger dcrstorage.Logger
if logger != nil {
dcrLogger = &loggerAdapter{logger: logger}
}
cacheAdapt := &cacheAdapter{cache: cache}
inner := dcrstorage.NewRedisStore(cacheAdapt, keyPrefix, dcrLogger)
return &redisStoreWrapper{inner: inner}
}
// Helper functions to convert between main package and internal package types
func convertCredsToInternal(creds *ClientRegistrationResponse) *dcrstorage.ClientRegistrationResponse {
if creds == nil {
return nil
}
return &dcrstorage.ClientRegistrationResponse{
SubjectType: creds.SubjectType,
LogoURI: creds.LogoURI,
RegistrationAccessToken: creds.RegistrationAccessToken,
RegistrationClientURI: creds.RegistrationClientURI,
Scope: creds.Scope,
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
TOSURI: creds.TOSURI,
PolicyURI: creds.PolicyURI,
ClientSecret: creds.ClientSecret,
ApplicationType: creds.ApplicationType,
ClientID: creds.ClientID,
ClientName: creds.ClientName,
JWKSURI: creds.JWKSURI,
ClientURI: creds.ClientURI,
Contacts: creds.Contacts,
GrantTypes: creds.GrantTypes,
ResponseTypes: creds.ResponseTypes,
RedirectURIs: creds.RedirectURIs,
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
ClientIDIssuedAt: creds.ClientIDIssuedAt,
}
}
func convertCredsFromInternal(creds *dcrstorage.ClientRegistrationResponse) *ClientRegistrationResponse {
if creds == nil {
return nil
}
return &ClientRegistrationResponse{
SubjectType: creds.SubjectType,
LogoURI: creds.LogoURI,
RegistrationAccessToken: creds.RegistrationAccessToken,
RegistrationClientURI: creds.RegistrationClientURI,
Scope: creds.Scope,
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
TOSURI: creds.TOSURI,
PolicyURI: creds.PolicyURI,
ClientSecret: creds.ClientSecret,
ApplicationType: creds.ApplicationType,
ClientID: creds.ClientID,
ClientName: creds.ClientName,
JWKSURI: creds.JWKSURI,
ClientURI: creds.ClientURI,
Contacts: creds.Contacts,
GrantTypes: creds.GrantTypes,
ResponseTypes: creds.ResponseTypes,
RedirectURIs: creds.RedirectURIs,
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
ClientIDIssuedAt: creds.ClientIDIssuedAt,
}
}
// NewDCRCredentialsStore creates a DCRCredentialsStore based on configuration.
// This factory function handles backend selection logic:
// - "file": Use file-based storage (default for backward compatibility)
// - "redis": Use Redis exclusively (fails if Redis unavailable)
// - "auto": Use Redis if available, fallback to file
func NewDCRCredentialsStore(
config *DynamicClientRegistrationConfig,
cacheManager *CacheManager,
logger *Logger,
) (DCRCredentialsStore, error) {
if config == nil {
return nil, fmt.Errorf("DCR config is nil")
}
if logger == nil {
logger = GetSingletonNoOpLogger()
}
backend := config.StorageBackend
if backend == "" {
backend = string(DCRStorageBackendAuto) // Default to auto selection
}
switch DCRStorageBackend(backend) {
case DCRStorageBackendFile:
logger.Info("Using file-based storage for DCR credentials")
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
case DCRStorageBackendRedis:
cache := getDCRCache(cacheManager)
if cache == nil {
return nil, fmt.Errorf("redis storage requested but Redis/cache not configured")
}
logger.Info("Using Redis storage for DCR credentials")
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
case DCRStorageBackendAuto:
// Try Redis first, fallback to file
cache := getDCRCache(cacheManager)
if cache != nil && cache.backend != nil {
logger.Info("Auto-selected Redis storage for DCR credentials")
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
}
logger.Info("Redis not available, using file storage for DCR credentials")
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
default:
return nil, fmt.Errorf("unknown DCR storage backend: %s", backend)
}
}
// getDCRCache safely retrieves the DCR credentials cache from the cache manager
func getDCRCache(cacheManager *CacheManager) *UniversalCache {
if cacheManager == nil {
return nil
}
cacheManager.mu.RLock()
defer cacheManager.mu.RUnlock()
if cacheManager.manager == nil {
return nil
}
return cacheManager.manager.GetDCRCredentialsCache()
}
+663
View File
@@ -0,0 +1,663 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik
package traefikoidc
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// TestFileCredentialsStore_SaveLoad tests the file-based credentials store
func TestFileCredentialsStore_SaveLoad(t *testing.T) {
t.Parallel()
// Create a temp directory for test files
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
testCreds := &ClientRegistrationResponse{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
RegistrationAccessToken: "test-access-token",
RegistrationClientURI: "https://example.com/register/test-client-id",
RedirectURIs: []string{"https://app.example.com/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
TokenEndpointAuthMethod: "client_secret_basic",
}
ctx := context.Background()
providerURL := "https://auth.example.com"
t.Run("save and load credentials", func(t *testing.T) {
// Save credentials
err := store.Save(ctx, providerURL, testCreds)
if err != nil {
t.Fatalf("Failed to save credentials: %v", err)
}
// Load credentials
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load credentials: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
// Verify fields
if loaded.ClientID != testCreds.ClientID {
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
}
if loaded.ClientSecret != testCreds.ClientSecret {
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
}
if loaded.RegistrationAccessToken != testCreds.RegistrationAccessToken {
t.Errorf("RegistrationAccessToken mismatch: got %s, want %s", loaded.RegistrationAccessToken, testCreds.RegistrationAccessToken)
}
})
t.Run("load non-existent credentials", func(t *testing.T) {
tempDir2 := t.TempDir()
store2 := NewFileCredentialsStore(filepath.Join(tempDir2, "nonexistent.json"), logger)
loaded, err := store2.Load(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Unexpected error for non-existent file: %v", err)
}
if loaded != nil {
t.Error("Expected nil for non-existent credentials")
}
})
t.Run("exists check", func(t *testing.T) {
exists, err := store.Exists(ctx, providerURL)
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if !exists {
t.Error("Expected credentials to exist")
}
exists, err = store.Exists(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if exists {
t.Error("Expected credentials to not exist")
}
})
t.Run("delete credentials", func(t *testing.T) {
err := store.Delete(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to delete credentials: %v", err)
}
exists, _ := store.Exists(ctx, providerURL)
if exists {
t.Error("Expected credentials to be deleted")
}
})
t.Run("delete non-existent credentials", func(t *testing.T) {
// Should not error
err := store.Delete(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Delete should not error for non-existent: %v", err)
}
})
}
// TestFileCredentialsStore_MultiProvider tests multi-provider support
func TestFileCredentialsStore_MultiProvider(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
provider1 := "https://auth1.example.com"
provider2 := "https://auth2.example.com"
creds1 := &ClientRegistrationResponse{
ClientID: "client-1",
ClientSecret: "secret-1",
}
creds2 := &ClientRegistrationResponse{
ClientID: "client-2",
ClientSecret: "secret-2",
}
// Save credentials for both providers
if err := store.Save(ctx, provider1, creds1); err != nil {
t.Fatalf("Failed to save creds1: %v", err)
}
if err := store.Save(ctx, provider2, creds2); err != nil {
t.Fatalf("Failed to save creds2: %v", err)
}
// Load and verify each provider's credentials
loaded1, err := store.Load(ctx, provider1)
if err != nil {
t.Fatalf("Failed to load creds1: %v", err)
}
if loaded1.ClientID != "client-1" {
t.Errorf("Provider 1 ClientID mismatch: got %s", loaded1.ClientID)
}
loaded2, err := store.Load(ctx, provider2)
if err != nil {
t.Fatalf("Failed to load creds2: %v", err)
}
if loaded2.ClientID != "client-2" {
t.Errorf("Provider 2 ClientID mismatch: got %s", loaded2.ClientID)
}
// Delete one shouldn't affect the other
if err := store.Delete(ctx, provider1); err != nil {
t.Fatalf("Failed to delete creds1: %v", err)
}
exists, _ := store.Exists(ctx, provider2)
if !exists {
t.Error("Provider 2 credentials should still exist")
}
}
// TestFileCredentialsStore_ConcurrentAccess tests thread safety
func TestFileCredentialsStore_ConcurrentAccess(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
creds := &ClientRegistrationResponse{
ClientID: "test-client",
ClientSecret: "test-secret",
}
var wg sync.WaitGroup
concurrency := 10
// Concurrent saves
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = store.Save(ctx, providerURL, creds)
}()
}
wg.Wait()
// Concurrent loads
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = store.Load(ctx, providerURL)
}()
}
wg.Wait()
// Final verification
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load after concurrent access: %v", err)
}
if loaded == nil || loaded.ClientID != "test-client" {
t.Error("Credentials corrupted after concurrent access")
}
}
// TestFileCredentialsStore_InvalidInput tests error handling
func TestFileCredentialsStore_InvalidInput(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
t.Run("save nil credentials", func(t *testing.T) {
err := store.Save(ctx, "https://example.com", nil)
if err == nil {
t.Error("Expected error for nil credentials")
}
})
t.Run("empty provider URL uses default path", func(t *testing.T) {
creds := &ClientRegistrationResponse{ClientID: "test"}
err := store.Save(ctx, "", creds)
if err != nil {
t.Fatalf("Save with empty provider URL failed: %v", err)
}
loaded, err := store.Load(ctx, "")
if err != nil {
t.Fatalf("Load with empty provider URL failed: %v", err)
}
if loaded == nil || loaded.ClientID != "test" {
t.Error("Failed to load credentials with empty provider URL")
}
})
}
// TestFileCredentialsStore_DefaultPath tests default path behavior
func TestFileCredentialsStore_DefaultPath(t *testing.T) {
t.Parallel()
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore("", logger)
// Just verify we can create with empty path and it has a default
if store.basePath() == "" {
t.Error("Expected default base path")
}
}
// TestRedisCredentialsStore_WithMemoryCache tests Redis store with in-memory cache
func TestRedisCredentialsStore_WithMemoryCache(t *testing.T) {
t.Parallel()
// Create an in-memory cache for testing
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
testCreds := &ClientRegistrationResponse{
ClientID: "redis-test-client",
ClientSecret: "redis-test-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
RegistrationAccessToken: "redis-test-token",
RedirectURIs: []string{"https://app.example.com/callback"},
}
t.Run("save and load credentials", func(t *testing.T) {
err := store.Save(ctx, providerURL, testCreds)
if err != nil {
t.Fatalf("Failed to save credentials: %v", err)
}
loaded, err := store.Load(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to load credentials: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
if loaded.ClientID != testCreds.ClientID {
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
}
if loaded.ClientSecret != testCreds.ClientSecret {
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
}
})
t.Run("exists check", func(t *testing.T) {
exists, err := store.Exists(ctx, providerURL)
if err != nil {
t.Fatalf("Exists check failed: %v", err)
}
if !exists {
t.Error("Expected credentials to exist")
}
})
t.Run("delete credentials", func(t *testing.T) {
err := store.Delete(ctx, providerURL)
if err != nil {
t.Fatalf("Failed to delete credentials: %v", err)
}
exists, _ := store.Exists(ctx, providerURL)
if exists {
t.Error("Expected credentials to be deleted")
}
})
t.Run("load non-existent credentials", func(t *testing.T) {
loaded, err := store.Load(ctx, "https://nonexistent.example.com")
if err != nil {
t.Fatalf("Unexpected error for non-existent: %v", err)
}
if loaded != nil {
t.Error("Expected nil for non-existent credentials")
}
})
}
// TestRedisCredentialsStore_TTLFromExpiry tests TTL calculation
func TestRedisCredentialsStore_TTLFromExpiry(t *testing.T) {
t.Parallel()
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
t.Run("expired credentials should fail", func(t *testing.T) {
expiredCreds := &ClientRegistrationResponse{
ClientID: "expired-client",
ClientSecret: "expired-secret",
ClientSecretExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // Already expired
}
err := store.Save(ctx, "https://expired.example.com", expiredCreds)
if err == nil {
t.Error("Expected error for expired credentials")
}
})
t.Run("credentials without expiry use default TTL", func(t *testing.T) {
creds := &ClientRegistrationResponse{
ClientID: "no-expiry-client",
ClientSecret: "no-expiry-secret",
ClientSecretExpiresAt: 0, // No expiry
}
err := store.Save(ctx, "https://noexpiry.example.com", creds)
if err != nil {
t.Fatalf("Failed to save credentials without expiry: %v", err)
}
})
}
// TestRedisCredentialsStore_InvalidInput tests error handling
func TestRedisCredentialsStore_InvalidInput(t *testing.T) {
t.Parallel()
cache := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeGeneral,
MaxSize: 100,
DefaultTTL: time.Hour,
Logger: GetSingletonNoOpLogger(),
})
defer cache.Close()
logger := GetSingletonNoOpLogger()
store := NewRedisCredentialsStore(cache, "", logger)
ctx := context.Background()
t.Run("save nil credentials", func(t *testing.T) {
err := store.Save(ctx, "https://example.com", nil)
if err == nil {
t.Error("Expected error for nil credentials")
}
})
}
// TestDCRStorageFactory tests the factory function
func TestDCRStorageFactory(t *testing.T) {
t.Parallel()
logger := GetSingletonNoOpLogger()
t.Run("nil config returns error", func(t *testing.T) {
_, err := NewDCRCredentialsStore(nil, nil, logger)
if err == nil {
t.Error("Expected error for nil config")
}
})
t.Run("file backend creates file store", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "file",
CredentialsFile: "/tmp/test-creds.json",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create file store: %v", err)
}
if store == nil {
t.Error("Expected store but got nil")
}
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore")
}
})
t.Run("redis backend without cache manager returns error", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "redis",
}
_, err := NewDCRCredentialsStore(config, nil, logger)
if err == nil {
t.Error("Expected error for redis backend without cache manager")
}
})
t.Run("auto backend without redis falls back to file", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "auto",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create auto store: %v", err)
}
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore for auto without redis")
}
})
t.Run("unknown backend returns error", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "unknown",
}
_, err := NewDCRCredentialsStore(config, nil, logger)
if err == nil {
t.Error("Expected error for unknown backend")
}
})
t.Run("empty backend defaults to auto", func(t *testing.T) {
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
StorageBackend: "",
}
store, err := NewDCRCredentialsStore(config, nil, logger)
if err != nil {
t.Fatalf("Failed to create store with empty backend: %v", err)
}
// Should default to file (auto without redis)
_, ok := store.(*FileCredentialsStore)
if !ok {
t.Error("Expected FileCredentialsStore for empty backend")
}
})
}
// TestDynamicClientRegistrar_WithStore tests registrar with store
func TestDynamicClientRegistrar_WithStore(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
}
registrar := NewDynamicClientRegistrarWithStore(
nil, // httpClient
logger,
config,
"https://auth.example.com",
store,
)
if registrar == nil {
t.Fatal("Expected registrar but got nil")
}
if registrar.store == nil {
t.Error("Expected store to be set")
}
// Test SetStore
newStore := NewFileCredentialsStore(filepath.Join(tempDir, "new.json"), logger)
registrar.SetStore(newStore)
if registrar.store != newStore {
t.Error("SetStore did not update the store")
}
}
// TestDynamicClientRegistrar_CredentialsFromStore tests loading from store
func TestDynamicClientRegistrar_CredentialsFromStore(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
providerURL := "https://auth.example.com"
ctx := context.Background()
// Pre-save credentials
testCreds := &ClientRegistrationResponse{
ClientID: "pre-saved-client",
ClientSecret: "pre-saved-secret",
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}
if err := store.Save(ctx, providerURL, testCreds); err != nil {
t.Fatalf("Failed to pre-save credentials: %v", err)
}
config := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
}
registrar := NewDynamicClientRegistrarWithStore(
nil,
logger,
config,
providerURL,
store,
)
// Test loading via the internal method
loaded, err := registrar.loadCredentialsFromStore(ctx)
if err != nil {
t.Fatalf("Failed to load from store: %v", err)
}
if loaded == nil {
t.Fatal("Expected credentials but got nil")
}
if loaded.ClientID != "pre-saved-client" {
t.Errorf("ClientID mismatch: got %s", loaded.ClientID)
}
}
// TestFileCredentialsStore_CorruptedFile tests handling of corrupted files
func TestFileCredentialsStore_CorruptedFile(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
basePath := filepath.Join(tempDir, "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(basePath, logger)
ctx := context.Background()
providerURL := "https://auth.example.com"
// Write corrupted JSON
filePath := store.getFilePath(providerURL)
if err := os.WriteFile(filePath, []byte("{corrupted json"), 0600); err != nil {
t.Fatalf("Failed to write corrupted file: %v", err)
}
// Should return error for corrupted file
_, err := store.Load(ctx, providerURL)
if err == nil {
t.Error("Expected error for corrupted JSON")
}
}
// TestFileCredentialsStore_DirectoryCreation tests auto directory creation
func TestFileCredentialsStore_DirectoryCreation(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
deepPath := filepath.Join(tempDir, "deep", "nested", "path", "credentials.json")
logger := GetSingletonNoOpLogger()
store := NewFileCredentialsStore(deepPath, logger)
ctx := context.Background()
creds := &ClientRegistrationResponse{ClientID: "test"}
err := store.Save(ctx, "https://example.com", creds)
if err != nil {
t.Fatalf("Failed to save with nested directory: %v", err)
}
loaded, err := store.Load(ctx, "https://example.com")
if err != nil {
t.Fatalf("Failed to load after nested directory creation: %v", err)
}
if loaded == nil || loaded.ClientID != "test" {
t.Error("Failed to load credentials from nested directory")
}
}
+16 -6
View File
@@ -25,7 +25,10 @@ The **audience** (`aud`) claim in a JWT identifies the intended recipient of the
### Why Does This Matter?
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
Audience validation rejects access tokens whose `aud` claim does not match the
expected audience, blocking the trivial form of token confusion where a token
issued for API A is presented to API B. (Defence in depth — pair with
short-lived tokens, rotation, and per-API client credentials.)
---
@@ -137,8 +140,8 @@ http:
**Recommended:** `true` for production
**What it does:**
- When `true`: Rejects sessions if access token audience doesn't match (prevents Scenario 2)
- When `false`: Logs warnings but allows fallback to ID token (backward compatible)
- When `true`: On audience mismatch, the middleware does **not** silently fall back to ID-token validation. It tries to refresh the access token first; if no refresh token is present (or refresh fails), the user is re-authenticated.
- When `false`: Logs warnings and falls back to ID-token validation (backward compatible).
**Example:**
```yaml
@@ -295,13 +298,20 @@ strictAudienceValidation: true
```
Look for `introspection_endpoint`
2. **Disable required introspection (less secure):**
2. **Configure the endpoint manually:**
```yaml
introspectionURL: https://YOUR_DOMAIN/oauth/introspect
```
Set this when Auth0 omits `introspection_endpoint` from discovery. The
manual value takes precedence over the discovered value.
3. **Disable required introspection (less secure):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: false # Falls back to ID token
```
3. **Use JWT access tokens instead** (recommended)
4. **Use JWT access tokens instead** (recommended)
---
@@ -349,7 +359,7 @@ When opaque tokens are detected:
**Cache behavior:**
- Cache key: Token hash
- TTL: 5 minutes or token expiry (whichever is shorter)
- TTL: 5 minutes; if the token's `exp` is sooner, the cache entry expires at `exp` instead. Tokens without `exp` use the flat 5-minute TTL.
- Reduces introspection requests for frequently used tokens
---
+262
View File
@@ -0,0 +1,262 @@
# Bearer Token (M2M) Authentication
Opt-in path that lets API clients present `Authorization: Bearer <jwt>` to
authenticate without going through the cookie-based OIDC redirect flow.
Designed for machine-to-machine (M2M) traffic — services calling other
services with tokens minted by your OIDC provider.
The bearer path lives next to the cookie path: both go through the same
post-auth pipeline (`forwardAuthorized`) that injects identity headers,
checks `allowedRolesAndGroups`, applies security headers, and forwards to
the backend. The only thing that differs is how the principal is established
for that single request.
## Quick start
```yaml
enableBearerAuth: true
audience: https://api.example.com # REQUIRED when bearer is enabled
clientID: my-api-client-id
providerURL: https://issuer.example.com
sessionEncryptionKey: <32+-byte secret>
callbackURL: /oauth2/callback
```
That is the minimum. Everything else has a secure default.
## Obtaining bearer tokens from your OIDC provider
The middleware only **validates** bearer tokens — minting them is the IdP's job. For M2M traffic the canonical mint flow is OAuth 2.0 **`client_credentials`** (RFC 6749 §4.4); some providers require **JWT bearer assertion** (RFC 7523) instead.
```
┌────────────┐ POST /token ┌──────────┐
│ client │ ───────────────────────────────►│ IdP │
│ (service) │ grant_type=client_credentials │ /token │
│ │ client_id=… │ │
│ │ client_secret=… (or JWT) │ │
│ │ audience=https://api.… ←── critical │
│ │ scope=api:read … │
│ │ ◄───────────────────────────────│ │
│ │ access_token (JWT) │ │
└────────────┘ └──────────┘
│
│ GET /protected
│ Authorization: Bearer <access_token>
▼
Your service (behind Traefik + this plugin)
```
The IdP returns a JWT signed by the same JWKs the middleware already trusts (it discovers them from `providerURL`/.well-known). On the first protected request, the middleware verifies signature + issuer + **audience** + `exp` + identifier claim, then forwards downstream with `X-Forwarded-User` set.
### Minimal worked example (Auth0-shape)
```bash
# 1. Mint a token
curl -s -X POST https://issuer.example.com/oauth/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "your-m2m-client-id",
"client_secret": "your-m2m-client-secret",
"audience": "https://api.example.com",
"scope": "api:read api:write"
}'
# → {"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":86400,…}
# 2. Use it
curl -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs…' https://api.example.com/protected
```
The `audience` field in the token request **must match** the `audience` you configured on the middleware. Mismatch → 401 with `Bearer error="invalid_token"`.
### Per-provider quick reference
| Provider | Grant | Token endpoint | Audience parameter | Notes |
|---|---|---|---|---|
| **Auth0** | `client_credentials` | `https://TENANT.auth0.com/oauth/token` | `audience=<your API identifier>` | Register an "API" + "Machine to Machine Application" authorised against that API. Without `audience` you get an opaque /userinfo token, which the bearer path rejects. See `docs/AUTH0_AUDIENCE_GUIDE.md`. |
| **Okta** | `client_credentials` | `https://TENANT.okta.com/oauth2/default/v1/token` | Configured in the authorization server; default `aud` is the auth-server URL | Service app must enable the `client_credentials` flow and be granted the requested scopes. |
| **Keycloak** | `client_credentials` | `https://kc/realms/REALM/protocol/openid-connect/token` | Configure an "Audience" mapper on a client scope, or use `client_id` as the audience | Client must have `serviceAccountsEnabled: true` plus role mappings. |
| **Entra ID / Azure AD** | `client_credentials` (v2.0 endpoint) | `https://login.microsoftonline.com/TENANT/oauth2/v2.0/token` | Pass `scope=<App ID URI>/.default`; `aud` ends up being the API's App ID URI | Requires an App Registration + API permissions + admin consent. **Use the v2.0 endpoint** — v1 issues Microsoft-proprietary access tokens that are opaque to non-Microsoft clients. |
| **AWS Cognito** | `client_credentials` | `https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/token` | Scopes from a "Resource Server" attached to your User Pool | App client must have `client_credentials` flow enabled. Use HTTP **Basic** auth header for `client_id:client_secret`. |
| **GitLab** | `client_credentials` | `https://gitlab.com/oauth/token` | Audience matches the GitLab issuer | Rarely used for protecting external APIs; better suited for GitLab's own resources. |
| **Google** | **JWT bearer (RFC 7523)** — *not* `client_credentials` | `https://oauth2.googleapis.com/token` | Signed assertion JWT carries `aud=https://oauth2.googleapis.com/token`; resulting access token is **opaque** unless you specifically request a Google-issued JWT for your API | Google service-account flow is not the best fit for this middleware (opaque tokens are rejected on the bearer path). Run Auth0 / Okta / Keycloak in front, or use ID-token-based flows on the cookie path. |
### RFC 7523 (JWT bearer assertion) — secretless alternative
When shared secrets are forbidden (FAPI, internal compliance), swap `client_secret` for a signed JWT assertion:
```
POST /token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<JWT signed by the client's private key>
```
The assertion JWT carries `iss=<client_id>`, `sub=<client_id>`, `aud=<token endpoint>`, `exp`. The IdP verifies the signature against a public key you've pre-registered and returns an access token.
This middleware already supports JWT assertions on the *middleware → IdP* hop via `clientAuthMethod: private_key_jwt` (see `docs/CONFIGURATION.md`). For the *client → IdP* hop, the same pattern applies — the client signs its own assertion.
### Operational notes
- **Token TTL is typically 1–24 hours.** Clients should refresh on `401`, not on a polling timer — saves the IdP.
- **Cache and reuse tokens.** The middleware caches verified tokens too, so repeated presentations are cheap. Clients SHOULD reuse a token until ~80 % of `expires_in`.
- **JWKS rotation is transparent.** The middleware auto-refreshes its JWKS cache when the IdP rotates keys. Clients don't need to do anything.
- **Revocation is generally not per-token** with `client_credentials`. If you need real-time revocation, set `requireTokenIntrospection: true` on the middleware and the IdP is consulted on every cache miss.
- **`scope` vs `audience`.** Scope says *what the client may do*; audience says *which service the token is for*. The middleware enforces audience; the backend service should enforce scope.
- **Secret hygiene.** Store `client_secret` in a secrets manager (Vault, AWS Secrets Manager, Kubernetes `Secret`). For higher assurance, switch the client to `private_key_jwt` (no shared secret at all).
### Quickest validation loop
```bash
# 1. Mint
TOKEN=$(curl -s -X POST https://issuer.example.com/oauth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"…","client_secret":"…","audience":"https://api.example.com"}' \
| jq -r .access_token)
# 2. Inspect claims to confirm aud/iss/exp match the middleware config
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# 3. Hit the protected route
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/protected
```
`HTTP/1.1 200` with `X-Forwarded-User` on the backend confirms the loop works end-to-end. `401` with `WWW-Authenticate: Bearer error="invalid_token"` plus a middleware debug log explaining the rejection (audience mismatch, ID token presented, `iat` outside the 24h window, etc.) confirms the hardening is firing as designed.
## Threat model and design rules
Bearer authentication has materially different security properties from
cookie sessions: no `HttpOnly`/`Secure`/`SameSite` shielding, the token is
visible in headers and logs, and it's easier to exfiltrate. The bearer path
treats every one of these as a first-class concern.
| Property | Behaviour | Why |
|---|---|---|
| Default state | `enableBearerAuth=false` | Bearer is opt-in; existing deployments observe no change. |
| Audience | **Mandatory.** Startup fails if `audience` is empty when bearer is enabled. | Eliminates the "token issued for service B accepted by service A" confusion attack. |
| Token format | JWT by default. An opaque (non-JWT) token is accepted only when both `requireTokenIntrospection=true` and `allowOpaqueTokens=true`. | JWTs carry their own signature, audience and issuer; an opaque token has no local verification, so introspection must be turned on explicitly for both purposes before one is trusted. |
| Opaque token client binding | When `audience` equals `clientID` (the common single-app case), the introspection response must carry a matching `client_id`, or an `aud` that contains `clientID`. | RFC 7662 §2.2 leaves client scoping to the authorization server's own policy. Without this check, any active token from the same IdP — including one issued to a different client — would authenticate. |
| Opaque token `iat` requirement | The introspection response for an opaque token must carry `iat`. A response with no `iat` is always rejected: `maxTokenAgeSeconds` has no "disabled" value — `0` or unset maps to a 24h default (see the config-reference row below) — so the age bound is always in effect. | Mirrors `enforceIatAge` on the JWT path: with an age bound configured, a token with nothing to bound its age against must fail closed, not skip the check. |
| Opaque token logout scope | A `sid`-only (front-channel) logout revokes an opaque bearer token only when the introspection response itself carries a `sid`. RFC 7662 does not define `sid`; when the IdP's response omits it, only a `sub`-based (backchannel) logout reaches the opaque token. | The middleware can only match invalidation against fields the introspection response actually returns. |
| `alg` allowlist | Hard-pinned asymmetric: `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. Checked **before** any JWKS fetch. | Denies `alg=none` and `alg=HS*` probes; prevents attacker noise from amplifying into JWKS round-trips. |
| `kid` hardening | Max 256 bytes; charset `[A-Za-z0-9._\-=]`. Checked **before** JWKS fetch. | Prevents cache-key explosion / pathological-`kid` JWKS amplification. |
| Token type | ID tokens are explicitly rejected (`nonce` claim, `typ: at+jwt`, `token_use=id`, scope/aud heuristics — reuses the existing `detectTokenType` helper). | ID tokens are not API credentials; treating them as such is classic token confusion. |
| Multi-audience | When `aud` is an array of length > 1, the token must carry `azp == clientID`. | OIDC §2 hardening against tokens minted for one client being replayed by another. |
| `iat` upper-age | Rejects tokens older than `maxTokenAgeSeconds` (default 24h). | Bounds clock-manipulation / forever-token abuse, even if `exp` is far in the future. |
| Identifier claim | `bearerIdentifierClaim` (default `"sub"`). Resolved value drives `X-Forwarded-User`. | Decoupled from the cookie path's `UserIdentifierClaim` (default `email`) so the M2M flow can never accidentally trust an unverified email. |
| Identifier sanitisation | Length cap (`maxIdentifierLength`, default 256). Rejects control chars, Unicode bidi-overrides (U+202A–U+202E, U+2066–U+2069), and the delimiters `, ; =`. | Defence in depth against downstream header injection / log injection / admin-UI spoofing. |
| JTI replay marking | No request path writes an access-token JTI to a replay cache (the only JTI replay check left is for backchannel logout tokens). Revocation still works without it: `RevokeToken` (called from logout) blacklists the raw token and, for a JWT, its `jti`; both the JWT and the opaque bearer path check that blacklist — raw token always, plus `jti` when the introspection response carries one — before accepting the token. | Revocation without replay-detection overhead: a legitimately reused, non-revoked bearer token is never falsely flagged as a replay. |
| Mixed bearer + cookie | **Cookie wins by default.** Flip to bearer-wins with `bearerOverridesCookie=true`. | Safer against browser/extension/proxy bearer injection scenarios. The cookie is the authoritative authenticator when present. |
| `Authorization` strip | `stripAuthorizationHeader=true` by default. | Keeps the raw token out of downstream services and their logs. |
| Excluded URLs | `Authorization` is stripped on excluded paths when `enableBearerAuth=true`. | Prevents bearer leakage into public health/metrics endpoint logs and prevents recon via excluded paths. |
| Per-IP throttle | After `bearerFailureThreshold` consecutive 401s from one source IP within `bearerFailureWindowSeconds`, further bearer requests from that IP return `429 Too Many Requests` + `Retry-After` for `bearerFailurePenaltySeconds`. | Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. |
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Only an HTTP 200 response with `active=false` returns `401`; any other endpoint failure (network error, or any other status including 401/403/408/429) returns `503`. | RFC 7662 §2.3 defines a 401/403 from the introspection endpoint as the resource's own client credentials being rejected, not a statement about the presented token, so it must never be read as "token revoked". |
| Response shape | `401 Unauthorized` with generic body. `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3 (toggleable via `bearerEmitWWWAuthenticate`). `403` for roles/groups denial. `429` for throttle. `503` for introspection-endpoint outage. | Auditable from spec to code; reason categories never leak into the response body. |
| Logging | Failure reason + identifier hash (SHA-256 truncated to 8 hex chars) logged at debug. Raw tokens are never logged. | Audit trail without secrets-in-logs. |
## Configuration reference
| Field | Default | Description |
|---|---|---|
| `enableBearerAuth` | `false` | Master switch for the bearer path. |
| `audience` | (unset) | **Required** when `enableBearerAuth=true`. Reuses the existing global `audience` field. |
| `bearerIdentifierClaim` | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
| `stripAuthorizationHeader` | `true` | Remove the `Authorization` header before forwarding to the backend. Disable only when a downstream needs to re-verify the bearer. |
| `bearerEmitWWWAuthenticate` | `true` | Include `WWW-Authenticate: Bearer error="..."` on 401 responses (RFC 6750 §3). Disable to reduce recon signal. |
| `bearerOverridesCookie` | `false` | Cookie wins when both are present (default). Set `true` for the AWS/GCP/Kubernetes bearer-wins convention. |
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age. `0` and unset both map to the 24h default — there is no value that disables the check. A JWT or opaque-introspection response with no `iat` is rejected once this bound is in effect, which it always is. |
| `maxIdentifierLength` | `256` | Length cap for the post-sanitisation identifier. |
| `bearerFailureThreshold` | `20` | Consecutive 401s from one IP that trip the throttle. |
| `bearerFailureWindowSeconds` | `60` | Rolling window over which 401s are counted. |
| `bearerFailurePenaltySeconds` | `60` | Duration of the 429 penalty box after the threshold trips. |
| `requireTokenIntrospection` | `false` | Call RFC 7662 introspection on every cache miss. Adds per-request IdP latency. |
| `allowOpaqueTokens` | `false` | Accept an opaque (non-JWT) bearer token, introspected via `requireTokenIntrospection`. Both flags are required together; this flag alone has no effect on the bearer path. |
## What the bearer path does NOT do
- **Human-user / browser flows.** The bearer path is M2M-only in this
iteration. Browser SPAs that want to attach a bearer to fetch calls work
if your backend treats them as machine clients, but the spec defaults are
tuned for service-to-service traffic.
- **Opaque access tokens by default.** Tokens must be JWTs unless both
`requireTokenIntrospection=true` and `allowOpaqueTokens=true` are set. For a
JWT, introspection stays a revocation overlay on top of local verification,
not a substitute for it; an opaque token has no local verification, so
introspection is its only check.
- **`email_verified` enforcement.** The bearer path rejects `email` as the
identifier claim at startup precisely because `email_verified` is not
enforced in this iteration. Adding human-user bearer support is a
follow-up that must include this check.
- **mTLS / API keys.** Out of scope. The `principal` abstraction enables
adding these later as additional auth methods that produce a principal
for the shared `forwardAuthorized` pipeline.
- **SSE / WebSocket bypass with bearer.** Bypass paths keep their existing
cookie-only behaviour; bearer headers are ignored on those endpoints.
Documented limitation; widen by removing the bypass if you need bearer on
streaming endpoints.
## Operational guidance
- **Always set `strictAudienceValidation: true` when bearer is enabled.**
Startup logs a recommendation if you don't.
- **Set a tight `maxTokenAgeSeconds`** for environments where tokens are
expected to be minted frequently — the default 24h is conservative.
- **Enable `requireTokenIntrospection`** if your IdP supports it and
revocation latency matters. Bearer-path introspection caches results for
a short window per token.
- **Also set `allowOpaqueTokens=true`** if your clients present opaque
(non-JWT) access tokens. `requireTokenIntrospection` alone does not admit
an opaque token; both flags are required together. Confirm your IdP's
introspection response carries `client_id` or `aud` so the client-binding
check above can pass.
- **Monitor 429s.** Sustained 429 traffic indicates either a buggy client
loop or an active credential-stuffing attempt. The throttle is your
primary signal for both.
- **`stripAuthorizationHeader=false` extends the token's blast radius** to
every downstream service that sees the request. Treat those services'
logs as token stores.
- **Bearer reuse is normal.** Don't enable per-token rate limiting; that's
what `bearerFailureThreshold` is for (per-IP, not per-token).
- **Cookie-wins is the safer default.** Only flip `bearerOverridesCookie`
if you control all clients and have audited that none of them present a
cookie alongside a bearer they don't intend to authenticate with.
## Failure response matrix
| Trigger | Status | Body | `WWW-Authenticate` |
|---|---|---|---|
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` |
| Token over `MaxLength` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Missing / oversized / bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Signature / issuer / audience / `exp` failure | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| `iat` older than `maxTokenAgeSeconds` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Multi-audience token without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Introspection returns HTTP 200 with `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Introspection endpoint HTTP failure (network error, or any non-200 status including 401/403/408/429) | 503 | `Service Unavailable` | (none) |
| Identifier claim missing / empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <bearerFailurePenaltySeconds>` |
| Roles / groups not allowed | 403 | `Access denied` | (none) |
## Known follow-ups (deferred)
These are documented as future work, not blockers:
- **Human-user bearer with `email_verified` enforcement.** Requires
decoupling the email-claim guard from the startup rejection and adding a
per-request `email_verified=true` check.
- **Introspection respects `client_assertion`.** The existing introspection
helper uses `client_secret_basic` only; operators on `private_key_jwt`
will see introspection silently use basic auth.
- **Per-route bearer configuration.** Single middleware-wide setting in this
iteration.
## References
- [PR design spec](superpowers/specs/2026-05-18-bearer-token-auth-design.md) — full design rationale, alternatives considered, and per-section sign-off history.
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage.
- [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) — OAuth 2.0 Token Introspection.
- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) — JWT Profile for OAuth 2.0 Access Tokens.
+388 -19
View File
@@ -5,6 +5,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
## Table of Contents
- [Required Parameters](#required-parameters)
- [Client Authentication](#client-authentication)
- [Optional Parameters](#optional-parameters)
- [Security Options](#security-options)
- [Session Management](#session-management)
@@ -22,7 +23,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|-----------|------|-------------|---------|
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
| `clientSecret` | string | OAuth 2.0 client secret | `your-client-secret` |
| `clientSecret` | string | OAuth 2.0 client secret. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`. Optional when `clientAuthMethod: private_key_jwt`. | `your-client-secret` |
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
@@ -45,6 +46,134 @@ spec:
---
## Client Authentication
The middleware supports three client authentication methods at the token and
revocation endpoints. The default is `client_secret_post` (current behavior);
`private_key_jwt` is opt-in and backwards compatible.
| Method | Default | Description |
|--------|---------|-------------|
| `client_secret_post` | yes | `client_id` + `client_secret` in the request body. |
| `client_secret_basic` | no | RFC 6749 §2.3.1 — `client_id` + `client_secret` in the `Authorization: Basic` header (form-urlencoded then base64); not in the body. |
| `private_key_jwt` | no | RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as `client_assertion`. |
Select via `clientAuthMethod`:
```yaml
clientAuthMethod: private_key_jwt
```
### client_secret_post
Default. The plugin sends `client_id` and `client_secret` as form parameters
in the token / revocation request body. No additional configuration required.
### private_key_jwt
Asymmetric client authentication per
[RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523). Use this when your
IdP enforces short secret TTLs, when policy mandates secretless clients, or
when you want to avoid distributing a shared secret to the proxy.
For each token / revocation request the plugin builds a JWS with:
- `iss` = `sub` = `clientID`
- `aud` = token endpoint URL
- `iat` = now, `exp` = now + 60s
- `jti` = random hex per request
- `kid` header = `clientAssertionKeyID`
**Required fields:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `clientAuthMethod` | string | `client_secret_post` | Set to `private_key_jwt`. |
| `clientAssertionPrivateKey` | string | none | Inline PEM private key. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8, PKCS#1, and SEC1 formats accepted. |
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk. Mutually exclusive with `clientAssertionPrivateKey`. |
| `clientAssertionKeyID` | string | none | `kid` header inserted in the JWS. Must match the public key registered with the IdP. |
| `clientAssertionAlg` | string | `RS256` | One of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`. |
When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
With `clientAssertionAlg` set to `RS256`, `RS384`, `RS512`, `PS256`, `PS384`,
or `PS512`, the RSA key must be 2048 bits or larger (RFC 7518 §3.3). The
plugin rejects a smaller key at startup instead of at every token exchange.
There is no override for this check.
**Example — inline PEM:**
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-auth
spec:
plugin:
traefikoidc:
providerURL: https://idp.example.com
clientID: my-client-id
sessionEncryptionKey: your-32-byte-encryption-key-here
callbackURL: /oauth2/callback
clientAuthMethod: private_key_jwt
clientAssertionKeyID: key-2026-01
clientAssertionAlg: RS256
clientAssertionPrivateKey: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
... (truncated) ...
-----END PRIVATE KEY-----
```
**Example — key on disk:**
```yaml
clientAuthMethod: private_key_jwt
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
clientAssertionKeyID: key-2026-01
clientAssertionAlg: RS256
```
**Generating an RS256 key with OpenSSL:**
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out client-key.pem
openssl rsa -in client-key.pem -pubout -out client-pub.pem
```
Register `client-pub.pem` (or its JWK form) with your IdP under the same
`kid` you set in `clientAssertionKeyID`.
**Notes:**
- The private key is parsed once at plugin startup. Key rotation requires a
Traefik reload.
- Assertion lifetime is fixed at 60 seconds.
- A fresh random `jti` is generated per request.
- The `aud` claim is the token endpoint URL (from discovery).
- Tracking issue:
[#135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
### client_secret_basic
Per [RFC 6749 §2.3.1][rfc6749-2-3-1], the plugin sends the client credentials
in an `Authorization: Basic` header instead of the body. Both halves
(`client_id`, `client_secret`) are form-urlencoded individually, joined with
a colon, then base64-encoded. Use this when your IdP requires Basic auth at
the token endpoint and rejects credentials in the body.
```yaml
clientAuthMethod: client_secret_basic
clientID: your-client-id
clientSecret: your-client-secret
```
[rfc6749-2-3-1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
---
## Optional Parameters
| Parameter | Type | Default | Description |
@@ -52,23 +181,91 @@ spec:
| `logoutURL` | string | `callbackURL + "/logout"` | Path for logout requests |
| `postLogoutRedirectURI` | string | `/` | Redirect URL after logout |
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
| `forceHTTPS` | bool | `false` | Force HTTPS for redirect URIs |
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
| `rateLimit` | int | `100` | Maximum requests per second |
| `excludedURLs` | []string | none | Paths that bypass authentication |
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. See [Logout Behavior](#logout-behavior) for what happens at logout when this is set. |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
| `clientAssertionPrivateKey` | string | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8 / PKCS#1 / SEC1. |
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
| `clientAssertionKeyID` | string | none | `kid` header for `private_key_jwt` assertions. Required when `clientAuthMethod: private_key_jwt`. |
| `clientAssertionAlg` | string | `RS256` | Signing algorithm for `private_key_jwt`. One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
| `extraAuthParams` | map | none | Extra query parameters appended to the authorization request (e.g. re-selective prompt). |
| `perSourceLoginRateLimit` | int | `0` (off) | Throttle OIDC auth events (callback + login initiation) per external client source, in auth events per minute. Keys and classifies the source by RemoteAddr only, never `X-Forwarded-For`, so a client cannot spoof it. Internal/loopback sources are never throttled. RemoteAddr is the TCP peer. Behind a load balancer or CDN, the limiter keys on the proxy address: a private address is never throttled, and a public address puts every client in one shared bucket. |
| `stripAuthCookies` | bool | `false` | Strip OIDC session cookies from the hop to the backend (mitigates HTTP 431). |
| `enableBackchannelLogout` | bool | `false` | Enable OIDC back-channel logout (IdP-initiated, server-to-server). |
| `backchannelLogoutURL` | string | derived | Back-channel logout endpoint path. |
| `enableFrontchannelLogout` | bool | `false` | Enable OIDC front-channel logout (browser iframe logout). |
| `frontchannelLogoutURL` | string | derived | Front-channel logout endpoint path. |
| `caCertPath` / `caCertPEM` | string | none | Custom CA bundle for OIDC TLS verification (filesystem path, or inline PEM). Mutually exclusive with `insecureSkipVerify`. |
| `insecureSkipVerify` | bool | `false` | Disable TLS verification for the OIDC client (load balancer / mTLS edge). Emits a loud warning at startup. |
### Logout Behavior
A request to `logoutURL` clears the session, redirects the browser, and
also revokes the session's tokens.
1. The plugin blacklists the access, ID, and refresh tokens locally, so this
instance rejects them immediately.
2. If `revocationURL` is set or discovered, the plugin also revokes the
access token and the refresh token at the provider (RFC 7009). Each
revocation call times out after 3 seconds. The two calls run one after
the other, so together they can delay the logout redirect by up to about
6 seconds. A revocation failure logs at error level and does not stop
the redirect. Deployments with no revocation endpoint log nothing for
this step.
3. The plugin redirects to `oidcEndSessionURL` (RP-initiated logout) only
when `oidcEndSessionURL` is set or discovered and the session still
holds an ID token. Otherwise it redirects to `postLogoutRedirectURI`.
4. When the plugin uses `oidcEndSessionURL`, it also sends
`post_logout_redirect_uri`. An absolute `postLogoutRedirectURI` value is
sent unchanged. A relative value, or the default `/`, is joined to the
origin of the redirect URI recorded at login. The plugin does not use
the logout request's `Host` header for this, because a client can spoof
it. The plugin omits the parameter only for a session created before
that origin was recorded. The provider then decides where to send the
user.
### TLS Termination at Load Balancer
If running Traefik behind a load balancer (AWS ALB, Google Cloud LB, Azure App Gateway) that terminates TLS:
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
the correct default behind any TLS-terminating load balancer (AWS ALB, Google
Cloud LB, Azure App Gateway) — `X-Forwarded-Proto` cannot be trusted (ALB may
overwrite it).
```yaml
forceHTTPS: true # Required for correct redirect URIs
```
Set `forceHTTPS: false` only when you serve OIDC over plaintext HTTP (local
dev). Otherwise leave it at default.
Without this setting, redirect URIs will use `http://` instead of `https://`, causing OAuth callback failures.
### Streaming Endpoints (SSE and WebSocket)
The middleware automatically bypasses the OIDC redirect for two request kinds
that browsers cannot follow a 302 on:
| Bypass | Triggered by |
|--------|--------------|
| Server-Sent Events (SSE) | `Accept: text/event-stream` |
| WebSocket upgrade | `Upgrade: websocket` + `Connection: upgrade` (RFC 6455) |
These requests do **not** require any explicit configuration — they are
handled implicitly. However, the bypass is **not** unauthenticated:
- A valid, encrypted session cookie is required. Requests without one are
rejected (the connection cannot proceed to the backend).
- The session cookie is sealed with `sessionEncryptionKey`, so the
`authenticated` flag cannot be forged.
- Validation is cookie-only — no JWK fetch / signature verification — so
streaming endpoints keep working when the OIDC provider is briefly
unavailable.
- The user identifier from the session is forwarded as `X-Forwarded-User`
(and `X-Auth-Request-User` unless `minimalHeaders: true`).
For browser clients, the user must complete the normal OIDC flow on a
regular HTTP page first; the resulting session cookie is then reused on the
SSE / WebSocket connection.
---
@@ -78,7 +275,8 @@ Without this setting, redirect URIs will use `http://` instead of `https://`, ca
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `audience` | string | `clientID` | Expected audience for access token validation |
| `audience` | string | `clientID`, or `resource` if set | Expected audience for access token validation |
| `resource` | string | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs — see [docs/RFC8707.md](RFC8707.md) |
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
@@ -90,6 +288,16 @@ audience: "https://my-api.example.com"
strictAudienceValidation: true
```
#### RFC 8707 Resource Indicators
```yaml
resource: "https://api.example.com"
```
Alternative to `audience` for IdPs that implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707).
See [docs/RFC8707.md](RFC8707.md) for exactly what request is made, why only a single
resource is supported, and current per-provider support.
#### Opaque Token Support
```yaml
@@ -104,6 +312,77 @@ strictAudienceValidation: true
|-----------|------|---------|-------------|
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
| `allowUnauthenticatedPreflight` | bool | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` with `Origin` + `Access-Control-Request-Method`); response body is still discarded. Default requires auth for every `OPTIONS` request. |
| `streamingRefresh` | bool | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request with `401` when the IdP revokes the grant (`invalid_grant`). Forward it as before when the IdP cannot be reached. |
### Discovered Endpoint Validation
When `providerURL` is `https://`, the plugin drops any endpoint the
`.well-known/openid-configuration` document advertises as plain `http://`
(authorization, token, jwks_uri, revocation, end_session, introspection,
registration). A dropped credential-bearing endpoint would otherwise send
the client secret or a token over an unauthenticated channel. There is no
configuration flag to disable this check.
A dropped endpoint logs an `ERROR`-level line naming it, and a dropped
`token`, `jwks_uri`, or `authorization` endpoint — the three logins cannot
function without — additionally logs a `SECURITY:`-prefixed line, because a
blank endpoint otherwise fails every login with no other signal:
```
SECURITY: dropped the discovered token endpoint "http://idp.example.com/token": it is plaintext http while providerURL "https://idp.example.com" is https, and this check has no override; requests needing the token endpoint will fail until the provider serves it over https
```
The `revocationURL`, `oidcEndSessionURL` and `introspectionURL` config
fields can supply those three endpoints directly, and when you set one of
them, a plaintext-`http://` drop of the matching discovered endpoint does
not break anything — the override replaces the endpoint right after
sanitize runs and the `SECURITY:` line does not fire for it.
Leave the override unset, though, and a drop is exactly as terminal as it is
for `token`, `jwks_uri` and `authorization`: RP-initiated logout falls back
to a local-only redirect (the IdP session stays alive), provider-side
revocation is skipped, or `requireTokenIntrospection` breaks — all with no
signal beyond the generic `ERROR` line. So `revocation`, `end_session` and
`introspection` log the same `SECURITY:` line too, whenever the matching
override is not set. `registration` never logs the `SECURITY:` line, under
any configuration — it is not covered by this check. Dynamic Client
Registration reads its own, separate override
(`dynamicClientRegistration.registrationEndpoint`), independently of this
discovery step, but only when you set it: leave it unset and a
plaintext-`http://` drop of the discovered registration endpoint makes
Dynamic Client Registration fail with only the generic `ERROR` line above —
no `SECURITY:`-tagged signal, unlike `revocation`, `end_session` and
`introspection`.
`token`, `jwks_uri` and `authorization` log the `SECURITY:` line
unconditionally — these three have no config field that can supply them at
all, so a drop always breaks login regardless of any other setting.
If your IdP's discovery document ever advertises an `http://` endpoint under
an `https://` `providerURL`, fix the discovery document (for example, a
TLS-terminating proxy in front of the IdP that does not forward
`X-Forwarded-Proto`) rather than relying on the plugin to relax the check.
### Bearer-token (M2M) authentication
Opt-in path that accepts `Authorization: Bearer <jwt>` instead of the cookie
session flow. M2M-only, default off, audience-mandatory. See
[docs/BEARER_AUTH.md](BEARER_AUTH.md) for the threat model and operational
guidance.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enableBearerAuth` | bool | `false` | Master switch. Startup fails if true with empty `audience` or with `bearerIdentifierClaim=email`. |
| `bearerIdentifierClaim` | string | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
| `bearerEmitWWWAuthenticate` | bool | `true` | Emit RFC 6750 `WWW-Authenticate: Bearer error="..."` hints on 401. |
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). `0` and unset both map to the 24h default; no value disables the check, and a bearer token or opaque-introspection response without `iat` is rejected. |
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
| `bearerFailurePenaltySeconds` | int | `60` | 429 + `Retry-After` duration after the threshold trips. |
---
@@ -113,8 +392,10 @@ strictAudienceValidation: true
|-----------|------|---------|-------------|
| `sessionMaxAge` | int | `86400` (24h) | Maximum session age in seconds |
| `refreshGracePeriodSeconds` | int | `60` | Seconds before expiry to attempt refresh |
| `maxRefreshTokenAgeSeconds` | int | `21600` | Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with `invalid_grant` retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set `0` to disable. Default `21600` (6h). |
| `cookieDomain` | string | auto-detected | Domain for session cookies |
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
| `cookiePath` | string | `/` | Path prefix for session cookies. Set it to the middleware's path (for example `/app`) so the browser does not send OIDC cookies to unprotected paths (avoids HTTP 431). |
### Multi-Subdomain Setup
@@ -254,22 +535,77 @@ minimalHeaders: true # Only forwards X-Forwarded-User
```yaml
headers:
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
```
**Template Variables:**
- `{{.Claims.field}}` - ID token claims
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
- `{{.AccessToken}}` - Raw access token
- `{{.IdToken}}` - Raw ID token
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
- `{{.RefreshToken}}` - Raw refresh token
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
**Important — file provider only:** Traefik's file provider runs every dynamic
configuration file through Go templating before the plugin sees it, so plain
`{{ }}` fails there with `can't evaluate field AccessToken in type bool`.
Escape with a raw string: ``value: "{{`{{.Claims.email}}`}}"``. All other
providers (Kubernetes CRD, Docker labels, Consul, ...) pass the value through
untouched — use the plain form shown above. Do not use quadruple braces
(`{{{{ }}}}`); no configuration path collapses them, and template validation
rejects them (issues #149, #151).
**Validation and the claims whitelist.** Header templates are parsed and
validated when the middleware loads; an invalid or disallowed template stops the
middleware from starting (the route then returns Traefik's
`invalid handler type: <nil>`). To prevent a template from accidentally
forwarding raw tokens or sensitive claims, only a fixed set of claim fields may
be emitted — the standard OIDC claims plus common provider claims (`email`,
`name`, `given_name`, `family_name`, `preferred_username`, `sub`, `groups`,
`roles`, `internal_role`, `role`, `department`, `organization`, `realm_access`,
`resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`, `locale`, `zoneinfo`,
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
**`allowedClaims`** extends the whitelist for claims your provider issues that
are not built in:
```yaml
allowedClaims:
- employee_id
- cost_center
headers:
- name: "X-Employee-Id"
value: "{{.Claims.employee_id}}"
```
Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
`get` (`{{get .Claims "employee_id"}}`). Subfields of a whitelisted map-valued
claim (e.g. `realm_access.roles`) are already accessible without listing them
individually.
**`toJson`** forwards an object or array claim as one JSON header:
```yaml
allowedClaims:
- tenant
headers:
- name: "X-Tenant"
value: "{{get .Claims \"tenant\" | toJson}}"
```
It only serializes values the template can already reach, so the whitelist
still applies; `{{toJson .Claims}}` is rejected. Control characters are
JSON-escaped, and values over 8192 bytes or with bidi-override characters are
dropped; an oversize value is logged at error level (header name only).
---
@@ -384,10 +720,14 @@ scopes:
### Dynamic Client Registration (RFC 7591)
Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials.
**Basic Configuration (Single Instance):**
```yaml
dynamicClientRegistration:
enabled: true
initialAccessToken: "your-token" # Optional
initialAccessToken: "your-token" # Optional, if provider requires it
persistCredentials: true
credentialsFile: "/tmp/oidc-credentials.json"
clientMetadata:
@@ -400,6 +740,35 @@ dynamicClientRegistration:
- "refresh_token"
```
**Multi-Replica Deployment (Kubernetes):**
For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions:
```yaml
dynamicClientRegistration:
enabled: true
persistCredentials: true
storageBackend: "redis" # Share credentials via Redis
redisKeyPrefix: "myapp:dcr:" # Optional custom prefix
clientMetadata:
redirect_uris:
- "https://your-app.com/oauth2/callback"
client_name: "My Application"
redis:
enabled: true
address: "redis:6379"
cacheMode: "redis"
```
**Storage Backend Options:**
| Backend | Description | Use Case |
|---------|-------------|----------|
| `file` | Store credentials in local file | Single instance deployments |
| `redis` | Store credentials in Redis | Multi-replica Kubernetes deployments |
| `auto` | Use Redis if available, fallback to file | Flexible deployments (default) |
### Multi-Replica Deployment
Without Redis, disable replay detection:
+95
View File
@@ -0,0 +1,95 @@
# Dynamic Client Registration (RFC 7591)
The middleware can register itself with an OIDC provider at startup instead of
using a pre-provisioned `clientID` / `clientSecret`. Useful for multi-tenant
deployments, self-service integrations, and ephemeral environments.
## How it works
1. Middleware reads `registration_endpoint` from `.well-known/openid-configuration`.
2. If `clientID` is empty, it `POST`s `clientMetadata` to the registration endpoint.
3. Returned `client_id` / `client_secret` are cached, optionally persisted.
4. Subsequent requests use the registered credentials.
For multi-replica deployments, set `storageBackend: redis` so all replicas
share one client and avoid registration races.
## Configuration
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-dcr
namespace: traefik
spec:
plugin:
traefikoidc:
providerURL: https://your-oidc-provider.com
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
callbackURL: /oauth2/callback
dynamicClientRegistration:
enabled: true
persistCredentials: true
storageBackend: redis # file | redis | auto
initialAccessToken: "" # optional, for protected endpoints
registrationEndpoint: "" # optional, override discovery
credentialsFile: /tmp/oidc-client-credentials.json
redisKeyPrefix: "dcr:creds:"
clientMetadata:
redirect_uris:
- https://app.example.com/oauth2/callback
client_name: My Application
application_type: web
grant_types: [authorization_code, refresh_token]
response_types: [code]
token_endpoint_auth_method: client_secret_basic
contacts: [admin@example.com]
```
## Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `enabled` | `false` | Enable DCR. |
| `persistCredentials` | `false` | Save returned credentials for reuse across restarts. |
| `storageBackend` | `auto` | `file`, `redis`, or `auto` (Redis if available, else file). |
| `credentialsFile` | `/tmp/oidc-client-credentials.json` | Path for file-backed storage. Mode `0600`. |
| `redisKeyPrefix` | (none — set explicitly) | Key prefix for Redis-backed storage. The code does not inject a default; if unset, keys have no prefix. `dcr:creds:` is a sensible convention. |
| `registrationEndpoint` | discovered | Override the discovered endpoint. |
| `initialAccessToken` | none | Bearer token for protected registration endpoints. |
| `clientMetadata.redirect_uris` | required | Callback URIs for the OAuth flow. |
| `clientMetadata.client_name` | none | Human-readable client name. |
| `clientMetadata.application_type` | `web` | `web` or `native`. |
| `clientMetadata.grant_types` | `[authorization_code, refresh_token]` | OAuth grant types. |
| `clientMetadata.response_types` | `[code]` | OAuth response types. |
| `clientMetadata.token_endpoint_auth_method` | `client_secret_basic` | `client_secret_basic`, `client_secret_post`, or `none`. |
| `clientMetadata.scope` | none | Space-separated scopes. |
| `clientMetadata.contacts` | none | Admin email addresses. |
| `clientMetadata.logo_uri` | none | Logo URL for consent screens. |
| `clientMetadata.client_uri` | none | Client homepage URL. |
| `clientMetadata.policy_uri` | none | Privacy policy URL. |
| `clientMetadata.tos_uri` | none | Terms of service URL. |
## Provider support
The middleware does not gate DCR by provider — if the provider exposes a
`registration_endpoint` in its discovery document (or you set
`registrationEndpoint` explicitly), DCR will attempt registration. The table
below is informational guidance based on each provider's published support.
| Provider | DCR | Notes |
|----------|-----|-------|
| Keycloak | Yes | Enable in realm settings. |
| Auth0 | Yes | Requires Management API token. |
| Okta | Yes | Enable Dynamic Client Registration in admin console. |
| Azure AD | Limited | Use App Registration API instead. |
| Google | No | Manual registration required. |
| AWS Cognito | No | Manual registration required. |
## Security notes
- Registration endpoints must be HTTPS (loopback excepted for local dev).
- Use `initialAccessToken` in production to gate registration.
- File-backed credentials use `0600`; protect the mount path.
- The plugin marks credentials invalid when within ~5 min of `client_secret_expires_at` but does **not** automatically re-register. If your provider sets a non-zero expiry, schedule manual rotation (delete the credentials file or Redis entry, restart) before that time.
+20 -99
View File
@@ -16,9 +16,8 @@ Guide for local development, testing, and contributing to the Traefik OIDC middl
## Prerequisites
- **Go 1.23+** for plugin compilation
- **Docker & Docker Compose** for local testing
- **OIDC Provider** credentials (Google, Azure, etc.)
- **Go 1.24+** (matches `go.mod`; CI runs Go 1.24.11)
- **OIDC Provider** credentials (Google, Azure, etc.) for any end-to-end test against a real provider
### Required Development Tools
@@ -40,110 +39,32 @@ go install golang.org/x/vuln/cmd/govulncheck@latest
## Local Development Setup
### Docker Compose Environment
The repository includes a Docker Compose setup for testing the plugin locally.
#### 1. Host Configuration
Add to `/etc/hosts`:
### Build and unit tests
```bash
127.0.0.1 hello.localhost
127.0.0.1 traefik.localhost
go mod tidy
go build ./...
go test ./... -short # fast loop, < 30 s
go test -race -timeout=15m ./...
```
#### 2. Plugin Configuration
### Sample plugin configurations
The plugin is loaded using Traefik's **local plugins mode**:
Working middleware/Traefik configs live in [`examples/`](../examples/):
- Plugin source: Parent directory (`../`)
- Mount path: `/plugins-local/src/github.com/lukaszraczylo/traefikoidc`
- Configuration: `experimental.localPlugins` in `traefik.yml`
- `complete-traefik-config.yaml` — full middleware example
- `redis-config.yaml` — Redis cache configuration
#### 3. OIDC Provider Setup
To run the plugin against a real Traefik instance, drop the project on disk
and load it via `experimental.localPlugins` in your Traefik static config —
see the [README install section](../README.md#install).
Edit `docker/dynamic.yml` with your provider details:
### Integration tests
**Google:**
```yaml
http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
providerURL: "https://accounts.google.com"
clientID: "your-client-id.apps.googleusercontent.com"
clientSecret: "your-google-client-secret"
sessionEncryptionKey: "your-32-character-encryption-key"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
scopes:
- "openid"
- "email"
- "profile"
```
**Azure AD:**
```yaml
http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
providerURL: "https://login.microsoftonline.com/your-tenant-id/v2.0"
clientID: "your-azure-client-id"
clientSecret: "your-azure-client-secret"
sessionEncryptionKey: "your-32-character-encryption-key"
callbackURL: "/oauth2/callback"
scopes:
- "openid"
- "email"
- "profile"
```
#### 4. Start Environment
Integration tests live in `integration/`. Run them explicitly:
```bash
cd docker
docker-compose up -d
```
#### 5. Test Plugin
- **Protected App**: http://hello.localhost (redirects to OIDC)
- **Traefik Dashboard**: http://traefik.localhost:8080
### Development Workflow
1. **Edit plugin code** in the project root
2. **Build and test** (optional syntax check):
```bash
go mod tidy
go build .
go test ./...
```
3. **Restart Traefik** to reload plugin:
```bash
docker-compose restart traefik
```
4. **Test changes** at http://hello.localhost
### Debugging
**View plugin logs:**
```bash
docker-compose logs -f traefik | grep traefikoidc
```
**Check plugin loading:**
```bash
docker-compose logs traefik | grep -i plugin
```
**Verify plugin directory:**
```bash
docker-compose exec traefik ls -la /plugins-local/src/github.com/lukaszraczylo/traefikoidc/
go test ./integration/... -run Integration -v
```
---
@@ -299,7 +220,7 @@ The repository uses GitHub Actions for comprehensive validation with 20+ paralle
#### Testing (9 suites)
- Race Detector
- Coverage (75% threshold)
- Coverage (70% threshold, enforced in `pr.yaml`)
- Memory Leaks
- Integration Tests
- Regression Tests
@@ -323,13 +244,13 @@ Tests run in parallel for:
#### Performance & Build (3 checks)
- Benchmarks
- Multi-platform Build (linux/darwin x amd64/arm64)
- Go Version Compatibility (Go 1.23 & 1.24)
- Go Version Compatibility (currently Go 1.24.11 in CI)
### Quality Gates
All PRs must pass:
- All parallel checks
- 75% test coverage minimum
- 70% test coverage minimum
- Zero security vulnerabilities
- No race conditions
- No memory leaks
+5 -3
View File
@@ -23,10 +23,10 @@ Configuration reference for each supported OIDC provider.
| Provider | OIDC Support | Refresh Tokens | Auto-Detection | ID Tokens |
|----------|-------------|----------------|----------------|-----------|
| Google | Full | Yes | `accounts.google.com` | Yes |
| Azure AD | Full | Yes | `login.microsoftonline.com` | Yes |
| Azure AD | Full | Yes | `login.microsoftonline.com`, `sts.windows.net` | Yes |
| Auth0 | Full | Yes | `*.auth0.com` | Yes |
| Okta | Full | Yes | `*.okta.com` | Yes |
| Keycloak | Full | Yes | `/auth/realms/` path | Yes |
| Okta | Full | Yes | `*.okta.com`, `*.oktapreview.com`, `*.okta-emea.com` | Yes |
| Keycloak | Full | Yes | host containing `keycloak`, or `/realms/` in path (matches both `/auth/realms/` legacy and `/realms/` modern) | Yes |
| AWS Cognito | Full | Yes | `cognito-idp.*.amazonaws.com` | Yes |
| GitLab | Full | Yes | `gitlab.com` | Yes |
| GitHub | OAuth 2.0 Only | No | `github.com` | No |
@@ -353,6 +353,8 @@ allowPrivateIPAddresses: true # Required for private IPs
- Roles: User Client Role mapper with "Add to ID token" enabled
- Groups: Group Membership mapper with "Add to ID token" enabled
See [KEYCLOAK_SETUP_GUIDE.md](KEYCLOAK_SETUP_GUIDE.md) for detailed step-by-step setup instructions, mapper configuration, troubleshooting, and performance optimization.
---
## AWS Cognito
+14 -6
View File
@@ -109,11 +109,11 @@ redis:
| `writeTimeout` | int | `3` | Write timeout (seconds) |
| `enableTLS` | bool | `false` | Enable TLS for connections |
| `tlsSkipVerify` | bool | `false` | Skip TLS certificate verification |
| `enableCircuitBreaker` | bool | `true` | Enable circuit breaker |
| `circuitBreakerThreshold` | int | `5` | Failures before circuit opens |
| `circuitBreakerTimeout` | int | `60` | Circuit reset timeout (seconds) |
| `enableHealthCheck` | bool | `true` | Enable periodic health checks |
| `healthCheckInterval` | int | `30` | Health check interval (seconds) |
| `enableCircuitBreaker` | bool | `false` | Wrap the Redis backend with a circuit breaker. **Recommended `true` in production.** |
| `circuitBreakerThreshold` | int | `5` | Consecutive failures before the circuit opens (only when `enableCircuitBreaker: true`). |
| `circuitBreakerTimeout` | int | `60` | Seconds the circuit stays open before allowing a probe (only when `enableCircuitBreaker: true`). |
| `enableHealthCheck` | bool | `false` | Wrap the Redis backend with periodic health checks. **Recommended `true` in production.** |
| `healthCheckInterval` | int | `30` | Health check interval in seconds (only when `enableHealthCheck: true`). |
| `hybridL1Size` | int | `500` | Max items in L1 cache (hybrid mode) |
| `hybridL1MemoryMB` | int64 | `10` | Max memory for L1 cache in MB |
@@ -134,13 +134,21 @@ REDIS_READ_TIMEOUT=3
REDIS_WRITE_TIMEOUT=3
REDIS_ENABLE_TLS=false
REDIS_TLS_SKIP_VERIFY=false
REDIS_HYBRID_L1_SIZE=500
REDIS_HYBRID_L1_MEMORY_MB=10
```
> Resilience fields (`enableCircuitBreaker`, `enableHealthCheck`,
> `circuitBreakerThreshold`, `circuitBreakerTimeout`, `healthCheckInterval`)
> have no environment variable fallback — set them in plugin configuration.
Invalid `cacheMode` values are rejected at plugin startup.
---
## Cache Modes
### Memory Mode (Default without Redis)
### Memory Mode (used when Redis is disabled)
```yaml
redis:
+54
View File
@@ -0,0 +1,54 @@
# RFC 8707 Resource Indicators
`resource` ([Config.Resource](../settings.go)) is an alternative to `audience` for IdPs that
implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). See
[docs/CONFIGURATION.md](CONFIGURATION.md#audience-validation) for the config reference; this
document covers exactly what request is made and which providers support it.
## What the plugin sends
- One `resource` parameter on the authorization request (`buildAuthURL`), added before
`extraAuthParams` so an operator-configured `resource` key there cannot override or
duplicate it.
- The same `resource` parameter on both the `authorization_code` and `refresh_token` grants
(`exchangeTokens`), per RFC 8707 §2.2 — so a refreshed token keeps the same audience binding
as the original grant.
Only a single resource is supported, not a list. RFC 8707 allows a server to accept multiple
`resource` values and scope the issued token to a subset of them, but no provider found to
support the parameter at all handles that usefully: one provider tested rejects a request
naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts
multiple resources but issues an opaque access token bound to none of them. A single value
avoids both failure modes.
## Relationship to `audience`
`resource` and `audience` both feed the same access-token audience check —
`strictAudienceValidation` applies identically to either — but they produce different requests
on the wire:
- `audience`, when explicitly configured, is sent as a non-standard `audience` request
parameter (see [docs/AUTH0\_AUDIENCE\_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md)).
- `resource` is sent as the RFC 8707 `resource` parameter instead.
- If `resource` is set and `audience` is not, the expected-audience check defaults to the
resource value, but this is validation-only: it does not cause an `audience` parameter to be
sent. Setting both is allowed — e.g. as a hedge against a provider that ignores `resource` —
in which case both parameters are sent.
## Provider support
Verified 2026-09-02 (Zitadel reverified separately against a live instance). RFC 8707 requires
a server that does not support `resource` to *ignore* it, but not every implementation does —
verify against your specific provider and version before relying on it.
| Provider | Support | Notes |
|---|---|---|
| node-oidc-provider | Supported | Via its `resourceIndicators` feature. |
| Zitadel | Ignored, not rejected | `oidc.AuthRequest` has no `resource` field; confirmed against a live instance — `/oauth/v2/authorize` returns the same `302` with or without `resource`, including a spec-invalid value. |
| Authentik | Rejected | Returns `invalid_target` ("Resource indicators are not supported"). |
| Keycloak | Not supported (through 26.6) | Its own guidance is an audience mapper plus `scope` instead ([keycloak/keycloak#41526](https://github.com/keycloak/keycloak/issues/41526)). |
| Ory Hydra / Fosite | Not implemented | Uses its own non-standard `audience` request parameter instead. |
| Azure AD / Entra ID v2.0 (MSAL) | Reported to reject | When combined with v2-style `scope`, rather than ignoring it ([writeup](https://www.groff.dev/blog/azure-entra-id-mcp-server-authentication-incompatibilities)). Do not enable against an Entra ID v2 tenant without confirming. The v1.0 endpoint has a separate, pre-RFC `resource` parameter with similar but not identical semantics. |
| Auth0 | Not verified | Documents a Resource Parameter Compatibility Profile mapping `resource` → `audience` ([Auth0 docs](https://auth0.com/ai/docs/mcp/guides/resource-param-compatibility-profile)). |
| AWS Cognito | Not verified | Announced Oct 2025 for user-pool authorization-code/implicit grants ([announcement](https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-cognito-resource-indicators-protection-oauth-2-0-resources)). |
| Okta, Google, GitLab | Unverified | No confirming documentation found either way. |
+2 -2
View File
@@ -6,8 +6,8 @@ Comprehensive testing infrastructure for traefikoidc.
| Metric | Value |
|--------|-------|
| Test files | 99 |
| Lines of test code | ~65,500 |
| Test files | 110 |
| Lines of test code | ~72,000 |
| Code coverage | 71.0% |
| Race conditions | None (all pass with `-race`) |
+162 -5
View File
@@ -90,6 +90,7 @@
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
<a href="#deployment" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Deployment</a>
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
<a href="#logout" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Logout</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
@@ -114,6 +115,7 @@
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
<a href="#deployment" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Deployment</a>
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
<a href="#logout" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Logout</a>
</div>
</div>
</nav>
@@ -193,7 +195,7 @@
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration</p>
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration with Redis storage support for multi-replica deployments</p>
</div>
</div>
</div>
@@ -640,7 +642,7 @@ spec:
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientSecret</code></td>
<td class="py-2 px-3">OAuth 2.0 client secret</td>
<td class="py-2 px-3">OAuth 2.0 client secret. Only required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is unset or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">sessionEncryptionKey</code></td>
@@ -716,6 +718,11 @@ spec:
<td class="py-2 px-3">86400</td>
<td class="py-2 px-3">Maximum session age in seconds (24 hours default)</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">maxRefreshTokenAgeSeconds</code></td>
<td class="py-2 px-3">21600</td>
<td class="py-2 px-3">Heuristic upper bound on stored refresh-token lifetime (6 hours default). Past this, the plugin treats the RT as expired without contacting the IdP. Set <code>0</code> to disable.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">cookiePrefix</code></td>
<td class="py-2 px-3">_oidc_raczylo_</td>
@@ -728,9 +735,14 @@ spec:
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code></td>
<td class="py-2 px-3">clientID</td>
<td class="py-2 px-3">clientID, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code> if set</td>
<td class="py-2 px-3">Custom audience for access token validation</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">RFC 8707 resource indicator, alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code> for supporting IdPs — see docs/RFC8707.md.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
<td class="py-2 px-3">false</td>
@@ -746,15 +758,48 @@ spec:
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Require RFC 7662 introspection for opaque tokens</td>
</tr>
<tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">disableReplayDetection</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Disable JTI replay detection (for multi-replica without Redis)</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code></td>
<td class="py-2 px-3">client_secret_post</td>
<td class="py-2 px-3">Selects how the plugin authenticates to the token endpoint. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Inline PEM private key used to sign client assertions for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyPath</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">Path to a PEM private key file. Alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code>.</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyID</code></td>
<td class="py-2 px-3">none</td>
<td class="py-2 px-3">JWS <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">kid</code> header value. Required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionAlg</code></td>
<td class="py-2 px-3">RS256</td>
<td class="py-2 px-3">Signing algorithm for the client assertion. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES512</code>.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Private Key JWT (RFC 7523)</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Use this when your IdP (Entra ID, Okta, Auth0, Keycloak) pressures short-lived secrets, or when policy mandates secretless service-to-service authentication. The plugin signs a 60-second assertion with the configured private key and sends it as <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_assertion</code> instead of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret</code>. Public-key registration on the IdP replaces shared-secret rotation. See <a href="https://www.rfc-editor.org/rfc/rfc7523" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">RFC 7523</a> and <a href="https://github.com/lukaszraczylo/traefikoidc/issues/135" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">issue #135</a>.</p>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>clientAuthMethod: private_key_jwt
clientAssertionKeyPath: /etc/traefik/oidc-client.pem
clientAssertionKeyID: my-client-key-2026
# clientSecret no longer required</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Google Workspace with Domain Restriction</h3>
@@ -856,7 +901,54 @@ spec:
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.enableTLS</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Enable TLS for Redis connections</td>
<td class="py-2 px-3">Enable TLS for Redis connections (e.g. AWS ElastiCache in-transit encryption)</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.tlsSkipVerify</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Skip TLS server certificate verification (testing only; not recommended in production)</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Dynamic Client Registration (RFC 7591)</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:</p>
<div class="overflow-x-auto mb-4">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Default</th>
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-400">
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.enabled</code></td>
<td class="py-2 px-3">false</td>
<td class="py-2 px-3">Enable dynamic client registration</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.persistCredentials</code></td>
<td class="py-2 px-3">true</td>
<td class="py-2 px-3">Persist registered credentials across restarts</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.storageBackend</code></td>
<td class="py-2 px-3">auto</td>
<td class="py-2 px-3">Storage backend: <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">file</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis</code>, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">auto</code> (uses Redis if available)</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.redisKeyPrefix</code></td>
<td class="py-2 px-3">dcr:creds:</td>
<td class="py-2 px-3">Redis key prefix for DCR credentials</td>
</tr>
<tr>
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.clientMetadata.redirect_uris</code></td>
<td class="py-2 px-3">-</td>
<td class="py-2 px-3">Redirect URIs for the registered client (required)</td>
</tr>
</tbody>
</table>
@@ -1177,6 +1269,71 @@ spec:
</div>
</section>
<!-- IdP-Initiated Logout Section -->
<section id="logout" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">IdP-Initiated Logout</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Support for OIDC Back-Channel and Front-Channel Logout specifications</p>
</div>
<div class="max-w-4xl mx-auto">
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-server mr-2 text-blue-500"></i>
Back-Channel Logout
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Server-to-server logout notification. The IdP sends a signed JWT (logout_token) directly to your application when a user logs out.
</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Signed JWT logout tokens</li>
<li>&#8226; Session ID (sid) based invalidation</li>
<li>&#8226; Subject (sub) based invalidation</li>
<li>&#8226; Works behind firewalls</li>
</ul>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-browser mr-2 text-purple-500"></i>
Front-Channel Logout
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Browser-based logout via iframe. The IdP embeds an iframe pointing to your logout endpoint during user logout.
</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
<li>&#8226; Iframe-based session termination</li>
<li>&#8226; Immediate cookie invalidation</li>
<li>&#8226; Simple GET request handling</li>
<li>&#8226; Issuer validation</li>
</ul>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuration Example</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
middlewares:
oidc-auth:
plugin:
traefikoidc:
# ... other OIDC configuration ...
# Back-Channel Logout (server-to-server)
enableBackchannelLogout: true
backchannelLogoutURL: "/backchannel-logout"
# Front-Channel Logout (browser-based)
enableFrontchannelLogout: true
frontchannelLogoutURL: "/frontchannel-logout"</code></pre>
<p class="text-gray-600 dark:text-gray-400 text-sm mt-4">
Configure your IdP with the full URLs (e.g., <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">https://your-app.example.com/backchannel-logout</code>).
When a user logs out from the IdP, all their sessions across your applications will be invalidated.
</p>
</div>
</div>
</div>
</section>
<!-- Why Choose Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
@@ -0,0 +1,459 @@
# Bearer Token Authentication — Design Spec
- **Date**: 2026-05-18
- **Status**: Design — pending implementation plan
- **Supersedes**: PR #93 (broken implementation; recommended to close in favour of this design)
## 1. Summary
Add an opt-in path that lets API clients (machine-to-machine) authenticate by presenting a signed access token in the `Authorization: Bearer <token>` header, bypassing the cookie-based OIDC redirect flow. Identity, roles, and authorization checks remain consistent with the existing cookie path; the only thing that changes is how the principal is established for that single request.
The feature is implemented by extracting a shared `forwardAuthorized` pipeline from the existing `processAuthorizedRequest`, introducing a `principal` value type, and adding a small bearer-specific entrypoint that builds a principal directly from a verified JWT — without synthesising a fake `SessionData`.
## 2. Motivation
PR #93 attempted this feature by building an in-memory `SessionData` from JWT claims and reusing `processAuthorizedRequest`. The approach has three latent defects:
1. The synthetic session omits `mainSession.Values["user_identifier"]`. `processAuthorizedRequest` reads it via `GetUserIdentifier()`; when empty it bails to `defaultInitiateAuthentication` and issues an OIDC redirect. The feature is non-functional in practice despite the unit test passing.
2. `verifyToken` accepts both ID tokens (audience match against `clientID`) and access tokens. ID tokens are not API credentials; treating them as such is a classic token-confusion vector.
3. `verifyToken` adds JTI to the replay blacklist on first verify. Once the verified-token cache evicts, subsequent reuse of the same bearer token triggers a false-positive replay rejection.
Rather than patch a synthetic-session approach that will keep generating bugs as `SessionData` evolves, this spec replaces it with a cleaner abstraction where session lifecycle and post-auth header injection live in separate units.
## 3. Goals
- Accept `Authorization: Bearer <jwt>` from M2M clients, validate the token, and forward the request downstream with identity headers populated.
- Enforce the same `allowedRolesAndGroups` policy as the cookie path.
- Default-off; safe defaults when enabled (audience required, ID tokens rejected, identifier sanitised).
- No behavioural change to the cookie path. Existing tests must continue to pass without modification.
## 4. Non-Goals
- Human-user / browser flows. Bearer is M2M-only in this iteration.
- Pure opaque access tokens on the bearer path. Tokens must be JWTs; introspection (RFC 7662) is supported *on top of* JWT verification for revocation state, not as a substitute for it.
- mTLS, API keys, or any other auth method. The `principal` abstraction enables them later, but they are not delivered here.
- Per-route bearer configuration. Single middleware-wide setting.
## 5. Decided Requirements
| Topic | Decision |
|---|---|
| Consumer type | Machine-to-machine (M2M) only |
| Token format | JWT only (signature, issuer, audience, exp) |
| Audience | Mandatory when feature enabled; startup fails if `Audience == ""` |
| Token type | Access tokens only; ID tokens explicitly rejected |
| Revocation | JWT-only verification by default; introspection (RFC 7662) opt-in via existing `RequireTokenIntrospection` |
| Identity claim | New `BearerIdentifierClaim` config (string, default `"sub"`). Bearer path reads this claim exclusively; does NOT use `UserIdentifierClaim` (which defaults to `"email"` and drives the cookie path). Resolved value must be a non-empty string. `sub` is mandatory per `jwt.go:416` regardless, so even with a different `BearerIdentifierClaim` the token must still carry a valid `sub`. Decoupling avoids the M2M-vs-human-user identity-claim conflict and the email-spoofing footgun. |
| Identifier sanitisation | Reject value containing any `unicode.IsControl` char, any Unicode bidi-override (U+202A–U+202E, U+2066–U+2069), leading/trailing whitespace, commas, semicolons, equals signs. Max length 256 bytes. |
| Token classifier | **Reuse existing `detectTokenType(jwt, token)` at `token_manager.go:187-303`** which already handles `nonce`, `typ: at+jwt`, `token_use`, `scope`, and aud-vs-clientID priority. Bearer path rejects any token where `detectTokenType == true` (ID token). Do not invent a parallel classifier. |
| Algorithm pinning | Hard-pin `alg ∈ {RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512}`, enforced **before** JWKS lookup on the bearer path. Prevents wasted JWKS fetches for `alg=none`/HS attacker probes. |
| `kid` hardening | `kid` ≤ 256 bytes, charset `[A-Za-z0-9._\-=]`. Reject before JWKS lookup. |
| Token age | Bearer path enforces `now - iat <= MaxTokenAgeSeconds` (default 86400 / 24h, configurable). Cookie path unchanged. |
| Multi-audience policy | If `aud` is an array (length > 1), require `azp` claim to be present and equal to `clientID`. Single-string `aud` unaffected. |
| Mixed bearer + cookie precedence | **Cookie wins by default** when both are presented (safer for browser scenarios). Operator opt-in: `BearerOverridesCookie=true` to flip. Either way, a warning is logged on the request. |
| Bearer + excluded URL | `Authorization` header is **stripped** before forwarding when the request hits an excluded URL. Prevents bearer leaking into public endpoints' downstream logs and prevents recon via excluded paths. |
| Per-source bearer 401 throttle | New sharded cache `failedBearerAttempts` keyed by client IP. After N (default 20) consecutive 401s from one IP within 1 minute, reject further bearer requests from that IP with 429 for 60s. Applied BEFORE `verifyToken` to deny JWKS amplification. |
| `Authorization` header passthrough | New `StripAuthorizationHeader` config, default `true` |
| Roles/groups gating | Same `allowedRolesAndGroups` rules as cookie path |
| Default state | `EnableBearerAuth` = `false` |
| JTI replay marking | Suppressed on bearer path; cookie path unchanged |
| Failure response shape | 401 with generic body; `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 |
| Introspection endpoint outage | 503 (distinguishes infra outage from token rejection) |
| Mixed bearer + cookie | Bearer wins; cookie ignored on that request |
| SSE/WS bypass + bearer | Bypass paths keep cookie-only check; bearer header ignored on SSE/WS |
## 6. Architecture
```
┌──────────────────┐
HTTP req ──► │ ServeHTTP │ (existing entry; adds bearer detection)
└─────────┬────────┘
┌───────────┴────────────┐
▼ ▼
cookie / session bearer (Authorization: Bearer …)
│ │
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ buildPrincipal │ │ buildPrincipal │
│ FromSession() │ │ FromBearerToken() │
└────────┬───────┘ └─────────┬──────────┘
│ produces *principal │
└──────────────┬───────────┘
▼
┌────────────────────────────┐
│ forwardAuthorized(rw,req,p)│ (shared pipeline)
│ • roles/groups gate │
│ • header injection │
│ • header templates │
│ • security headers │
│ • cookie stripping │
│ • next.ServeHTTP │
└────────────────────────────┘
```
**Invariant**: `forwardAuthorized` never touches session storage. Session-specific concerns (Save, IsDirty, backchannel-logout invalidation) stay inside `processAuthorizedRequest` around the call to `forwardAuthorized`.
**Feature gate**: when `EnableBearerAuth == false`, the bearer-detection check in `ServeHTTP` is a no-op. Existing deployments observe byte-identical behaviour.
## 7. Components
### 7.1 `principal` type (new file `principal.go`)
```go
type principalSource int
const (
sourceSession principalSource = iota
sourceBearer
)
type principal struct {
Identifier string // drives X-Forwarded-User
Email string // optional, "" for M2M
Subject string // sub claim
ClientID string // azp / client_id, M2M caller
Claims map[string]interface{} // raw claims for templates / groups
AccessToken string // for X-Auth-Request-Token (gated by minimalHeaders)
IDToken string // "" on bearer path
RefreshToken string // "" on bearer path
Source principalSource
}
```
Pure data. No methods that mutate it. No I/O. No manager pointer.
### 7.2 `buildPrincipalFromSession(*SessionData) *principal` (new in `principal.go`)
Read-only adapter over existing `SessionData` getters: `GetUserIdentifier`, `GetEmail`, `GetAccessToken`, `GetIDToken`, `GetRefreshToken`, cached claims via `GetIDTokenClaims`. Does not write back to the session. This is the only function that still knows about `SessionData`.
### 7.3 `buildPrincipalFromBearerToken(token string) (*principal, error)` (new in `bearer_auth.go`)
1. **Length / format guards**: `len(token) <= AccessTokenConfig.MaxLength`, exactly two dots, non-empty after trim.
2. **Parse header for early alg/kid pinning** (without trusting payload): decode JOSE header; reject if `alg` ∉ asymmetric allowlist; reject if `kid` missing, > 256 bytes, or contains chars outside `[A-Za-z0-9._\-=]`. This happens **before** JWKS lookup so attacker noise doesn't amplify into JWKS fetches.
3. **Per-IP 401 throttle check**: if this IP is in the `failedBearerAttempts` penalty box, return 429 immediately.
4. `t.verifyToken(token, verifyOpts{skipReplayMarking: true})` — reuses signature, issuer, audience, expiration, JTI Get (replay detection). The `skipReplayMarking` flag gates ONLY the JTI Set at `token_manager.go:108-143`; the JTI Get at `token_manager.go:44-47, 80-89` remains active so revoked tokens (via `RevokeToken` adding to blacklist) are still rejected.
5. **Re-parse claims** (`parseJWT(token)` is cheap and already done internally; reuse via a single decode if practical).
6. **Token-type guard**: call existing `detectTokenType(jwt, token)` (`token_manager.go:187-303`). Reject when it returns `true` (ID token). Belt-and-braces: also reject if `claims["nonce"]` is a non-empty string or `claims["token_use"] == "id"`.
7. **Multi-audience hardening**: if `claims["aud"]` is a `[]interface{}` with length > 1, require `claims["azp"]` to be a non-empty string equal to `t.clientID`; reject otherwise.
8. **`iat` upper-age bound**: reject when `time.Now().Unix() - int64(claims["iat"].(float64)) > MaxTokenAgeSeconds` (default 86400).
9. **Optional introspection**: if `requireTokenIntrospection` is set, call `introspectToken`; reject if `active == false` (401); surface 503 on transport failure. Bearer-path introspection cache TTL is capped at 60s (not 5min) to keep the "real-time revocation" promise close to true.
10. **Identifier resolution**: read `t.bearerIdentifierClaim` (defaults to `"sub"`); do NOT use `t.userIdentifierClaim` (cookie path's setting, default `email`). The bearer path does NOT fall back to other claims because `jwt.Verify` already enforces non-empty `sub` (`jwt.go:416-419`). Empty/missing identifier → 401.
11. **Identifier sanitisation**: trim, then reject if length > 256 OR contains any of: `unicode.IsControl`, bidi-override (U+202A–U+202E, U+2066–U+2069), `,`, `;`, `=`.
12. Return `&principal{ Source: sourceBearer, … }`.
On any failure path: increment the per-IP `failedBearerAttempts` counter; return the appropriate HTTP status (401 / 403 / 429 / 503) without revealing the failure reason in the response body. Reason is logged at debug only, with the identifier (if resolved) hashed via SHA-256 truncated to 8 hex chars.
### 7.4 `forwardAuthorized(rw, req, *principal)` (new in `middleware.go`, extracted)
The shared post-auth pipeline. Lifted verbatim from the existing `processAuthorizedRequest`:
1. Roles/groups extraction via existing `extractGroupsAndRolesFromClaims`.
2. `allowedRolesAndGroups` gate (existing logic).
3. Inject `X-Forwarded-User`, `X-User-Groups`, `X-User-Roles`.
4. Inject `X-Auth-Request-*` (gated by `minimalHeaders`).
5. Header templates.
6. Security headers.
7. Cookie strip when `stripAuthCookies`.
8. **New**: `Authorization` header strip when `stripAuthorizationHeader` AND `principal.Source == sourceBearer`.
9. `t.next.ServeHTTP(rw, req)`.
Does not call `Save`, does not check `IsDirty`. Session persistence stays with the cookie-path caller.
### 7.5 `handleBearerRequest(rw, req)` (new in `bearer_auth.go`)
```
1. Detect "Authorization: Bearer <token>" (case-insensitive prefix).
2. token = TrimSpace(authHeader[7:]); reject empty.
3. p, err := buildPrincipalFromBearerToken(token).
On err → 401 with WWW-Authenticate, log reason at debug.
4. forwardAuthorized(rw, req, p).
```
Target: ~40 lines.
### 7.6 Refactor of `processAuthorizedRequest` (modify `middleware.go`)
Splits along the principal boundary:
- Session-specific part (backchannel-logout invalidation, `IsDirty` / `Save`) stays in `processAuthorizedRequest`.
- Everything else moves to `forwardAuthorized`.
- `processAuthorizedRequest` ends with `forwardAuthorized(rw, req, buildPrincipalFromSession(session))`.
### 7.7 `verifyOpts` extension to `verifyToken` (modify `token_manager.go`)
Add a parameter struct:
```go
type verifyOpts struct {
skipReplayMarking bool // suppress JTI Set (token_manager.go:108-143); blacklist Get stays active
}
```
Both the type and field are unexported (internal-only knob). Signature change: `verifyToken(token string)` becomes `verifyToken(token string, opts verifyOpts)`. Existing callers pass `verifyOpts{}` (zero value = current behaviour). Bearer path passes `verifyOpts{skipReplayMarking: true}`.
**Critical semantics — must be reflected in implementation and tests:**
- `skipReplayMarking` only gates the **Set** at `token_manager.go:108-143` (the call adding the JTI to the blacklist and replay cache).
- The blacklist **Get** at `token_manager.go:44-47, 80-89` stays unconditionally active on the bearer path. Tokens revoked via `RevokeToken` (which adds the JTI to the blacklist) MUST still be rejected on the bearer path.
- Must NOT be implemented by mutating `t.disableReplayDetection` (struct field) — that would create a cross-request race that disables replay protection globally.
A targeted regression test exercises: bearer token verified once → admin calls `RevokeToken` adding the JTI to the blacklist → same token replayed → 401.
### 7.8 Config additions (modify `settings.go`)
```go
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
```
Defaults (applied in `CreateConfig` for the bearer-related fields; values >0 only honoured when `EnableBearerAuth=true`):
- `EnableBearerAuth`: `false`.
- `BearerIdentifierClaim`: `"sub"`.
- `StripAuthorizationHeader`: `true`.
- `BearerEmitWWWAuthenticate`: `true` (RFC 6750 hint enabled by default; flip to false if recon-exposure is a concern).
- `BearerOverridesCookie`: `false` (cookie wins when both present; flip to `true` for the legacy/industry-default behaviour).
- `MaxTokenAgeSeconds`: `86400` (24h upper bound on `iat`).
- `MaxIdentifierLength`: `256`.
- `BearerFailureThreshold`: `20` (consecutive 401s per IP before throttle).
- `BearerFailureWindowSeconds`: `60`.
- `BearerFailurePenaltySeconds`: `60` (429 reply for this long after threshold tripped).
### 7.9 Startup validation (modify `main.go` `New()`)
- `EnableBearerAuth && Audience == ""` → fatal error.
- `EnableBearerAuth && !StrictAudienceValidation` → warning log (recommended hardening).
- `EnableBearerAuth && BearerIdentifierClaim == "email"` → fatal error (the bearer path is M2M and an `email` identifier without `email_verified` enforcement is a spoofing vector; default `BearerIdentifierClaim=sub` avoids this; explicit override to `email` is rejected).
- `EnableBearerAuth && MaxTokenAgeSeconds <= 0` → reset to default 86400 with info log.
- `EnableBearerAuth && BearerFailureThreshold <= 0` → reset to default 20 with info log.
## 8. Data Flow
### 8.1 Bearer path
```
ServeHTTP entry (pre-init paths unchanged: logout, backchannel, frontchannel, excluded URLs, SSE/WS bypass)
│
├─ enableBearerAuth == false? → fall through to cookie path
│
└─ enableBearerAuth == true AND Authorization starts with "Bearer "
│
▼
handleBearerRequest
│
├─ format guards (empty, length, segment count)
│
▼
verifyToken(token, verifyOpts{SkipReplayMarking: true})
│ signature, issuer, audience (strict), exp
│
▼
classifyToken(claims) → reject ID tokens
│
▼
if requireTokenIntrospection: introspectToken → active check
│
▼
resolveIdentifier(claims) → sanitiseIdentifier
│
▼
principal{Source: sourceBearer, …}
│
▼
forwardAuthorized(rw, req, principal)
│
├─ roles/groups gate (403 on deny)
├─ header injection
├─ header templates
├─ security headers
├─ strip OIDC cookies (existing)
├─ strip Authorization header (new, when configured)
└─ next.ServeHTTP(rw, req)
```
### 8.2 Cookie path (refactored, semantically unchanged)
```
processAuthorizedRequest
1. Session validity / backchannel-logout invalidation (unchanged).
2. principal := buildPrincipalFromSession(session).
3. forwardAuthorized(rw, req, principal).
4. if session.IsDirty(): session.Save().
```
## 9. Error Handling
| Trigger | Status | Body | WWW-Authenticate | Debug log reason |
|---|---|---|---|---|
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` | empty bearer token |
| Token over MaxLength | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token exceeds max length |
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` | malformed JWT |
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | unsupported alg |
| Missing/oversized/bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid kid |
| Signature / issuer / aud / exp fail | 401 | `Unauthorized` | `Bearer error="invalid_token"` | reason from verifyToken (category only) |
| `iat` older than MaxTokenAgeSeconds | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token too old (iat outside age bound) |
| Multi-aud without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | multi-aud token without azp match |
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` | ID tokens not accepted on bearer path |
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token JTI in blacklist |
| Introspection `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token inactive at IdP |
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) | introspection unavailable |
| Identifier claim missing/empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` | no identifier claim |
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid identifier characters |
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <BearerFailurePenaltySeconds>` | source IP in penalty box |
| Roles/groups not allowed | 403 | `Access denied` | (none) | user not in allowedRolesAndGroups |
Responses never include token contents, never include the raw failure reason, and never set `Location` headers (API clients cannot follow redirects).
## 10. Edge Cases
1. **Both bearer header and cookie session present.** Cookie wins by default (safer against browser/extension/proxy bearer injection). `BearerOverridesCookie=true` flips to bearer-wins. Either way: WARN log includes both source markers so operators can audit.
2. **`Authorization: Basic …`.** Not bearer; cookie path runs as today.
3. **`Authorization: Bearer ` (trailing space, no value).** Empty after trim → 401.
4. **Mixed-case prefix (`bearer`, `BEARER`, `BeArEr`).** Case-insensitive prefix check; token value preserved verbatim.
5. **Multiple `Authorization` headers.** Use only the first (Go `http.Header.Get` default). Documented.
6. **Bearer during OIDC init wait.** Bearer requests also block on init: we need `issuerURL`, `audience`, JWKs ready. If init fails, bearer requests return 503 just like cookie requests.
7. **SSE / WebSocket bypass with bearer.** Bypass paths keep cookie-only behaviour. Operators who want bearer on streaming endpoints must remove SSE/WS bypass. Documented.
8. **Logout endpoint with bearer.** Logout runs before bearer detection. Treated as cookie-session logout; bearer token revocation requires IdP-side action.
9. **Excluded URLs with bearer.** Bypass excluded URLs as today; bearer not validated on excluded paths. ADDITIONALLY: `Authorization: Bearer` is stripped from the request before forwarding so the token can't leak into the excluded endpoint's downstream logs / metrics scrapers / health checks.
10. **Concurrent identical bearer requests.** Existing `tokenCache` is concurrency-safe; no new locking.
11. **Client rotates token between requests.** Independent verification per token; independent cache entries.
12. **Clock skew.** Use existing `jwt.Verify` leeway. (If absent, add ±30s as a separate change; out of scope here.)
## 11. Testing Strategy
### 11.1 Integration tests (new `bearer_auth_test.go`)
Table-driven test against a real `httptest.Server` and the full `ServeHTTP` flow. Coverage matrix:
- Valid access token + allowed roles → 200, `next` ran, `X-Forwarded-User` set.
- Valid token without configured roles → 200.
- Wrong audience, expired, tampered signature → 401, `next` did not run.
- ID token presented → 401 (`ID tokens not accepted`).
- Malformed JWT (2 segments) → 401.
- Oversized token (> MaxLength) → 401.
- Empty bearer → 401.
- Missing identifier claim → 401.
- Identifier containing `\r\n` → 401.
- `allowedRolesAndGroups` mismatch → 403.
- `allowedRolesAndGroups` match → 200.
- `EnableBearerAuth=false` + bearer header → cookie path runs (302 to `/authorize`).
- Bearer + valid cookie session → bearer wins, 200.
- `StripAuthorizationHeader=true` → downstream sees no `Authorization`.
- `StripAuthorizationHeader=false` → downstream sees `Authorization`.
- Case variants (`bearer`, `BEARER`) → 200.
- SSE bypass + bearer → cookie-only check applies (bearer ignored).
- **Replay regression**: same token 1000 times in a row → all 200.
- **Cache-evict regression**: same token, force-evict `tokenCache` between iterations (call `tokenCache.Delete` directly), replay → still 200 (verifies `skipReplayMarking` doesn't poison the blacklist).
- **Revocation-while-bearer regression**: bearer token verified once → admin calls `RevokeToken` adding JTI to blacklist → same token presented → 401 (verifies blacklist Get stays active on bearer path even with `skipReplayMarking` set).
- **Alg-pin: token signed with `alg=none`** → 401, no JWKS fetch happens (verify with a counting mock).
- **`kid` injection: 50KB random kid** → 401 immediately, no JWKS fetch.
- **Per-IP throttle**: 21 bad bearer requests from same IP within 1 minute → 22nd returns 429 + Retry-After.
- **`iat` upper-age**: token with `iat = now - 25h` → 401 (older than 24h default).
- **Multi-aud without azp**: aud = `["a", "b"]`, no azp → 401.
- **Multi-aud with matching azp**: aud = `["api-aud", "other"]`, azp = clientID → 200.
- **Identifier with bidi-override**: sub contains U+202E → 401.
- **Identifier with comma**: sub = `"alice,bob"` → 401.
- **Identifier over 256 bytes** → 401.
- **`UserIdentifierClaim=email` at startup with EnableBearerAuth=true** → startup fails.
- **Excluded URL + bearer**: bearer header presented on excluded URL → request forwarded, downstream sees no `Authorization` header (stripped).
### 11.2 Unit tests (in `bearer_auth_test.go`)
- `classifyToken`: ID-token detection, access-token detection by `scope`/`scp`/`token_use`, ambiguous → reject.
- `resolveIdentifier`: precedence (`userIdentifierClaim` → `sub` → `client_id`/`azp`); missing → error; empty string → error.
- `sanitizeIdentifier`: rejects all `unicode.IsControl`; accepts email/sub-style values.
### 11.3 Introspection tests (`bearer_auth_introspection_test.go`)
- Token valid + introspection `active=true` → 200.
- Token valid + introspection `active=false` → 401.
- Introspection endpoint 500 → 503.
- Second request hits introspection cache (no second HTTP call).
### 11.4 Startup validation tests (extend `settings_test.go` / `main_test.go`)
- `EnableBearerAuth=true, Audience=""` → `New()` errors.
- `EnableBearerAuth=true, StrictAudienceValidation=false` → succeeds with warning.
- `EnableBearerAuth=false` → no validation; existing tests untouched.
### 11.5 Cookie-path regression suite
- All existing `TestServeHTTP_*` tests in `main_servehttp_test.go` pass unmodified.
- Add: cookie session, `EnableBearerAuth=true`, no bearer header → identical behaviour to baseline.
- Add: dirty session still triggers `Save()` after refactor.
### 11.6 Principal invariants
- `buildPrincipalFromSession`: `Source == sourceSession`; `IDToken` / `RefreshToken` populated when present in session.
- `buildPrincipalFromBearerToken`: `Source == sourceBearer`; `IDToken == ""`, `RefreshToken == ""`.
- `forwardAuthorized` produces identical headers for equivalent principals regardless of source.
### 11.7 Coverage gate
- New code in `bearer_auth.go` and `principal.go`: ≥ 90% line coverage.
- `forwardAuthorized` coverage ≥ existing `processAuthorizedRequest` coverage baseline.
### 11.8 Out of scope (follow-ups)
- Load test of bearer vs cookie hot path.
- Fuzzing the JWT parser.
- Additional auth methods (mTLS, API keys) — design enables them, but they are separate work.
## 12. Migration / Rollout
Default-off. Existing deployments observe no behavioural change. Operators opt in by setting:
```yaml
enableBearerAuth: true
audience: https://api.example.com # required when bearer enabled
# optional:
stripAuthorizationHeader: true # default
requireTokenIntrospection: false # default; set true for real-time revocation
userIdentifierClaim: client_id # optional override; defaults to sub fallback chain
```
Documentation: update `docs/CONFIGURATION.md` with a bearer-auth section, and add a new `docs/BEARER_AUTH.md` covering the security model, threat assumptions (token issuer is trusted; audience must be set; bearer means trust the issuer's revocation policy unless introspection enabled), and recommended configurations for common IdPs.
## 13. Security Considerations
| Concern | Mitigation |
|---|---|
| Token confusion (ID token used as bearer) | Reuse `detectTokenType` (`token_manager.go:187-303`) which checks `nonce`, `typ: at+jwt`, `token_use`, `scope`, aud-vs-clientID. Belt-and-braces: explicit `nonce` + `token_use == "id"` rejection on top. |
| Audience confusion (token for service B accepted by A) | `Audience` mandatory at startup; verified via existing `VerifyJWTSignatureAndClaims`; multi-aud tokens require matching `azp == clientID`. |
| Replay-via-blacklist false positive | `verifyOpts{skipReplayMarking: true}` on bearer path. Gates ONLY the Set; the Get stays so revoked tokens still fail. |
| Revocation lag | Optional RFC 7662 introspection. Bearer-path introspection cache TTL capped at 60s. Set `RequireTokenIntrospection=true` for real-time revocation. |
| `alg`-confusion / `alg=none` attacks | Hard-pin asymmetric allowlist at bearer entry, **before** JWKS fetch. Prevents wasted upstream calls and locks out HS/none probes. |
| `kid` injection / JWKS amplification | `kid` length cap (256 bytes) + charset allowlist enforced at bearer entry. |
| Bearer 401 brute-force / oracle | Per-IP `failedBearerAttempts` cache; configurable threshold + penalty box returning 429 + `Retry-After`. |
| `iat` clock-manipulation / forever-tokens | `MaxTokenAgeSeconds` upper bound (default 24h); cookie path unchanged. |
| Identifier-driven header injection | `sanitizeIdentifier`: length cap, control-char + bidi-override + `,;=` rejection. `net/http` rejects CRLF on the wire too (defence in depth). |
| Token leakage downstream | `StripAuthorizationHeader=true` by default. Also: `Authorization` stripped on excluded-URL requests so bearer can't leak into health/metrics downstream logs. |
| Token-in-logs | All log paths log reason categories, not raw tokens. Identifier hashed via SHA-256 truncated to 8 hex chars before any info/warn-level emission (full identifier only at debug). New `safeLogAuthEvent(category, hashedIdentifier, reasonCode)` helper makes this hard to misuse. |
| `email` claim spoofing | Startup fails if `EnableBearerAuth && UserIdentifierClaim == "email"`. Future human-user bearer iteration must add `email_verified` enforcement. |
| Bypass on SSE / WS endpoints | SSE/WS bypass keeps cookie-only behaviour; bearer ignored. Operators choose to widen if needed. |
| Mixed bearer + cookie precedence | Cookie wins by default (safer for browser scenarios); `BearerOverridesCookie=true` flips. WARN log on both-present requests. |
| Configuration drift (operator forgets audience) | Startup fails when `EnableBearerAuth=true && Audience==""`. |
| Downstream blast radius when `StripAuthorizationHeader=false` | Documented: forwarded bearer extends token's blast radius to all downstream services. Logs at those services become token stores. Operators must treat downstream log policy accordingly. |
| Introspection auth method (pre-existing gap, called out) | `token_introspection.go:80` uses `client_secret_basic` only; does not honour `private_key_jwt`. Out of scope for this PR but documented as a follow-up; operators using `ClientAuthMethod=private_key_jwt` + `RequireTokenIntrospection=true` should be aware introspection will use basic auth. |
## 14. Open Questions
None — all design decisions resolved during brainstorming + security review. Implementation may surface incidental questions (e.g. exact clock-skew leeway in `jwt.Verify`); those are out of scope for this spec and handled in the implementation plan.
## 14a. Security Review Reference
This design was reviewed by the `security-reviewer` subagent on 2026-05-18. Findings incorporated:
- **Critical**: C1 (classifier reuses `detectTokenType`), C2 (sub fallback dropped — unreachable due to `jwt.go:416`), C3 (replay-marking gates only Set, not Get; revocation regression test added).
- **High**: H1 (alg pinned at bearer entry), H2 (kid length + charset), H3 (cookie wins by default, configurable), H4 (per-IP 401 throttle), H5 (multi-aud requires azp).
- **Medium**: M1 (identifier max-length + bidi reject + delimiter chars), M2 (introspection cache TTL capped at 60s on bearer path), M4 (log-hashing via SHA-256[:8]), M5 (StripAuth blast-radius documented), M6 (iat upper-age bound), M7 (Authorization stripped on excluded URLs).
- **Low/Nit**: L2 (renamed to `BearerEmitWWWAuthenticate`), N3 (startup rejects `UserIdentifierClaim=email`).
- **Documented as pre-existing gaps (follow-up PRs)**: M3 (introspection auth method doesn't honour `private_key_jwt`).
## 15. Implementation Plan Reference
To be produced by the `writing-plans` skill in a follow-up document at `docs/superpowers/plans/2026-05-18-bearer-token-auth-plan.md`. The plan decomposes this design into ordered, independently-testable PRs.
+162 -191
View File
@@ -7,7 +7,9 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -50,7 +52,9 @@ type DynamicClientRegistrar struct {
logger *Logger
config *DynamicClientRegistrationConfig
registrationResponse *ClientRegistrationResponse
store DCRCredentialsStore // Storage backend for credentials
providerURL string
scopes []string // Runtime auth/scopes from the operator config (merged)
mu sync.RWMutex
}
@@ -73,8 +77,50 @@ func NewDynamicClientRegistrar(
}
}
// NewDynamicClientRegistrarWithStore creates a new dynamic client registrar with a specific storage backend
func NewDynamicClientRegistrarWithStore(
httpClient *http.Client,
logger *Logger,
dcrConfig *DynamicClientRegistrationConfig,
providerURL string,
store DCRCredentialsStore,
) *DynamicClientRegistrar {
if logger == nil {
logger = GetSingletonNoOpLogger()
}
return &DynamicClientRegistrar{
httpClient: httpClient,
logger: logger,
config: dcrConfig,
providerURL: providerURL,
store: store,
}
}
// SetStore sets the credentials store for the registrar
// This allows setting the store after creation when the cache manager is available
func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) {
r.mu.Lock()
defer r.mu.Unlock()
r.store = store
}
// EffectiveTokenEndpointAuthMethod returns the token-endpoint auth method that
// was (or will be) advertised to the IdP during registration:
// ClientMetadata.TokenEndpointAuthMethod if set, else client_secret_post
// (RFC 7591 confidential-client default). The IdP provisions the client
// according to this method, so the runtime must authenticate to the token
// endpoint with the SAME method or every exchange fails (R157).
func (r *DynamicClientRegistrar) EffectiveTokenEndpointAuthMethod() string {
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
return r.config.ClientMetadata.TokenEndpointAuthMethod
}
return "client_secret_post"
}
// RegisterClient performs dynamic client registration with the OIDC provider
// It first attempts to load existing credentials from a file if persistence is enabled,
// It first attempts to load existing credentials from storage if persistence is enabled,
// then registers a new client if no valid credentials exist.
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
if r.config == nil || !r.config.Enabled {
@@ -83,10 +129,13 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Try to load existing credentials if persistence is enabled
if r.config.PersistCredentials {
if resp, err := r.loadCredentials(); err == nil && resp != nil {
resp, err := r.loadCredentialsFromStore(ctx)
if err != nil {
r.logger.Debugf("Failed to load credentials from store: %v", err)
} else if resp != nil {
// Check if credentials are still valid (not expired)
if r.areCredentialsValid(resp) {
r.logger.Info("Loaded existing client credentials from file")
r.logger.Info("Loaded existing client credentials from storage")
r.mu.Lock()
r.registrationResponse = resp
r.mu.Unlock()
@@ -108,8 +157,10 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Validate the endpoint URL
if !strings.HasPrefix(endpoint, "https://") {
// Allow http only for localhost/development
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
// Allow http only for loopback/development endpoints. Use a
// host-boundary check (not a prefix) so e.g.
// http://localhost.evil.com isn't treated as localhost.
if !isLoopbackRegistrationEndpoint(endpoint) {
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
}
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
@@ -170,6 +221,19 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
return nil, fmt.Errorf("registration response missing client_id")
}
// The plugin's runtime client-auth default is client_secret_post
// (settings.go Config.Validate); RFC 7591 registers a confidential
// client. For a secret-based auth method the response must carry a
// client_secret, otherwise every token exchange fails. Adopting a
// secret-less client would silently break all auth.
authMethod := "client_secret_post"
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
}
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && regResp.ClientSecret == "" {
return nil, fmt.Errorf("registration response missing client_secret for auth method %q", authMethod)
}
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
// Cache the response
@@ -179,7 +243,7 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Persist credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
if err := r.saveCredentialsToStore(ctx, &regResp); err != nil {
r.logger.Errorf("Failed to persist client credentials: %v", err)
// Don't fail registration if persistence fails
}
@@ -200,11 +264,41 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
// Required: redirect_uris
if len(metadata.RedirectURIs) > 0 {
// Reject malformed or wrong-scheme entries before they reach the IdP:
// RFC 7591 expects absolute http/https redirect URIs, and an invalid
// one sent to the IdP would corrupt the registered callback.
for _, uri := range metadata.RedirectURIs {
parsed, perr := url.ParseRequestURI(uri)
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return nil, fmt.Errorf("redirect_uri %q must be an absolute http(s) URL", uri)
}
}
reqData["redirect_uris"] = metadata.RedirectURIs
} else {
return nil, fmt.Errorf("redirect_uris is required for client registration")
}
// Validate the optional URI metadata fields with the same absolute
// http(s) rule as redirect_uris. A relative or non-http(s) value
// (e.g. a malformed jwks_uri on a private_key_jwt client) would be
// forwarded to the IdP and make the registered client unverifiable at
// runtime (R141).
for _, field := range []struct{ val, name string }{
{metadata.LogoURI, "logo_uri"},
{metadata.ClientURI, "client_uri"},
{metadata.PolicyURI, "policy_uri"},
{metadata.TOSURI, "tos_uri"},
{metadata.JWKSURI, "jwks_uri"},
} {
if field.val == "" {
continue
}
parsed, perr := url.ParseRequestURI(field.val)
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return nil, fmt.Errorf("%s %q must be an absolute http(s) URL", field.name, field.val)
}
}
// Optional fields - only include if set
if len(metadata.ResponseTypes) > 0 {
reqData["response_types"] = metadata.ResponseTypes
@@ -259,8 +353,10 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
if metadata.TokenEndpointAuthMethod != "" {
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
} else {
// Default to client_secret_basic for confidential clients
reqData["token_endpoint_auth_method"] = "client_secret_basic"
// Default must match the plugin's runtime client-auth default
// (client_secret_post, settings.go Config.Validate) so an IdP that
// enforces the registered method does not reject the token exchange.
reqData["token_endpoint_auth_method"] = "client_secret_post"
}
if metadata.DefaultMaxAge > 0 {
@@ -277,6 +373,13 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
if metadata.Scope != "" {
reqData["scope"] = metadata.Scope
} else if len(r.scopes) > 0 {
// Register with the union of runtime auth scopes when the operator
// gave no explicit registration scope. IdPs that only grant
// registered/advertised scopes (Keycloak offline_access, Auth0)
// otherwise register a client whose later token/refresh grant is
// rejected with invalid_scope, breaking refresh (R124).
reqData["scope"] = strings.Join(deduplicateScopes(r.scopes), " ")
}
return json.Marshal(reqData)
@@ -295,6 +398,19 @@ func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationRes
return false
}
// For a secret-based auth method the response must carry a client_secret,
// mirroring the fresh-registration check (RegisterClient). A stored
// record with a client_id but empty secret would otherwise be accepted
// and installed as t.clientSecret="", breaking every token exchange
// (R124).
authMethod := "client_secret_post"
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
}
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && resp.ClientSecret == "" {
return false
}
// Check if secret has expired
if resp.ClientSecretExpiresAt > 0 {
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
@@ -315,11 +431,33 @@ func (r *DynamicClientRegistrar) credentialsFilePath() string {
return "/tmp/oidc-client-credentials.json"
}
// saveCredentials persists client credentials to a file
// loadCredentialsFromStore loads client credentials from the configured storage backend
// Falls back to legacy file-based loading if no store is configured
func (r *DynamicClientRegistrar) loadCredentialsFromStore(ctx context.Context) (*ClientRegistrationResponse, error) {
// Use store if available
if r.store != nil {
return r.store.Load(ctx, r.providerURL)
}
// Fallback to legacy file-based loading
return r.loadCredentials()
}
// saveCredentialsToStore persists client credentials to the configured storage backend
// Falls back to legacy file-based saving if no store is configured
func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, resp *ClientRegistrationResponse) error {
// Use store if available
if r.store != nil {
return r.store.Save(ctx, r.providerURL, resp)
}
// Fallback to legacy file-based saving
return r.saveCredentials(resp)
}
// saveCredentials persists client credentials to a file (legacy method)
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
filePath := r.credentialsFilePath()
data, err := json.MarshalIndent(resp, "", " ")
data, err := json.MarshalIndent(resp, "", " ") // #nosec G117 -- the registration response, including client_secret, is the credential this function persists
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
@@ -333,7 +471,7 @@ func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationRespons
return nil
}
// loadCredentials loads client credentials from a file
// loadCredentials loads client credentials from a file (legacy method)
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
filePath := r.credentialsFilePath()
@@ -354,187 +492,20 @@ func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse,
return &resp, nil
}
// UpdateClientRegistration updates an existing client registration using RFC 7592
// This requires the registration_client_uri and registration_access_token from the original registration
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to update")
// isLoopbackRegistrationEndpoint reports whether the (plaintext) registration
// endpoint targets a loopback host, matching the hostname boundary (not a
// string prefix) so http://localhost.evil.com is not treated as localhost.
func isLoopbackRegistrationEndpoint(endpoint string) bool {
u, err := url.Parse(endpoint)
if err != nil || !u.IsAbs() {
return false
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
host := u.Hostname()
if host == "localhost" {
return true
}
// Build update request
reqBody, err := r.buildRegistrationRequest()
if err != nil {
return nil, fmt.Errorf("failed to build update request: %w", err)
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return true
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("update request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read update response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("update failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse update response: %w", err)
}
// Update cache
r.mu.Lock()
r.registrationResponse = &regResp
r.mu.Unlock()
// Persist updated credentials if enabled
if r.config.PersistCredentials {
if err := r.saveCredentials(&regResp); err != nil {
r.logger.Errorf("Failed to persist updated credentials: %v", err)
}
}
r.logger.Infof("Successfully updated client registration for client ID: %s", regResp.ClientID)
return &regResp, nil
}
// ReadClientRegistration reads the current client registration using RFC 7592
func (r *DynamicClientRegistrar) ReadClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return nil, fmt.Errorf("no existing registration to read")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cachedResp.RegistrationClientURI, nil)
if err != nil {
return nil, fmt.Errorf("failed to create read request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("read request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Handle error responses
if resp.StatusCode != http.StatusOK {
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return nil, fmt.Errorf("read failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return nil, fmt.Errorf("read failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var regResp ClientRegistrationResponse
if err := json.Unmarshal(body, &regResp); err != nil {
return nil, fmt.Errorf("failed to parse read response: %w", err)
}
return &regResp, nil
}
// DeleteClientRegistration deletes the client registration using RFC 7592
func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) error {
r.mu.RLock()
cachedResp := r.registrationResponse
r.mu.RUnlock()
if cachedResp == nil {
return fmt.Errorf("no existing registration to delete")
}
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
return fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
}
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cachedResp.RegistrationClientURI, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete request failed: %w", err)
}
defer resp.Body.Close()
// Handle error responses (204 No Content is success)
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var regError ClientRegistrationError
if jsonErr := json.Unmarshal(body, &regError); jsonErr == nil && regError.Error != "" {
return fmt.Errorf("delete failed: %s - %s", regError.Error, regError.ErrorDescription)
}
return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body))
}
// Clear cache
r.mu.Lock()
r.registrationResponse = nil
r.mu.Unlock()
// Remove credentials file if persistence is enabled
if r.config.PersistCredentials {
filePath := r.credentialsFilePath()
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
r.logger.Errorf("Failed to remove credentials file: %v", err)
}
}
r.logger.Info("Successfully deleted client registration")
return nil
return false
}
+3 -253
View File
@@ -541,6 +541,7 @@ func TestCredentialsValidation(t *testing.T) {
name: "valid non-expiring credentials",
response: &ClientRegistrationResponse{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: 0, // Never expires
},
expected: true,
@@ -549,6 +550,7 @@ func TestCredentialsValidation(t *testing.T) {
name: "valid future-expiring credentials",
response: &ClientRegistrationResponse{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
},
expected: true,
@@ -598,7 +600,7 @@ func TestBuildRegistrationRequest(t *testing.T) {
"redirect_uris": []interface{}{"https://example.com/callback"},
"response_types": []interface{}{"code"},
"grant_types": []interface{}{"authorization_code", "refresh_token"},
"token_endpoint_auth_method": "client_secret_basic",
"token_endpoint_auth_method": "client_secret_post",
},
expectError: false,
},
@@ -735,258 +737,6 @@ func TestDCRConfigDefaults(t *testing.T) {
}
}
// TestUpdateClientRegistration tests the RFC 7592 client update functionality
func TestUpdateClientRegistration(t *testing.T) {
updateCalled := false
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
updateCalled = true
// Verify authorization header
if r.Header.Get("Authorization") == "" {
t.Error("Missing Authorization header for update")
}
resp := ClientRegistrationResponse{
ClientID: "updated-client-id",
ClientSecret: "updated-client-secret",
RegistrationAccessToken: "new-access-token",
RegistrationClientURI: r.URL.String(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
dcrConfig := &DynamicClientRegistrationConfig{
Enabled: true,
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
},
}
registrar := NewDynamicClientRegistrar(
server.Client(),
NewLogger("DEBUG"),
dcrConfig,
server.URL,
)
// Set up cached response with management credentials
registrar.mu.Lock()
registrar.registrationResponse = &ClientRegistrationResponse{
ClientID: "original-client-id",
ClientSecret: "original-client-secret",
RegistrationAccessToken: "access-token",
RegistrationClientURI: server.URL + "/register/client123",
}
registrar.mu.Unlock()
// Perform update
ctx := context.Background()
resp, err := registrar.UpdateClientRegistration(ctx)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if !updateCalled {
t.Error("Update endpoint was not called")
}
if resp.ClientID != "updated-client-id" {
t.Errorf("Updated ClientID mismatch: got %s", resp.ClientID)
}
}
// TestDeleteClientRegistration tests the RFC 7592 client deletion functionality
func TestDeleteClientRegistration(t *testing.T) {
deleteCalled := false
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
deleteCalled = true
w.WriteHeader(http.StatusNoContent)
}
}))
defer server.Close()
tempDir := t.TempDir()
credentialsFile := filepath.Join(tempDir, "credentials.json")
// Create a credentials file to test deletion
os.WriteFile(credentialsFile, []byte(`{"client_id":"test"}`), 0600)
dcrConfig := &DynamicClientRegistrationConfig{
Enabled: true,
PersistCredentials: true,
CredentialsFile: credentialsFile,
}
registrar := NewDynamicClientRegistrar(
server.Client(),
NewLogger("DEBUG"),
dcrConfig,
server.URL,
)
// Set up cached response with management credentials
registrar.mu.Lock()
registrar.registrationResponse = &ClientRegistrationResponse{
ClientID: "test-client-id",
RegistrationAccessToken: "access-token",
RegistrationClientURI: server.URL + "/register/client123",
}
registrar.mu.Unlock()
// Perform delete
ctx := context.Background()
err := registrar.DeleteClientRegistration(ctx)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
if !deleteCalled {
t.Error("Delete endpoint was not called")
}
// Verify cache is cleared
if registrar.GetCachedResponse() != nil {
t.Error("Cached response should be cleared after deletion")
}
// Verify credentials file is deleted
if _, err := os.Stat(credentialsFile); !os.IsNotExist(err) {
t.Error("Credentials file should be deleted")
}
}
// TestReadClientRegistration tests the RFC 7592 client read functionality
func TestReadClientRegistration(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
resp := ClientRegistrationResponse{
ClientID: "read-client-id",
ClientSecret: "read-client-secret",
RedirectURIs: []string{"https://example.com/callback"},
ResponseTypes: []string{"code"},
GrantTypes: []string{"authorization_code"},
ApplicationType: "web",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
registrar := NewDynamicClientRegistrar(
server.Client(),
NewLogger("DEBUG"),
dcrConfig,
server.URL,
)
// Set up cached response with management credentials
registrar.mu.Lock()
registrar.registrationResponse = &ClientRegistrationResponse{
ClientID: "original-client-id",
RegistrationAccessToken: "access-token",
RegistrationClientURI: server.URL + "/register/client123",
}
registrar.mu.Unlock()
// Read registration
ctx := context.Background()
resp, err := registrar.ReadClientRegistration(ctx)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if resp.ClientID != "read-client-id" {
t.Errorf("Read ClientID mismatch: got %s", resp.ClientID)
}
}
// TestOperationsWithoutCachedResponse tests error handling when no cached response exists
func TestOperationsWithoutCachedResponse(t *testing.T) {
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
registrar := NewDynamicClientRegistrar(
&http.Client{},
NewLogger("DEBUG"),
dcrConfig,
"https://example.com",
)
ctx := context.Background()
// Test Update without cached response
_, err := registrar.UpdateClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "no existing registration") {
t.Errorf("Update should fail without cached response: %v", err)
}
// Test Read without cached response
_, err = registrar.ReadClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "no existing registration") {
t.Errorf("Read should fail without cached response: %v", err)
}
// Test Delete without cached response
err = registrar.DeleteClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "no existing registration") {
t.Errorf("Delete should fail without cached response: %v", err)
}
}
// TestOperationsWithoutManagementCredentials tests error handling without management URIs
func TestOperationsWithoutManagementCredentials(t *testing.T) {
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
registrar := NewDynamicClientRegistrar(
&http.Client{},
NewLogger("DEBUG"),
dcrConfig,
"https://example.com",
)
// Set up cached response WITHOUT management credentials
registrar.mu.Lock()
registrar.registrationResponse = &ClientRegistrationResponse{
ClientID: "test-client-id",
// Missing RegistrationAccessToken and RegistrationClientURI
}
registrar.mu.Unlock()
ctx := context.Background()
// Test Update without management credentials
_, err := registrar.UpdateClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "registration management not supported") {
t.Errorf("Update should fail without management credentials: %v", err)
}
// Test Read without management credentials
_, err = registrar.ReadClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "registration management not supported") {
t.Errorf("Read should fail without management credentials: %v", err)
}
// Test Delete without management credentials
err = registrar.DeleteClientRegistration(ctx)
if err == nil || !stringContains(err.Error(), "registration management not supported") {
t.Errorf("Delete should fail without management credentials: %v", err)
}
}
// stringContains is a helper function to check if a string contains a substring
func stringContains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr))
+31
View File
@@ -2,6 +2,8 @@ package traefikoidc
import (
"context"
"crypto"
"fmt"
"net/http"
"sync"
"sync/atomic"
@@ -40,6 +42,35 @@ func (m *EnhancedMockJWKCache) GetJWKS(ctx context.Context, jwksURL string, http
return m.JWKS, m.Err
}
func (m *EnhancedMockJWKCache) 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
}
if jwks == nil {
return nil, fmt.Errorf("JWKS is nil")
}
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()
default:
return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
}
}
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
}
func (m *EnhancedMockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
}
func (m *EnhancedMockJWKCache) Cleanup() {
atomic.AddInt32(&m.CleanupCalls, 1)
m.mu.Lock()
+439 -29
View File
@@ -191,12 +191,23 @@ type CircuitBreaker struct {
maxFailures int
// timeout is how long to wait before allowing requests in half-open state
timeout time.Duration
// resetTimeout is how long to wait before transitioning from open to half-open
// resetTimeout is how long to stay in half-open (after the open timeout)
// before a successful response fully closes the circuit
resetTimeout time.Duration
// state tracks the current circuit breaker state
state CircuitBreakerState
// failures counts consecutive failures
failures int64
// halfOpenSince is when the circuit entered half-open; used to enforce
// resetTimeout before a success can fully close it
halfOpenSince time.Time
// openedAt is when the circuit last transitioned into the Open state
// (Closed->Open or HalfOpen->Open, set in recordFailure). allowRequest
// and IsAvailable gate the Open->HalfOpen transition on openedAt instead
// of the promoted lastFailureTime, so a request rejected while already
// open cannot re-arm the open timer and keep the circuit open forever
// under steady traffic (FIX-01).
openedAt time.Time
}
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
@@ -239,12 +250,32 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
cb.RecordRequest()
if !cb.allowRequest() {
// A request rejected while the circuit is open is an admission
// outcome, not a never-started request: count it in total_failures
// so GetBaseMetrics' success_rate (successes/total_requests)
// reflects actual admission (R180). This intentionally bypasses the
// promoted BaseRecoveryMechanism.RecordFailure(), which also sets
// lastFailureTime -- allowRequest's Open->HalfOpen timer used to
// read that same field, so every rejection re-armed it and the
// circuit never left Open under steady traffic (FIX-01). The open
// timer now runs off cb.openedAt instead, set only on a genuine
// Closed->Open or HalfOpen->Open transition in recordFailure.
atomic.AddInt64(&cb.totalFailures, 1)
return fmt.Errorf("circuit breaker is open")
}
err := fn()
if err != nil {
cb.recordFailure()
// A terminal client error (HTTPError 4xx other than 429) reflects a
// per-user problem such as invalid_grant, not a downstream-service
// failure. It must not trip the state machine: in particular it
// must not reopen a half-open circuit, or the 15s/30s half-open
// hold (resetTimeout) extends an outage on any single per-user
// error (FIX-09). It is still recorded in the base metrics below,
// since it is a real error returned to the caller.
if !isTerminalClientError(err) {
cb.recordFailure()
}
cb.RecordFailure()
return err
}
@@ -254,6 +285,37 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
return nil
}
// isTerminalClientError reports whether err is an HTTPError whose status
// code is a client error (4xx) other than 429 Too Many Requests or 408
// Request Timeout. Such errors indicate the request itself was rejected by
// the far end (for example a replayed authorization code), not that the far
// end is unhealthy, so the circuit breaker's failure-counting state machine
// ignores them (FIX-09). 429 and 408 are excluded because they are the
// service itself signaling it is overloaded or slow, a genuine health
// signal, not a per-request rejection — the same classification FIX-13
// applies in token_validation_rs.go and bearer_auth.go, and that
// internal/recovery/base.go and metrics.go apply via their retryable-status
// checks.
//
// This uses a plain type assertion, not errors.As. Under yaegi v0.16.1 (the
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
// errors.As(err, &target) panics with "errors: *target must be interface
// or implement error" whenever target's pointed-to type (*HTTPError here)
// is itself interpreted, regardless of err's own concrete type. This
// function runs on every fn() error in ExecuteWithContext, on the
// default-on token-exchange/refresh path, so that panic reaches production
// (FIX-09). The production error also reaches the breaker unwrapped
// (helpers.go returns *HTTPError directly), so a type assertion is
// sufficient and needs no Unwrap chain walk.
func isTerminalClientError(err error) bool {
httpErr, ok := err.(*HTTPError)
if !ok {
return false
}
return httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 &&
httpErr.StatusCode != 429 && httpErr.StatusCode != 408
}
// Execute executes a function through the circuit breaker without context.
// This is provided for backward compatibility with existing code.
func (cb *CircuitBreaker) Execute(fn func() error) error {
@@ -273,8 +335,9 @@ func (cb *CircuitBreaker) allowRequest() bool {
return true
case CircuitBreakerOpen:
if now.Sub(cb.lastFailureTime) > cb.timeout {
if now.Sub(cb.openedAt) > cb.timeout {
cb.state = CircuitBreakerHalfOpen
cb.halfOpenSince = now
cb.logger.Infof("Circuit breaker transitioning to half-open state")
return true
}
@@ -300,11 +363,13 @@ func (cb *CircuitBreaker) recordFailure() {
case CircuitBreakerClosed:
if cb.failures >= int64(cb.maxFailures) {
cb.state = CircuitBreakerOpen
cb.openedAt = time.Now()
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
}
case CircuitBreakerHalfOpen:
cb.state = CircuitBreakerOpen
cb.openedAt = time.Now()
cb.LogError("Circuit breaker returned to open state after failure in half-open")
}
}
@@ -317,6 +382,15 @@ func (cb *CircuitBreaker) recordSuccess() {
switch cb.state {
case CircuitBreakerHalfOpen:
// Honor resetTimeout: do not fully close until the circuit has been
// in half-open for at least resetTimeout. This provides the
// documented open -> half-open -> (reset cooldown) -> closed
// hysteresis instead of closing on the very first half-open
// success, which would let a stray success immediately reopen
// (bounce). While in half-open requests are still allowed as probes.
if time.Since(cb.halfOpenSince) < cb.resetTimeout {
return
}
cb.failures = 0
cb.state = CircuitBreakerClosed
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
@@ -348,7 +422,24 @@ func (cb *CircuitBreaker) Reset() {
// IsAvailable returns whether the circuit breaker is currently allowing requests.
// This provides a quick way to check if the service is available.
func (cb *CircuitBreaker) IsAvailable() bool {
return cb.allowRequest()
cb.mutex.Lock()
defer cb.mutex.Unlock()
switch cb.state {
case CircuitBreakerClosed:
return true
case CircuitBreakerHalfOpen:
return true
case CircuitBreakerOpen:
// Read-only availability probe: report that a request would be
// admitted only after the open timeout has elapsed, but do NOT
// transition to half-open — IsAvailable must not take a permit or
// mutate circuit state. Real traffic admission is gated by
// allowRequest (triggerRequest) which performs the transition.
return time.Since(cb.openedAt) > cb.timeout
default:
return false
}
}
// GetMetrics returns comprehensive metrics about the circuit breaker.
@@ -507,12 +598,157 @@ func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error
}
// Execute runs the given function with retry logic (for backward compatibility)
// Execute executes a function with retry logic (backward compatibility).
// Execute runs a function with retry logic (backward compatibility).
// This method provides the same functionality as ExecuteWithContext.
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
return re.ExecuteWithContext(ctx, fn)
}
// singleUseRetryableErrors are error fragments that prove the request was NEVER
// sent to the server (dial/connect failures). Retrying those is safe.
var singleUseRetryableErrors = []string{
"connection refused",
"network unreachable",
"no route to host",
"temporary failure",
}
// isNeverSentError reports whether err proves a single-use request was never
// sent to the server, so retrying it cannot re-present already-consumed
// input (an authorization code or a rotated refresh token).
//
// A *HTTPError means a response WAS received: the request definitely
// reached the endpoint, regardless of what its body says. Before this
// check, ExecuteSingleUseWithContext substring-matched singleUseRetryableErrors
// against the raw error text, and helpers.go's token-endpoint HTTPError
// carries up to 10 KiB of the response body in its Message -- an IdP 500
// whose body happened to mention "connection refused" was retried, re-
// sending a consumed code or a rotated refresh token (FIX-14).
//
// A *net.OpError is NOT proof by itself: Go's net package uses that same
// type for a "dial" failure before any request reaches the wire, but also
// for a "read" or "write" failure AFTER the connection is already
// established, when the request may already have reached the IdP. Only Op
// "dial" (a failed connect) or "proxyconnect" (net/http's failed CONNECT to
// an HTTP proxy) prove the request never went out, so the fragment match is
// scoped to those two.
func isNeverSentError(err error) bool {
// Plain type assertion, not errors.As: under yaegi v0.16.1 (the
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
// errors.As(err, &target) panics with "errors: *target must be
// interface or implement error" whenever target's pointed-to type
// (*HTTPError here) is itself interpreted, regardless of err's own
// concrete type. ExecuteSingleUseWithContext calls this on every fn()
// error, on the default-on authorization-code-exchange/refresh path,
// so that panic reaches production (FIX-14). The production error
// reaches here unwrapped (helpers.go returns *HTTPError directly), so
// a type assertion is sufficient and needs no Unwrap chain walk.
if _, ok := err.(*HTTPError); ok {
return false
}
// errors.As against the compiled *net.OpError type is yaegi-safe: the
// panic above is specific to an interpreted target type, and
// *net.OpError is a native stdlib type.
var opErr *net.OpError
if errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect") {
return isSubstringMatch(opErr.Error(), singleUseRetryableErrors)
}
return false
}
// ExecuteSingleUseWithContext retries only on errors that prove the request was
// never sent (connect/dial failures). It intentionally does NOT retry on
// "timeout": for a single-use operation such as the authorization-code
// exchange, a timeout may mean the provider already consumed the one-time code,
// so re-sending it would surface invalid_grant permanently even though the
// first attempt succeeded.
func (re *RetryExecutor) ExecuteSingleUseWithContext(ctx context.Context, fn func() error) error {
re.RecordRequest()
var lastErr error
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
err := fn()
if err == nil {
if attempt > 1 {
re.LogInfo("Operation succeeded after %d attempts", attempt)
}
re.RecordSuccess()
return nil
}
lastErr = err
if !isNeverSentError(err) {
re.RecordFailure()
return err
}
if attempt == re.config.MaxAttempts {
re.RecordFailure()
break
}
delay := re.calculateDelay(attempt)
if attempt == 1 || attempt%3 == 0 {
re.LogDebug("Retrying single-use operation after %v (attempt %d/%d): %v",
delay, attempt, re.config.MaxAttempts, err)
}
select {
case <-ctx.Done():
re.RecordFailure()
return ctx.Err()
case <-time.After(delay):
}
}
finalErr := fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
return finalErr
}
// isSubstringMatch reports whether s contains any of the given fragments.
func isSubstringMatch(s string, fragments []string) bool {
lower := strings.ToLower(s)
for _, frag := range fragments {
if strings.Contains(lower, frag) {
return true
}
}
return false
}
// asHTTPError reports whether err is, or (via a chain of Unwrap() error
// methods) wraps, an *HTTPError. It exists because errors.As cannot be used
// here: under yaegi v0.16.1 (the interpreter Traefik uses to load this
// plugin, pinned in Makefile:9), errors.As(err, &target) panics with
// "errors: *target must be interface or implement error" whenever target's
// pointed-to type is itself interpreted, and *HTTPError is declared in this
// plugin so it is always interpreted at runtime — regardless of err's own
// concrete type (see isTerminalClientError above for the same hazard on a
// path that does not need the Unwrap walk).
//
// This manually reimplements errors.As's single-chain Unwrap() walk using
// only type assertions and errors.Unwrap, both yaegi-safe, so native
// classification is byte-for-byte identical to errors.As (see
// TestIsRetryableError_WrappedTransient). The one place native and yaegi
// genuinely diverge: an interpreted *HTTPError wrapped with
// fmt.Errorf("...%w", e) is found by this walk in native Go but NOT under
// yaegi (an interpreter quirk with wrapped interpreted-type targets,
// verified this session) — so every producer feeding isRetryableError must
// keep returning an *HTTPError unwrapped (exchangeTokens already does; see
// helpers.go).
func asHTTPError(err error) (*HTTPError, bool) {
for err != nil {
if httpErr, ok := err.(*HTTPError); ok {
return httpErr, true
}
err = errors.Unwrap(err)
}
return nil, false
}
// isRetryableError checks if an error should trigger a retry
// isRetryableError determines if an error should trigger a retry attempt.
// Checks error message against configured retryable error patterns.
@@ -523,6 +759,17 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
return false
}
// A terminal HTTP error (a real status that is neither 5xx nor 429)
// must be classified by its status BEFORE any message-substring scan.
// Otherwise a 4xx whose provider message merely contains a
// retryable-looking word — e.g. "certificate"/"tls" (isCertificateError),
// "EOF" (isEOFError), or a generic "timeout" — would be reclassified
// retryable and retried to MaxAttempts on a permanent error, repeating
// the failing request (R123, R157).
if statusHTTP, ok := asHTTPError(err); ok && statusHTTP.StatusCode != 0 && statusHTTP.StatusCode < 500 && statusHTTP.StatusCode != 429 {
return false
}
// Check for Traefik default certificate error (startup race condition)
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
if isTraefikDefaultCertError(err) {
@@ -539,19 +786,20 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
return true
}
errStr := err.Error()
errStr := strings.ToLower(err.Error())
for _, retryableErr := range re.config.RetryableErrors {
if contains(errStr, retryableErr) {
if contains(errStr, strings.ToLower(retryableErr)) {
return true
}
}
if netErr, ok := err.(net.Error); ok {
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return true
}
errStr := netErr.Error()
errStr := strings.ToLower(netErr.Error())
temporaryPatterns := []string{
"connection refused",
"connection reset",
@@ -568,8 +816,19 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
if httpErr, ok := err.(*HTTPError); ok {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
// asHTTPError (not errors.As) so retry classification survives error
// wrapping: a transient or 5xx/429 error returned as fmt.Errorf("...:
// %w", err) from an upstream layer must still be retried. Direct type
// assertions on the top-level error silently misclassified wrapped
// transient errors as permanent, giving up before any retry (R111).
// errors.As itself cannot be used under yaegi -- see asHTTPError's doc
// comment.
if httpErr, ok := asHTTPError(err); ok {
status := httpErr.StatusCode
// A 0 StatusCode means the producer used HTTPError purely as a
// message carrier (no real HTTP status). Treat it as unknown
// (non-retryable) rather than spuriously matching 5xx (R118).
return status != 0 && (status >= 500 || status == 429)
}
return false
@@ -762,6 +1021,10 @@ type GracefulDegradation struct {
config GracefulDegradationConfig
mutex sync.RWMutex
shutdownOnce sync.Once
// beforeSharedTaskStop is a test seam, nil in production. Close calls it
// after deciding this is the last live instance and before stopping the
// shared health-check task.
beforeSharedTaskStop func()
}
// GracefulDegradationConfig holds configuration for graceful degradation behavior.
@@ -788,6 +1051,53 @@ func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
// NewGracefulDegradation creates a new graceful degradation manager
// NewGracefulDegradation creates a new graceful degradation mechanism.
// Initializes fallback and health check maps and starts background health monitoring.
// gdInstances tracks live GracefulDegradation instances so the single shared
// health-check task can run every instance's health checks (not just the first)
// and so the task is only stopped when the last instance closes.
var gdInstances = struct {
sync.RWMutex
set map[*GracefulDegradation]struct{}
}{set: make(map[*GracefulDegradation]struct{})}
// globalHealthCheckMu serializes the global pass. globalPerformHealthChecks
// runs from more than one goroutine — the shared singleton task's run loop
// AND the once-guarded StartBackgroundTask path both drive it — so without
// this, any health check registered via the public RegisterHealthCheck
// API would be invoked concurrently (a latent production race, and the
// source of an intermittent full-suite race in tests). Serializing the
// whole pass keeps registered health checks single-threaded (R189).
var globalHealthCheckMu sync.Mutex
// gdLifecycleMu serializes GracefulDegradation registration with the
// last-instance check and shared-task stop in Close. Without it, an instance
// created after Close decided it was the last one, but before the stop, adopted
// the still-running task, and the stop then killed the task that the new live
// instance relies on. With it, a new instance either counts toward the
// last-instance check or registers after the stop, and its routine replaces
// the stopped task. The health-check pass never takes this lock, so Close can
// hold it while BackgroundTask.Stop waits for an in-progress pass.
var gdLifecycleMu sync.Mutex
// globalPerformHealthChecks runs the health check pass for every live
// GracefulDegradation instance. The shared singleton task calls this, so a
// health check registered on ANY instance is exercised, and closing one
// instance does not stall recovery for the others.
func globalPerformHealthChecks() {
globalHealthCheckMu.Lock()
defer globalHealthCheckMu.Unlock()
gdInstances.RLock()
instances := make([]*GracefulDegradation, 0, len(gdInstances.set))
for gd := range gdInstances.set {
instances = append(instances, gd)
}
gdInstances.RUnlock()
for _, gd := range instances {
gd.performHealthChecks()
}
}
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
gd := &GracefulDegradation{
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
@@ -798,6 +1108,22 @@ func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *G
}
gd.stopChan = make(chan struct{})
// Register synchronously, before the health-check goroutine starts
// (FIX-18). Registration used to happen only inside startHealthCheckRoutine,
// which runs in its own goroutine, so a Close() called immediately after
// NewGracefulDegradation returned could race ahead of it: Close's
// gdInstances.delete would find nothing to remove, and the goroutine's
// later add would then register an already-closed gd permanently.
// startHealthCheckRoutine no longer touches gdInstances at all — this is
// now the ONLY place a GracefulDegradation is added to the set, so there
// is no second, racing registration left to worry about.
gdLifecycleMu.Lock()
gdInstances.Lock()
gdInstances.set[gd] = struct{}{}
gdInstances.Unlock()
gdLifecycleMu.Unlock()
go gd.startHealthCheckRoutine()
return gd
@@ -859,8 +1185,9 @@ func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary f
// isServiceDegraded checks if a service is currently degraded
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
// Uses a write lock because the recovery-timeout branch deletes from the map.
gd.mutex.Lock()
defer gd.mutex.Unlock()
degradedTime, exists := gd.degradedServices[serviceName]
if !exists {
@@ -901,25 +1228,54 @@ func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{},
return fallback()
}
// startHealthCheckRoutine starts the background health check routine
// startHealthCheckRoutine starts the background health check routine.
//
// FIX-18: this runs in its own goroutine, started by NewGracefulDegradation
// right after it registers gd in gdInstances. A Close() called immediately
// after NewGracefulDegradation returns can race ahead of this goroutine
// getting scheduled at all. Take gd.mutex up front and re-check gd.stopChan
// before doing anything else: if Close() already closed it, this goroutine
// must not create (or adopt) the shared health-check task for an
// already-closed instance — that would leak the process-global task
// forever, since nothing would ever be left registered to stop it.
func (gd *GracefulDegradation) startHealthCheckRoutine() {
// Use singleton task registry to prevent multiple instances
gd.mutex.Lock()
select {
case <-gd.stopChan:
// Close() already ran before this goroutine got scheduled. Do not
// register or start the shared task on behalf of a closed instance.
gd.mutex.Unlock()
return
default:
}
// Use the singleton task registry so multiple GracefulDegradation
// instances share one health-check goroutine. The task function is the
// global pass over every live instance, not this instance's own health
// checks — otherwise only the first instance's checks would run.
//
// gd.mutex stays held through CreateSingletonTask and the healthCheckTask
// assignment below: CreateSingletonTask only touches the global task
// registry (a different lock), never gd.mutex, so this cannot deadlock,
// and it keeps the stopChan check and the assignment atomic with respect
// to a concurrent Close().
registry := GetGlobalTaskRegistry()
task, err := registry.CreateSingletonTask(
"graceful-degradation-health-check",
gd.config.HealthCheckInterval,
gd.performHealthChecks,
globalPerformHealthChecks,
gd.BaseRecoveryMechanism.logger,
nil, // No specific wait group
)
if err != nil {
gd.mutex.Unlock()
gd.BaseRecoveryMechanism.logger.Errorf("Failed to create health check task: %v", err)
return
}
gd.mutex.Lock()
gd.healthCheckTask = task
gd.mutex.Unlock()
@@ -954,7 +1310,7 @@ func (gd *GracefulDegradation) GetDegradedServices() []string {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
var degraded []string
degraded := make([]string, 0, len(gd.degradedServices))
for serviceName := range gd.degradedServices {
degraded = append(degraded, serviceName)
}
@@ -971,25 +1327,58 @@ func (gd *GracefulDegradation) Reset() {
gd.LogInfo("Graceful degradation state has been reset")
}
// Close shuts down the graceful degradation system and cleans up resources
// Close shuts down the graceful degradation system and cleans up resources.
func (gd *GracefulDegradation) Close() {
gd.shutdownOnce.Do(func() {
// Signal shutdown
// Signal shutdown under gd.mutex. startHealthCheckRoutine checks
// stopChan and creates or adopts the shared task under the same
// lock, so an in-flight routine finishes before this instance
// unregisters below. Without the lock, a routine that passed its
// check could reach the task registry after the last Close stopped
// the task, and RegisterBackgroundTask would start a fresh task
// that no live instance can stop. Release the lock before the stop:
// BackgroundTask.Stop waits for a health-check pass that takes gd.mutex.
gd.mutex.Lock()
select {
case <-gd.stopChan:
// Already closed
default:
close(gd.stopChan)
}
// Stop health check task
gd.mutex.Lock()
task := gd.healthCheckTask
gd.mutex.Unlock()
if task != nil {
task.Stop()
// Don't set to nil to avoid race conditions
// Stop the shared health-check task only when this was the last live
// instance. The task is process-global and shared by every
// GracefulDegradation instance; stopping it on any one instance's
// close would kill health checks / recovery for all surviving
// instances (mirror of the singleton-token-cleanup lastInstance gate).
// Hold gdLifecycleMu across the last-instance check and the stop, so a
// concurrent NewGracefulDegradation cannot register in between and
// adopt a task that is about to stop.
gdLifecycleMu.Lock()
defer gdLifecycleMu.Unlock()
gdInstances.Lock()
delete(gdInstances.set, gd)
lastInstance := len(gdInstances.set) == 0
gdInstances.Unlock()
if lastInstance {
if gd.beforeSharedTaskStop != nil {
gd.beforeSharedTaskStop()
}
// Stop by name through the resource manager rather than reading
// this instance's own gd.healthCheckTask field: with two or more
// GracefulDegradation instances, the shared task can already be
// running — created by a SIBLING instance's startHealthCheckRoutine
// goroutine — while THIS instance's own goroutine has not yet run
// and populated its local healthCheckTask field. Reading that
// still-nil local field here would skip the stop and leak the
// task forever even though this genuinely is the last instance.
// StopBackgroundTask looks up the registry's current entry
// directly, so it is correct regardless of which instance's
// goroutine happened to create the task (FIX-18).
_ = GetResourceManager().StopBackgroundTask("graceful-degradation-health-check")
}
gd.logger.Debug("GracefulDegradation shut down successfully")
@@ -1049,6 +1438,21 @@ func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
}
}
// Close shuts down the resources owned by this ErrorRecoveryManager,
// currently its GracefulDegradation instance (which unregisters from
// gdInstances and, if this was the last live instance, stops the shared
// health-check task). Safe to call multiple times; safe on a nil receiver.
// FIX-18: every caller that constructs an ErrorRecoveryManager must call
// Close, or its GracefulDegradation leaks in gdInstances forever.
func (erm *ErrorRecoveryManager) Close() {
if erm == nil {
return
}
if erm.gracefulDegradation != nil {
erm.gracefulDegradation.Close()
}
}
// GetCircuitBreaker gets or creates a circuit breaker for a service
// GetCircuitBreaker returns the circuit breaker for a specific service.
// Creates a new circuit breaker if one doesn't exist for the service.
@@ -1071,8 +1475,14 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
cb := erm.GetCircuitBreaker(serviceName)
return erm.retryExecutor.Execute(ctx, func() error {
return cb.Execute(fn)
// Retry INNER, circuit OUTER (matching TokenResilienceManager and the
// documented R106 compositing rule). Wrapping cb around the whole retry
// run means one logical call counts as a single circuit failure even
// when it was retried internally; the previous nesting (cb inside the
// retry loop) recorded each retry attempt as a separate failure and
// could open the circuit after only one flaky-but-retryable call (R144).
return cb.ExecuteWithContext(ctx, func() error {
return erm.retryExecutor.Execute(ctx, fn)
})
}
+47
View File
@@ -0,0 +1,47 @@
package traefikoidc
import (
"context"
"testing"
)
// TestIsTerminalClientError408IsTransient pins that HTTP 408 Request Timeout
// is treated as a transient, service-health signal by isTerminalClientError,
// not as a terminal per-request client error. A token endpoint (or a proxy
// in front of it) returns 408 when the upstream is slow, which is the same
// reasoning FIX-13 already applies in token_validation_rs.go's classifier
// and bearer_auth.go, and that internal/recovery/base.go and metrics.go
// apply via their RetryableStatusCodes/isRetryable checks. isTerminalClientError
// must classify 408 the same way, or a burst of token-endpoint 408s never
// opens the refresh circuit breaker.
//
// Fail-on-old: isTerminalClientError excluded only 429, so it reported 408
// as terminal.
func TestIsTerminalClientError408IsTransient(t *testing.T) {
err := &HTTPError{StatusCode: 408, Message: "request timeout"}
if isTerminalClientError(err) {
t.Fatal("408 Request Timeout must be treated as transient (like 429), not a terminal client error")
}
}
// TestCircuitBreakerHalfOpenRequestTimeoutReopens pins the same contract at
// the circuit-breaker level: a 408 probe result must still reopen a
// half-open breaker, exactly like the existing 429 case
// (TestCircuitBreakerHalfOpenRateLimitReopens in error_recovery_fix09_test.go).
//
// Fail-on-old: with 408 misclassified as terminal, recordFailure's HalfOpen
// exemption for terminal client errors (FIX-09) swallowed it and the breaker
// stayed HalfOpen instead of reopening.
func TestCircuitBreakerHalfOpenRequestTimeoutReopens(t *testing.T) {
cb := newFix09HalfOpenBreaker(t)
err := cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 408, Message: "request timeout"}
})
if err == nil {
t.Fatal("expected the probe's own error to be returned to the caller")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("a 408 must still reopen a half-open circuit like 429, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
+87
View File
@@ -0,0 +1,87 @@
package traefikoidc
import (
"context"
"errors"
"testing"
"time"
)
// TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow is a regression test
// for FIX-01.
//
// Before the fix, ExecuteWithContext called the promoted
// BaseRecoveryMechanism.RecordFailure() on every request rejected while the
// circuit is open. RecordFailure sets lastFailureTime, the same field
// allowRequest reads as the Open -> HalfOpen timer. Every rejection then
// re-armed the timer, so under steady traffic shorter than Timeout the
// breaker never left Open (see error_recovery.go:253,288 pre-fix).
//
// Fail-on-old: with a request every 10ms and Timeout 50ms, no request is
// ever admitted within the 500ms budget, because each rejection pushes the
// open window forward by another Timeout.
func TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 50 * time.Millisecond,
ResetTimeout: 30 * time.Second,
}, NewLogger("error"))
// Trip the breaker open with a single real failure.
if err := cb.ExecuteWithContext(context.Background(), func() error {
return errors.New("boom")
}); err == nil {
t.Fatal("expected the tripping call to return its own error")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("expected circuit to be open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
}
deadline := time.Now().Add(500 * time.Millisecond)
admitted := false
for time.Now().Before(deadline) {
callErr := cb.ExecuteWithContext(context.Background(), func() error {
return nil
})
if callErr == nil {
admitted = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !admitted {
t.Fatal("expected a probe to be admitted once Timeout elapsed with no real failure in between; open-circuit rejections must not re-arm the open timer")
}
}
// TestCircuitBreakerOpenRejectionStillCountedInMetrics pins the R180 intent
// alongside FIX-01: an open-circuit rejection is still an admission outcome
// and must still increment total_failures, even though it may no longer
// touch the open timer.
func TestCircuitBreakerOpenRejectionStillCountedInMetrics(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 1,
Timeout: time.Hour,
ResetTimeout: time.Hour,
}, NewLogger("error"))
if err := cb.ExecuteWithContext(context.Background(), func() error {
return errors.New("boom")
}); err == nil {
t.Fatal("expected the tripping call to return its own error")
}
if err := cb.ExecuteWithContext(context.Background(), func() error {
t.Fatal("fn must not be called while the circuit is open")
return nil
}); err == nil {
t.Fatal("expected the rejected call to return an error")
}
metrics := cb.GetBaseMetrics()
totalFailures := metrics["total_failures"].(int64)
if totalFailures != 2 {
t.Errorf("total_failures = %d, want 2 (1 real failure + 1 open rejection)", totalFailures)
}
}
+131
View File
@@ -0,0 +1,131 @@
package traefikoidc
import (
"context"
"errors"
"net"
"testing"
"time"
)
// newFix09HalfOpenBreaker builds a breaker, trips it with one real (non
// client) failure, and waits past Timeout so the next ExecuteWithContext
// call performs the Open -> HalfOpen probe transition itself.
func newFix09HalfOpenBreaker(t *testing.T) *CircuitBreaker {
t.Helper()
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, NewLogger("error"))
if err := cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
}); err == nil {
t.Fatal("expected the tripping call to return its own error")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("setup: expected circuit open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
}
time.Sleep(150 * time.Millisecond) // past Timeout: next call probes half-open
return cb
}
// TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen is a regression
// test for FIX-09. A terminal client error (HTTPError 4xx other than 429,
// e.g. invalid_grant from a replayed code) reflects a per-user problem, not
// a downstream-service failure, so it must not reopen a half-open breaker.
//
// Fail-on-old: recordFailure's HalfOpen case reopened unconditionally on
// any fn() error, so the circuit is Open (not HalfOpen) right after this
// probe.
func TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen(t *testing.T) {
cb := newFix09HalfOpenBreaker(t)
err := cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
})
if err == nil {
t.Fatal("expected the probe's own error to be returned to the caller")
}
if cb.GetState() != CircuitBreakerHalfOpen {
t.Fatalf("a terminal client 4xx must not reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
}
// Past resetTimeout relative to the half-open entry: a subsequent
// success must still be able to fully close the circuit.
time.Sleep(60 * time.Millisecond)
if err := cb.ExecuteWithContext(context.Background(), func() error { return nil }); err != nil {
t.Fatalf("expected the closing probe to succeed, got %v", err)
}
if cb.GetState() != CircuitBreakerClosed {
t.Fatalf("expected circuit to close after a half-open success following the ignored client error, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
// TestCircuitBreakerHalfOpenRateLimitReopens pins that HTTP 429 (the
// service itself signaling it is overloaded) still reopens a half-open
// circuit, unlike an ordinary terminal client 4xx.
func TestCircuitBreakerHalfOpenRateLimitReopens(t *testing.T) {
cb := newFix09HalfOpenBreaker(t)
err := cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 429, Message: "rate limited"}
})
if err == nil {
t.Fatal("expected the probe's own error to be returned to the caller")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("a 429 must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
// TestCircuitBreakerHalfOpenNetworkErrorReopens pins that a genuine network
// error still reopens a half-open circuit.
func TestCircuitBreakerHalfOpenNetworkErrorReopens(t *testing.T) {
cb := newFix09HalfOpenBreaker(t)
err := cb.ExecuteWithContext(context.Background(), func() error {
return &net.OpError{Op: "dial", Err: errors.New("connection refused")}
})
if err == nil {
t.Fatal("expected the probe's own error to be returned to the caller")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("a network error must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
// TestCircuitBreakerClosedTerminalClientErrorStaysClosed pins FIX-09's
// scope choice: the terminal-client-error exemption applies to the Closed
// state too, not only half-open, so a per-user invalid_grant never trips
// the breaker at all. A following genuine 5xx still opens it normally.
func TestCircuitBreakerClosedTerminalClientErrorStaysClosed(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 100 * time.Millisecond,
ResetTimeout: 50 * time.Millisecond,
}, NewLogger("error"))
err := cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
})
if err == nil {
t.Fatal("expected the probe's own error to be returned to the caller")
}
if cb.GetState() != CircuitBreakerClosed {
t.Fatalf("a terminal client 4xx must not trip a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
}
err = cb.ExecuteWithContext(context.Background(), func() error {
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
})
if err == nil {
t.Fatal("expected the tripping call to return its own error")
}
if cb.GetState() != CircuitBreakerOpen {
t.Fatalf("a genuine 5xx must still open a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
+104
View File
@@ -0,0 +1,104 @@
package traefikoidc
import (
"context"
"errors"
"net"
"syscall"
"testing"
)
// TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse is a
// regression test for FIX-14.
//
// helpers.go's token-endpoint exchange returns a *HTTPError whose Message
// carries up to 10 KiB of the raw response body once a non-200 status is
// received (helpers.go:192-195). Before the fix, ExecuteSingleUseWithContext
// matched singleUseRetryableErrors as a plain substring of err.Error(), so
// an IdP 500 whose body happens to mention "connection refused" (e.g. the
// IdP's own downstream error) was retried up to MaxAttempts times even
// though the request had definitely already reached the server. For a
// single-use operation (authorization-code exchange, refresh) re-sending
// consumed input causes invalid_grant or refresh-token-family revocation.
//
// Fail-on-old: fn is called 3 times (MaxAttempts) instead of once.
func TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 1,
MaxDelay: 1,
BackoffFactor: 1,
}, NewLogger("error"))
calls := 0
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
calls++
return &HTTPError{
StatusCode: 500,
Message: "token endpoint returned status 500: {\"error\":\"server_error\",\"detail\":\"upstream: connection refused\"}",
}
})
if err == nil {
t.Fatal("expected the HTTPError to be returned")
}
if calls != 1 {
t.Fatalf("fn called %d times, want 1: an HTTP response proves the request was already sent and must never be retried for a single-use operation", calls)
}
}
// TestExecuteSingleUseWithContextStillRetriesRealDialError guards against
// over-broadening the FIX-14 fix: a genuine dial/connect failure -- proof
// the request never reached the server -- must still be retried.
func TestExecuteSingleUseWithContextStillRetriesRealDialError(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 1,
MaxDelay: 1,
BackoffFactor: 1,
}, NewLogger("error"))
calls := 0
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
calls++
if calls == 1 {
return &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}
}
return nil
})
if err != nil {
t.Fatalf("expected the retried call to succeed, got: %v", err)
}
if calls != 2 {
t.Fatalf("fn called %d times, want 2: a real dial error must still be retried", calls)
}
}
// TestExecuteSingleUseWithContextNeverRetriesPostSendOpError pins FIX-14's
// minor finding: isNeverSentError must match a *net.OpError only for Op
// "dial" or "proxyconnect" (proof the request never reached the far end).
// A "read"/"write" OpError happens after the connection is already
// established, when the single-use request may already have reached the
// IdP, and must not be retried.
func TestExecuteSingleUseWithContextNeverRetriesPostSendOpError(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: 1,
MaxDelay: 1,
BackoffFactor: 1,
}, NewLogger("error"))
calls := 0
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
calls++
return &net.OpError{Op: "read", Net: "tcp", Err: syscall.EHOSTUNREACH}
})
if err == nil {
t.Fatal("expected the OpError to be returned")
}
if calls != 1 {
t.Fatalf("fn called %d times, want 1: a post-send (read/write) OpError must not be retried for a single-use operation", calls)
}
}
+75 -16
View File
@@ -89,18 +89,50 @@ func TestCircuitBreakerHalfOpenTransition(t *testing.T) {
time.Sleep(150 * time.Millisecond)
allowed := false
_ = cb.Execute(func() error {
allowed = true
return nil
})
if !allowed {
t.Error("Request should be allowed in half-open state")
// First request after the open timeout probes half-open. A single success
// does NOT immediately close: the circuit stays half-open until
// resetTimeout has elapsed (reset-cooldown hysteresis).
if err := cb.Execute(func() error { return nil }); err != nil {
t.Errorf("Request should be allowed in half-open state, got: %v", err)
}
if cb.GetState() != CircuitBreakerHalfOpen {
t.Errorf("Circuit should stay half-open during reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
}
// After resetTimeout elapses, a successful request fully closes it.
time.Sleep(60 * time.Millisecond)
if err := cb.Execute(func() error { return nil }); err != nil {
t.Errorf("Request should be allowed, got: %v", err)
}
if cb.GetState() != CircuitBreakerClosed {
t.Errorf("Circuit should be closed after successful half-open request, got %v", cb.GetState())
t.Errorf("Circuit should be closed after successful request following reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
// TestCircuitBreakerResetTimeoutEnforced is a regression test for the dead
// resetTimeout config field: it must be consulted before a half-open success
// can fully close the circuit. This failed on the old code where resetTimeout
// was set but never read, so a single half-open success closed the circuit
// immediately regardless of the configured reset cooldown.
func TestCircuitBreakerResetTimeoutEnforced(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 1,
Timeout: 50 * time.Millisecond,
ResetTimeout: time.Minute,
}, nil)
_ = cb.Execute(func() error { return errors.New("fail") })
if cb.GetState() != CircuitBreakerOpen {
t.Fatal("circuit should be open after failure")
}
time.Sleep(70 * time.Millisecond) // past the open timeout -> half-open
if err := cb.Execute(func() error { return nil }); err != nil {
t.Fatalf("request should be allowed in half-open state, got: %v", err)
}
if cb.GetState() != CircuitBreakerHalfOpen {
t.Fatalf("circuit must remain half-open until resetTimeout elapses, got %v", circuitBreakerStateToString(cb.GetState()))
}
}
@@ -243,7 +275,13 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
time.Sleep(150 * time.Millisecond)
if !cb.IsAvailable() {
t.Error("Circuit should be available in half-open state")
t.Error("Circuit should report availability once the open timeout has elapsed")
}
// R94: IsAvailable is a read-only probe and must not mutate circuit
// state. Merely checking availability must NOT transition the circuit
// to half-open (that is the job of real request admission).
if got := cb.GetState(); got != CircuitBreakerOpen {
t.Errorf("IsAvailable must not change circuit state; expected Open, got %v", got)
}
}
@@ -1097,6 +1135,7 @@ func TestBaseRecoveryMechanism_LogMethods(t *testing.T) {
func TestErrorRecoveryManagerCreation(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
t.Cleanup(erm.Close)
if erm == nil {
t.Fatal("Expected non-nil error recovery manager")
@@ -1113,6 +1152,7 @@ func TestErrorRecoveryManagerCreation(t *testing.T) {
func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
t.Cleanup(erm.Close)
cb1 := erm.GetCircuitBreaker("service1")
cb2 := erm.GetCircuitBreaker("service1")
@@ -1133,6 +1173,7 @@ func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
t.Cleanup(erm.Close)
success := false
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
@@ -1151,6 +1192,7 @@ func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
func TestErrorRecoveryManagerMetrics(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
t.Cleanup(erm.Close)
_ = erm.GetCircuitBreaker("service1")
_ = erm.GetCircuitBreaker("service2")
@@ -1170,6 +1212,7 @@ func TestErrorRecoveryManagerMetrics(t *testing.T) {
func TestErrorRecoveryManagerIntegration(t *testing.T) {
logger := GetSingletonNoOpLogger()
erm := NewErrorRecoveryManager(logger)
t.Cleanup(erm.Close)
t.Run("circuit breaker and retry integration", func(t *testing.T) {
cb := NewCircuitBreaker(CircuitBreakerConfig{
@@ -1271,9 +1314,12 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
defer gd.Close()
t.Run("register health check", func(t *testing.T) {
healthy := true
var healthy atomic.Bool
healthy.Store(true)
// The health check runs on a background goroutine (performHealthChecks at
// the configured interval); a plain captured bool would be a data race.
healthCheck := func() bool {
return healthy
return healthy.Load()
}
gd.RegisterHealthCheck("service1", healthCheck)
@@ -1281,7 +1327,7 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
gd.markServiceDegraded("service1")
assert.True(t, gd.isServiceDegraded("service1"))
healthy = true
healthy.Store(true)
time.Sleep(100 * time.Millisecond)
})
}
@@ -1507,6 +1553,13 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// The shared health-check task runs this gd's checks in the background
// (globalPerformHealthChecks). Hold its pass lock so that pass cannot
// run the check concurrently with the direct call below or change the
// degraded state between the assertions. The deferred Unlock runs
// before the deferred Close, so Close never waits on a blocked pass.
globalHealthCheckMu.Lock()
defer globalHealthCheckMu.Unlock()
healthCheckCalled := false
gd.RegisterHealthCheck("test-service", func() bool {
@@ -1529,6 +1582,9 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
// Keep the shared background pass out of this subtest (see above).
globalHealthCheckMu.Lock()
defer globalHealthCheckMu.Unlock()
gd.RegisterHealthCheck("failing-service", func() bool {
return false
@@ -1609,9 +1665,12 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
return "fallback data", nil
})
serviceHealthy := false
var serviceHealthy atomic.Bool
serviceHealthy.Store(false)
// Health checks run on a background goroutine (performHealthChecks at the
// configured interval); a plain captured bool is a data race.
gd.RegisterHealthCheck("critical-service", func() bool {
return serviceHealthy
return serviceHealthy.Load()
})
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
@@ -1634,7 +1693,7 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
assert.NoError(t, err3)
assert.Equal(t, "fallback data", result3)
serviceHealthy = true
serviceHealthy.Store(true)
time.Sleep(250 * time.Millisecond)
metrics := gd.GetMetrics()
+25 -15
View File
@@ -51,7 +51,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.8.0
version: v1.0.29
log:
level: INFO
@@ -101,6 +101,16 @@ http:
providerURL: "https://auth.example.com"
callbackURL: "/oauth2/callback"
# ----------------------------------------------------------------
# Optional: switch to RFC 7523 private_key_jwt client auth
# (Entra ID, Okta, Auth0, Keycloak). Replaces clientSecret with a
# signed JWT assertion. See README for details and PEM formats.
# ----------------------------------------------------------------
# clientAuthMethod: "private_key_jwt"
# clientAssertionKeyPath: "/etc/traefik/oidc/client-key.pem"
# clientAssertionKeyID: "prod-key-2026"
# clientAssertionAlg: "RS256" # or PS256/384/512, ES256/384/512
# Session Configuration
sessionEncryptionKey: "prod-encryption-key-64-chars-long-keep-it-secret-and-safe"
sessionMaxAge: 28800 # 8 hours
@@ -253,7 +263,7 @@ services:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
- "--experimental.plugins.traefikoidc.version=v0.8.0"
- "--experimental.plugins.traefikoidc.version=v1.0.29"
ports:
- "80:80"
- "443:443"
@@ -435,22 +445,22 @@ spec:
# you can set these. NOTE: Plugin configuration takes precedence!
# Docker Compose env file (.env)
---
# ----- (below is an example .env file, NOT YAML — shown for reference) -----
# OIDC Configuration
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_PROVIDER_URL=https://auth.example.com
# OIDC_CLIENT_ID=your-client-id
# OIDC_CLIENT_SECRET=your-client-secret
# OIDC_PROVIDER_URL=https://auth.example.com
# Redis Configuration (fallback)
REDIS_ENABLED=true
REDIS_ADDRESS=redis:6379
REDIS_PASSWORD=yourredispassword
REDIS_DB=0
REDIS_KEY_PREFIX=traefikoidc:
REDIS_CACHE_MODE=hybrid
REDIS_POOL_SIZE=20
REDIS_ENABLE_CIRCUIT_BREAKER=true
REDIS_ENABLE_HEALTH_CHECK=true
# REDIS_ENABLED=true
# REDIS_ADDRESS=redis:6379
# REDIS_PASSWORD=yourredispassword
# REDIS_DB=0
# REDIS_KEY_PREFIX=traefikoidc:
# REDIS_CACHE_MODE=hybrid
# REDIS_POOL_SIZE=20
# REDIS_ENABLE_CIRCUIT_BREAKER=true
# REDIS_ENABLE_HEALTH_CHECK=true
# ============================================================================
+1 -1
View File
@@ -6,7 +6,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.8.0
version: v1.0.29
# Dynamic configuration (dynamic.yml or labels)
http:
+226
View File
@@ -0,0 +1,226 @@
package traefikoidc
// Regression tests for FIX-02 (review-2026-09-10 finding at
// middleware.go:105): shouldBypassAuth bypassed OIDC auth for ANY request
// whose method is OPTIONS, not only genuine CORS preflights. A real
// preflight always carries both an Origin header and an
// Access-Control-Request-Method header; the R124 fix checked only the
// method, so an unauthenticated `OPTIONS <protected-path>` with neither
// header reached the backend unauthenticated (reproduced against
// http.FileServer: 200 with the protected file's body).
//
// TestServeHTTP_ForgedPreflightDoesNotLeakBody hardens the residual gap a
// follow-up review found in that fix: the Origin +
// Access-Control-Request-Method check only rules out an accidental bare
// OPTIONS request, not a deliberate attacker, since any non-browser client
// can set both headers itself. bypassReasonOptions still forwarded to next
// with no session check, so a forged preflight against http.FileServer
// still returned the protected file's body.
//
// A later maintainer decision (the AllowUnauthenticatedPreflight config
// option, settings.go) restored the released default: every OPTIONS request
// -- genuine preflight or not -- needs auth unless the operator opts in.
// Every test below that exercises the bypass path (positive or negative)
// sets allowUnauthenticatedPreflight: true so it keeps testing the
// Origin+Access-Control-Request-Method gate itself rather than the flag
// gate placed in front of it. The default-off case is pinned separately by
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth in
// fix02b_allow_unauthenticated_preflight_test.go.
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestShouldBypassAuth_BareOptionsDoesNotBypass pins the negative case: a
// bare OPTIONS request carrying neither Origin nor
// Access-Control-Request-Method is not a CORS preflight and must go
// through the normal auth pipeline like any other method.
func TestShouldBypassAuth_BareOptionsDoesNotBypass(t *testing.T) {
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
t.Fatalf("bare OPTIONS (no Origin, no Access-Control-Request-Method) must not bypass auth, got bypass reason %q", reason)
}
}
// TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass pins a partial
// case: Origin alone (e.g. a plain cross-origin GET, not a preflight) must
// not bypass either -- both headers are required.
func TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass(t *testing.T) {
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
req.Header.Set("Origin", "https://app.example.com")
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
t.Fatalf("OPTIONS with only Origin set must not bypass auth, got bypass reason %q", reason)
}
}
// TestShouldBypassAuth_GenuinePreflightBypasses pins the positive case: a
// real CORS preflight (Origin + Access-Control-Request-Method) must still
// bypass auth, so a browser's actual preflight keeps working.
func TestShouldBypassAuth_GenuinePreflightBypasses(t *testing.T) {
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
bypass, reason := tObj.shouldBypassAuth(req)
if !bypass {
t.Fatal("a genuine CORS preflight (Origin + Access-Control-Request-Method) must bypass auth")
}
if reason != bypassReasonOptions {
t.Fatalf("preflight bypass reason = %q, want %q", reason, bypassReasonOptions)
}
}
// TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend is the
// end-to-end reproduction from the finding: an unauthenticated OPTIONS
// request against a protected path, with no session cookie and no CORS
// headers, must not reach the next handler. Mirrors the reviewer's
// http.FileServer repro that returned the protected file's body.
func TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
})
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
allowUnauthenticatedPreflight: true,
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if nextCalled {
t.Fatalf("unauthenticated bare OPTIONS must not reach the backend, but next handler ran (status=%d body=%q)", rw.Code, rw.Body.String())
}
if rw.Code == http.StatusOK && strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
t.Fatalf("protected content leaked through an unauthenticated OPTIONS request: status=%d body=%q", rw.Code, rw.Body.String())
}
}
// TestServeHTTP_GenuinePreflightReachesBackend confirms the fix does not
// regress the R124 behavior it builds on: a real CORS preflight for the
// same protected path must still reach the backend unconditionally.
func TestServeHTTP_GenuinePreflightReachesBackend(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
w.WriteHeader(http.StatusNoContent)
})
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
allowUnauthenticatedPreflight: true,
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if !nextCalled {
t.Fatalf("a genuine CORS preflight must still reach the backend, got status=%d body=%q", rw.Code, rw.Body.String())
}
}
// TestServeHTTP_ForgedPreflightDoesNotLeakBody reproduces the finding's own
// exploit: a non-browser client sends an OPTIONS request carrying BOTH
// Origin and Access-Control-Request-Method (indistinguishable from a real
// preflight to shouldBypassAuth) against a protected path, with no session
// cookie. It must not receive the backend's response body, even though the
// request still forwards unauthenticated so a real browser preflight keeps
// getting its CORS headers answered.
func TestServeHTTP_ForgedPreflightDoesNotLeakBody(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
next := http.FileServer(http.Dir(dir))
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
allowUnauthenticatedPreflight: true,
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
req.Header.Set("Origin", "https://attacker.example")
req.Header.Set("Access-Control-Request-Method", "GET")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
t.Fatalf("forged preflight leaked protected content: status=%d body=%q", rw.Code, rw.Body.String())
}
}
@@ -0,0 +1,167 @@
package traefikoidc
// Regression tests for the FIX-02 maintainer decision: the released
// behavior required authentication for every OPTIONS request by default.
// FIX-02 (fix02_options_bypass_test.go) narrowed an accidental
// unconditional OPTIONS bypass down to genuine CORS preflights (Origin +
// Access-Control-Request-Method), but that still left every preflight
// unauthenticated by default -- a behavior change from the release. This
// file pins the maintainer decision to restore released behavior: the
// preflight bypass now only applies when the operator opts in via
// AllowUnauthenticatedPreflight (default false). With the flag off, OPTIONS
// goes through the normal auth pipeline like any other method.
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth pins the default
// (AllowUnauthenticatedPreflight=false) behavior: even a genuine CORS
// preflight (Origin + Access-Control-Request-Method) must NOT reach the
// backend without a session -- it goes through the normal auth pipeline
// (redirect or 401) exactly like any other unauthenticated request. This is
// the released behavior FIX-02 must restore by default.
func TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
w.WriteHeader(http.StatusNoContent)
})
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
// AllowUnauthenticatedPreflight left at its zero value (false):
// default config must not bypass auth for OPTIONS.
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if nextCalled {
t.Fatalf("default config (allowUnauthenticatedPreflight=false) must send a genuine OPTIONS preflight through the normal auth pipeline, but the backend was called unconditionally: status=%d body=%q", rw.Code, rw.Body.String())
}
if rw.Code == http.StatusNoContent {
t.Fatalf("default config must not answer the preflight bypass response (204 from next); got status=%d", rw.Code)
}
}
// TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded
// pins the opt-in behavior: with AllowUnauthenticatedPreflight=true, a
// genuine CORS preflight still bypasses auth (so real browser preflights
// keep working) and the optionsPreflightWriter still discards the body
// (FIX-02's forged-preflight hardening keeps applying when the flag is on).
func TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
next := http.FileServer(http.Dir(dir))
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
allowUnauthenticatedPreflight: true,
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
t.Fatalf("allowUnauthenticatedPreflight=true must still discard the preflight response body: status=%d body=%q", rw.Code, rw.Body.String())
}
}
// TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth
// pins that the flag only widens the genuine-preflight gate -- a bare
// OPTIONS request (no Origin, no Access-Control-Request-Method) still needs
// auth even with AllowUnauthenticatedPreflight=true.
func TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
})
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: next,
sessionManager: sm,
redirURLPath: "/callback",
authURL: "https://idp.example.com/authorize",
issuerURL: "https://idp.example.com",
clientID: "test-client-id",
scopes: []string{"openid"},
initComplete: initComplete,
allowUnauthenticatedPreflight: true,
}
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if nextCalled {
t.Fatalf("allowUnauthenticatedPreflight=true must not bypass auth for a bare OPTIONS request (no Origin/Access-Control-Request-Method), but the backend was called: status=%d body=%q", rw.Code, rw.Body.String())
}
}
+183
View File
@@ -0,0 +1,183 @@
package traefikoidc
// Regression tests for FIX-05 (review-2026-09-10 finding at session.go:2409,
// session.go:3099): SetRefreshToken/SetIDToken re-Get the gorilla-registry-
// cached chunk *sessions.Session objects for the NEW token, fill them, and
// only THEN call the expire helper for the OLD chunks. Because gorilla's
// Registry.Get returns the SAME cached object per cookie name for a given
// request, the "expire old" pass re-Gets those identical objects and wipes
// session.Values on every non-new one -- erasing the NEW chunk data this
// call just wrote. A session loaded from existing (legacy) chunk cookies
// whose replacement token also needs chunking loses the new token entirely.
//
// Adapted from the throwaway repro at
// .claude/review-2026-09-10/repro/zz_g1_chunkwipe_test.go and
// zz_g1_chunkwipe_id_test.go (TestG1ChunkWipeRefresh / TestG1ChunkWipeAccessControl
// / TestG1ChunkWipeIDToken), renamed to describe the behavior they pin.
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// randChunkTestToken returns a random URL-safe token of n raw bytes, large
// enough (with maxCookieSize=1400) to force the chunked storage path.
func randChunkTestToken(t *testing.T, n int) string {
t.Helper()
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
// randChunkTestJWT returns a structurally valid (3-dot-separated) JWT whose
// payload is padded past maxCookieSize, so SetIDToken takes the chunked path.
func randChunkTestJWT(t *testing.T) string {
t.Helper()
enc := base64.RawURLEncoding.EncodeToString
header := enc([]byte(`{"alg":"RS256","typ":"JWT","kid":"k"}`))
now := time.Now().Unix()
payload := enc([]byte(fmt.Sprintf(
`{"iss":"https://idp","sub":"u","aud":"c","iat":%d,"exp":%d,"pad":"%s"}`,
now, now+3600, randChunkTestToken(t, 3000),
)))
return header + "." + payload + "." + randChunkTestToken(t, 256)
}
// mergeSetCookiesIntoJar copies a response's Set-Cookie headers into jar,
// dropping any cookie the response expired (MaxAge < 0), so the next request
// carries forward only what a real browser would still hold.
func mergeSetCookiesIntoJar(jar map[string]*http.Cookie, rr *httptest.ResponseRecorder) {
for _, c := range rr.Result().Cookies() {
if c.MaxAge < 0 {
delete(jar, c.Name)
continue
}
jar[c.Name] = c
}
}
// newRequestWithJarCookies builds a request carrying every cookie currently
// held in jar, simulating a browser replaying its cookie jar on the next
// request to the same session.
func newRequestWithJarCookies(jar map[string]*http.Cookie) *http.Request {
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/", nil)
for _, c := range jar {
r.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
}
return r
}
// runChunkedTokenReplaceRegression drives three requests against one
// SessionManager: request 1 stores a large (chunked) token A and saves it,
// request 2 loads the cookies A produced and replaces the token with a
// different large (chunked) token B, request 3 loads the cookies B produced
// and must read back B, not A and not empty.
func runChunkedTokenReplaceRegression(
t *testing.T,
set func(*SessionData, string),
get func(*SessionData) string,
tokA, tokB string,
) {
t.Helper()
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
jar := map[string]*http.Cookie{}
r1 := newRequestWithJarCookies(jar)
s1, err := sm.GetSession(r1)
if err != nil {
t.Fatalf("GetSession (request 1): %v", err)
}
set(s1, tokA)
rr1 := httptest.NewRecorder()
if err := s1.Save(r1, rr1); err != nil {
t.Fatalf("Save (request 1): %v", err)
}
s1.returnToPoolSafely()
mergeSetCookiesIntoJar(jar, rr1)
r2 := newRequestWithJarCookies(jar)
s2, err := sm.GetSession(r2)
if err != nil {
t.Fatalf("GetSession (request 2): %v", err)
}
if got := get(s2); got != tokA {
t.Fatalf("request 2 must load token A written by request 1 before replacing it: got len=%d, want tokA", len(got))
}
set(s2, tokB)
if got := get(s2); got != tokB {
t.Fatalf("in-memory read immediately after replacing the chunked token must return the NEW token: got len=%d (isA=%v), want tokB", len(got), got == tokA)
}
rr2 := httptest.NewRecorder()
if err := s2.Save(r2, rr2); err != nil {
t.Fatalf("Save (request 2): %v", err)
}
s2.returnToPoolSafely()
mergeSetCookiesIntoJar(jar, rr2)
r3 := newRequestWithJarCookies(jar)
s3, err := sm.GetSession(r3)
if err != nil {
t.Fatalf("GetSession (request 3): %v", err)
}
defer s3.returnToPoolSafely()
got := get(s3)
if got != tokB {
t.Fatalf("after replacing a chunked token loaded from cookies: got len=%d (isTokA=%v), want tokB (the user would be silently signed out)", len(got), got == tokA)
}
}
// TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies pins
// FIX-05 (session.go:2409): replacing a chunked refresh token that was
// loaded from existing chunk cookies must not wipe the newly written chunks.
func TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
tokA := randChunkTestToken(t, 4500)
tokB := randChunkTestToken(t, 4500)
runChunkedTokenReplaceRegression(
t,
func(s *SessionData, v string) { s.SetRefreshToken(v) },
func(s *SessionData) string { return s.GetRefreshToken() },
tokA, tokB,
)
}
// TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies pins
// FIX-05 (session.go:3099): the same bug in SetIDToken, using structurally
// valid JWTs since SetIDToken rejects non-JWT-shaped input.
func TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
tokA := randChunkTestJWT(t)
tokB := randChunkTestJWT(t)
runChunkedTokenReplaceRegression(
t,
func(s *SessionData, v string) { s.SetIDToken(v) },
func(s *SessionData) string { return s.GetIDToken() },
tokA, tokB,
)
}
// TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies is the
// control: SetAccessToken already uses the expire-then-fill order (it was
// unaffected by the R154 regression) and must stay green both before and
// after FIX-05, proving the fix does not depend on a shared helper bug.
func TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
tokA := randChunkTestToken(t, 4500)
tokB := randChunkTestToken(t, 4500)
runChunkedTokenReplaceRegression(
t,
func(s *SessionData, v string) { s.SetAccessToken(v) },
func(s *SessionData) string { return s.GetAccessToken() },
tokA, tokB,
)
}
+163
View File
@@ -0,0 +1,163 @@
package traefikoidc
// Regression tests for FIX-06 (review-2026-09-10 finding at
// middleware.go:309, high severity): ServeHTTP wraps the response writer
// in *trackingWriter for panic-commit tracking (rw = tw) and then hands
// that SAME wrapped writer to every t.next.ServeHTTP call, including the
// SSE/WebSocket bypass and the authenticated forwarding path. Traefik runs
// this plugin interpreted under yaegi v0.16.1, and yaegi's stdlib
// interop layer composes a boundary wrapper only for ResponseWriter+
// Hijacker crossing into compiled code -- not for Flusher or Unwrap. So
// Traefik's compiled ReverseProxy calling
// http.NewResponseController(rw).Flush() on the interpreted trackingWriter
// gets "feature not supported" in production, even though trackingWriter
// declares Flush/Unwrap and native tests (which never cross an
// interpreter boundary) pass. See checkSSEFlushReachesNext in cmd/yaegicheck/main.go (run by
// `make yaegi-validate`) for the interop reproduction.
//
// The fix hands t.next.ServeHTTP the ORIGINAL writer ServeHTTP received
// (trackingWriter.Unwrap()), never the wrapper, on every forwarding path.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// nextWriterSpy is a minimal next handler that records whether the
// http.ResponseWriter it was handed is (or, transitively, still wraps) a
// *trackingWriter, so a test can assert the downstream writer is the real
// one Traefik would give a compiled ReverseProxy -- not the interpreted
// wrapper.
func nextWriterSpy(called *bool, sawTrackingWriter *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*called = true
if _, ok := w.(*trackingWriter); ok {
*sawTrackingWriter = true
}
w.WriteHeader(http.StatusOK)
})
}
// authenticatedRequestWithCookie builds a request carrying a saved,
// authenticated session cookie for user, so the SSE/WebSocket bypass's
// cookie-only auth check (applyBypassUserHeaders) admits it.
func authenticatedRequestWithCookie(t *testing.T, sm *SessionManager, user string) *http.Request {
t.Helper()
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
session, err := sm.GetSession(setupReq)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetAuthenticated(true)
session.SetUserIdentifier(user)
rr := httptest.NewRecorder()
if err := session.Save(setupReq, rr); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
for _, c := range rr.Result().Cookies() {
req.AddCookie(c)
}
return req
}
// TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter pins FIX-06 on the
// SSE bypass path.
func TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
var called, sawTrackingWriter bool
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: nextWriterSpy(&called, &sawTrackingWriter),
sessionManager: sm,
initComplete: initComplete,
}
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
req.Header.Set("Accept", "text/event-stream")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if !called {
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated SSE bypass must forward", rw.Code, rw.Body.String())
}
if sawTrackingWriter {
t.Fatal("next handler received the interpreted trackingWriter on the SSE bypass path, not the original ResponseWriter")
}
}
// TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter pins FIX-06 on
// the WebSocket upgrade bypass path.
func TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
var called, sawTrackingWriter bool
initComplete := make(chan struct{})
close(initComplete)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
name: "test",
next: nextWriterSpy(&called, &sawTrackingWriter),
sessionManager: sm,
initComplete: initComplete,
}
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
rw := httptest.NewRecorder()
tObj.ServeHTTP(rw, req)
if !called {
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated WebSocket bypass must forward", rw.Code, rw.Body.String())
}
if sawTrackingWriter {
t.Fatal("next handler received the interpreted trackingWriter on the WebSocket bypass path, not the original ResponseWriter")
}
}
// TestForwardAuthorized_NextReceivesUnwrappedWriter pins FIX-06 on the
// authenticated forwarding path (forwardAuthorized is ServeHTTP's and
// bearer_auth.go's shared tail call to t.next.ServeHTTP). ServeHTTP always
// calls forwardAuthorized with rw already wrapped in *trackingWriter (see
// middleware.go:309); reproduce that shape directly here.
func TestForwardAuthorized_NextReceivesUnwrappedWriter(t *testing.T) {
var called, sawTrackingWriter bool
oidc := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
next: nextWriterSpy(&called, &sawTrackingWriter),
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
tw := &trackingWriter{ResponseWriter: rec}
p := &principal{Identifier: "[email protected]"}
oidc.forwardAuthorized(tw, req, p)
if !called {
t.Fatal("next handler was not invoked")
}
if sawTrackingWriter {
t.Fatal("next handler received the interpreted trackingWriter on the authenticated path, not the original ResponseWriter")
}
}
+124
View File
@@ -0,0 +1,124 @@
package traefikoidc
// Regression test for FIX-10 (review-2026-09-10 finding at session.go:1784,
// middleware.go:566/789/883): a request-scoped deferred pool-return has no
// way to tell a stale holder from the current owner (ABA on the pool-return
// CAS). ServeHTTP acquires a session and defers its return; deep in the call
// chain, session.Clear() returns the SAME object to the pool and the
// handler re-acquires it under a fresh GetSession/newSession call. If a
// concurrent request pops that pooled object before the ORIGINAL deferred
// return fires, the stale return's CompareAndSwap(true, false) still
// succeeds against the new owner, incorrectly releasing (and Reset()ting) a
// session another request is actively using.
//
// Adapted from the throwaway repro at
// .claude/review-2026-09-10/repro/zz_g1_aba_test.go (TestG1StaleDeferABA),
// renamed to describe the behavior it pins and rewritten against the
// generation-gated return the fix introduces (ownerGeneration /
// returnToPoolIfOwner) instead of the raw returnToPoolSafely the bug lived
// in -- a caller must adopt the new API for the fix to apply, so the test
// exercises the same call the fixed middleware.go ServeHTTP now makes.
//
// The ABA reuse step (does the pool hand the SAME object back to a second
// GetSession after Clear) is itself an unspecified sync.Pool implementation
// detail, not part of the contract this fix establishes. Rather than depend
// on that timing (flaky: it did not reproduce on every run), this test
// drives the two field mutations GetSession/newSession make on every real
// handout -- inUse.Store(true) then generation.Add(1) -- directly on the
// SAME object Clear() just returned, deterministically recreating "a new
// owner acquired this exact object" regardless of pool internals.
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner reproduces the
// ABA sequence: request A acquires session O and captures its ownership
// generation (mirrors ServeHTTP's defer registration at middleware.go:566).
// O is cleared and returned to the pool (mirrors session.Clear at
// middleware.go:774/868); a new owner then claims the SAME object the way
// GetSession/newSession do (mirrors the re-acquire at
// middleware.go:781/875) and starts using it. A's stale deferred return
// then fires (mirrors ServeHTTP's defer running at function exit) and must
// be a no-op against the new owner's live ownership.
func TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
r1 := httptest.NewRequest("GET", "https://app.example.com/", nil)
o, err := sm.GetSession(r1)
if err != nil {
t.Fatalf("GetSession (request A): %v", err)
}
// ServeHTTP captures the generation right after acquiring, then defers
// a generation-gated return instead of an unconditional one.
staleGen := o.ownerGeneration()
if err := o.Clear(r1, httptest.NewRecorder()); err != nil {
t.Fatalf("Clear: %v", err)
}
if o.inUse.Load() {
t.Fatalf("Clear must return the object to the pool (inUse still true)")
}
// A new owner claims the exact same object: this is the sequence
// GetSession/newSession run on every real handout -- generation,
// sessionOwner, then inUse (session.go:1076-1078, 1108-1110) --
// reproduced directly so the test does not depend on sync.Pool's
// unspecified reuse timing. sessionOwner must be set to the new
// generation, not left at 0: a stale returnToPoolIfOwner call gates on
// sessionOwner, so leaving it 0 would make the object look already-free
// rather than owned by someone else, which is what this test needs to
// tell apart.
o.sessionOwner.Store(o.generation.Add(1))
o.inUse.Store(true)
o.SetUserIdentifier("bob")
if staleGen == o.ownerGeneration() {
t.Fatalf("test setup bug: new owner's generation (%d) must differ from the captured stale generation (%d)", o.ownerGeneration(), staleGen)
}
// A's stale deferred return fires at ServeHTTP exit, after the new
// owner has already claimed and started using the same object.
o.returnToPoolIfOwner(staleGen)
if !o.inUse.Load() {
t.Fatalf("stale deferred return (captured generation %d) released a session owned by another holder (current generation %d)", staleGen, o.ownerGeneration())
}
if o.GetUserIdentifier() != "bob" {
t.Fatalf("stale deferred return corrupted the new owner's session data: got user %q, want \"bob\"", o.GetUserIdentifier())
}
o.returnToPoolSafely()
}
// TestSessionPoolReturn_CurrentGenerationStillReturns is the control: a
// non-stale generation-gated return (the common case -- no Clear+reacquire
// happened) must still return the object to the pool, so the fix does not
// leak sessions on the ordinary path.
func TestSessionPoolReturn_CurrentGenerationStillReturns(t *testing.T) {
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
r := httptest.NewRequest("GET", "https://app.example.com/", nil)
s, err := sm.GetSession(r)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
gen := s.ownerGeneration()
s.returnToPoolIfOwner(gen)
if s.inUse.Load() {
t.Fatalf("returnToPoolIfOwner with the current generation must return the object to the pool (inUse still true)")
}
}
+255
View File
@@ -0,0 +1,255 @@
package traefikoidc
import (
"context"
"testing"
"time"
)
// FIX-18: main.go wires up TWO GracefulDegradation instances per plugin
// instance — one directly via NewErrorRecoveryManager (t.errorRecoveryManager)
// and one nested inside NewTokenResilienceManager's own ErrorRecoveryManager
// (t.tokenResilienceManager). Production Close() only ever closed the first
// one (t.errorRecoveryManager.gracefulDegradation.Close()), so the second gd
// leaked forever in the package-level gdInstances registry and the shared
// "graceful-degradation-health-check" task's last-instance stop gate
// (len(gdInstances.set)==0) never fired.
//
// gdInstancesLen (below) reads the size of the package-level gdInstances set.
// The test asserts on the DELTA around creating and closing both managers, so
// it stays correct regardless of what other tests in this package leave
// registered.
func TestFix18_ClosingBothManagersEmptiesGDInstances(t *testing.T) {
logger := GetSingletonNoOpLogger()
before := gdInstancesLen()
erm := NewErrorRecoveryManager(logger)
trm := NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
afterNew := gdInstancesLen()
if afterNew != before+2 {
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances (one direct, one nested in TokenResilienceManager); got delta %d", afterNew-before)
}
// Let each instance's async health-check goroutine (started by
// NewGracefulDegradation) settle — either it assigns the shared task, or
// it observes an already-closed instance and bails — before closing.
// This makes the Close() calls below exercise the real "task already
// assigned, stop it by name" path deterministically on every run instead
// of sometimes racing the goroutine (FIX-18 review, item 4).
waitForHealthRoutineSettled(t, erm.gracefulDegradation)
waitForHealthRoutineSettled(t, trm.errorRecoveryManager.gracefulDegradation)
// Close every gd this wiring created. Before FIX-18, only erm's gd had a
// Close path reachable from TraefikOidc.Close (via
// t.errorRecoveryManager.gracefulDegradation.Close()); trm's own gd had
// no Close method to call at all.
erm.Close()
trm.Close()
afterClose := gdInstancesLen()
if afterClose != before {
t.Fatalf("gdInstances leaked %d entries after closing every gd this wiring created (afterClose delta=%d, want 0) — TokenResilienceManager's own GracefulDegradation was never closed", afterClose-before, afterClose-before)
}
// Only assert the shared health-check task itself stopped when this test
// is known to be the sole registrant (before==0): the task is a
// process-global singleton shared by every GracefulDegradation created
// anywhere in the suite, so this assertion would be flaky under a
// nonzero baseline left by another test.
if before == 0 && GetResourceManager().IsTaskRunning("graceful-degradation-health-check") {
t.Fatal("graceful-degradation-health-check task must stop once gdInstances is empty (last-instance stop gate)")
}
}
// TestFix18_TraefikOidcCloseEmptiesGDInstances pins the FIX-18 major-finding
// gap left by the test above: calling erm.Close()/trm.Close() directly stays
// green even if the real (*TraefikOidc).Close() is reverted to skip one of
// them (utilities.go's t.errorRecoveryManager.Close() /
// t.tokenResilienceManager.Close() calls). This test wires both managers on
// a TraefikOidc exactly as NewWithContext does (main.go's
// t.errorRecoveryManager = NewErrorRecoveryManager(...) followed by
// t.tokenResilienceManager = NewTokenResilienceManager(...)) and drives the
// real Close(), so it only passes when Close() actually reaches both.
func TestFix18_TraefikOidcCloseEmptiesGDInstances(t *testing.T) {
logger := GetSingletonNoOpLogger()
before := gdInstancesLen()
oidc := &TraefikOidc{logger: logger}
oidc.errorRecoveryManager = NewErrorRecoveryManager(logger)
oidc.tokenResilienceManager = NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
afterNew := gdInstancesLen()
if afterNew != before+2 {
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances; got delta %d", afterNew-before)
}
waitForHealthRoutineSettled(t, oidc.errorRecoveryManager.gracefulDegradation)
waitForHealthRoutineSettled(t, oidc.tokenResilienceManager.errorRecoveryManager.gracefulDegradation)
// Keep a sentinel instance alive across Close() so this test's Close()
// call is never the last live instance: this test only asserts on
// gdInstances, but Close()'s "am I last" decision (stopIfLastInstance)
// also stops process-global singleton tasks — singleton-token-cleanup
// and, via GetGlobalTaskRegistry().StopAllTasks(), every other
// registered singleton (e.g. memory-monitor) — that this test does not
// own and must not stop out from under the rest of the suite.
registerLiveInstance()
t.Cleanup(func() { unregisterLiveInstance() })
// Balance Close()'s own unregisterLiveInstance() call: New() always
// registers before it returns an instance for Close() to eventually
// unregister, and this test's synthetic instance must too, or it skews
// the process-global liveInstanceCount permanently negative for every
// other test that runs afterward in the same binary (FIX-35 territory,
// but liveInstanceCount is shared package state regardless of which
// finding a given test targets).
registerLiveInstance()
if err := oidc.Close(); err != nil {
t.Fatalf("Close returned an error: %v", err)
}
after := gdInstancesLen()
if after != before {
t.Fatalf("gdInstances leaked %d entries after TraefikOidc.Close (delta=%d, want 0) — Close must close both t.errorRecoveryManager and t.tokenResilienceManager", after-before, after-before)
}
}
// TestFix18_CloseImmediatelyAfterNewDoesNotReRegister pins the exact race the
// FIX-18 review's scratch probe found: NewGracefulDegradation followed
// immediately by Close(), before the async health-check goroutine has had a
// chance to run. The old startHealthCheckRoutine re-added gd to gdInstances
// from inside that goroutine (after Close had already deleted it), permanently
// re-registering an already-closed instance and restarting the shared
// health-check task for it with nothing left able to ever stop it again.
func TestFix18_CloseImmediatelyAfterNewDoesNotReRegister(t *testing.T) {
t.Cleanup(snapshotAndClearGdInstancesForTest())
cfg := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
gd.Close()
// Give the async health-check goroutine every opportunity to run and,
// under the bug, re-register the closed instance.
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
gdInstances.RLock()
n := len(gdInstances.set)
gdInstances.RUnlock()
if n != 0 {
t.Fatalf("closed instance was re-registered: gdInstances has %d entries, want 0", n)
}
time.Sleep(time.Millisecond)
}
}
// TestFix18_NewWithContextClosesManagersOnEarlyError pins the FIX-18 minor
// finding: NewWithContext creates t.errorRecoveryManager and
// t.tokenResilienceManager, then returns an error from a later validation
// step (here, an invalid private_key_jwt key) without ever closing either
// one — leaking their GracefulDegradation instances in gdInstances forever.
func TestFix18_NewWithContextClosesManagersOnEarlyError(t *testing.T) {
before := gdInstancesLen()
cfg := &Config{
ProviderURL: "https://accounts.google.com",
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
RateLimit: 100,
ClientAuthMethod: "private_key_jwt",
ClientAssertionKeyID: "test-kid",
ClientAssertionPrivateKey: "-----BEGIN PRIVATE KEY-----\nnot-valid-base64-content!!!\n-----END PRIVATE KEY-----\n",
}
plugin, err := NewWithContext(context.Background(), cfg, nil, "fix18-bad-key")
if err == nil {
if plugin != nil {
plugin.Close()
}
t.Fatal("expected NewWithContext to fail on an invalid private_key_jwt key")
}
if plugin != nil {
t.Fatal("NewWithContext must return a nil instance on error")
}
after := gdInstancesLen()
if after != before {
t.Fatalf("NewWithContext's error path leaked %d GracefulDegradation instance(s) in gdInstances (before=%d after=%d) — the errorRecoveryManager and tokenResilienceManager created just before the failure were never closed", after-before, before, after)
}
}
// healthRoutineSettled reports whether startHealthCheckRoutine has finished
// making its one-time decision for this instance: either it assigned a
// health-check task, or it observed stopChan already closed and returned
// without assigning one (in which case it never will, since it runs at most
// once). Tests use this to wait for the async goroutine NewGracefulDegradation
// starts to settle before asserting on shared state, instead of depending on
// goroutine-scheduling timing.
//
// Test-only: moved out of error_recovery.go (production code) because its
// only caller is waitForHealthRoutineSettled below, following the pattern
// FIX-39 established for resetGlobalTransportPoolForTest
// (internal/pool/export_test.go) — a test-only helper has no business being
// compiled into the production binary.
func (gd *GracefulDegradation) healthRoutineSettled() bool {
gd.mutex.RLock()
defer gd.mutex.RUnlock()
if gd.healthCheckTask != nil {
return true
}
select {
case <-gd.stopChan:
return true
default:
return false
}
}
// waitForHealthRoutineSettled polls until gd's async startHealthCheckRoutine
// goroutine has settled (see GracefulDegradation.healthRoutineSettled).
func waitForHealthRoutineSettled(t *testing.T, gd *GracefulDegradation) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if gd.healthRoutineSettled() {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("health-check goroutine did not settle (task assigned or instance already closed) in time")
}
// gdInstancesLen reads the current size of the package-level gdInstances set
// under its own lock.
func gdInstancesLen() int {
gdInstances.RLock()
defer gdInstances.RUnlock()
return len(gdInstances.set)
}
// snapshotAndClearGdInstancesForTest captures the current contents of the
// package-level gdInstances registry and replaces it with a fresh empty set,
// mirroring resetGdInstancesForTest (review_r34_regression_test.go). Unlike
// that helper, it returns a restore func the caller registers with
// t.Cleanup: a bare reset with no restore permanently discards whatever
// GracefulDegradation instances other tests in the same binary had
// registered before this test ran, instead of only clearing for the
// duration of this test.
func snapshotAndClearGdInstancesForTest() func() {
gdInstances.Lock()
saved := gdInstances.set
gdInstances.set = make(map[*GracefulDegradation]struct{})
gdInstances.Unlock()
return func() {
gdInstances.Lock()
gdInstances.set = saved
gdInstances.Unlock()
}
}
+114
View File
@@ -0,0 +1,114 @@
package traefikoidc
import (
"sync/atomic"
"testing"
"time"
)
// FIX-19: RegisterBackgroundTask replaced a registered-but-not-yet-started
// task (started==0, stopped==0) unconditionally. CreateSingletonTask runs
// Register -> IsTaskRunning -> StartBackgroundTask without holding tasksMu
// across the three calls (autocleanup.go:612-632), so a second concurrent
// caller can land in RegisterBackgroundTask in the window before the first
// caller's own Start() call runs.
//
// This test reproduces that exact interleaving deterministically, without
// real goroutines, by issuing the calls in the documented race order:
//
// 1. Caller A registers the task (T1).
// 2. Caller A reads its handle from the registry (mirrors
// CreateSingletonTask's internal StartBackgroundTask lookup) but has not
// called Start() on it yet.
// 3. Caller B registers the SAME name before A calls Start() — at head this
// replaces the map entry with a fresh task (T2).
// 4. Caller A calls Start() on the handle it read in step 2.
// 5. Caller B starts whatever is now registered under the name.
//
// At head this starts two independent tickers (T1 wrapping count1, T2
// wrapping count2). Only the object actually stored in the registry (T2) is
// reachable from StopAllTasks, so T1 becomes an orphan that keeps running
// forever past Close/StopAllTasks. After the fix, step 3's register call
// keeps the existing (still-pending) T1 instead of replacing it, so there is
// only ever one task, and count2 (which would wrap a second, orphaned task)
// never starts at all.
func TestFix19_RegisterBackgroundTaskKeepsPendingTaskNoOrphan(t *testing.T) {
rm := GetResourceManager()
name := "fix19-" + t.Name()
var count1, count2 int32
// Step 1: caller A registers T1.
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
atomic.AddInt32(&count1, 1)
}); err != nil {
t.Fatalf("first RegisterBackgroundTask: %v", err)
}
// Step 2: caller A reads its handle, but has not started it yet.
rm.tasksMu.RLock()
aTask := rm.tasks[name]
rm.tasksMu.RUnlock()
if aTask == nil {
t.Fatal("task not present in registry after first RegisterBackgroundTask")
}
// Step 3: caller B registers the same name before A calls Start().
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
atomic.AddInt32(&count2, 1)
}); err != nil {
t.Fatalf("second RegisterBackgroundTask: %v", err)
}
// Step 4: caller A starts the handle it read in step 2.
aTask.Start()
// Step 5: caller B starts whatever is now registered under the name.
if err := rm.StartBackgroundTask(name); err != nil {
t.Fatalf("StartBackgroundTask: %v", err)
}
// Let both tickers (or, after the fix, the single ticker) tick a few
// times before stopping.
time.Sleep(60 * time.Millisecond)
// Stop only the named task this test created, not rm.StopAllTasks(): the
// ResourceManager is a process-global singleton, and StopAllTasks stops
// every task registered on it, including unrelated singletons (e.g.
// memory-monitor, singleton-token-cleanup) that other tests in the same
// binary may have started and still expect running.
if err := rm.StopBackgroundTask(name); err != nil {
t.Fatalf("StopBackgroundTask(%q): %v", name, err)
}
// Snapshot count1 right after the stop, then again after another window
// long enough for several more ticks. A well-stopped task's counter must
// not move between the two snapshots.
afterStop1 := atomic.LoadInt32(&count1)
time.Sleep(60 * time.Millisecond)
final1 := atomic.LoadInt32(&count1)
if final1 != afterStop1 {
t.Fatalf("task wrapping count1 kept running after StopBackgroundTask (orphan escaped registry): %d -> %d", afterStop1, final1)
}
if afterStop1 == 0 {
t.Fatal("precondition: task wrapping count1 never ran at all")
}
// count2 must never have run: after the fix, the second
// RegisterBackgroundTask call keeps the existing pending task instead of
// constructing and starting a second one.
if got := atomic.LoadInt32(&count2); got != 0 {
t.Fatalf("second RegisterBackgroundTask call must not create and start a second task while the first is still pending; count2=%d", got)
}
// Cleanup: make sure the task this test created cannot keep running past
// the test even if an assertion above already failed it.
aTask.Stop()
rm.tasksMu.RLock()
bTask := rm.tasks[name]
rm.tasksMu.RUnlock()
if bTask != nil {
bTask.Stop()
}
}
+294
View File
@@ -0,0 +1,294 @@
package traefikoidc
import (
"bytes"
"io"
"log"
"strings"
"testing"
)
// fix26CapturingLogger builds a *Logger whose Errorf output lands in buf
// while Info/Debug are discarded, so a test can assert on exactly what
// reaches the error stream without touching the process-wide os.Stderr.
func fix26CapturingLogger(buf *bytes.Buffer) *Logger {
return &Logger{
logError: log.New(buf, "", 0),
logInfo: log.New(io.Discard, "", 0),
logDebug: log.New(io.Discard, "", 0),
}
}
// TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly
// pins FIX-26. validateDiscoveredEndpoint already drops (R146) a discovered
// http endpoint when providerURL is https, and the pre-existing "Ignoring
// discovered ..." ERROR line already names the endpoint, the URL, and the
// scheme-downgrade reason (it wraps ErrDiscoveredEndpointSchemeDowngrade).
// What was missing was a way to tell that rejection apart, at a glance, from
// every other validation rejection (SSRF block, malformed URL, path
// traversal, ...) for the three endpoints login cannot function without. The
// fix adds one additional, distinctly-tagged SECURITY line — naming the
// endpoint and stating there is no config override — for exactly those
// three (jwks_uri, authorization, token); the other four discovered
// endpoints (revocation, end_session, introspection, registration) keep
// only the generic line, since three of them (revocationURL,
// oidcEndSessionURL, introspectionURL) have an operator override that
// replaces them right after sanitize runs.
func TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly(t *testing.T) {
var buf bytes.Buffer
tObj := &TraefikOidc{
logger: fix26CapturingLogger(&buf),
providerURL: "https://provider.example.com",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{
TokenURL: "http://provider.example.com/token",
})
if tObj.tokenURL != "" {
t.Fatalf("an http token endpoint under an https provider must still be dropped, got %q", tObj.tokenURL)
}
logged := buf.String()
if !strings.Contains(logged, "SECURITY:") {
t.Fatalf("dropping an endpoint for the https-pin reason must log a SECURITY-tagged line, got:\n%s", logged)
}
if !strings.Contains(logged, "token") {
t.Fatalf("the SECURITY line must name the dropped endpoint (token), got:\n%s", logged)
}
if !strings.Contains(logged, "http://provider.example.com/token") {
t.Fatalf("the SECURITY line must name the dropped URL, got:\n%s", logged)
}
if !strings.Contains(logged, "no override") {
t.Fatalf("the SECURITY line must state that this check has no override, got:\n%s", logged)
}
}
// TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog is the
// negative case: well-formed https endpoints (jwks_uri, authorization,
// token) must not trip the new SECURITY line.
func TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog(t *testing.T) {
var buf bytes.Buffer
tObj := &TraefikOidc{
logger: fix26CapturingLogger(&buf),
providerURL: "https://provider.example.com",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{
JWKSURL: "https://provider.example.com/jwks",
AuthURL: "https://provider.example.com/auth",
TokenURL: "https://provider.example.com/token",
})
if tObj.tokenURL != "https://provider.example.com/token" {
t.Fatalf("an https token endpoint must be accepted, got %q", tObj.tokenURL)
}
if strings.Contains(buf.String(), "SECURITY:") {
t.Fatalf("accepted https endpoints must not log a SECURITY line, got:\n%s", buf.String())
}
}
// TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly extends the
// pin to the other two endpoints login cannot function without: jwks_uri
// (signature verification) and authorization (the redirect target). Each
// must independently trigger its own named SECURITY line.
func TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly(t *testing.T) {
for _, tc := range []struct {
field string
endpoint string
}{
{field: "jwks_uri", endpoint: "http://provider.example.com/jwks"},
{field: "authorization", endpoint: "http://provider.example.com/auth"},
} {
t.Run(tc.field, func(t *testing.T) {
var buf bytes.Buffer
tObj := &TraefikOidc{
logger: fix26CapturingLogger(&buf),
providerURL: "https://provider.example.com",
}
md := &ProviderMetadata{}
switch tc.field {
case "jwks_uri":
md.JWKSURL = tc.endpoint
case "authorization":
md.AuthURL = tc.endpoint
}
tObj.updateMetadataEndpoints(md)
logged := buf.String()
if !strings.Contains(logged, "SECURITY:") {
t.Fatalf("%s: dropping the endpoint must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
}
if !strings.Contains(logged, tc.field) {
t.Fatalf("%s: the SECURITY line must name the endpoint, got:\n%s", tc.field, logged)
}
})
}
}
// TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine pins that
// the registration endpoint never trips the SECURITY-tagged line. It is the
// one discovered endpoint of the four (end_session, revocation,
// introspection, registration) with no config*URL field checked inside
// updateMetadataEndpoints at all: RegisterClient reads dcrConfig.
// RegistrationEndpoint directly (main.go, outside this function) whenever
// it is set, so a dropped discovered value here is orthogonal to whether
// that separate override exists.
func TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine(t *testing.T) {
var buf bytes.Buffer
tObj := &TraefikOidc{
logger: fix26CapturingLogger(&buf),
providerURL: "https://provider.example.com",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{
RegistrationURL: "http://provider.example.com/register",
})
logged := buf.String()
if !strings.Contains(logged, "Ignoring discovered registration") {
t.Fatalf("registration must still be dropped with the generic line, got:\n%s", logged)
}
if strings.Contains(logged, "SECURITY:") {
t.Fatalf("registration must never log a SECURITY line, got:\n%s", logged)
}
}
// TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine
// pins the re-review fix at main.go:658 (low severity): a scheme-downgrade
// drop of end_session, revocation or introspection is exactly as terminal
// as a drop of jwks_uri/authorization/token WHEN the operator has not set
// the matching override (oidcEndSessionURL / revocationURL /
// introspectionURL) -- logout falls back to a local-only redirect, provider
// revocation is skipped, or requireTokenIntrospection breaks, all silently
// except for the generic "Ignoring discovered" line. The old
// discoveredEndpointHasNoOverride was static and always answered false for
// these three regardless of whether an override was actually configured, so
// the SECURITY line never fired for them even with no override at all. It
// must now fire exactly when the corresponding override is empty.
func TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine(t *testing.T) {
for _, tc := range []struct {
field string
endpoint string
}{
{field: "end_session", endpoint: "http://provider.example.com/logout"},
{field: "revocation", endpoint: "http://provider.example.com/revoke"},
{field: "introspection", endpoint: "http://provider.example.com/introspect"},
} {
t.Run(tc.field, func(t *testing.T) {
var buf bytes.Buffer
// Deliberately no configEndSessionURL/configRevocationURL/
// configIntrospectionURL set: the operator configured no
// override for any of these three.
tObj := &TraefikOidc{
logger: fix26CapturingLogger(&buf),
providerURL: "https://provider.example.com",
}
md := &ProviderMetadata{}
switch tc.field {
case "end_session":
md.EndSessionURL = tc.endpoint
case "revocation":
md.RevokeURL = tc.endpoint
case "introspection":
md.IntrospectionURL = tc.endpoint
}
tObj.updateMetadataEndpoints(md)
logged := buf.String()
if !strings.Contains(logged, "Ignoring discovered "+tc.field) {
t.Fatalf("%s: the endpoint must still be dropped with the generic line, got:\n%s", tc.field, logged)
}
if !strings.Contains(logged, "SECURITY:") {
t.Fatalf("%s: dropping it with no configured override must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
}
if !strings.Contains(logged, "no override") {
t.Fatalf("%s: the SECURITY line must state that this check has no override, got:\n%s", tc.field, logged)
}
})
}
}
// TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint pins the
// second half of the same finding, table-driven over all three endpoints
// that carry an override: setting the operator override for an endpoint
// discovery drops for the scheme-downgrade reason means the endpoint does
// not stay empty — the override runs right after sanitize and replaces it,
// so a "requests needing this endpoint will fail" style claim would be
// false for these three. Only end_session was pinned before; a regression
// that stopped applying configRevocationURL or configIntrospectionURL
// specifically would have left every test green (re-review finding at
// fix26_scheme_downgrade_test.go:218).
func TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint(t *testing.T) {
for _, tc := range []struct {
field string
droppedURL string
overrideURL string
buildMD func(dropped string) *ProviderMetadata
buildTObj func(logger *Logger, override string) *TraefikOidc
resultingURL func(tObj *TraefikOidc) string
}{
{
field: "end_session",
droppedURL: "http://provider.example.com/logout",
overrideURL: "https://provider.example.com/logout-override",
buildMD: func(dropped string) *ProviderMetadata {
return &ProviderMetadata{EndSessionURL: dropped}
},
buildTObj: func(logger *Logger, override string) *TraefikOidc {
return &TraefikOidc{
logger: logger,
providerURL: "https://provider.example.com",
configEndSessionURL: override,
}
},
resultingURL: func(tObj *TraefikOidc) string { return tObj.endSessionURL },
},
{
field: "revocation",
droppedURL: "http://provider.example.com/revoke",
overrideURL: "https://provider.example.com/revoke-override",
buildMD: func(dropped string) *ProviderMetadata {
return &ProviderMetadata{RevokeURL: dropped}
},
buildTObj: func(logger *Logger, override string) *TraefikOidc {
return &TraefikOidc{
logger: logger,
providerURL: "https://provider.example.com",
configRevocationURL: override,
}
},
resultingURL: func(tObj *TraefikOidc) string { return tObj.revocationURL },
},
{
field: "introspection",
droppedURL: "http://provider.example.com/introspect",
overrideURL: "https://provider.example.com/introspect-override",
buildMD: func(dropped string) *ProviderMetadata {
return &ProviderMetadata{IntrospectionURL: dropped}
},
buildTObj: func(logger *Logger, override string) *TraefikOidc {
return &TraefikOidc{
logger: logger,
providerURL: "https://provider.example.com",
configIntrospectionURL: override,
}
},
resultingURL: func(tObj *TraefikOidc) string { return tObj.introspectionURL },
},
} {
t.Run(tc.field, func(t *testing.T) {
var buf bytes.Buffer
tObj := tc.buildTObj(fix26CapturingLogger(&buf), tc.overrideURL)
tObj.updateMetadataEndpoints(tc.buildMD(tc.droppedURL))
if got := tc.resultingURL(tObj); got != tc.overrideURL {
t.Fatalf("%s: the configured override must replace the dropped discovered endpoint, got %q, want %q", tc.field, got, tc.overrideURL)
}
if strings.Contains(buf.String(), "SECURITY:") {
t.Fatalf("%s: has a config override, so no SECURITY line applies, got:\n%s", tc.field, buf.String())
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
package traefikoidc
// Regression test for FIX-34 (review-2026-09-10 finding at
// middleware.go:1169, low severity): forwardAuthorized truncated an
// oversized ID token to headerTemplateMaxLen (8192) bytes and still
// forwarded it in X-Auth-Request-Token. A truncated JWT is structurally
// invalid; a downstream that verifies the signature rejects it (fail
// closed, fine), but one that merely base64-decodes claims without
// verifying reads a corrupted, silently-wrong value. The header must be
// dropped instead, matching the fail-closed pattern already used elsewhere
// in forwardAuthorized (X-Forwarded-User / X-Auth-Request-User on a failed
// sanitization).
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating pins
// FIX-34: an ID token longer than headerTemplateMaxLen must not appear in
// X-Auth-Request-Token at all, truncated or otherwise.
func TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating(t *testing.T) {
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: NewLogger("error"),
// minimalHeaders defaults false: the X-Auth-Request-* block (where
// X-Auth-Request-Token is set) only runs when it is false.
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
oversizedIDToken := strings.Repeat("a", headerTemplateMaxLen+1)
p := &principal{
Identifier: "user",
IDToken: oversizedIDToken,
}
oidc.forwardAuthorized(rw, req, p)
if got := req.Header.Get("X-Auth-Request-Token"); got != "" {
t.Fatalf("X-Auth-Request-Token must be dropped (not truncated) for an oversized ID token; got %d bytes, want header absent", len(got))
}
}
// TestForwardAuthorized_IDTokenWithinLimitStillForwarded is the control: an
// ID token at or under the header budget must still be forwarded
// unmodified, so the fix does not regress the common case.
func TestForwardAuthorized_IDTokenWithinLimitStillForwarded(t *testing.T) {
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: NewLogger("error"),
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
idToken := "header.payload.signature"
p := &principal{
Identifier: "user",
IDToken: idToken,
}
oidc.forwardAuthorized(rw, req, p)
if got := req.Header.Get("X-Auth-Request-Token"); got != idToken {
t.Fatalf("X-Auth-Request-Token = %q, want the unmodified ID token %q", got, idToken)
}
}
+179
View File
@@ -0,0 +1,179 @@
package traefikoidc
import (
"context"
"testing"
"time"
)
// FIX-35: Close() decided "am I the last live instance" once, early in its
// shutdown sequence (unregisterLiveInstance()'s return value), then reused
// that stale boolean much later to decide whether to stop process-global
// singleton tasks (memory-monitor, singleton-token-cleanup,
// singleton-metadata-refresh-*). Session/cache teardown happens in between,
// taking real time. A concurrently-created new instance (an overlapping
// Traefik reload) can register and adopt those same singletons in that
// window; the old instance's stale decision then stops them anyway, and the
// new instance runs with no memory monitor, token cleanup, or health checks
// until the next reload.
//
// TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose drives the REAL
// (*TraefikOidc).Close() through this exact window via closeTestHook, rather
// than re-implementing Close's stop decision inline: a test that never calls
// Close itself stays green even if the production fix (utilities.go's fresh,
// lock-guarded re-check; main.go's registerLiveInstance-before-adoption
// ordering) is reverted, which is a much weaker regression pin.
//
// It establishes its own liveInstanceCount baseline (save, zero, restore via
// t.Cleanup) instead of assuming the package-global counter is already 0.
// Without that, a live instance left registered by an earlier test in the
// same run (any order under -shuffle) makes the pre-FIX-35 stale "was last"
// decision already false on its own, so this test cannot tell a reverted fix
// from a working one — it passes either way.
func TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose(t *testing.T) {
// Save whether memory-monitor was already running before ResetGlobalMemoryMonitor
// below stops it, and restore that state afterward (registered first, so
// it runs LAST among this function's cleanups, after ResetGlobalMemoryMonitor
// has already torn the singleton down) — otherwise this test permanently
// destroys memory-monitor for every other test that runs afterward in the
// same binary, regardless of what state it found the process in.
wasMemoryMonitorRunning := GetResourceManager().IsTaskRunning("memory-monitor")
t.Cleanup(func() {
if wasMemoryMonitorRunning {
GetGlobalMemoryMonitor().StartMonitoring(context.Background(), time.Second)
}
})
ResetGlobalMemoryMonitor()
t.Cleanup(ResetGlobalMemoryMonitor)
t.Cleanup(func() { closeTestHook = nil })
baseline := resetLiveInstanceCountForTest()
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
// --- Old instance: already live, has adopted the memory-monitor singleton
// (mirrors main.go's registerLiveInstance() followed by
// memoryMonitor.StartMonitoring()).
registerLiveInstance()
mm := GetGlobalMemoryMonitor()
oldCtx, oldCancel := context.WithCancel(context.Background())
defer oldCancel()
mm.StartMonitoring(oldCtx, time.Second)
if !GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("precondition: memory-monitor must be running after StartMonitoring")
}
// --- Simulate the concurrent New(): a new instance registers and adopts
// the memory-monitor singleton in the window between the old instance's
// unregisterLiveInstance() and its gated singleton stops. closeTestHook
// fires from inside the real Close(), at exactly that point.
newCtx, newCancel := context.WithCancel(context.Background())
defer newCancel()
closeTestHook = func() {
registerLiveInstance()
mm.StartMonitoring(newCtx, time.Second) // adopts: already running, so this is a no-op start.
}
// Undo the hook's registerLiveInstance for the new instance and actually
// stop the task so it does not outlive this test. Runs via t.Cleanup
// (LIFO, before the baseline restore above) so it still executes even if
// an assertion below fails the test early.
t.Cleanup(func() {
unregisterLiveInstance()
_ = GetResourceManager().StopBackgroundTask("memory-monitor")
})
// --- Drive the real Close() on an instance built the same way New()
// builds one for this purpose: registered live, nothing else set (every
// other field Close touches is nil-guarded).
oldInstance := &TraefikOidc{logger: GetSingletonNoOpLogger()}
if err := oldInstance.Close(); err != nil {
t.Fatalf("Close returned an error: %v", err)
}
if !GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("memory-monitor must keep running: a new instance registered and adopted it before Close's last-instance decision")
}
}
// TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop pins the FIX-35
// review's major finding: a separate fresh-read check only wraps the counter
// read. Used as `if check() { stop() }`, liveInstanceMu is
// released as soon as the check returns, before stop() (which can block for
// real time — BackgroundTask.Stop waits up to 5s per task) ever runs. A
// concurrent registerLiveInstance() can land in that gap and have its
// just-adopted singleton killed by a stop decision made before it
// registered.
//
// stopIfLastInstance closes that window by holding liveInstanceMu across
// both the check and the stop call. This test proves the lock is genuinely
// held for the stop's full duration: while a slow stop callback is running,
// a concurrent registerLiveInstance() must not be able to complete.
func TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop(t *testing.T) {
baseline := resetLiveInstanceCountForTest()
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
stopStarted := make(chan struct{})
releaseStop := make(chan struct{})
stopDone := make(chan struct{})
go func() {
stopIfLastInstance(func() {
close(stopStarted)
<-releaseStop // hold the "stop" open, simulating BackgroundTask.Stop's wait.
})
close(stopDone)
}()
select {
case <-stopStarted:
case <-time.After(2 * time.Second):
t.Fatal("stopIfLastInstance never invoked its stop callback at baseline (count 0)")
}
registerDone := make(chan struct{})
go func() {
registerLiveInstance()
close(registerDone)
}()
select {
case <-registerDone:
t.Fatal("registerLiveInstance completed while the stop callback was still running — liveInstanceMu was not held across the stop, reopening the check-then-stop window")
case <-time.After(100 * time.Millisecond):
// Expected: still blocked on liveInstanceMu.
}
close(releaseStop)
select {
case <-registerDone:
case <-time.After(2 * time.Second):
t.Fatal("registerLiveInstance did not complete after the stop callback released liveInstanceMu")
}
<-stopDone
unregisterLiveInstance() // balance the registerLiveInstance above
}
// resetLiveInstanceCountForTest establishes a clean baseline (0) for the
// package-level liveInstanceCount so a test's last-instance decisions are not
// skewed by instances registered-and-never-unregistered by earlier tests in
// the same process. Test-only; mirrors resetGdInstancesForTest. Returns the
// value liveInstanceCount held before the reset so the caller can restore it
// via t.Cleanup (restoreLiveInstanceCountForTest) instead of permanently
// clobbering a nonzero baseline left by another test in the same run.
func resetLiveInstanceCountForTest() int32 {
liveInstanceMu.Lock()
defer liveInstanceMu.Unlock()
previous := liveInstanceCount
liveInstanceCount = 0
return previous
}
// restoreLiveInstanceCountForTest sets the package-level liveInstanceCount
// back to a value captured by resetLiveInstanceCountForTest. Test-only.
func restoreLiveInstanceCountForTest(previous int32) {
liveInstanceMu.Lock()
defer liveInstanceMu.Unlock()
liveInstanceCount = previous
}
+48
View File
@@ -0,0 +1,48 @@
package traefikoidc
import (
"crypto/rand"
"crypto/rsa"
"errors"
"strings"
"testing"
)
// TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits pins FIX-42: the
// RFC 7518 §3.3 minimum key-size rejection that 8640451 added to
// validateAlgKeyMatch (client_assertion.go:107) for private_key_jwt RS*/PS*
// signing keys, undocumented in that commit. FIX-25's sibling decision keeps
// the rejection as-is and records it in CHANGELOG.md; this test pins the
// contract with a named, matchable sentinel error so the rejection survives
// refactors and callers can detect it with errors.Is instead of matching a
// log string.
func TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("failed to generate 1024-bit test RSA key: %v", err)
}
err = validateAlgKeyMatch("RS256", key)
if err == nil {
t.Fatal("a 1024-bit RSA key must be rejected for RS256, got nil error")
}
if !errors.Is(err, ErrRSAKeyTooSmall) {
t.Fatalf("error must wrap ErrRSAKeyTooSmall so callers can match it with errors.Is, got: %v", err)
}
const wantSubstring = "requires an RSA key of at least 2048 bits"
if !strings.Contains(err.Error(), wantSubstring) {
t.Fatalf("error message must state the requirement (%q), got: %v", wantSubstring, err)
}
}
// TestValidateAlgKeyMatch_Accepts2048BitRSAKey is the boundary/positive
// case: a key at exactly the 2048-bit floor must be accepted.
func TestValidateAlgKeyMatch_Accepts2048BitRSAKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate 2048-bit test RSA key: %v", err)
}
if err := validateAlgKeyMatch("RS256", key); err != nil {
t.Fatalf("a 2048-bit RSA key must be accepted for RS256, got: %v", err)
}
}
@@ -0,0 +1,83 @@
package traefikoidc
import (
"testing"
"time"
)
// TestSharedHealthTaskSurvivesCreateDuringLastClose pins the create-versus-
// last-close race on the shared graceful-degradation health-check task.
//
// Close() decides it is the last live instance and then stops the shared task.
// A GracefulDegradation created between that decision and the stop used to
// register at once, and its health routine adopted the still-running task.
// The stop then killed the task that the new, live instance relies on, and
// nothing restarted it.
//
// The test pauses the last instance's Close() between the decision and the
// stop (beforeSharedTaskStop), creates a new instance, and then lets the stop
// run. The new instance is live, so the shared task must be running at the end.
func TestSharedHealthTaskSurvivesCreateDuringLastClose(t *testing.T) {
t.Cleanup(snapshotAndClearGdInstancesForTest())
cfg := DefaultGracefulDegradationConfig()
cfg.HealthCheckInterval = time.Hour
logger := GetSingletonNoOpLogger()
gdA := NewGracefulDegradation(cfg, logger)
waitForHealthRoutineSettled(t, gdA)
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
t.Fatal("precondition: shared health-check task must run while gdA is live")
}
parked := make(chan struct{})
release := make(chan struct{})
gdA.beforeSharedTaskStop = func() {
close(parked)
<-release
}
closed := make(chan struct{})
go func() {
gdA.Close()
close(closed)
}()
select {
case <-parked:
case <-time.After(5 * time.Second):
t.Fatal("gdA.Close did not reach the shared-task stop")
}
created := make(chan *GracefulDegradation, 1)
go func() { created <- NewGracefulDegradation(cfg, logger) }()
// Without serialization, gdB registers at once and its routine adopts the
// still-running task before gdA stops it. With serialization, gdB waits
// behind gdA's stop; the bound only limits how long the test waits then.
var gdB *GracefulDegradation
select {
case gdB = <-created:
waitForHealthRoutineSettled(t, gdB)
case <-time.After(200 * time.Millisecond):
}
close(release)
select {
case <-closed:
case <-time.After(10 * time.Second):
t.Fatal("gdA.Close did not return")
}
if gdB == nil {
select {
case gdB = <-created:
case <-time.After(10 * time.Second):
t.Fatal("NewGracefulDegradation did not return after gdA.Close")
}
}
defer gdB.Close()
waitForHealthRoutineSettled(t, gdB)
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
t.Fatal("shared health-check task is stopped although gdB is still live")
}
}
+132
View File
@@ -0,0 +1,132 @@
package traefikoidc
import (
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
const gdHealthTaskName = "graceful-degradation-health-check"
// TestSharedHealthTaskNotRestartedAfterLastClose pins the stop-versus-start
// race behind the flaky TestSharedHealthTaskSurvivesOtherInstanceClose.
//
// startHealthCheckRoutine checks gd.stopChan under gd.mutex and then creates
// or adopts the shared health-check task. Close() used to close stopChan
// without gd.mutex, so a routine that had already passed its check could
// reach the task registry AFTER the last Close() stopped the task.
// RegisterBackgroundTask replaces a stopped task with a fresh one, so the
// routine restarted the shared task with no live instance left to stop it.
//
// The test parks gd2's routine right after its stopChan check by holding the
// task-registry mutex (Close never takes it), closes both instances, and then
// releases the routine. The shared task must stay stopped.
func TestSharedHealthTaskNotRestartedAfterLastClose(t *testing.T) {
t.Cleanup(snapshotAndClearGdInstancesForTest())
cfg := DefaultGracefulDegradationConfig()
cfg.HealthCheckInterval = time.Hour
logger := GetSingletonNoOpLogger()
gd1 := NewGracefulDegradation(cfg, logger)
defer gd1.Close()
waitForHealthRoutineSettled(t, gd1)
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
t.Fatal("precondition: shared health-check task must run while gd1 is live")
}
globalTaskRegistryMutex.Lock()
registryLocked := true
releaseRegistry := func() {
if registryLocked {
registryLocked = false
globalTaskRegistryMutex.Unlock()
}
}
defer releaseRegistry()
gd2 := NewGracefulDegradation(cfg, logger)
defer gd2.Close()
waitForGoroutineParkedIn(t, "(*GracefulDegradation).startHealthCheckRoutine", "traefikoidc.GetGlobalTaskRegistry")
gd1.Close()
closed := make(chan struct{})
go func() {
gd2.Close()
close(closed)
}()
// Release the parked routine once the last Close() has stopped the task.
// A Close() that waits for the in-flight routine keeps the task running,
// so the bound only limits how long this test waits in that case.
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) && GetResourceManager().IsTaskRunning(gdHealthTaskName) {
time.Sleep(time.Millisecond)
}
releaseRegistry()
select {
case <-closed:
case <-time.After(10 * time.Second):
t.Fatal("gd2.Close did not return")
}
// gd2's routine holds gd2.mutex until it has created or adopted a task,
// so taking the lock waits for the routine to finish.
gd2.mutex.RLock()
routineTask := gd2.healthCheckTask
gd2.mutex.RUnlock()
if routineTask != nil && atomic.LoadInt32(&routineTask.stopped) == 0 {
routineTask.Stop()
t.Error("gd2's health routine holds a running task after the last GracefulDegradation closed")
}
if GetResourceManager().IsTaskRunning(gdHealthTaskName) {
_ = GetResourceManager().StopBackgroundTask(gdHealthTaskName)
t.Error("shared health-check task was restarted after the last GracefulDegradation closed")
}
}
// waitForGoroutineParkedIn polls all goroutine stacks until one stack contains
// every given frame substring.
func waitForGoroutineParkedIn(t *testing.T, frames ...string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if goroutineStackContains(frames...) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("no goroutine parked in %v", frames)
}
func goroutineStackContains(frames ...string) bool {
buf := make([]byte, 1<<20)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
buf = buf[:n]
break
}
buf = make([]byte, 2*len(buf))
}
for _, stack := range strings.Split(string(buf), "\n\n") {
if stackHasAllFrames(stack, frames) {
return true
}
}
return false
}
func stackHasAllFrames(stack string, frames []string) bool {
for _, f := range frames {
if !strings.Contains(stack, f) {
return false
}
}
return true
}
+20
View File
@@ -0,0 +1,20 @@
package traefikoidc
// Shared GracefulDegradation-instance-registry test helper for
// round-regression and FIX-round tests.
//
// Moved here for the R4 re-review (closing the rest of FIX-41): resetGdInstancesForTest
// was declared inside review_r34_regression_test.go but consumed by
// fix18_gd_lifecycle_test.go, a FIX-round test file — the dependency was not
// even confined to other round files. Every review_rNN*_test.go and
// fixNN*_test.go file must depend only on shared helpers like this one,
// never on another round file's private declarations.
// resetGdInstancesForTest establishes a clean baseline for the process-global
// gdInstances registry so a test's "last instance" and iteration assertions
// are not skewed by instances created by earlier tests in the same process.
func resetGdInstancesForTest() {
gdInstances.Lock()
gdInstances.set = make(map[*GracefulDegradation]struct{})
gdInstances.Unlock()
}
+6 -1
View File
@@ -2,10 +2,15 @@ module github.com/lukaszraczylo/traefikoidc
go 1.24.0
// Pin the toolchain to a stdlib version without the go1.26.5 CVEs
// (GO-2026-6089 net/http ReadHeaderTimeout, GO-2026-5972 asn1 recursion,
// GO-2026-5026 idna) reported by govulncheck (R219). Auto-downloaded.
toolchain go1.26.6
require (
github.com/alicebob/miniredis/v2 v2.35.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.3.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.10.0
golang.org/x/time v0.14.0
+2 -2
View File
@@ -12,12 +12,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
+8
View File
@@ -63,6 +63,14 @@ func (m *GoroutineManager) StartGoroutine(name string, fn func(context.Context))
m.wg.Done()
m.mu.Lock()
managedGoroutine.running = false
// Prune the map entry once this goroutine has finished so that
// finished goroutines don't accumulate unboundedly (R157). Only
// delete when the current entry still IS the finishing
// goroutine: a concurrent Start with the same name may have
// already replaced it with a live one.
if cur, ok := m.goroutines[goroutineName]; ok && cur == managedGoroutine {
delete(m.goroutines, goroutineName)
}
m.mu.Unlock()
// Recover from panics
+8 -9
View File
@@ -45,6 +45,7 @@ func TestStartGoroutine(t *testing.T) {
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
executed.Store(true)
<-ctx.Done() // keep alive so it is tracked while running
})
// Give goroutine time to execute
@@ -54,6 +55,7 @@ func TestStartGoroutine(t *testing.T) {
t.Error("Expected goroutine to execute")
}
// A live goroutine is tracked (R157: finished goroutines are pruned).
status := gm.GetStatus()
if len(status) != 1 {
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
@@ -370,8 +372,9 @@ func TestGetStatus(t *testing.T) {
status := gm.GetStatus()
if len(status) != 2 {
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
// R157: finished goroutines are pruned, so only the live one remains.
if len(status) != 1 {
t.Errorf("Expected 1 live goroutine in status, got %d", len(status))
}
if runningStatus, exists := status["running"]; exists {
@@ -394,12 +397,8 @@ func TestGetStatus(t *testing.T) {
t.Error("Expected 'running' goroutine in status")
}
if quickStatus, exists := status["quick"]; exists {
if quickStatus.Running {
t.Error("Expected 'quick' goroutine to be marked as not running")
}
} else {
t.Error("Expected 'quick' goroutine in status")
if _, exists := status["quick"]; exists {
t.Error("Expected finished 'quick' goroutine to be pruned from status")
}
}
@@ -435,7 +434,7 @@ func TestConcurrentStartGoroutine(t *testing.T) {
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
gm.StartGoroutine(name, func(ctx context.Context) {
counter.Add(1)
time.Sleep(50 * time.Millisecond)
<-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
counter.Add(-1)
})
}(i)
+54
View File
@@ -0,0 +1,54 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"text/template"
)
func ownedHeaderOIDC(t *testing.T, names ...string) *TraefikOidc {
t.Helper()
m := map[string]*template.Template{}
for _, n := range names {
m[n] = template.Must(template.New(n).Option("missingkey=zero").Parse("{{.Claims.tenant}}"))
}
return &TraefikOidc{logger: NewLogger("error"), headerTemplates: m}
}
// A templated header the middleware owns must never carry a client value:
// an optional claim that renders empty must leave the header absent.
func TestTemplatedHeaderDropsClientSpoof(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Tenant")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Tenant", "admin-corp")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
if got := req.Header.Get("X-Tenant"); got != "" {
t.Fatalf("client-supplied X-Tenant reached the backend: %q", got)
}
}
func TestTemplatedHeaderRenderedValueWins(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Tenant")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Tenant", "evil")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"tenant": "acme"}})
if got := req.Header.Get("X-Tenant"); got != "acme" {
t.Fatalf("X-Tenant = %q, want acme", got)
}
}
// Identity headers are injected before templates run; an empty render must
// not clobber them, and Authorization stays as received.
func TestTemplatedHeaderKeepsIdentityAndAuthorization(t *testing.T) {
o := ownedHeaderOIDC(t, "X-Forwarded-User", "Authorization")
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-User", "alice")
req.Header.Set("Authorization", "Bearer abc")
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
if got := req.Header.Get("X-Forwarded-User"); got != "alice" {
t.Fatalf("identity header clobbered: %q", got)
}
if got := req.Header.Get("Authorization"); got != "Bearer abc" {
t.Fatalf("Authorization changed: %q", got)
}
}
+229 -22
View File
@@ -6,17 +6,32 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
)
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
// "f47ac10b-58cc-4372-a567-0e02b2c3d479") backed by crypto/rand. Used for CSRF
// tokens and other opaque random identifiers — replaces github.com/google/uuid
// to keep the plugin stdlib-only on the production path.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("could not generate UUID: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// generateNonce creates a cryptographically secure random nonce for OIDC flows.
// The nonce is used to prevent replay attacks and associate client sessions with ID tokens.
// Returns:
@@ -92,9 +107,15 @@ type TokenResponse struct {
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant)
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
data := url.Values{
"grant_type": {grantType},
"client_id": {t.clientID},
"client_secret": {t.clientSecret},
"grant_type": {grantType},
}
// Snapshot client credentials under metadataMu: DCR rewrites clientID/
// clientSecret/audience at runtime (R137).
clientID, clientSecret, clientAuthMethod, _, clientAssertion := t.clientCredentials()
// client_id is sent in the body for every method except client_secret_basic,
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
if clientAuthMethod != "client_secret_basic" || clientAssertion != nil {
data.Set("client_id", clientID)
}
if grantType == "authorization_code" {
@@ -108,6 +129,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
data.Set("refresh_token", codeOrToken)
}
// RFC 8707 §2.2: repeat the same resource indicator sent on the
// authorization request so refreshed tokens keep the same audience
// binding as the original grant.
if t.resource != "" {
data.Set("resource", t.resource)
}
client := t.tokenHTTPClient
if client == nil {
// Use shared transport pool to prevent memory leaks
@@ -126,18 +154,35 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
}
}
// Read tokenURL with RLock
// Read tokenURL with RLock — needed as audience for private_key_jwt (RFC 7523 §3).
t.metadataMu.RLock()
tokenURL := t.tokenURL
t.metadataMu.RUnlock()
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
useBasicAuth := false
if clientAssertion != nil {
assertion, err := clientAssertion.Sign(tokenURL, clientID)
if err != nil {
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
}
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
data.Set("client_assertion", assertion)
} else if clientAuthMethod == "client_secret_basic" {
useBasicAuth = true
} else {
data.Set("client_secret", clientSecret)
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if useBasicAuth {
setOAuthBasicAuth(req, clientID, clientSecret)
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
if err != nil {
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
}
@@ -149,14 +194,33 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
if resp.StatusCode != http.StatusOK {
limitReader := io.LimitReader(resp.Body, 1024*10)
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
// Return a *HTTPError so the retry executor's isRetryableError can
// classify transient 5xx/429 responses and actually retry (R118).
// A plain error here made the 5xx/429 retry branch dead in
// production — every token-endpoint status was treated as permanent.
return nil, &HTTPError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes)),
}
}
var tokenResponse TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
// Cap the decoded body so a corrupted or malicious oversized 200-body cannot
// drive an unbounded read/allocation. Token responses are small (a few KB).
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tokenResponse); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
// RFC 6749 §5.1: a token response MUST include an access_token. A 200
// that omitted it is a protocol violation, not a success — treat it as an
// error here so no caller authenticates with a blank token (R158).
// Previously this seam returned success with an empty access_token and
// each caller had to re-check "AccessToken == \"\"", which several
// paths missed.
if tokenResponse.AccessToken == "" {
return nil, fmt.Errorf("token endpoint returned 200 without an access_token (RFC 6749 §5.1)")
}
return &tokenResponse, nil
}
@@ -169,6 +233,17 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
// Returns:
// - *TokenResponse: New token set from the authorization server
// - An error if the refresh operation fails
//
// redactToken returns a short, stable hash of a token credential suitable for
// debug logging without leaking the credential itself (R138).
func redactToken(token string) string {
if token == "" {
return "(empty)"
}
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:4])
}
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
ctx := context.Background()
@@ -183,7 +258,8 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
t.logger.Debugf("Token response: %+v", tokenResponse)
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
return tokenResponse, nil
}
@@ -218,10 +294,37 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
// It wraps the UniversalCache with token-specific operations.
type TokenCache struct {
// ns scopes entries to one middleware instance (issuer|client|audience)
// so a verdict cached by one instance cannot satisfy another.
ns atomic.Value
// cache is the underlying universal cache implementation
cache *UniversalCache
}
// SetNamespace sets the verification context that scopes this cache's keys.
func (tc *TokenCache) SetNamespace(ns string) {
if tc == nil {
return
}
tc.ns.Store(ns)
}
// Namespace returns the scope set by SetNamespace, or "" when none is set.
func (tc *TokenCache) Namespace() string {
if tc == nil {
return ""
}
ns, _ := tc.ns.Load().(string)
return ns
}
func (tc *TokenCache) key(token string) string {
if ns, _ := tc.ns.Load().(string); ns != "" {
return "t-" + ns + "|" + token
}
return "t-" + token
}
// NewTokenCache creates and initializes a new TokenCache.
// It uses the global cache manager to ensure singleton behavior.
func NewTokenCache() *TokenCache {
@@ -238,7 +341,7 @@ func NewTokenCache() *TokenCache {
// - claims: Parsed claims from the token
// - expiration: The duration for which the cache entry should be valid
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = "t-" + token
token = tc.key(token)
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
}
@@ -250,7 +353,7 @@ func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiratio
// - map[string]interface{}: The cached claims if found
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
token = "t-" + token
token = tc.key(token)
value, found := tc.cache.Get(token)
if !found {
return nil, false
@@ -263,7 +366,7 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
// Parameters:
// - token: The raw token string to remove from the cache
func (tc *TokenCache) Delete(token string) {
token = "t-" + token
token = tc.key(token)
tc.cache.Delete(token)
}
@@ -336,6 +439,7 @@ func createStringMap(keys []string) map[string]struct{} {
// and redirects to the provider's logout endpoint or configured post-logout URI.
// It handles potential errors during session retrieval or clearing.
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
t.logger.Debug("Processing logout request")
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Error getting session: %v", err)
@@ -344,6 +448,14 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
}
idToken := session.GetIDToken()
accessToken := session.GetAccessToken()
refreshToken := session.GetRefreshToken()
// Capture the trusted origin BEFORE Clear wipes the session. redirect_url
// was persisted at authentication-initiate time (R136) and is therefore
// the exact scheme+host the IdP already accepted as this deployment's
// redirect_uri — unlike the current request's Host/X-Forwarded-Host,
// the browser cannot spoof it on this logout request (FIX-07).
trustedOrigin := trustedOriginFromRedirectURL(session.GetRedirectURL())
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing session: %v", err)
@@ -351,15 +463,74 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
return
}
host := utils.DetermineHost(req)
scheme := utils.DetermineScheme(req, t.forceHTTPS)
baseURL := fmt.Sprintf("%s://%s", scheme, host)
// Blacklist the session's tokens so a token captured before logout (e.g. a
// bearer mode access token) cannot be reused until its natural expiry.
// RevokeToken is a no-op-safe local blacklist (guards nil) and skips
// empty values.
for _, tok := range []string{accessToken, idToken, refreshToken} {
if tok != "" {
t.RevokeToken(tok)
}
}
// Best-effort provider-side revocation (single logout): the local
// blacklist only stops reuse in-process, but a token captured before
// logout (e.g. a bearer-mode access token or the refresh token) stays
// valid at the IdP and a captured refresh token could keep minting new
// tokens. Revoke both access and refresh tokens at the provider when a
// revocation endpoint is available. Each call is bounded by
// revocationRequestTimeout so a slow or unreachable provider cannot
// hold the logout redirect open indefinitely (FIX-33). A genuine
// failure is logged at error level; the expected "no revocation
// endpoint configured" case (most deployments) stays silent so it
// doesn't log on every logout.
if accessToken != "" {
if err := t.RevokeTokenWithProvider(accessToken, "access_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
t.logger.Errorf("Provider-side revocation failed for access_token: %v", err)
}
}
if refreshToken != "" {
if err := t.RevokeTokenWithProvider(refreshToken, "refresh_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
t.logger.Errorf("Provider-side revocation failed for refresh_token: %v", err)
}
}
postLogoutRedirectURI := t.postLogoutRedirectURI
if postLogoutRedirectURI == "" {
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
// localRedirect is used when there is no provider end-session endpoint and
// the plugin redirects the browser itself. It must never be an absolute URL
// derived from the request host (X-Forwarded-Host is client-controllable and
// would be an open redirect); use a host-relative path, or the operator's
// own configured absolute URL, instead.
//
// The post_logout_redirect_uri we hand to the IdP has the same constraint:
// deriving it from baseURL (scheme://client-controlled host) lets an
// attacker's X-Forwarded-Host steer the browser to an arbitrary origin
// after logout (open redirect through the IdP). So we build it from
// trustedOrigin (the redirect_uri origin the IdP already accepted at
// login, R136) instead; when no trusted origin was recorded (pre-R136
// session, or logout without a completed login), BuildLogoutURL omits
// the parameter entirely rather than guessing (FIX-07 corrects R68,
// which dropped the parameter unconditionally for every non-absolute
// config value, including the documented default of '/').
localRedirect := "/"
switch {
case strings.HasPrefix(postLogoutRedirectURI, "http"):
// Operator configured an explicit absolute URL: already a trusted
// origin on its own, independent of trustedOrigin.
localRedirect = postLogoutRedirectURI
case postLogoutRedirectURI == "":
postLogoutRedirectURI = "" // no trusted origin below leaves it empty
if trustedOrigin != "" {
postLogoutRedirectURI = trustedOrigin + "/"
}
default:
// Relative operator-configured path: trusted for the plugin's own
// redirect, rebuilt against the trusted origin for the IdP.
localRedirect = normalizeLogoutPath(postLogoutRedirectURI)
postLogoutRedirectURI = ""
if trustedOrigin != "" {
postLogoutRedirectURI = trustedOrigin + localRedirect
}
}
// Read endSessionURL with RLock
@@ -374,11 +545,34 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
http.Error(rw, "Logout error", http.StatusInternalServerError)
return
}
// Logout redirects must never be cached: a heuristic-caching proxy
// replaying the 302 would re-trigger logout for an already
// logged-out session (R147 cache-directive contract).
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, logoutURL, http.StatusFound)
return
}
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, localRedirect, http.StatusFound)
}
// trustedOriginFromRedirectURL extracts "scheme://host" from the redirect_uri
// persisted at authentication-initiate time (R136), for reuse as the trusted
// origin when building post_logout_redirect_uri (FIX-07). That redirect_uri
// was already accepted by the IdP as this deployment's own callback, so its
// origin is safe to reuse — unlike the current request's Host/
// X-Forwarded-Host, which the browser controls. Returns "" when redirectURL
// is empty or is not an absolute http(s) URL.
func trustedOriginFromRedirectURL(redirectURL string) string {
if redirectURL == "" {
return ""
}
u, err := url.Parse(redirectURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return ""
}
return u.Scheme + "://" + u.Host
}
// BuildLogoutURL constructs a logout URL for the OIDC provider's end session endpoint.
@@ -407,6 +601,19 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
return u.String(), nil
}
// setOAuthBasicAuth sets the Authorization header per RFC 6749 §2.3.1: the
// client_id and client_secret are form-urlencoded individually, joined with a
// colon, then base64-encoded. This differs from http.Request.SetBasicAuth,
// which skips the form-urlencode step — that matters for credentials with
// reserved characters (`:`, `@`, `+`, `%`, etc.) where the wire format would
// otherwise diverge from what the spec mandates.
func setOAuthBasicAuth(req *http.Request, clientID, clientSecret string) {
user := url.QueryEscape(clientID)
pass := url.QueryEscape(clientSecret)
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
req.Header.Set("Authorization", "Basic "+auth)
}
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
// This ensures that OAuth scope parameters don't contain duplicates which could
// cause issues with some authorization servers.
+29
View File
@@ -0,0 +1,29 @@
package traefikoidc
import (
"regexp"
"testing"
)
// TestNewUUIDv4 verifies the in-house UUID v4 generator produces RFC 4122
// compliant identifiers. Locks in the replacement for github.com/google/uuid
// — a regression here would weaken the CSRF token used in the OIDC flow.
func TestNewUUIDv4(t *testing.T) {
rfc4122v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
const samples = 1000
seen := make(map[string]struct{}, samples)
for i := 0; i < samples; i++ {
got, err := newUUIDv4()
if err != nil {
t.Fatalf("newUUIDv4 failed: %v", err)
}
if !rfc4122v4.MatchString(got) {
t.Fatalf("UUID %q does not match RFC 4122 v4 format", got)
}
if _, dup := seen[got]; dup {
t.Fatalf("duplicate UUID emitted within %d samples: %q", samples, got)
}
seen[got] = struct{}{}
}
}
+25
View File
@@ -0,0 +1,25 @@
package traefikoidc
import "testing"
func TestValidateHostCanonicalForms(t *testing.T) {
o := &TraefikOidc{}
blocked := []string{
"localhost.", "LOCALHOST.", "metadata.google.internal.", "metadata.google.internal",
"169.254.169.254.", "fe80::1%eth0", "[fe80::1]", "::1",
}
for _, h := range blocked {
if err := o.validateHost(h); err == nil {
t.Errorf("validateHost(%q) = nil, want blocked", h)
}
}
allowed := []string{"idp.example.com", "idp.example.com.", "2606:4700::1111"}
for _, h := range allowed {
if err := o.validateHost(h); err != nil {
t.Errorf("validateHost(%q) = %v, want allowed", h, err)
}
}
if !isLoopbackHost("localhost.") {
t.Error("isLoopbackHost(localhost.) = false")
}
}
+41 -14
View File
@@ -3,6 +3,7 @@ package traefikoidc
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
@@ -25,10 +26,16 @@ type HTTPClientConfig struct {
Timeout time.Duration
MaxConnsPerHost int
WriteBufferSize int
UseCookieJar bool
ForceHTTP2 bool
DisableKeepAlives bool
DisableCompression bool
// RootCAs is an optional certificate pool used for TLS verification.
// A nil pool means "use the system trust store" (default behavior).
RootCAs *x509.CertPool
// InsecureSkipVerify disables TLS certificate verification.
// ONLY set this for local development against self-signed certificates.
InsecureSkipVerify bool
UseCookieJar bool
ForceHTTP2 bool
DisableKeepAlives bool
DisableCompression bool
}
// DefaultHTTPClientConfig returns the default configuration for general use
@@ -54,16 +61,23 @@ func DefaultHTTPClientConfig() HTTPClientConfig {
}
}
// TokenHTTPClientConfig returns configuration optimized for token operations
// TokenHTTPClientConfig returns configuration optimized for token operations.
func TokenHTTPClientConfig() HTTPClientConfig {
config := DefaultHTTPClientConfig()
config.Timeout = 10 * time.Second // Shorter timeout for token operations
config.MaxRedirects = 50 // Token endpoints may redirect more
config.UseCookieJar = true // Enable cookie jar for token operations
// UseCookieJar stays false (DefaultHTTPClientConfig's default). This
// config builds the single per-plugin-instance pooled token client
// (main.go, CreateTokenHTTPClient), shared by every user's token and
// refresh exchange. A jar on a shared client stores whatever
// Set-Cookie the IdP returns for one user's exchange and replays it on
// the next user's request to the same host - cross-user state
// confusion at the IdP (FIX-12; R70 had turned this on). Token/refresh
// calls are stateless back-channel requests and need no jar.
return config
}
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls.
func OIDCProviderHTTPClientConfig() HTTPClientConfig {
config := DefaultHTTPClientConfig()
config.Timeout = 15 * time.Second // Slightly longer for OIDC operations
@@ -71,7 +85,10 @@ func OIDCProviderHTTPClientConfig() HTTPClientConfig {
config.MaxIdleConnsPerHost = 25 // More connections per OIDC provider
config.MaxConnsPerHost = 50 // Allow more concurrent requests to OIDC provider
config.IdleConnTimeout = 90 * time.Second // Keep connections alive longer for reuse
config.UseCookieJar = true // Enable cookie jar for session management
// UseCookieJar stays false, for the same cross-user reason as
// TokenHTTPClientConfig above (FIX-12): any client built from this
// config through the shared transport pool is one instance shared by
// every user, so a jar would leak cookies between them.
return config
}
@@ -131,10 +148,13 @@ func (f *HTTPClientFactory) ValidateHTTPClientConfig(config *HTTPClientConfig) e
return nil
}
// CreateHTTPClient creates an HTTP client with the given configuration
// Validates configuration parameters before creating the client
func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Client {
// Set defaults for zero values before validation
// applyHTTPClientDefaults fills zero-valued fields in cfg with the standard
// conservative defaults so that a partially-populated config never yields an
// unbounded HTTP client (zero overall Timeout or ResponseHeaderTimeout would make
// a stalled upstream hang forever). All construction paths MUST converge on this
// helper so behavior is identical: CreateHTTPClient and the shared transport
// pool (CreatePooledHTTPClient / GetOrCreateTransport).
func applyHTTPClientDefaults(config *HTTPClientConfig) {
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
}
@@ -171,6 +191,13 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
if config.ReadBufferSize == 0 {
config.ReadBufferSize = 4096
}
}
// CreateHTTPClient creates an HTTP client with the given configuration
// Validates configuration parameters before creating the client
func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Client {
// Set defaults for zero values before validation
applyHTTPClientDefaults(&config)
// Validate configuration - only fail on critical errors
if err := f.ValidateHTTPClientConfig(&config); err != nil {
@@ -202,8 +229,8 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
InsecureSkipVerify: false, // Always verify certificates
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
+7 -2
View File
@@ -18,7 +18,10 @@ func TestOIDCProviderHTTPClientConfigUnit(t *testing.T) {
assert.Equal(t, 25, config.MaxIdleConnsPerHost, "OIDC provider should have 25 max idle conns per host")
assert.Equal(t, 50, config.MaxConnsPerHost, "OIDC provider should have 50 max conns per host")
assert.Equal(t, 90*time.Second, config.IdleConnTimeout, "OIDC provider should have 90s idle conn timeout")
assert.True(t, config.UseCookieJar, "OIDC provider should have cookie jar enabled")
// UseCookieJar must stay false (FIX-12): this config feeds the shared
// pooled client, and a jar there would leak one user's IdP cookies to
// every other user of the same pooled instance.
assert.False(t, config.UseCookieJar, "OIDC provider pooled client must not have a shared cookie jar")
}
// TestCreateDefaultClientUnit tests CreateDefaultClient function
@@ -38,7 +41,9 @@ func TestCreateTokenClientUnit(t *testing.T) {
require.NotNil(t, client)
assert.NotNil(t, client.Transport, "client should have transport")
assert.NotNil(t, client.Jar, "token client should have cookie jar")
// No jar (FIX-12): TokenHTTPClientConfig's UseCookieJar stays false so
// a client built from it never carries cross-request cookie state.
assert.Nil(t, client.Jar, "token client must not have a cookie jar")
assert.Equal(t, 10*time.Second, client.Timeout, "token client should have 10s timeout")
}
+58
View File
@@ -0,0 +1,58 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestPooledTokenClient_DoesNotShareCookieJarAcrossRequests regresses
// FIX-12: R70 gave CreatePooledHTTPClient's client a cookie jar whenever
// UseCookieJar is set, and TokenHTTPClientConfig (used to build the single
// per-plugin-instance tokenHTTPClient, main.go) set UseCookieJar: true. That
// one client, and its one jar, is shared by every user's token/refresh
// exchange. An IdP Set-Cookie on user A's exchange was stored in the shared
// jar and replayed on user B's next token request to the same host -
// cross-user state confusion at the IdP. Token/refresh calls are stateless
// back-channel requests and must never carry a cross-request jar.
func TestPooledTokenClient_DoesNotShareCookieJarAcrossRequests(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if requests == 1 {
// Simulate the IdP setting a session/affinity cookie on user A's
// token exchange response.
http.SetCookie(w, &http.Cookie{Name: "idp_sess", Value: "alice"})
w.WriteHeader(http.StatusOK)
return
}
// User B's request: must not carry the cookie set on user A's call.
if c, err := r.Cookie("idp_sess"); err == nil {
t.Errorf("request %d carried leaked cookie idp_sess=%s from an earlier request", requests, c.Value)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := CreatePooledHTTPClient(TokenHTTPClientConfig())
// Request 1 (user A): receives and would store the IdP's Set-Cookie if
// the client has a jar.
resp1, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("request 1: %v", err)
}
resp1.Body.Close()
// Request 2 (user B, same pooled client, same host): must be a clean
// request with no cookie carried over from request 1.
resp2, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("request 2: %v", err)
}
resp2.Body.Close()
if requests != 2 {
t.Fatalf("want 2 requests reaching the server, got %d", requests)
}
}
+140 -34
View File
@@ -3,8 +3,10 @@ package traefikoidc
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"sync"
"sync/atomic"
"time"
@@ -25,6 +27,10 @@ type sharedTransport struct {
lastUsed time.Time
transport *http.Transport
refCount int
// tlsKey identifies the TLS trust settings (CA pool + InsecureSkipVerify)
// this transport was built with, so the at-limit fallback only reuses a
// transport whose TLS configuration matches the caller's.
tlsKey string
}
var (
@@ -52,20 +58,44 @@ func GetGlobalTransportPool() *SharedTransportPool {
// GetOrCreateTransport gets or creates a shared transport with the given config
func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *http.Transport {
// SECURITY FIX: Check client limit before creating new transport
// Apply the same zero-value defaults as CreateHTTPClient so a partially
// populated config never yields an unbounded client/transport, and so the
// pool cache key reflects normalized values (configKey). Bounded here,
// before the client-count gate, so both the gate and the key see
// normalized timeouts.
applyHTTPClientDefaults(&config)
// SECURITY FIX: Check client limit before creating new transport.
if atomic.LoadInt32(&p.clientCount) >= p.maxClients {
// Return existing transport if limit reached
p.mu.RLock()
defer p.mu.RUnlock()
// At the client limit: only reuse a transport that was built for the
// SAME config (same TLS trust store). refCount is mutated under the
// write lock to avoid a data race, and a transport created for a
// different configuration is never handed back — doing so could apply
// the wrong (possibly verification-disabled) TLS settings to a request.
want := tlsConfigKey(config)
p.mu.Lock()
defer p.mu.Unlock()
for _, shared := range p.transports {
if shared != nil && shared.transport != nil {
if shared != nil && shared.transport != nil && shared.tlsKey == want {
shared.refCount++
shared.lastUsed = time.Now()
return shared.transport
}
}
// If no transport available, return nil (caller should handle)
return nil
// No TLS-compatible transport available. Returning nil would make the
// caller fall back to http.DefaultTransport, silently dropping the
// configured RootCAs / InsecureSkipVerify — custom-CA TLS would
// break and self-signed skip-verify would be lost (R95). Build and
// return the correctly-configured transport instead; honoring TLS
// settings takes precedence over the soft client cap.
t := newSharedTransport(config)
p.transports[p.configKey(config)] = &sharedTransport{
transport: t,
refCount: 1,
lastUsed: time.Now(),
tlsKey: want,
}
atomic.AddInt32(&p.clientCount, 1)
return t
}
p.mu.Lock()
@@ -82,8 +112,26 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
// Increment client count
atomic.AddInt32(&p.clientCount, 1)
// Create new transport with conservative limits
transport := &http.Transport{
transport := newSharedTransport(config)
p.transports[key] = &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
tlsKey: tlsConfigKey(config),
}
return transport
}
// newSharedTransport builds a properly-configured *http.Transport from the
// given config, enforcing TLS 1.2+ and secure cipher suites. Shared by the
// normal acquisition path and the at-cap fallback so the TLS settings are
// NEVER silently dropped (the at-cap fallback previously returned nil,
// making the caller fall back to http.DefaultTransport and lose the
// configured RootCAs / InsecureSkipVerify — R95).
func newSharedTransport(config HTTPClientConfig) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{
@@ -102,30 +150,22 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
InsecureSkipVerify: false,
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
ExpectContinueTimeout: config.ExpectContinueTimeout,
MaxIdleConns: 10, // SECURITY FIX: Further reduced
MaxIdleConnsPerHost: 2, // SECURITY FIX: Limited connections
IdleConnTimeout: 30 * time.Second, // Reduced from 5 minutes
MaxIdleConns: config.MaxIdleConns,
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
IdleConnTimeout: config.IdleConnTimeout,
DisableKeepAlives: config.DisableKeepAlives,
MaxConnsPerHost: 5, // SECURITY FIX: Strict limit
MaxConnsPerHost: config.MaxConnsPerHost,
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
DisableCompression: config.DisableCompression,
WriteBufferSize: config.WriteBufferSize,
ReadBufferSize: config.ReadBufferSize,
}
p.transports[key] = &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
}
return transport
}
// ReleaseTransport decrements the reference count for a transport
@@ -205,28 +245,82 @@ func (p *SharedTransportPool) performCleanup() {
// configKey generates a unique key for a config
func (p *SharedTransportPool) configKey(config HTTPClientConfig) string {
// Simple key based on main parameters
return string(rune(config.MaxConnsPerHost)) + string(rune(config.MaxIdleConnsPerHost))
// Pool transports by every parameter the built *http.Transport actually
// consumes. RootCAs and InsecureSkipVerify MUST be part of the key:
// otherwise a middleware configured with a custom CA would share a
// transport with one using the system store, silently bypassing its
// CA configuration. The remaining fields affect connection behavior
// (HTTP version, compression, keep-alives, timeouts, buffer sizes,
// dial settings); omitting them would hand a caller a transport built
// with different settings than it requested (e.g. wrong compression).
skip := "0"
if config.InsecureSkipVerify {
skip = "1"
}
return fmt.Sprintf("%d|%d|%p|%s|%d|%d|%d|%v|%v|%v|%d|%d|%d|%d|%d|%d",
config.MaxConnsPerHost,
config.MaxIdleConnsPerHost,
config.RootCAs,
skip,
config.DialTimeout,
config.KeepAlive,
config.TLSHandshakeTimeout,
config.ForceHTTP2,
config.DisableKeepAlives,
config.DisableCompression,
config.ResponseHeaderTimeout,
config.ExpectContinueTimeout,
config.WriteBufferSize,
config.ReadBufferSize,
config.IdleConnTimeout,
config.MaxIdleConns,
)
}
// Cleanup closes all transports and stops the cleanup goroutine
// tlsConfigKey identifies only the TLS trust settings of a config — the CA pool
// and the InsecureSkipVerify flag. Two configs with the same tlsConfigKey are
// safe to serve from the same transport even if other (non-TLS) parameters such
// as connection limits differ; configs with different TLS settings are not.
func tlsConfigKey(config HTTPClientConfig) string {
skip := "0"
if config.InsecureSkipVerify {
skip = "1"
}
return fmt.Sprintf("%p|%s", config.RootCAs, skip)
}
// Cleanup closes all transports, resets the pool, and restarts the cleanup
// goroutine so the (singleton) pool remains fully usable afterwards. Prior
// to R125, Cleanup canceled the cleanup goroutine but never restarted it
// and left clientCount unreset: a reused singleton then had a permanently
// dead cleaner (idle conns never pruned) and a stale count (the soft
// maxClients cap keyed off it was wrong after reuse).
func (p *SharedTransportPool) Cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
// Stop the cleanup goroutine
if p.cancel != nil {
p.cancel()
}
for _, shared := range p.transports {
shared.transport.CloseIdleConnections()
if shared != nil && shared.transport != nil {
shared.transport.CloseIdleConnections()
}
}
p.transports = make(map[string]*sharedTransport)
atomic.StoreInt32(&p.clientCount, 0)
oldCancel := p.cancel
ctx, cancel := context.WithCancel(context.Background())
p.ctx = ctx
p.cancel = cancel
p.mu.Unlock()
if oldCancel != nil {
oldCancel()
}
go p.cleanupIdleTransports(ctx)
}
// CreatePooledHTTPClient creates an HTTP client using the shared transport pool
func CreatePooledHTTPClient(config HTTPClientConfig) *http.Client {
// Apply the same zero-value defaults as CreateHTTPClient so a partially
// populated config still gets a bounded overall request Timeout.
applyHTTPClientDefaults(&config)
pool := GetGlobalTransportPool()
transport := pool.GetOrCreateTransport(config)
@@ -235,6 +329,18 @@ func CreatePooledHTTPClient(config HTTPClientConfig) *http.Client {
Transport: transport,
}
// Attach a jar only for callers that opt in via UseCookieJar, matching
// CreateHTTPClient (http_client_factory.go). TokenHTTPClientConfig and
// OIDCProviderHTTPClientConfig deliberately leave UseCookieJar false:
// a client built through this pool from either config is one instance
// shared across every user, so a jar would leak one user's cookies
// (e.g. Set-Cookie on a token/refresh response) into another user's
// requests to the same host (FIX-12).
if config.UseCookieJar {
jar, _ := cookiejar.New(nil) // Safe to ignore: nil options rarely fail
client.Jar = jar
}
// Configure redirect policy
maxRedirects := config.MaxRedirects
if maxRedirects == 0 {
+74 -5
View File
@@ -63,9 +63,17 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
}
config := DefaultHTTPClientConfig()
config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache
transport := pool.GetOrCreateTransport(config)
assert.Nil(t, transport, "should return nil when at client limit")
// R95: at the client limit with no TLS-compatible transport, we must
// still return a correctly-configured transport (never nil, which
// previously fell back to http.DefaultTransport and dropped TLS
// settings).
require.NotNil(t, transport, "at-cap transport must not be nil")
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("at-cap transport must preserve the requested TLS settings")
}
})
t.Run("client limit with existing transport", func(t *testing.T) {
@@ -256,7 +264,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
assert.Len(t, pool.transports, 0, "all transports should be removed")
})
t.Run("cleanup cancels context", func(t *testing.T) {
t.Run("cleanup cancels the old context and stays reusable", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
@@ -270,10 +278,16 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
pool.Cleanup()
select {
case <-pool.ctx.Done():
// Context was canceled
case <-ctx.Done():
// The previous cleanup context was canceled (goroutine stopped).
case <-time.After(100 * time.Millisecond):
t.Error("context should be canceled")
t.Error("the previous cleanup context should be canceled")
}
// R125: Cleanup must leave the pool fully reusable — a fresh
// context is installed and new transports can still be acquired.
if tr := pool.GetOrCreateTransport(DefaultHTTPClientConfig()); tr == nil {
t.Error("pool must remain usable after Cleanup")
}
})
@@ -689,3 +703,58 @@ func TestSharedTransportPoolEdgeCases(t *testing.T) {
assert.Equal(t, int32(0), finalCount, "client count should decrement on cleanup")
})
}
// TestSharedTransportPoolAppliesConfigLimits is a regression test: the pooled
// transport must apply the configured connection limits (MaxConnsPerHost,
// MaxIdleConnsPerHost, MaxIdleConns, IdleConnTimeout) rather than hardcoded
// constants. Previously GetOrCreateTransport built the transport with fixed
// values (5/2/10/30s) while configKey still incorporated these config fields,
// so the config was valid, keyed, and validated but silently ignored
// (dead config) — e.g. OIDCProviderHTTPClientConfig's tuning had no effect.
func TestSharedTransportPoolAppliesConfigLimits(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
config := DefaultHTTPClientConfig()
config.MaxConnsPerHost = 37
config.MaxIdleConnsPerHost = 13
config.MaxIdleConns = 41
config.IdleConnTimeout = 7 * time.Second
transport := pool.GetOrCreateTransport(config)
require.NotNil(t, transport)
assert.Equal(t, 37, transport.MaxConnsPerHost, "MaxConnsPerHost must come from config")
assert.Equal(t, 13, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost must come from config")
assert.Equal(t, 41, transport.MaxIdleConns, "MaxIdleConns must come from config")
assert.Equal(t, 7*time.Second, transport.IdleConnTimeout, "IdleConnTimeout must come from config")
}
// TestCreatePooledHTTPClient_CookieJarOption regresses a config no-effect:
// CreatePooledHTTPClient ignored config.UseCookieJar, so pooled token/OIDC
// clients dropped the cookie jar that the non-pooled CreateHTTPClient
// honors (queue cookies from auth-server token/refresh responses were
// never stored or re-sent).
func TestCreatePooledHTTPClient_CookieJarOption(t *testing.T) {
t.Run("enabled", func(t *testing.T) {
client := CreatePooledHTTPClient(HTTPClientConfig{UseCookieJar: true})
if client.Jar == nil {
t.Fatal("expected a cookie jar when UseCookieJar is true")
}
})
t.Run("disabled", func(t *testing.T) {
client := CreatePooledHTTPClient(HTTPClientConfig{UseCookieJar: false})
if client.Jar != nil {
t.Fatal("expected no cookie jar when UseCookieJar is false")
}
})
}
+238
View File
@@ -0,0 +1,238 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestEnhanceSessionSecurity_CaseVariantForwardedHostStaysHostScoped(t *testing.T) {
cases := []struct {
name string
xfh string
}{
{name: "exact-case parent", xfh: "corp.example.com"},
{name: "upper-case parent", xfh: "CORP.example.com"},
{name: "mixed-case parent", xfh: "Corp.Example.COM"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sm := createTestSessionManager(t)
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "https://app.corp.example.com/", nil)
req.Host = "app.corp.example.com"
req.Header.Set("X-Forwarded-Host", tc.xfh)
opts := sm.EnhanceSessionSecurity(nil, req)
if opts.Domain != "app.corp.example.com" {
t.Fatalf("Domain = %q, want host-scoped app.corp.example.com", opts.Domain)
}
})
}
t.Run("case-variant forwarded host equal to serving host is kept", func(t *testing.T) {
sm := createTestSessionManager(t)
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "https://internal.svc/", nil)
req.Host = "internal.svc"
req.Header.Set("X-Forwarded-Host", "App.Example.com")
opts := sm.EnhanceSessionSecurity(nil, req)
if opts.Domain != "app.example.com" {
t.Fatalf("Domain = %q, want app.example.com", opts.Domain)
}
})
}
func TestEnforceIatAge_RejectsFutureIat(t *testing.T) {
const maxAge = 24 * time.Hour
now := time.Now()
cases := []struct {
name string
iat time.Time
wantErr bool
}{
{name: "now", iat: now},
{name: "within future skew", iat: now.Add(ClockSkewToleranceFuture / 2)},
{name: "one hour ahead", iat: now.Add(time.Hour), wantErr: true},
{name: "just beyond skew", iat: now.Add(ClockSkewToleranceFuture + 10*time.Second), wantErr: true},
{name: "older than max age", iat: now.Add(-48 * time.Hour), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := enforceIatAge(map[string]interface{}{"iat": float64(tc.iat.Unix())}, maxAge)
if (err != nil) != tc.wantErr {
t.Fatalf("enforceIatAge err = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestJWKCache_CooldownServesParsedEntryWithoutReparse(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("keygen: %v", err)
}
c := NewJWKCache()
url := fmt.Sprintf("https://jwks.invalid/%d", time.Now().UnixNano())
// The raw set is empty, so only the pre-parsed entry can satisfy the lookup.
_ = c.cache.SetLocal(url, &JWKSet{}, time.Hour)
_ = c.cache.SetLocal(url+parsedKeysSuffix, &parsedJWKS{keys: map[string]crypto.PublicKey{"k1": &priv.PublicKey}}, time.Hour)
c.lastSignatureRefresh[url] = time.Now()
got, err := c.getPublicKeyFresh(context.Background(), url, "k1", http.DefaultClient)
if err != nil {
t.Fatalf("getPublicKeyFresh: %v", err)
}
if got != crypto.PublicKey(&priv.PublicKey) {
t.Fatal("cooldown branch did not serve the cached parsed key")
}
}
func TestFrontchannelLogout_FloodCannotEvictSignedMarkers(t *testing.T) {
cache := &CacheInterfaceWrapper{cache: NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeSession,
MaxSize: 5000,
DefaultTTL: sessionInvalidationTTL,
Logger: NewLogger("error"),
SkipAutoCleanup: true,
MonotonicMarkers: true,
})}
oidc := &TraefikOidc{
logger: NewLogger("error"),
enableFrontchannelLogout: true,
sessionInvalidationCache: cache,
issuerURL: "https://idp.example.com",
}
t.Cleanup(func() { _ = cache.cache.Close() })
if err := oidc.invalidateSession("", "real-subject"); err != nil {
t.Fatalf("invalidateSession: %v", err)
}
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err != nil {
t.Fatalf("mark jti: %v", err)
}
for i := 0; i < 6000; i++ {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/fc?iss=https://idp.example.com&sid=flood-%d", i), nil)
rw := httptest.NewRecorder()
oidc.handleFrontchannelLogout(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("flood request %d: status %d", i, rw.Code)
}
}
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sub", "real-subject")); !ok {
t.Error("backchannel sub marker evicted by front-channel flood")
}
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err == nil {
t.Error("jti replay marker evicted by front-channel flood: replay accepted")
}
held := 0
for i := 0; i < 6000; i++ {
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", fmt.Sprintf("flood-%d", i))); ok {
held++
}
}
if held > maxFrontchannelMarkers {
t.Errorf("front-channel markers held = %d, want <= %d", held, maxFrontchannelMarkers)
}
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", "flood-5999")); !ok {
t.Error("most recent front-channel marker must remain")
}
}
func TestMarkerQuota_Admit(t *testing.T) {
var q markerQuota
if got := q.admit("a", 2); got != "" {
t.Errorf("admit a = %q, want none", got)
}
if got := q.admit("a", 2); got != "" {
t.Errorf("re-admit a = %q, want none", got)
}
if got := q.admit("b", 2); got != "" {
t.Errorf("admit b = %q, want none", got)
}
if got := q.admit("c", 2); got != "a" {
t.Errorf("admit c evicted %q, want a", got)
}
}
func TestServeHTTP_InitWaitQueueFullFailsFast(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
oidc.initWaiters = maxInitWaiters
rw := httptest.NewRecorder()
start := time.Now()
oidc.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/protected", nil))
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rw.Code)
}
if rw.Header().Get("Retry-After") == "" {
t.Error("missing Retry-After header")
}
if time.Since(start) > 5*time.Second {
t.Errorf("rejected after %v, want immediate", time.Since(start))
}
if oidc.initWaiters != maxInitWaiters {
t.Errorf("initWaiters = %d, want unchanged %d", oidc.initWaiters, maxInitWaiters)
}
}
func TestServeHTTP_InitWaiterReleasedOnCancel(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(ctx)
oidc.ServeHTTP(httptest.NewRecorder(), req)
if oidc.initWaiters != 0 {
t.Fatalf("initWaiters = %d after canceled wait, want 0", oidc.initWaiters)
}
}
func TestCleanupOldCookies_CapsDeletionHeaders(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-bytes-long!!", false, "cookie.example.com", "testpref", 0, newNoOpLogger())
if err != nil {
t.Fatalf("session manager: %v", err)
}
t.Cleanup(func() { _ = sm.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "a.b.example.com"
for i := 0; i < 180; i++ {
req.AddCookie(&http.Cookie{Name: fmt.Sprintf("testpref_%d", i), Value: "x"})
}
rw := httptest.NewRecorder()
sm.CleanupOldCookies(rw, req)
got := len(rw.Result().Cookies())
if got == 0 {
t.Fatal("expected deletion cookies for the first wave")
}
if limit := maxCleanupCookies * 4; got > limit {
t.Fatalf("deletion Set-Cookie headers = %d, want <= %d", got, limit)
}
for _, c := range rw.Result().Cookies() {
if !strings.HasPrefix(c.Name, "testpref_") {
t.Errorf("unexpected deletion cookie %q", c.Name)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
func TestIDTokenMemoFollowsStoredToken(t *testing.T) {
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef", false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
t.Cleanup(func() {
if err := sm.Shutdown(); err != nil {
t.Errorf("Shutdown: %v", err)
}
})
future := float64(time.Now().Add(time.Hour).Unix())
first := tokenWithGroups(t, map[string]interface{}{"sub": "first", "exp": future})
second := tokenWithGroups(t, map[string]interface{}{"sub": "second", "exp": future})
session, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
steps := []struct {
name string
write func()
want string
}{
{"first read", func() { session.SetIDToken(first) }, first},
{"repeat read", func() {}, first},
{"after overwrite", func() { session.SetIDToken(second) }, second},
{"after clearing", func() { session.SetIDToken("") }, ""},
{"after Reset", func() { session.SetIDToken(first); _ = session.GetIDToken(); session.Reset() }, ""},
}
for _, st := range steps {
st.write()
if got := session.GetIDToken(); got != st.want {
t.Fatalf("%s: GetIDToken = %q, want %q", st.name, got, st.want)
}
if st.name == "repeat read" && session.idTokenMemoRaw != first {
t.Fatal("repeat read did not go through the memo; the test no longer covers it")
}
}
if session.idTokenMemoRaw != "" || session.idTokenMemo != "" {
t.Fatal("Reset left an ID-token memo on the pooled session")
}
}
-740
View File
@@ -1,740 +0,0 @@
package traefikoidc
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// InputValidator provides comprehensive input validation and sanitization
// to protect against common security vulnerabilities including SQL injection,
// XSS, path traversal, and other injection attacks. It validates and sanitizes
// various input types used in OIDC authentication flows.
type InputValidator struct {
usernameRegex *regexp.Regexp
tokenRegex *regexp.Regexp
logger *Logger
urlRegex *regexp.Regexp
emailRegex *regexp.Regexp
sqlInjectionPatterns []string
pathTraversalPatterns []string
xssPatterns []string
maxUsernameLength int
maxURLLength int
maxTokenLength int
maxEmailLength int
maxClaimLength int
maxHeaderLength int
allowPrivateIPAddresses bool // Allow private IP addresses in URL validation
}
// ValidationResult encapsulates the outcome of input validation.
// It includes the sanitized value, detected security risks, validation
// errors and warnings, and an overall validity status.
type ValidationResult struct {
SanitizedValue string `json:"sanitized_value,omitempty"`
SecurityRisk string `json:"security_risk,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
IsValid bool `json:"is_valid"`
}
// InputValidationConfig defines the configuration parameters for input validation.
// It specifies maximum lengths for various input types and controls whether
// strict validation mode is enabled.
type InputValidationConfig struct {
MaxTokenLength int `json:"max_token_length"`
MaxURLLength int `json:"max_url_length"`
MaxHeaderLength int `json:"max_header_length"`
MaxClaimLength int `json:"max_claim_length"`
MaxEmailLength int `json:"max_email_length"`
MaxUsernameLength int `json:"max_username_length"`
StrictMode bool `json:"strict_mode"`
AllowPrivateIPAddresses bool `json:"allow_private_ip_addresses"` // Allow private IP addresses in URL validation
}
// DefaultInputValidationConfig returns a secure default configuration
// for input validation with reasonable limits based on industry standards
// and security best practices.
func DefaultInputValidationConfig() InputValidationConfig {
return InputValidationConfig{
MaxTokenLength: 50000, // 50KB for tokens
MaxURLLength: 2048, // Standard URL length limit
MaxHeaderLength: 8192, // 8KB for headers
MaxClaimLength: 1024, // 1KB for individual claims
MaxEmailLength: 254, // RFC 5321 limit
MaxUsernameLength: 64, // Reasonable username limit
StrictMode: true, // Enable strict validation by default
}
}
// NewInputValidator creates a new input validator with the specified configuration.
// It compiles all necessary regex patterns and initializes security pattern lists.
//
// Parameters:
// - config: Validation configuration with size limits and mode settings.
// - logger: Logger instance for recording validation events.
//
// Returns:
// - A configured InputValidator instance.
// - An error if regex compilation fails.
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
// Compile regex patterns
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if err != nil {
return nil, fmt.Errorf("failed to compile email regex: %w", err)
}
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
if err != nil {
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
}
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile token regex: %w", err)
}
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
if err != nil {
return nil, fmt.Errorf("failed to compile username regex: %w", err)
}
return &InputValidator{
maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength,
maxHeaderLength: config.MaxHeaderLength,
maxClaimLength: config.MaxClaimLength,
maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
emailRegex: emailRegex,
urlRegex: urlRegex,
tokenRegex: tokenRegex,
usernameRegex: usernameRegex,
sqlInjectionPatterns: []string{
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "exec", "execute", "script",
},
xssPatterns: []string{
"<script", "</script>", "javascript:", "vbscript:",
"onload=", "onerror=", "onclick=", "onmouseover=",
"<iframe", "<object", "<embed", "<link", "<meta",
},
pathTraversalPatterns: []string{
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
"..%2f", "..%5c", "%252e%252e%252f",
},
logger: logger,
}, nil
}
// ValidateToken validates JWT tokens and similar token strings
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty token
if token == "" {
result.IsValid = false
result.Errors = append(result.Errors, "token cannot be empty")
return result
}
// Check length limits
if len(token) > iv.maxTokenLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
return result
}
// Check for minimum reasonable length
if len(token) < 10 {
result.IsValid = false
result.Errors = append(result.Errors, "token is too short to be valid")
return result
}
// Check for valid JWT structure (3 parts separated by dots)
parts := strings.Split(token, ".")
if len(parts) != 3 {
result.IsValid = false
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
return result
}
// Validate each part is base64url encoded
for i, part := range parts {
if !iv.isValidBase64URL(part) {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
return result
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(token); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for null bytes and control characters
if iv.containsNullBytes(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains null bytes")
return result
}
if iv.containsControlCharacters(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(token) {
result.IsValid = false
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
return result
}
result.SanitizedValue = token
return result
}
// ValidateEmail validates email addresses
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty email
if email == "" {
result.IsValid = false
result.Errors = append(result.Errors, "email cannot be empty")
return result
}
// Check length limits
if len(email) > iv.maxEmailLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
return result
}
// Sanitize email (trim whitespace, convert to lowercase)
sanitized := strings.TrimSpace(strings.ToLower(email))
// Check regex pattern
if !iv.emailRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "email format is invalid")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Additional email-specific validations
parts := strings.Split(sanitized, "@")
if len(parts) != 2 {
result.IsValid = false
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
return result
}
localPart, domain := parts[0], parts[1]
// Validate local part
if len(localPart) == 0 || len(localPart) > 64 {
result.IsValid = false
result.Errors = append(result.Errors, "email local part length is invalid")
return result
}
// Validate domain
if len(domain) == 0 || len(domain) > 253 {
result.IsValid = false
result.Errors = append(result.Errors, "email domain length is invalid")
return result
}
// Check for consecutive dots
if strings.Contains(sanitized, "..") {
result.IsValid = false
result.Errors = append(result.Errors, "email contains consecutive dots")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateURL validates URLs
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty URL
if urlStr == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL cannot be empty")
return result
}
// Check length limits
if len(urlStr) > iv.maxURLLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
return result
}
// Sanitize URL (trim whitespace)
sanitized := strings.TrimSpace(urlStr)
// Parse URL
parsedURL, err := url.Parse(sanitized)
if err != nil {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
return result
}
// Check scheme
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
result.IsValid = false
result.Errors = append(result.Errors, "URL scheme must be http or https")
return result
}
// Prefer HTTPS
if parsedURL.Scheme == "http" {
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
}
// Check host
if parsedURL.Host == "" {
result.IsValid = false
result.Errors = append(result.Errors, "URL must have a valid host")
return result
}
// Check for localhost or private IPs for security
// Allow localhost for HTTPS (development/testing) but warn about it
hostname := strings.ToLower(parsedURL.Hostname())
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
if parsedURL.Scheme == "https" {
// Allow HTTPS localhost for development but warn
result.Warnings = append(result.Warnings, "localhost URLs should only be used for development/testing")
} else {
// Reject non-HTTPS localhost for security
result.IsValid = false
result.Errors = append(result.Errors, "non-HTTPS localhost URLs are not allowed for security")
return result
}
}
// Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled
if !iv.allowPrivateIPAddresses {
if strings.HasPrefix(hostname, "10.") ||
strings.HasPrefix(hostname, "192.168.") ||
strings.HasPrefix(hostname, "172.") {
// For 172.x check if it's in the 172.16.0.0/12 range
if strings.HasPrefix(hostname, "172.") {
parts := strings.Split(hostname, ".")
if len(parts) >= 2 {
if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 {
result.IsValid = false
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
return result
}
}
} else {
result.IsValid = false
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
return result
}
}
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
// Check for path traversal attempts
if iv.containsPathTraversal(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "URL contains path traversal patterns")
return result
}
result.SanitizedValue = sanitized
return result
}
// ValidateUsername validates usernames
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check for empty username
if username == "" {
result.IsValid = false
result.Errors = append(result.Errors, "username cannot be empty")
return result
}
// Check length limits
if len(username) > iv.maxUsernameLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
return result
}
// Check minimum length
if len(username) < 2 {
result.IsValid = false
result.Errors = append(result.Errors, "username must be at least 2 characters long")
return result
}
// Sanitize username (trim whitespace)
sanitized := strings.TrimSpace(username)
// Check regex pattern
if !iv.usernameRegex.MatchString(sanitized) {
result.IsValid = false
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
result.SecurityRisk = risk
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
}
result.SanitizedValue = sanitized
return result
}
// ValidateClaim validates individual JWT claims
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check claim name
if claimName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "claim name cannot be empty")
return result
}
// Check claim value length
if len(claimValue) > iv.maxClaimLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
return result
}
// Check for null bytes and control characters
if iv.containsNullBytes(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains null bytes")
return result
}
if iv.containsControlCharacters(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(claimValue) {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
result.SecurityRisk = risk
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
return result
}
// Check for excessive unicode (emojis and special characters)
unicodeCount := 0
runeCount := 0
for _, r := range claimValue {
runeCount++
if r > 127 { // Non-ASCII character
unicodeCount++
}
}
// If more than 50% of the characters are unicode, consider it suspicious
if runeCount > 0 && unicodeCount > runeCount/2 {
result.IsValid = false
result.Errors = append(result.Errors, "claim value contains excessive unicode characters")
return result
}
// Specific validations based on claim name
switch claimName {
case "email":
emailResult := iv.ValidateEmail(claimValue)
if !emailResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, emailResult.Errors...)
}
result.Warnings = append(result.Warnings, emailResult.Warnings...)
result.SanitizedValue = emailResult.SanitizedValue
case "iss", "aud":
urlResult := iv.ValidateURL(claimValue)
if !urlResult.IsValid {
// For issuer/audience, we're more lenient - just warn
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
}
result.SanitizedValue = claimValue
case "preferred_username", "username":
usernameResult := iv.ValidateUsername(claimValue)
if !usernameResult.IsValid {
result.IsValid = false
result.Errors = append(result.Errors, usernameResult.Errors...)
}
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
result.SanitizedValue = usernameResult.SanitizedValue
default:
// Generic string validation
result.SanitizedValue = strings.TrimSpace(claimValue)
}
return result
}
// ValidateHeader validates HTTP header values
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
// Check header name
if headerName == "" {
result.IsValid = false
result.Errors = append(result.Errors, "header name cannot be empty")
return result
}
// Check for control characters in header name (including CRLF)
if iv.containsControlCharacters(headerName) {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains control characters")
return result
}
// Check for CRLF injection in header name
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
return result
}
// Check header value length
if len(headerValue) > iv.maxHeaderLength {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
return result
}
// Check for null bytes and control characters (except allowed ones)
if iv.containsNullBytes(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains null bytes")
return result
}
// Check for CRLF injection
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
return result
}
// Check for control characters in header value
if iv.containsControlCharacters(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains control characters")
return result
}
// Validate UTF-8 encoding
if !utf8.ValidString(headerValue) {
result.IsValid = false
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
return result
}
// Check for suspicious patterns
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
result.SecurityRisk = risk
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
return result
}
result.SanitizedValue = strings.TrimSpace(headerValue)
return result
}
// isValidBase64URL checks if a string is valid base64url encoding
func (iv *InputValidator) isValidBase64URL(s string) bool {
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
for _, r := range s {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// containsNullBytes checks if a string contains null bytes
func (iv *InputValidator) containsNullBytes(s string) bool {
return strings.Contains(s, "\x00")
}
// containsControlCharacters checks if a string contains control characters
func (iv *InputValidator) containsControlCharacters(s string) bool {
for _, r := range s {
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
}
// containsPathTraversal checks for path traversal patterns
func (iv *InputValidator) containsPathTraversal(s string) bool {
lowerS := strings.ToLower(s)
for _, pattern := range iv.pathTraversalPatterns {
if strings.Contains(lowerS, pattern) {
return true
}
}
return false
}
// detectSecurityRisk detects potential security risks in input
func (iv *InputValidator) detectSecurityRisk(input string) string {
lowerInput := strings.ToLower(input)
// Check for SQL injection patterns
for _, pattern := range iv.sqlInjectionPatterns {
if strings.Contains(lowerInput, pattern) {
return "sql_injection"
}
}
// Check for XSS patterns
for _, pattern := range iv.xssPatterns {
if strings.Contains(lowerInput, pattern) {
return "xss"
}
}
// Check for path traversal
if iv.containsPathTraversal(input) {
return "path_traversal"
}
// Check for excessive length (potential DoS)
if len(input) > 10000 {
return "excessive_length"
}
// Check for suspicious character patterns
if iv.containsNullBytes(input) {
return "null_bytes"
}
// Check for binary data patterns
nonPrintableCount := 0
for _, r := range input {
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
nonPrintableCount++
}
}
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
return "binary_data"
}
return ""
}
// SanitizeInput provides general input sanitization
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
// Trim whitespace
sanitized := strings.TrimSpace(input)
// Truncate if too long
if len(sanitized) > maxLength {
sanitized = sanitized[:maxLength]
}
// Remove null bytes
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
// Remove other control characters except tab, newline, carriage return
var result strings.Builder
for _, r := range sanitized {
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
result.WriteRune(r)
}
}
return result.String()
}
// ValidateBoundaryValues validates numeric boundary values
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
var numValue int64
switch v := value.(type) {
case int:
numValue = int64(v)
case int32:
numValue = int64(v)
case int64:
numValue = v
case float64:
numValue = int64(v)
if float64(numValue) != v {
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
}
default:
result.IsValid = false
result.Errors = append(result.Errors, "value is not a numeric type")
return result
}
if numValue < min {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
}
if numValue > max {
result.IsValid = false
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
}
return result
}
-895
View File
@@ -1,895 +0,0 @@
package traefikoidc
import (
"strings"
"testing"
)
func TestInputValidator(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid token validation", func(t *testing.T) {
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc" // trufflehog:ignore
result := validator.ValidateToken(validToken)
if !result.IsValid {
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
}
})
t.Run("Invalid token validation", func(t *testing.T) {
invalidTokens := []string{
"", // Empty token
"invalid.token", // Invalid format
"a.b", // Too few parts
"a.b.c.d", // Too many parts
}
for _, token := range invalidTokens {
result := validator.ValidateToken(token)
if result.IsValid {
t.Errorf("Expected invalid token '%s' to fail validation", token)
}
}
})
t.Run("Valid email validation", func(t *testing.T) {
validEmails := []string{
"[email protected]",
"[email protected]",
"[email protected]",
}
for _, email := range validEmails {
result := validator.ValidateEmail(email)
if !result.IsValid {
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
}
}
})
t.Run("Invalid email validation", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"invalid", // No @ symbol
"@domain.com", // No local part
"user@", // No domain
"user@domain", // No TLD
"[email protected]", // Double dots
}
for _, email := range invalidEmails {
result := validator.ValidateEmail(email)
if result.IsValid {
t.Errorf("Expected invalid email '%s' to fail validation", email)
}
}
})
t.Run("Valid URL validation", func(t *testing.T) {
validURLs := []string{
"https://example.com",
"https://sub.domain.com/path",
"https://localhost:8080/callback",
}
for _, url := range validURLs {
result := validator.ValidateURL(url)
if !result.IsValid {
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
}
}
})
t.Run("Invalid URL validation", func(t *testing.T) {
invalidURLs := []string{
"", // Empty
"not-a-url", // Invalid format
"ftp://example.com", // Wrong scheme
"https://", // No host
}
for _, url := range invalidURLs {
result := validator.ValidateURL(url)
if result.IsValid {
t.Errorf("Expected invalid URL '%s' to fail validation", url)
}
}
})
t.Run("Valid username validation", func(t *testing.T) {
validUsernames := []string{
"user123",
"test_user",
"user-name",
}
for _, username := range validUsernames {
result := validator.ValidateUsername(username)
if !result.IsValid {
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
}
}
})
t.Run("Invalid username validation", func(t *testing.T) {
invalidUsernames := []string{
"", // Empty
"a", // Too short
strings.Repeat("a", 100), // Too long
"user name", // Spaces
}
for _, username := range invalidUsernames {
result := validator.ValidateUsername(username)
if result.IsValid {
t.Errorf("Expected invalid username '%s' to fail validation", username)
}
}
})
t.Run("Valid claim validation", func(t *testing.T) {
validClaims := map[string]string{
"sub": "user123",
"email": "[email protected]",
"name": "John Doe",
}
for key, value := range validClaims {
result := validator.ValidateClaim(key, value)
if !result.IsValid {
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid claim validation", func(t *testing.T) {
invalidClaims := map[string]string{
"": "value", // Empty key
"long_key": strings.Repeat("a", 10000), // Too long value
}
for key, value := range invalidClaims {
result := validator.ValidateClaim(key, value)
if result.IsValid {
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
}
}
})
t.Run("Valid header validation", func(t *testing.T) {
validHeaders := map[string]string{
"Authorization": "Bearer token123",
"Content-Type": "application/json",
"X-Custom": "custom-value",
}
for key, value := range validHeaders {
result := validator.ValidateHeader(key, value)
if !result.IsValid {
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
}
}
})
t.Run("Invalid header validation", func(t *testing.T) {
invalidHeaders := map[string]string{
"": "value", // Empty key
"Invalid\nKey": "value", // Control characters in key
"key": "value\r\n", // Control characters in value
}
for key, value := range invalidHeaders {
result := validator.ValidateHeader(key, value)
if result.IsValid {
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
}
}
})
}
func TestSanitizeInput(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
tests := []struct {
name string
input string
expected string
maxLen int
}{
{
name: "Normal text",
input: "Hello World",
maxLen: 100,
expected: "Hello World",
},
{
name: "Control characters",
input: "text\x00with\x01control\x02chars",
maxLen: 100,
expected: "textwithcontrolchars",
},
{
name: "Truncation",
input: "very long text",
maxLen: 5,
expected: "very ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.SanitizeInput(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestValidateBoundaryValues(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Valid boundary values", func(t *testing.T) {
validValues := []interface{}{
int(50),
int64(100),
float64(75.5),
}
for _, value := range validValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if !result.IsValid {
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
}
}
})
t.Run("Invalid boundary values", func(t *testing.T) {
invalidValues := []interface{}{
int(-1),
int64(2000),
"not a number",
}
for _, value := range invalidValues {
result := validator.ValidateBoundaryValues(value, 1, 1000)
if result.IsValid {
t.Errorf("Expected invalid boundary value %v to fail validation", value)
}
}
})
}
func TestDefaultInputValidationConfig(t *testing.T) {
config := DefaultInputValidationConfig()
if config.MaxTokenLength <= 0 {
t.Error("Expected positive MaxTokenLength")
}
if config.MaxEmailLength <= 0 {
t.Error("Expected positive MaxEmailLength")
}
if config.MaxUsernameLength <= 0 {
t.Error("Expected positive MaxUsernameLength")
}
if config.MaxClaimLength <= 0 {
t.Error("Expected positive MaxClaimLength")
}
if config.MaxHeaderLength <= 0 {
t.Error("Expected positive MaxHeaderLength")
}
if !config.StrictMode {
t.Error("Expected StrictMode to be true by default")
}
}
func TestInputValidationHelpers(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("isValidBase64URL", func(t *testing.T) {
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
if !validator.isValidBase64URL(validBase64URL) {
t.Error("Expected valid base64url to be recognized")
}
invalidBase64URL := "invalid+base64/with+padding="
if validator.isValidBase64URL(invalidBase64URL) {
t.Error("Expected invalid base64url to be rejected")
}
})
t.Run("containsNullBytes", func(t *testing.T) {
withNull := "text\x00with\x00null"
if !validator.containsNullBytes(withNull) {
t.Error("Expected string with null bytes to be detected")
}
withoutNull := "normal text"
if validator.containsNullBytes(withoutNull) {
t.Error("Expected string without null bytes to pass")
}
})
t.Run("containsControlCharacters", func(t *testing.T) {
withControl := "text\x01with\x02control"
if !validator.containsControlCharacters(withControl) {
t.Error("Expected string with control characters to be detected")
}
withoutControl := "normal text"
if validator.containsControlCharacters(withoutControl) {
t.Error("Expected string without control characters to pass")
}
})
t.Run("containsPathTraversal", func(t *testing.T) {
withTraversal := "../../../etc/passwd"
if !validator.containsPathTraversal(withTraversal) {
t.Error("Expected path traversal to be detected")
}
normalPath := "/normal/path"
if validator.containsPathTraversal(normalPath) {
t.Error("Expected normal path to pass")
}
})
t.Run("detectSecurityRisk", func(t *testing.T) {
riskyInputs := []string{
"<script>alert('xss')</script>",
"'; DROP TABLE users; --",
"javascript:alert('xss')",
}
for _, input := range riskyInputs {
if validator.detectSecurityRisk(input) == "" {
t.Errorf("Expected security risk to be detected in: %s", input)
}
}
safeInput := "normal safe text"
if validator.detectSecurityRisk(safeInput) != "" {
t.Error("Expected safe input to pass security check")
}
})
}
func TestInputValidationEdgeCases(t *testing.T) {
config := DefaultInputValidationConfig()
logger := NewLogger("debug")
validator, err := NewInputValidator(config, logger)
if err != nil {
t.Fatalf("Failed to create validator: %v", err)
}
t.Run("Empty inputs", func(t *testing.T) {
// Most validations should reject empty inputs
if result := validator.ValidateToken(""); result.IsValid {
t.Error("Expected empty token to be rejected")
}
if result := validator.ValidateEmail(""); result.IsValid {
t.Error("Expected empty email to be rejected")
}
if result := validator.ValidateURL(""); result.IsValid {
t.Error("Expected empty URL to be rejected")
}
if result := validator.ValidateUsername(""); result.IsValid {
t.Error("Expected empty username to be rejected")
}
})
t.Run("Very long inputs", func(t *testing.T) {
longString := strings.Repeat("a", 10000)
if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid {
t.Error("Expected very long email to be rejected")
}
if result := validator.ValidateUsername(longString); result.IsValid {
t.Error("Expected very long username to be rejected")
}
})
t.Run("Unicode handling", func(t *testing.T) {
unicodeEmail := "用户@example.com"
// Should handle unicode gracefully
validator.ValidateEmail(unicodeEmail) // Don't fail on unicode
unicodeUsername := "用户名"
validator.ValidateUsername(unicodeUsername) // Don't fail on unicode
})
}
// TestInputValidatorValidateToken tests comprehensive token validation
func TestInputValidatorValidateToken(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
token string
description string
expectValid bool
}{
{
name: "ValidJWTToken",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjJ9.signature", // trufflehog:ignore
expectValid: true,
description: "Valid JWT token should pass validation",
},
{
name: "InvalidOpaqueToken",
token: "opaque_access_token_that_is_long_enough_to_pass",
expectValid: false,
description: "Opaque token (non-JWT) should fail validation",
},
{
name: "EmptyToken",
token: "",
expectValid: false,
description: "Empty token should fail validation",
},
{
name: "TokenWithNullBytes",
token: "token_with_null\x00byte",
expectValid: false,
description: "Token with null bytes should fail validation",
},
{
name: "TokenTooLong",
token: strings.Repeat("a", config.MaxTokenLength+1),
expectValid: false,
description: "Token exceeding max length should fail validation",
},
{
name: "TokenWithControlCharacters",
token: "token_with_control\x01character",
expectValid: false,
description: "Token with control characters should fail validation",
},
{
name: "TokenWithHighUnicode",
token: "token_with_unicode_\uffff",
expectValid: false,
description: "Token with high unicode characters should fail validation",
},
{
name: "MaliciousJWTWithExtraData",
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig.malicious_extra", // trufflehog:ignore
expectValid: false,
description: "JWT with extra malicious data should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateToken(tt.token)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateEmail tests email validation edge cases
func TestInputValidatorValidateEmail(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
email string
description string
expectValid bool
}{
{
name: "ValidEmail",
email: "[email protected]",
expectValid: true,
description: "Valid email should pass validation",
},
{
name: "ValidEmailWithSubdomain",
email: "[email protected]",
expectValid: true,
description: "Valid email with subdomain should pass validation",
},
{
name: "EmptyEmail",
email: "",
expectValid: false,
description: "Empty email should fail validation",
},
{
name: "EmailWithoutAtSign",
email: "userexample.com",
expectValid: false,
description: "Email without @ sign should fail validation",
},
{
name: "EmailWithNullBytes",
email: "user@example\x00.com",
expectValid: false,
description: "Email with null bytes should fail validation",
},
{
name: "EmailTooLong",
email: strings.Repeat("a", config.MaxEmailLength-10) + "@example.com",
expectValid: false,
description: "Email exceeding max length should fail validation",
},
{
name: "EmailWithControlCharacters",
email: "user\[email protected]",
expectValid: false,
description: "Email with control characters should fail validation",
},
{
name: "MaliciousEmailWithScriptTag",
email: "user<script>@example.com",
expectValid: false,
description: "Email with script tag should fail validation",
},
{
name: "EmailWithUnicodeCharacters",
email: "üser@éxample.com",
expectValid: false,
description: "Email with unicode should fail basic validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateEmail(tt.email)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateURL tests URL validation with security focus
func TestInputValidatorValidateURL(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
url string
description string
expectValid bool
}{
{
name: "ValidHTTPSURL",
url: "https://example.com/path",
expectValid: true,
description: "Valid HTTPS URL should pass validation",
},
{
name: "ValidHTTPURL",
url: "http://example.com/path",
expectValid: true,
description: "Valid HTTP URL should pass validation",
},
{
name: "EmptyURL",
url: "",
expectValid: false,
description: "Empty URL should fail validation",
},
{
name: "InvalidScheme",
url: "ftp://example.com",
expectValid: false,
description: "URL with invalid scheme should fail validation",
},
{
name: "URLWithNullBytes",
url: "https://example\x00.com",
expectValid: false,
description: "URL with null bytes should fail validation",
},
{
name: "URLTooLong",
url: "https://" + strings.Repeat("a", config.MaxURLLength) + ".com",
expectValid: false,
description: "URL exceeding max length should fail validation",
},
{
name: "MalformedURL",
url: "https://",
expectValid: false,
description: "Malformed URL should fail validation",
},
{
name: "HTTPSLocalhostURL",
url: "https://localhost:8080/path",
expectValid: true,
description: "HTTPS localhost URL should be allowed for development",
},
{
name: "HTTPLocalhostURL",
url: "http://localhost:8080/path",
expectValid: false,
description: "HTTP localhost URL should fail validation for security",
},
{
name: "PrivateIPURL",
url: "https://192.168.1.1/path",
expectValid: false,
description: "Private IP URL should fail validation for security",
},
{
name: "JavaScriptURL",
url: "javascript:alert(1)",
expectValid: false,
description: "JavaScript URL should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateURL(tt.url)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateClaim tests claim validation with security focus
func TestInputValidatorValidateClaim(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
claimName string
claimValue string
description string
expectValid bool
}{
{
name: "ValidStringClaim",
claimName: "email",
claimValue: "[email protected]",
expectValid: true,
description: "Valid string claim should pass validation",
},
{
name: "ValidNumberClaim",
claimName: "exp",
claimValue: "1516239022",
expectValid: true,
description: "Valid number claim should pass validation",
},
{
name: "EmptyClaimName",
claimName: "",
claimValue: "value",
expectValid: false,
description: "Empty claim name should fail validation",
},
{
name: "ClaimWithNullBytes",
claimName: "test",
claimValue: "value\x00with_null",
expectValid: false,
description: "Claim with null bytes should fail validation",
},
{
name: "ClaimValueTooLong",
claimName: "test",
claimValue: strings.Repeat("a", config.MaxClaimLength+1),
expectValid: false,
description: "Claim value exceeding max length should fail validation",
},
{
name: "ClaimWithControlCharacters",
claimName: "test",
claimValue: "value\x01with_control",
expectValid: false,
description: "Claim with control characters should fail validation",
},
{
name: "MaliciousClaimWithHTML",
claimName: "test",
claimValue: "<script>alert('xss')</script>",
expectValid: false,
description: "Claim with HTML/script should fail validation",
},
{
name: "ClaimWithExcessiveUnicode",
claimName: "test",
claimValue: strings.Repeat("🚀", 100), // Many unicode chars
expectValid: false,
description: "Claim with excessive unicode should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateClaim(tt.claimName, tt.claimValue)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateHeader tests HTTP header validation
func TestInputValidatorValidateHeader(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
headerName string
headerValue string
description string
expectValid bool
}{
{
name: "ValidHeader",
headerName: "Authorization",
headerValue: "Bearer token123",
expectValid: true,
description: "Valid header should pass validation",
},
{
name: "ValidContentType",
headerName: "Content-Type",
headerValue: "application/json",
expectValid: true,
description: "Valid content type header should pass validation",
},
{
name: "EmptyHeaderName",
headerName: "",
headerValue: "value",
expectValid: false,
description: "Empty header name should fail validation",
},
{
name: "HeaderWithNullBytes",
headerName: "test",
headerValue: "value\x00with_null",
expectValid: false,
description: "Header with null bytes should fail validation",
},
{
name: "HeaderValueTooLong",
headerName: "test",
headerValue: strings.Repeat("a", config.MaxHeaderLength+1),
expectValid: false,
description: "Header value exceeding max length should fail validation",
},
{
name: "HeaderWithCRLF",
headerName: "test",
headerValue: "value\r\nMalicious: header",
expectValid: false,
description: "Header with CRLF should fail validation to prevent injection",
},
{
name: "HeaderWithControlCharacters",
headerName: "test",
headerValue: "value\x01with_control",
expectValid: false,
description: "Header with control characters should fail validation",
},
{
name: "MaliciousHeaderWithHTML",
headerName: "test",
headerValue: "<script>alert('xss')</script>",
expectValid: false,
description: "Header with HTML/script should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateHeader(tt.headerName, tt.headerValue)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}
// TestInputValidatorValidateUsername tests username validation
func TestInputValidatorValidateUsername(t *testing.T) {
config := DefaultInputValidationConfig()
validator, _ := NewInputValidator(config, newNoOpLogger())
tests := []struct {
name string
username string
description string
expectValid bool
}{
{
name: "ValidUsername",
username: "john_doe",
expectValid: true,
description: "Valid username should pass validation",
},
{
name: "ValidUsernameWithNumbers",
username: "user123",
expectValid: true,
description: "Valid username with numbers should pass validation",
},
{
name: "EmptyUsername",
username: "",
expectValid: false,
description: "Empty username should fail validation",
},
{
name: "UsernameWithNullBytes",
username: "user\x00name",
expectValid: false,
description: "Username with null bytes should fail validation",
},
{
name: "UsernameTooLong",
username: strings.Repeat("a", config.MaxUsernameLength+1),
expectValid: false,
description: "Username exceeding max length should fail validation",
},
{
name: "UsernameWithSpecialChars",
username: "user@name",
expectValid: false,
description: "Username with special characters should fail validation",
},
{
name: "UsernameWithSpaces",
username: "user name",
expectValid: false,
description: "Username with spaces should fail validation",
},
{
name: "UsernameWithControlCharacters",
username: "user\x01name",
expectValid: false,
description: "Username with control characters should fail validation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validator.ValidateUsername(tt.username)
if result.IsValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More