Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 124601d559 chore(release): v1.0.36 2026-09-10 13:52:07 +00:00
413 changed files with 21094 additions and 35296 deletions
+52
View File
@@ -0,0 +1,52 @@
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"]
+1 -38
View File
@@ -18,43 +18,6 @@ jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.26.x"
go-version: "1.25.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
+1 -1
View File
@@ -19,5 +19,5 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.26.x"
go-version: "1.25.x"
secrets: inherit
+1 -1
View File
@@ -1,4 +1,4 @@
docker/
.claude/
.claude/*.out
*.test
.leann/
-7
View File
@@ -72,13 +72,6 @@ testData:
value: "{{.Claims.email}}"
- name: Authorization
value: "Bearer {{.AccessToken}}"
# Object/array claim as one JSON header; the claim must be whitelisted.
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}'
# Extends the built-in claim whitelist for templated headers.
allowedClaims:
- tenant
# Security headers (default profile is enabled out of the box)
securityHeaders:
-146
View File
@@ -1,146 +0,0 @@
# 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`).
+3 -3
View File
@@ -29,7 +29,7 @@ vet: ## Run 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
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || echo "golangci-lint not installed; skipping"
.PHONY: staticcheck
staticcheck: ## Run staticcheck (matches the CI "Static Analysis" job; catches U1000 unused, etc.)
@@ -37,8 +37,8 @@ staticcheck: ## Run staticcheck (matches the CI "Static Analysis" job; catches U
@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)
test: ## Run the test suite
$(GO) test ./... -count=1 -timeout $(TEST_TIMEOUT)
.PHONY: vendor
vendor: ## Refresh and vendor dependencies
+7 -69
View File
@@ -126,20 +126,13 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `refreshGracePeriodSeconds` | `60` | Proactively refresh tokens this many seconds before expiry. |
| `maxRefreshTokenAgeSeconds` | `21600` | Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set `0` to disable. Tune to match your IdP's RT TTL. |
| `rateLimit` | `100` | Requests/sec. Min `10`. |
| `perSourceLoginRateLimit` | `0` (off) | Throttle OIDC auth events (authorization-code callback + login initiation) per external client source, 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 (proxies, in-cluster) are never throttled. `0` disables. 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. |
| `logLevel` | `info` | `debug`, `info`, `error`. |
| `audience` | `clientID`, or `resource` if set | Custom access-token audience (Auth0 custom APIs). |
| `resource` | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs (see [docs/RFC8707.md](docs/RFC8707.md)). |
| `strictAudienceValidation` | `false` | Reject mismatched audiences. **Set `true` in production.** |
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
| `revocationURL` / `oidcEndSessionURL` / `introspectionURL` | `""` | Override the discovered revocation / end-session / introspection endpoints; take precedence when discovery omits them. |
| `maxIdentifierLength` | `256` | Bound the post-sanitisation length of the bearer identity header (`X-Forwarded-User`). |
| `bearerFailureWindowSeconds` | `60` | Rolling window (s) over which per-IP bearer 401s are counted toward the throttle. |
| `bearerFailurePenaltySeconds` | `60` | Seconds an IP is kept in the 429 penalty box after the bearer failure threshold. |
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
| `allowPrivateIPAddresses` | `false` | Permit private-IP `providerURL` (internal Keycloak, etc.). |
| `allowUnauthenticatedPreflight` | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`); the response body is still discarded. Default requires auth for every `OPTIONS` request, preflight or not. |
| `streamingRefresh` | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request when the IdP revokes the grant. |
| `minimalHeaders` | `false` | Reduce forwarded headers (mitigates HTTP 431). |
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
@@ -162,31 +155,11 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
(previously signed only) and their cryptographic lifetime tracks
`sessionMaxAge` (previously a fixed 30 days). Existing cookies become invalid
on upgrade, so users re-authenticate one time.
- **A rollback to v1.0.25 or earlier also invalidates sessions.** Those
releases cannot read the encrypted cookies that v1.0.26 and later issue.
They log `securecookie: the value is not valid` and `No state in
callback`, and the browser loops through the login. After a rollback, tell
users to delete
the `_oidc_raczylo_*` cookies (or the cookies with your `cookiePrefix`)
for the affected hosts. The cookies are `HttpOnly`, so users must delete
them in the browser site-data settings.
- **Invalid configuration now fails closed at startup** instead of being
silently accepted: a `sessionEncryptionKey` shorter than 32 bytes, a
`rateLimit` below 10, a missing `callbackURL`, or a non-HTTPS remote
`providerURL` are rejected. Plaintext HTTP is permitted only for loopback
hosts (local development).
- **A `private_key_jwt` RSA key under 2048 bits now fails startup** instead
of signing locally and failing `invalid_client` at every token exchange
(RFC 7518 §3.3). Generate a 2048-bit-or-larger key before upgrading. No
override exists.
- **A discovered `http://` endpoint under an `https://` `providerURL` is now
dropped**, not used as-is: the plugin refuses to send a client secret or
token over plaintext HTTP even when the IdP's own discovery document
advertises it that way. A dropped `token`, `jwks_uri`, or `authorization`
endpoint breaks login; a `SECURITY:`-tagged log line names the endpoint.
See [Discovered Endpoint
Validation](docs/CONFIGURATION.md#discovered-endpoint-validation). No
override exists.
### TLS termination at a load balancer
@@ -264,10 +237,9 @@ Hardening built in by default:
`Authorization: Bearer` header arrive on the same request, the cookie path
runs (safer against browser/extension/proxy bearer injection). Set
`bearerOverridesCookie: true` for the AWS/GCP/Kubernetes convention.
- **Revocation enforced.** No access-token JTI is recorded for replay;
`RevokeToken` (called from logout) blacklists the raw token (and a JWT's
`jti`), and both the JWT and the opaque bearer path reject a blacklisted
token.
- **Replay protection preserved.** The bearer path skips the JTI **Set**
(so the same token can be reused) but the **Get** stays active —
`RevokeToken` still terminates a bearer token immediately.
- **Excluded URLs strip Authorization.** When `enableBearerAuth=true`,
excluded paths (e.g. `/health`, `/metrics`) get the `Authorization` header
removed before forwarding so the token can't leak into public endpoint
@@ -313,17 +285,9 @@ WebSocket upgrade. The middleware handles this automatically:
- They are **not** unauthenticated — a valid encrypted session cookie is
required, otherwise the request is rejected. The session must already exist
(i.e. the user logged in via a normal HTTP page first).
- `X-Forwarded-User` and the configured `headers` are forwarded from the
session. Header templates render from the claims in the session cookie.
- `X-Forwarded-User` is forwarded from the session.
- Validation is cookie-only (no JWK fetch), so streaming keeps working during
brief IdP outages. Back-channel and front-channel logout still apply: a
session that the IdP logged out is rejected.
- By default, the token expiry is not checked, so a session stays usable
for streaming until the session cookie expires. Set `streamingRefresh: true`
to refresh an expired token on the request. If the IdP rejects the refresh
(`invalid_grant`), the request gets `401`. If the IdP cannot be reached,
the request is forwarded as before. On a WebSocket upgrade, the refreshed
session cookie arrives on the `101 Switching Protocols` response.
brief IdP outages.
No configuration needed — this is implicit behavior.
@@ -441,34 +405,8 @@ headers:
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default`
and `toJson` are the only functions allowed.
### Forwarding an object claim as JSON
`toJson` serializes a claim (object, array or scalar) to compact JSON, so a
backend can parse the whole claim itself instead of the middleware flattening
it into one header per key:
```yaml
allowedClaims:
- tenant
headers:
- name: X-Tenant
value: '{{get .Claims "tenant" | toJson}}' # {"id":"t-1","plan":"pro"}
- name: X-Groups
value: "{{toJson .Claims.groups}}" # ["admin","users"]
```
- The claim must be on the whitelist (built-in or `allowedClaims`).
`{{toJson .Claims}}`, `{{toJson .}}` and non-listed claims are rejected.
- An absent or empty claim skips the header instead of sending `""`.
- Control characters are JSON-escaped, so a claim cannot inject extra header
lines.
- A rendered value over 8192 bytes, or one with bidi-override characters, is
dropped. The oversize case is logged at error level (header name only, never
the value); keep the claim small, since proxies commonly cap headers at
8-16 KB.
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`/`default`
are the only functions allowed.
> **File-provider users: escape the braces.** Traefik's file provider runs
> every dynamic configuration file through Go templating before the plugin
@@ -1,49 +0,0 @@
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
@@ -1,24 +0,0 @@
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)
}
}
}
+18
View File
@@ -326,12 +326,16 @@ 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,6 +399,8 @@ 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)
@@ -402,6 +408,8 @@ 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)")
@@ -452,6 +460,8 @@ 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)
@@ -514,6 +524,8 @@ 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)
@@ -546,6 +558,8 @@ 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")
@@ -655,6 +669,8 @@ 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)
@@ -676,6 +692,8 @@ 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")
+15 -203
View File
@@ -1,37 +1,12 @@
package traefikoidc
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"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
@@ -39,16 +14,6 @@ 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")
}
@@ -103,34 +68,9 @@ 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
@@ -157,42 +97,9 @@ 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 {
@@ -201,17 +108,12 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
return
}
// 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")
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
csrfToken, nonce)
t.logger.Debugf("Redirecting user to OIDC provider: %s", redactAuthorizeURLForLog(authURL))
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
t.logger.Debugf("Redirecting user to OIDC provider: %s", 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)
}
@@ -222,31 +124,7 @@ 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)
@@ -255,28 +133,15 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
}
defer session.returnToPoolSafely()
t.logger.Debugf("Handling callback, URL: %s", redactCallbackURL(req.URL.String()))
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
if req.URL.Query().Get("error") != "" {
errorDescription := req.URL.Query().Get("error_description")
if errorDescription == "" {
errorDescription = req.URL.Query().Get("error")
}
// 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)
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)
return
}
@@ -290,7 +155,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(), redactCallbackURL(req.URL.String()))
session.GetAuthenticated(), req.URL.String())
cookie, err := req.Cookie("_oidc_raczylo_m")
if err != nil {
@@ -303,7 +168,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
return
}
if !constantTimeStringCompare(state, csrfToken) {
if 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
@@ -323,49 +188,15 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
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
}
@@ -373,7 +204,6 @@ 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
}
@@ -381,57 +211,41 @@ 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.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusBadRequest)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
return
}
sessionNonce := session.GetNonce()
if sessionNonce == "" {
t.logger.Error("Nonce not found in session during callback")
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusBadRequest)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
return
}
if !constantTimeStringCompare(nonceClaim, sessionNonce) {
if nonceClaim != sessionNonce {
t.logger.Error("Nonce claim does not match session nonce during callback")
t.clearOneTimeAuthState(session, req, rw)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusBadRequest)
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
return
}
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
userIdentifier, _ := claimScalarString(claims[t.userIdentifierClaim])
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
if userIdentifier == "" {
// Try "sub" as fallback since it's required by OIDC spec
if t.userIdentifierClaim != "sub" {
userIdentifier, _ = claimScalarString(claims["sub"])
userIdentifier, _ = claims["sub"].(string)
}
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
}
@@ -468,8 +282,6 @@ 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)
}
+2 -4
View File
@@ -957,10 +957,8 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() {
// Call handleCallback
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
// 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)
// Should return internal server error due to nonce mismatch
s.Equal(http.StatusInternalServerError, rw2.Code)
}
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
+24 -118
View File
@@ -28,12 +28,6 @@ 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.
@@ -52,12 +46,6 @@ 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,
@@ -93,7 +81,6 @@ 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)
@@ -101,21 +88,8 @@ func (bt *BackgroundTask) Start() {
return
}
// 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
}
// Reserve the task slot immediately when starting
registry.cb.OnTaskStart(bt.name)
atomic.StoreInt32(&bt.started, 1)
bt.internalWG.Add(1)
@@ -140,14 +114,6 @@ 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
}
@@ -168,35 +134,16 @@ func (bt *BackgroundTask) Stop() {
// This avoids the race condition with WaitGroup
select {
case <-bt.doneChan:
// Normal completion: the goroutine has returned, so it is safe
// to wait on the WaitGroup.
bt.internalWG.Wait()
// Normal completion
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)
}
}
})
}
// 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()
// Wait for the internal WaitGroup synchronously after doneChan signals
bt.internalWG.Wait()
})
}
// run is the main loop for the background task.
@@ -240,7 +187,7 @@ func (bt *BackgroundTask) run() {
}
return
default:
bt.safeRunTask()
bt.taskFunc()
}
for {
@@ -259,7 +206,7 @@ func (bt *BackgroundTask) run() {
}
return
default:
bt.safeRunTask()
bt.taskFunc()
}
case <-bt.stopChan:
if bt.logger != nil {
@@ -382,30 +329,9 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
}
}
// 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
}
}
// OnTaskStart records a task starting execution
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
atomic.AddInt32(&cb.concurrentTasks, 1)
cb.tasksMu.Lock()
cb.activeTasks[taskName] = struct{}{}
cb.tasksMu.Unlock()
@@ -416,7 +342,6 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
atomic.LoadInt32(&cb.concurrentTasks), taskName)
}
return true
}
// OnTaskComplete records a task completing execution
@@ -538,13 +463,10 @@ 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
// 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.
tr.cb.OnTaskSuccess(name)
if tr.logger != nil {
tr.logger.Debug("Registered background task: %s", name)
@@ -577,18 +499,9 @@ func (tr *TaskRegistry) GetTask(name string) (*BackgroundTask, bool) {
return task, exists
}
// 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.
// StopAllTasks stops all registered background tasks
func (tr *TaskRegistry) StopAllTasks() {
// First, stop tasks registered directly in this registry.
// Copy the tasks map to avoid deadlock with GetTaskCount().
// First, 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 {
@@ -598,15 +511,13 @@ func (tr *TaskRegistry) StopAllTasks() {
tr.tasks = make(map[string]*BackgroundTask)
tr.mu.Unlock()
for _, task := range tasksCopy {
// Now stop all tasks without holding the lock
for name, 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
@@ -810,14 +721,9 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
historyLen := len(mm.statsHistory)
if historyLen >= 2 {
prev := mm.statsHistory[historyLen-2]
// 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)
}
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()
+9 -9
View File
@@ -265,14 +265,14 @@ func TestAzureOIDCRegression(t *testing.T) {
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
// 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).
// Should not be authenticated due to validation failure
if authenticated {
t.Error("Should not be authenticated when token validation fails")
}
if needsRefresh || expired {
t.Error("With no refresh token and no tokens to probe, should be (false,false,false)")
// 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")
}
// Verify CSRF token is still preserved in session
@@ -691,7 +691,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
return session
},
expectedAuth: false,
expectedRefresh: false,
expectedRefresh: true,
expectedExpired: false,
description: "Unauthenticated Azure session without refresh token",
},
@@ -725,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: false,
expectedAuth: true,
expectedRefresh: false,
expectedExpired: true,
description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
expectedExpired: false,
description: "Azure session with opaque access token",
},
{
name: "AuthenticatedWithBothTokensInvalid",
+35 -452
View File
@@ -12,13 +12,8 @@
// - iat upper-age cap bounds clock-skew / forever-token abuse.
// - Multi-audience tokens require matching azp.
// - Per-IP 401 throttle returns 429 + Retry-After after a threshold.
// - No path writes an access-token JTI to a replay cache (FIX-17 removed
// that writer; the only JTI replay tracking left is for backchannel
// logout tokens, logout.go checkAndMarkLogoutJTIProcessed). RevokeToken
// (called from logout) still blacklists the raw token and, for a JWT,
// its jti; both the JWT and opaque-introspection bearer paths check
// that blacklist before accepting a token, so revocation stays
// effective without replay-detection overhead.
// - JTI Set is suppressed (skipReplayMarking) but JTI Get stays — revoked
// tokens (RevokeToken adds to blacklist) are still rejected.
// - Identifier is read from BearerIdentifierClaim (default "sub"), never
// from UserIdentifierClaim, to avoid the unverified-email spoofing path.
// - Identifier is sanitized: length cap, control chars, bidi-override,
@@ -98,10 +93,6 @@ const (
type bearerError struct {
kind bearerErrorKind
reason string
// retryAfter carries the actual remaining penalty when known (e.g. the
// per-IP penalty box), so writeBearerError emits the true value instead
// of the full configured penalty.
retryAfter time.Duration
}
func (e *bearerError) Error() string { return e.reason }
@@ -119,32 +110,6 @@ type joseHeader struct {
Typ string `json:"typ"`
}
// joseHeaderWellFormed reports whether token is structurally a JWT: exactly
// two dot-separated segments whose header decodes as a JSON JOSE header.
// It intentionally applies NO alg/kid policy (unlike parseBearerJOSEHeader)
// so the JWT-vs-opaque routing gate classifies purely on structure: a
// structurally-valid JWT with a disallowed alg stays on the JWT path
// (where local policy rejects it), while an opaque token whose two dots
// are coincidental falls through to opaque introspection (R166).
func joseHeaderWellFormed(token string) bool {
dot := strings.IndexByte(token, '.')
if dot <= 0 {
return false
}
if strings.Count(token[dot+1:], ".") != 1 {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(token[:dot])
if err != nil {
raw, err = base64.URLEncoding.DecodeString(token[:dot])
if err != nil {
return false
}
}
var hdr joseHeader
return json.Unmarshal(raw, &hdr) == nil
}
// parseBearerJOSEHeader decodes the first JWT segment for early alg/kid pinning.
// Does not touch the payload or signature — those are the verifier's job.
// Returns nil on success; *bearerError on rejection so the handler can map
@@ -188,22 +153,19 @@ func parseBearerJOSEHeader(token string) *bearerError {
// header value, or "" if the rune is acceptable. Shared core of the bearer-path
// identifier sanitizer and the cookie-path header claim sanitizer: rejects
// control chars (CRLF/header injection), Unicode bidi-override runes (RTL
// spoofing of admin UI / SIEM), and the delimiters , ; = plus the bracketing
// characters " { } — each of these in a group name / identifier would break a
// downstream comma-joined list or a naive CSV/JSON-style parser consuming the
// header (a comma would inject extra entries into a comma-joined header).
// spoofing of admin UI / SIEM), and the delimiters , ; = (a comma in a group
// name would inject extra entries into a comma-joined header).
func headerClaimRuneReason(r rune) string {
if reason := headerInjectionRuneReason(r); reason != "" {
return reason
}
// The , ; = delimiters and the " { } bracketing chars are only unsafe for
// values placed into delimited or list contexts (a comma-joined header, or
// an identifier downstreams may split). They are valid in arbitrary single
// header values, so this stricter check is used for the cookie-path
// identifier and the group/role list, NOT for free-form templated header
// output (see headerValueReason).
if r == ',' || r == ';' || r == '=' || r == '"' || r == '{' || r == '}' {
return "delimiter or bracketing character"
// The , ; = delimiters are only unsafe for values placed into delimited or
// list contexts (a comma-joined header, or an identifier downstreams may
// split). They are valid in arbitrary single header values, so this stricter
// check is used for the cookie-path identifier and the group/role list, NOT
// for free-form templated header output (see headerValueReason).
if r == ',' || r == ';' || r == '=' {
return "delimiter character"
}
return ""
}
@@ -223,9 +185,6 @@ func headerInjectionRuneReason(r rune) string {
return ""
}
// headerReasonTooLong is the headerValueReason result for an over-long value.
const headerReasonTooLong = "exceeds max length"
// headerValueReason reports why value is unsafe to forward as a free-form HTTP
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
// disables the check) and values containing control or bidi-override runes, but
@@ -233,7 +192,7 @@ const headerReasonTooLong = "exceeds max length"
// never includes the value, so it is safe to log.
func headerValueReason(value string, maxLen int) string {
if maxLen > 0 && len(value) > maxLen {
return headerReasonTooLong
return "exceeds max length"
}
for _, r := range value {
if reason := headerInjectionRuneReason(r); reason != "" {
@@ -311,12 +270,11 @@ func resolveBearerIdentifier(claims map[string]interface{}, claimName string) (s
if !ok {
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("missing claim %q", claimName))
}
// Accept scalar numbers (e.g. a numeric sub) by stringifying them,
// so a numeric identifier claim doesn't 401 a valid principal.
if s, isScalar := claimScalarString(raw); isScalar {
return s, nil
str, ok := raw.(string)
if !ok {
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q not a string", claimName))
}
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q is not a scalar string or number", claimName))
return str, nil
}
// enforceMultiAudienceAzp implements the spec hardening: when aud is a
@@ -357,25 +315,13 @@ func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerE
}
iatRaw, ok := claims["iat"].(float64)
if !ok {
// iat is OPTIONAL per RFC 7519 §4.1.6, and jwt.Verify validates it
// only when present (R126) — this branch IS reachable. maxAge is NOT
// an operator opt-in: New() (main.go's config-default closure) always
// sets maxTokenAge > 0 — 0/unset becomes 24h — so this branch rejects
// every iat-less bearer JWT in production, not just when an operator
// has explicitly configured maxTokenAgeSeconds. A token with no iat
// has nothing to bound its age against, so fail closed rather than
// silently skip the check.
// jwt.Verify already requires iat; this branch shouldn't be reached.
return newBearerError(bearerErrInvalidToken, "missing iat claim")
}
iat := time.Unix(int64(iatRaw), 0)
if time.Since(iat) > maxAge {
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
}
// The opaque introspection path never runs jwt.Verify, so a future iat
// would otherwise defeat the age bound for the token's whole lifetime.
if time.Until(iat) > ClockSkewToleranceFuture {
return newBearerError(bearerErrInvalidToken, "token iat in the future")
}
return nil
}
@@ -404,20 +350,6 @@ type bearerFailureTracker struct {
threshold int
window time.Duration
penalty time.Duration
// nextSweepAt is the map size recordFailure next scans the map at. A
// static per-call gate (len > defaultBearerEntrySweepThreshold) made
// EVERY recordFailure call re-scan the entire map once a flood of
// distinct source IPs crossed the threshold, turning attacker-controlled
// traffic into O(n^2) work under b.mu while blocked() (called on every
// bearer request) waits on the same lock (FIX-28). nextSweepAt doubles
// after each sweep (bounded below by defaultBearerEntrySweepThreshold),
// amortizing the scan so its frequency grows logarithmically with the
// flood size instead of linearly.
nextSweepAt int
// sweepPasses counts how many times recordFailure has run the full-map
// staleness scan. Exposed for tests to confirm the amortization actually
// bounds scan frequency.
sweepPasses int
}
type bearerFailureEntry struct {
@@ -426,10 +358,6 @@ type bearerFailureEntry struct {
count int
}
// defaultBearerEntrySweepThreshold is the map size above which recordFailure
// sweeps stale entries, bounding memory under a flood of distinct source IPs.
const defaultBearerEntrySweepThreshold = 1024
func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bearerFailureTracker {
if threshold <= 0 {
threshold = 20
@@ -441,11 +369,10 @@ func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bear
penalty = 60 * time.Second
}
return &bearerFailureTracker{
entries: make(map[string]*bearerFailureEntry),
threshold: threshold,
window: window,
penalty: penalty,
nextSweepAt: defaultBearerEntrySweepThreshold,
entries: make(map[string]*bearerFailureEntry),
threshold: threshold,
window: window,
penalty: penalty,
}
}
@@ -477,43 +404,6 @@ func (b *bearerFailureTracker) recordFailure(ip string) {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
// Bound per-IP map growth (R117): entries for source IPs that fail once
// and never return were never removed. Sweep stale entries (no active
// penalty box, too old to matter) once the map grows past a nominal
// size, keeping steady-state memory bounded.
//
// An entry that has not tripped the penalty box yet has a ZERO
// penaltyUntil, which is always Before(cutoff) — so checking only
// e.penaltyUntil.Before(cutoff) discarded every in-progress (not yet
// tripped) counter on every sweep, regardless of how recently it
// started. Once the map passed the threshold, a source whose failures
// interleaved with sweeps could never accumulate enough to trip (FIX-28).
// Only remove an entry once it is BOTH untripped-or-expired AND outside
// the counting window, so a fresh, still-accumulating counter survives.
//
// The sweep itself is gated on b.nextSweepAt, not the static threshold:
// a flood of distinct, still-fresh source IPs (attacker-controlled) that
// crosses defaultBearerEntrySweepThreshold and stays above it made every
// subsequent recordFailure call re-scan the whole map for nothing (the
// predicate above finds nothing to delete while entries stay fresh),
// which is 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
// (FIX-28). nextSweepAt doubles after each sweep so the scan frequency
// grows logarithmically with the flood size instead of on every call.
if len(b.entries) > b.nextSweepAt {
cutoff := now.Add(-(b.window + b.penalty))
for k, e := range b.entries {
if e.penaltyUntil.Before(cutoff) && now.Sub(e.firstFailureAt) > b.window {
delete(b.entries, k)
}
}
b.sweepPasses++
next := 2 * len(b.entries)
if next < defaultBearerEntrySweepThreshold {
next = defaultBearerEntrySweepThreshold
}
b.nextSweepAt = next
}
e, ok := b.entries[ip]
if !ok || now.Sub(e.firstFailureAt) > b.window {
e = &bearerFailureEntry{firstFailureAt: now}
@@ -558,10 +448,9 @@ func clientIPForBearer(req *http.Request) string {
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
host = req.RemoteAddr
return req.RemoteAddr
}
// One IPv6 /64 shares a key, so a single host cannot mint 2^64 entries.
return limiterKey(host)
return host
}
// --- Bearer auth entrypoint ---
@@ -631,10 +520,7 @@ func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request
case bearerErrThrottled:
status = http.StatusTooManyRequests
body = "Too Many Requests"
retryAfter = err.retryAfter
if retryAfter <= 0 {
retryAfter = t.bearerFailurePenalty
}
retryAfter = t.bearerFailurePenalty
case bearerErrIntrospectionUnavailable:
status = http.StatusServiceUnavailable
body = "Service Unavailable"
@@ -649,10 +535,6 @@ func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request
if retryAfter > 0 {
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
}
// Auth-rejection responses must never be cached (R101 contract): a
// cached 429/401 could be replayed to a client well after the
// penalty box expired or the session recovered.
rw.Header().Set("Cache-Control", "no-store")
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
rw.WriteHeader(status)
_, _ = rw.Write([]byte(body)) // Safe to ignore: best-effort error body write
@@ -671,11 +553,12 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
ip := clientIPForBearer(req)
if blocked, retryAfter := t.bearerFailureTracker.blocked(ip); blocked {
// Carry the actual remaining penalty (diverges from the configured
// default on clock-skew, partial-window expiry) so writeBearerError
// emits the true Retry-After rather than the full penalty.
throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
throttled.retryAfter = retryAfter
// Preserve the actual retry-after even if it diverged from the
// configured default (clock-skew, partial-window expiry).
if retryAfter > 0 {
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
}
t.writeBearerError(rw, req, throttled)
return
}
@@ -691,51 +574,12 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token exceeds max length"))
return
}
// Determine whether this is genuinely a JWT. RFC 7662 token
// introspection covers opaque access tokens; when the operator
// requires live introspection (requireTokenIntrospection, whose
// documented purpose is exactly opaque-token support via
// client_secret_basic), introspect the opaque token on the bearer
// path instead of rejecting it — the session path already does
// (validateOpaqueToken). Previously the JWT-shape gate ran before
// the introspection branch, so every opaque token got a 401 even
// with introspection on (R159).
//
// A token is only treated as a JWT when it has exactly two dots AND
// its header actually parses as a JOSE header. Dot count alone is
// insufficient: an opaque token that coincidentally contains exactly
// two dots used to be routed onto the JWT path and rejected even
// under requireTokenIntrospection (header parse would fail) — it must
// instead be introspected like any other opaque token (R166).
isJWT := joseHeaderWellFormed(token)
if !isJWT {
if t.requireTokenIntrospection {
p, bErr := t.buildPrincipalFromOpaqueIntrospection(token)
if bErr != nil {
if bErr.kind != bearerErrIntrospectionUnavailable {
t.bearerFailureTracker.recordFailure(ip)
}
t.writeBearerError(rw, req, bErr)
return
}
t.bearerFailureTracker.recordSuccess(ip)
if t.logger != nil {
t.logger.Debugf("bearer auth success (introspected opaque): identifier_hash=%s path=%s",
hashIdentifierForLog(p.Identifier), req.URL.Path)
}
t.forwardAuthorized(rw, req, p)
return
}
if strings.Count(token, ".") != 2 {
t.bearerFailureTracker.recordFailure(ip)
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a valid JWT"))
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a 3-segment JWT"))
return
}
// Structurally a JWT: enforce the alg/kid policy EARLY (before the
// cached fast-path in buildPrincipalFromBearerToken can short-circuit
// on a prior positive verdict). Without this pin an alg=none or
// oversized-kid token that had been cached as verified would sail
// through without re-checking.
if bErr := parseBearerJOSEHeader(token); bErr != nil {
t.bearerFailureTracker.recordFailure(ip)
t.writeBearerError(rw, req, bErr)
@@ -744,13 +588,7 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
p, bErr := t.buildPrincipalFromBearerToken(token)
if bErr != nil {
// Count only authentication failures (401/403) toward the per-IP
// throttle. An introspection outage (503) is infrastructure, not a
// client failure; recording it would trip a 429 penalty that a
// single success can't clear even after the endpoint recovers.
if bErr.kind != bearerErrIntrospectionUnavailable {
t.bearerFailureTracker.recordFailure(ip)
}
t.bearerFailureTracker.recordFailure(ip)
t.writeBearerError(rw, req, bErr)
return
}
@@ -767,7 +605,7 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
// described in spec §7.3 and returns a principal ready for forwardAuthorized.
// Returns a typed *bearerError on failure so the caller can map to status.
func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *bearerError) {
if err := t.verifyTokenWithOpts(token); err != nil {
if err := t.verifyTokenWithOpts(token, verifyOpts{skipReplayMarking: true}); err != nil {
return nil, newBearerError(bearerErrInvalidToken, "token verification failed: "+err.Error())
}
@@ -791,9 +629,7 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
return nil, newBearerError(bearerErrInvalidToken, "token_use=id rejected")
}
// Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137).
clientID, _, _, _, _ := t.clientCredentials()
if bErr := enforceMultiAudienceAzp(claims, clientID); bErr != nil {
if bErr := enforceMultiAudienceAzp(claims, t.clientID); bErr != nil {
return nil, bErr
}
if bErr := enforceIatAge(claims, t.maxTokenAge); bErr != nil {
@@ -806,40 +642,6 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
}
}
// Honor IdP-initiated (backchannel/front-channel) logout. The cookie
// path re-checks sessionInvalidationCache on every request; the bearer
// path previously did not, so a still-cryptographically-valid access
// token for a logged-out subject kept returning 200 (and, with
// requireTokenIntrospection, a cached positive verdict extended the
// stale window). Reject here, mirroring the cookie path: use the
// token's iat as its creation time so a token issued before the
// logout is invalidated, while a legitimately freshly-issued token
// (iat after logout) still passes (R146). iat is OPTIONAL per RFC 7519
// §4.1.6 and jwt.Verify no longer requires it (R126), so an iat-less
// token DOES reach here now. Falling back to time.Now() made such a
// token always look newer than any logout, so IdP-initiated logout
// never revoked it — reintroducing the R98 defect the cookie path's
// own fallback (sessionCreatedAtForInvalidation) avoids by using the
// zero time instead: fail closed rather than open (FIX-24). In
// production this branch is mostly defense in depth: enforceIatAge
// above already rejects an iat-less token before reaching here whenever
// maxTokenAge > 0. New() always sets it so: config.MaxTokenAgeSeconds
// <= 0 (unset, or an operator explicitly configuring 0) maps to a 24h
// default (main.go:359-364), it is never mapped to "disabled" — there
// is no config value that makes maxTokenAge 0. This fallback protects
// an iat-less token's logout check only in the case New()'s mapping
// doesn't reach: a test that constructs TraefikOidc with maxTokenAge=0
// directly, bypassing New().
subjectForInvalidation, _ := claims["sub"].(string)
sidForInvalidation, _ := claims["sid"].(string)
var createdAt time.Time
if iat, ok := claims["iat"].(float64); ok {
createdAt = time.Unix(int64(iat), 0)
}
if t.isSessionInvalidated(sidForInvalidation, subjectForInvalidation, createdAt) {
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
}
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
if bErr != nil {
return nil, bErr
@@ -850,7 +652,7 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
}
subject, _ := claims["sub"].(string)
clientID, _ = claims["azp"].(string)
clientID, _ := claims["azp"].(string)
if clientID == "" {
clientID, _ = claims["client_id"].(string)
}
@@ -865,178 +667,6 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
}, nil
}
// buildPrincipalFromOpaqueIntrospection authenticates a bearer access
// token that has no JWT shape by live RFC 7662 introspection, and builds
// a principal whose identifier is the introspected subject. It mirrors
// introspectOnBearerPath's classification (active, token_type, expiry,
// audience) and binds the compliant subject. Only used on the bearer
// path when requireTokenIntrospection is enabled (R159).
//
// Unlike a JWT bearer token — whose signature, aud and azp are already
// bound to this client by jwt.Verify/enforceMultiAudienceAzp before this
// function's counterpart ever runs — an opaque token has no local
// verification at all; introspection is the only gate. So this function
// re-applies every JWT-path gate the introspection branch used to skip
// (R159 gap): allowOpaqueTokens, client binding, bearerIdentifierClaim +
// sanitizeBearerIdentifier, maxTokenAge, isSessionInvalidated, the local
// tokenBlacklist (raw token and jti) and nbf.
func (t *TraefikOidc) buildPrincipalFromOpaqueIntrospection(token string) (*principal, *bearerError) {
if !t.allowOpaqueTokens {
return nil, newBearerError(bearerErrInvalidToken, "opaque tokens are not enabled (set allowOpaqueTokens to true)")
}
// Same local-revocation gate the JWT path applies first
// (verifyTokenWithOpts, token_manager.go:71-75): handleLogout calls
// RevokeToken on the session's access token specifically so a token
// captured before logout cannot be reused (helpers.go). Without this
// check here, a captured opaque bearer token kept authenticating as the
// revoked subject for as long as the IdP still reported it active —
// provider-side revocation is best-effort (RevokeTokenWithProvider logs
// and continues on failure) and is not configured for most deployments.
if t.tokenBlacklist != nil {
if b, exists := t.tokenBlacklist.Get(token); exists && b != nil {
return nil, newBearerError(bearerErrTokenInactive, "token has been revoked")
}
}
resp, err := t.introspectToken(token)
if err != nil {
// Any introspection transport/HTTP error — including a 4xx, which
// per RFC 7662 s2.3 means OUR OWN client credentials were rejected,
// not that the presented token is bad — is an availability problem,
// not a verdict on the token. Only a 200 response with active=false
// is (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping).
return nil, newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
}
if !resp.Active {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
}
// Mirror introspectOnBearerPath (R149): reject a definite non-access
// token_type (e.g. refresh_token); accept "access_token" and "Bearer".
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
return nil, newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
}
if resp.Exp > 0 && time.Now().After(time.Unix(resp.Exp, 0)) {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token expired")
}
// Mirrors the session path's strict nbf comparison in
// validateOpaqueToken (token_introspection.go:258-263): no clock-skew
// tolerance. This is NOT the same check the JWT path applies — its
// verifyNotBefore (jwt.go:281, via verifyTimeConstraint) allows
// ClockSkewTolerancePast (jwt.go:23, 10s) before failing. RFC 7662
// s2.2 defines nbf with the same semantics as RFC 7519's nbf claim.
if resp.Nbf > 0 && time.Now().Before(time.Unix(resp.Nbf, 0)) {
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token not yet valid (nbf)")
}
// Same jti-blacklist gate the JWT path applies on a cached-token hit
// (token_manager.go:90-96): RevokeToken blacklists a JWT's jti too, and
// an IdP may echo that same jti in an opaque token's introspection
// response (e.g. a rotated token sharing the revoked JTI). Gated on
// disableReplayDetection like the JWT path, so an operator who has
// disabled replay/JTI tracking is not surprised by this new check.
if resp.Jti != "" && !t.disableReplayDetection && t.tokenBlacklist != nil {
if b, exists := t.tokenBlacklist.Get(resp.Jti); exists && b != nil {
return nil, newBearerError(bearerErrTokenInactive, "token replay detected (jti blacklisted)")
}
}
clientID, _, _, audience, _ := t.clientCredentials()
if audience != "" && audience != clientID {
// A distinct API audience is configured: the introspection response
// MUST carry a matching audience.
if resp.Aud == nil || verifyAudience(resp.Aud, audience) != nil {
return nil, newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
}
} else {
// audience == clientID (the common single-app default): RFC 7662
// s2.2 leaves client scoping to the authorization server's own
// policy, so without an explicit check here an opaque token issued
// to a completely different client at the same IdP would still
// report active=true and pass. Bind it to this client via either
// client_id or aud (client_id is RFC-optional).
clientBound := resp.ClientID == clientID ||
(resp.Aud != nil && verifyAudience(resp.Aud, clientID) == nil)
if !clientBound {
return nil, newBearerError(bearerErrTokenInactive, "introspection client_id/audience does not match this client")
}
}
// maxTokenAge bound on iat, via the SAME enforceIatAge the JWT path
// uses (not a hand-rolled duplicate): RFC 7662 iat is optional, but
// New() always sets maxTokenAge > 0 (0/unset becomes 24h,
// main.go's config-default closure), so a response with no iat gives
// nothing to bound the token's age against and must fail closed —
// exactly like enforceIatAge already does for a JWT with no iat
// (R126/FIX-27 made iat optional on the JWT path too, and that path
// fails closed rather than silently skipping the bound).
iatClaims := map[string]interface{}{}
if resp.Iat > 0 {
iatClaims["iat"] = float64(resp.Iat)
}
if bErr := enforceIatAge(iatClaims, t.maxTokenAge); bErr != nil {
return nil, bErr
}
// Honor IdP-initiated (backchannel/front-channel) logout, mirroring the
// JWT bearer path (R146). RFC 7662 does not define sid, but some
// providers return it anyway (FIX-03): when the response carries one,
// check it exactly like the JWT path does, so a sid-only (front-channel)
// logout — front-channel logout always records by sid only, and so does
// a backchannel logout token that carries only sid — can revoke an
// opaque token too, not just a backchannel logout recorded by sub. iat
// absent -> zero time (fail closed, same as the JWT path's FIX-24
// contract), so a token whose age cannot be bounded is treated as
// pre-dating any logout rather than as freshly issued.
var createdAt time.Time
if resp.Iat > 0 {
createdAt = time.Unix(resp.Iat, 0)
}
if t.isSessionInvalidated(resp.Sid, resp.Sub, createdAt) {
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
}
// Populate every non-empty IntrospectionResponse member the RFC 7662
// response can carry, not just sub/username, so bearerIdentifierClaim
// can resolve the identifier from any of them (FIX-03) — mirroring how
// the JWT bearer path resolves against the full decoded claim set.
claims := map[string]interface{}{}
if resp.Sub != "" {
claims["sub"] = resp.Sub
}
if resp.Username != "" {
claims["username"] = resp.Username
}
if resp.ClientID != "" {
claims["client_id"] = resp.ClientID
}
if resp.Scope != "" {
claims["scope"] = resp.Scope
}
if resp.Iss != "" {
claims["iss"] = resp.Iss
}
if resp.Jti != "" {
claims["jti"] = resp.Jti
}
if resp.Aud != nil {
claims["aud"] = resp.Aud
}
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
if bErr != nil {
return nil, bErr
}
identifier, bErr := sanitizeBearerIdentifier(rawIdentifier, t.maxIdentifierLength)
if bErr != nil {
return nil, bErr
}
return &principal{
Claims: claims,
Identifier: identifier,
Subject: resp.Sub,
AccessToken: token,
Source: sourceBearer,
}, nil
}
// introspectOnBearerPath calls the existing RFC 7662 introspector when the
// operator demands real-time revocation. Distinguishes "token revoked" (401)
// from "endpoint unavailable" (503) so transient infra failures don't look
@@ -1044,57 +674,10 @@ func (t *TraefikOidc) buildPrincipalFromOpaqueIntrospection(token string) (*prin
func (t *TraefikOidc) introspectOnBearerPath(token string) *bearerError {
resp, err := t.introspectToken(token)
if err != nil {
// Any introspection transport/HTTP error is an availability
// problem, never a verdict on the token: RFC 7662 s2.3 defines a
// 401/403 from the introspection endpoint as OUR OWN client
// credentials being rejected (e.g. a rotated client_secret), and
// 408/429 as the endpoint throttling or timing out. Only a 200
// response with active=false is a definite "not active" (s2.2).
// (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping — a
// misconfigured or throttled endpoint must not turn every valid
// token into a 401.)
return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
}
if !resp.Active {
return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
}
// Mirror the session path (validateOpaqueToken): an opaque-or-any
// token whose RFC 7662 token_type classifies it as a refresh token
// must not be honored as a bearer access token (R149). Only reject on
// a definite non-access match; compliant providers may omit it. RFC
// 7662's token_type is the RFC 6749 token type, whose value for an
// access token is "Bearer" (RFC 6750) — accept both spellings
// providers use (R156).
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
return newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
}
// Mirror the session path (validateOpaqueToken): an active but
// already-expired result must not pass. The positive-only cache is
// capped by time-until-exp when Exp is present, but a provider
// returning active=1 past exp would otherwise let the token pass on
// the bearer path while the identical token is rejected on the
// session path.
if resp.Exp > 0 {
expTime := time.Unix(resp.Exp, 0)
if time.Now().After(expTime) {
return newBearerError(bearerErrTokenInactive, "introspection reports token expired")
}
}
// Mirror the session path (validateOpaqueToken): when a distinct API
// audience is configured (audience != clientID), the introspection
// response MUST carry a matching audience. Fail closed on a missing or
// mismatched aud, otherwise a token minted for a different audience
// would pass the bearer path while the identical token is rejected on
// the session path. aud may be a single string or an array (RFC 7662).
// (R143)
clientID, _, _, audience, _ := t.clientCredentials()
if audience != "" && audience != clientID {
if resp.Aud == nil {
return newBearerError(bearerErrTokenInactive, "introspection reports no audience")
}
if err := verifyAudience(resp.Aud, audience); err != nil {
return newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
}
}
return nil
}
-46
View File
@@ -1,46 +0,0 @@
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)
}
})
}
+1 -39
View File
@@ -222,8 +222,7 @@ func TestResolveBearerIdentifier(t *testing.T) {
{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},
{name: "non-string claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -616,43 +615,6 @@ func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
}
}
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
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,72 +0,0 @@
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
@@ -1,100 +0,0 @@
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
@@ -1,390 +0,0 @@
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)
}
}
+79
View File
@@ -2,6 +2,7 @@ package traefikoidc
import (
"fmt"
"sync"
"testing"
"time"
)
@@ -160,3 +161,81 @@ 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++
}
})
})
}
+5 -8
View File
@@ -5,6 +5,10 @@ import (
"time"
)
const (
defaultBlacklistDuration = 24 * time.Hour
)
// CacheManager manages all caching components using the universal cache
type CacheManager struct {
manager *UniversalCacheManager
@@ -103,7 +107,7 @@ func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
cm.mu.RLock()
defer cm.mu.RUnlock()
return &JWKCache{cache: cm.manager.GetJWKCache(), lastForceRefresh: make(map[string]time.Time)}
return &JWKCache{cache: cm.manager.GetJWKCache()}
}
// GetSharedIntrospectionCache returns the shared token introspection cache
@@ -169,13 +173,6 @@ 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)
+308 -5
View File
@@ -178,11 +178,6 @@ 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)
@@ -470,6 +465,314 @@ 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
@@ -1,93 +0,0 @@
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
@@ -1,94 +0,0 @@
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)
}
}
+5 -49
View File
@@ -3,7 +3,6 @@ package traefikoidc
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
@@ -13,7 +12,6 @@ import (
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
@@ -21,13 +19,6 @@ import (
"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 {
@@ -103,43 +94,13 @@ func NewClientAssertionSigner(pemBytes []byte, alg, kid string) (*ClientAssertio
func validateAlgKeyMatch(alg string, key crypto.PrivateKey) error {
switch alg[0] {
case 'R', 'P': // RS* or PS*
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
if _, ok := key.(*rsa.PrivateKey); !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 {
if _, ok := key.(*ecdsa.PrivateKey); !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
}
@@ -159,14 +120,6 @@ func (s *ClientAssertionSigner) Sign(audience, clientID string) (string, error)
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 {
@@ -337,3 +290,6 @@ func buildClientAssertionSignerFromConfig(config *Config) (*ClientAssertionSigne
return NewClientAssertionSigner(pemBytes, alg, config.ClientAssertionKeyID)
}
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
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
@@ -1,95 +0,0 @@
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()
}
+42 -101
View File
@@ -7,32 +7,10 @@ import (
// REDACTED is the placeholder value for sensitive information
const REDACTED = "[REDACTED]"
// 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
}
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
}
// 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{} {
// MarshalJSON implements custom JSON marshaling 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
@@ -50,66 +28,11 @@ func (c Config) configToMap() map[string]interface{} {
result["allowedUsers"] = c.AllowedUsers
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
// 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).
// Redact sensitive fields
result["clientSecret"] = REDACTED
result["sessionEncryptionKey"] = REDACTED
result["clientAssertionPrivateKey"] = REDACTED
// Handle Redis config (redacts password)
// Handle Redis config
if c.Redis != nil {
redisMap := make(map[string]interface{})
redisMap["enabled"] = c.Redis.Enabled
@@ -121,29 +44,47 @@ func (c Config) configToMap() map[string]interface{} {
result["redis"] = redisMap
}
// 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())
return json.Marshal(result)
}
// 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
// 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
}
return result, nil
}
// MarshalJSON for RedisConfig to redact sensitive fields
-44
View File
@@ -1,44 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,67 +0,0 @@
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
@@ -1,55 +0,0 @@
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)
}
}
})
}
}
+18
View File
@@ -41,6 +41,24 @@ 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
// =============================================================================
+24 -22
View File
@@ -151,11 +151,9 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
// Configure custom claim names
ts.tOidc.roleClaimName = "custom_roles"
// 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.
// Create token with malformed role claim (not an array)
claims := map[string]interface{}{
"custom_roles": 12345,
"custom_roles": "this-should-be-an-array",
}
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
@@ -163,12 +161,15 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err)
}
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err != nil {
t.Fatalf("Expected no error for malformed role claim, got %v", err)
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
if err == nil {
t.Error("Expected error for malformed role claim, got nil")
}
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)
// 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())
}
}
@@ -190,12 +191,15 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err)
}
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err != nil {
t.Fatalf("Expected no error for malformed group claim, got %v", err)
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
if err == nil {
t.Error("Expected error for malformed group claim, got nil")
}
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)
// 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())
}
}
@@ -324,10 +328,9 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err)
}
// 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)
// Should only extract string elements
if !stringSliceEqual(roles, []string{"role1", "role2"}) {
t.Errorf("Expected roles [role1 role2], got %v", roles)
}
}
@@ -354,9 +357,8 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err)
}
// 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)
// Should only extract string elements
if !stringSliceEqual(groups, []string{"group1", "group2"}) {
t.Errorf("Expected groups [group1 group2], got %v", groups)
}
}
+8 -20
View File
@@ -133,10 +133,7 @@ treats every one of these as a first-class concern.
|---|---|---|
| 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. |
| Token format | JWT only (3 segments, JOSE-encoded). Opaque tokens are not accepted on the bearer path. | Matches the validation pipeline; opaque tokens require introspection only and bypass JWT-specific defences. |
| `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. |
@@ -144,12 +141,12 @@ treats every one of these as a first-class concern.
| `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. |
| JTI replay marking | Bearer path skips the JTI **Set** (so the same token can be reused until `exp`) but the **Get** stays active. | Allows legitimate bearer reuse without false-positive replay detection; revoked tokens (added to the blacklist by `RevokeToken`) still fail immediately. |
| 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". |
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Endpoint failure returns `503` (distinguishes infra outage from credential rejection). | Real-time revocation for high-assurance environments. Adds per-request IdP latency. |
| 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. |
@@ -163,13 +160,12 @@ treats every one of these as a first-class concern.
| `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. |
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age (24h). Set `0` to disable the check (not recommended). |
| `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
@@ -177,11 +173,8 @@ treats every one of these as a first-class concern.
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.
- **Opaque access tokens.** Tokens must be JWTs. Introspection is a
revocation overlay on top of JWT verification, not a substitute for it.
- **`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
@@ -203,11 +196,6 @@ treats every one of these as a first-class concern.
- **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.
@@ -234,8 +222,8 @@ treats every one of these as a first-class concern.
| 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) |
| Introspection reports `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
| Introspection endpoint failure | 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>` |
+3 -110
View File
@@ -96,11 +96,6 @@ For each token / revocation request the plugin builds a JWS with:
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
@@ -184,7 +179,7 @@ clientSecret: your-client-secret
| `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, 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. |
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. |
| `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 |
@@ -194,41 +189,6 @@ clientSecret: your-client-secret
| `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
@@ -312,57 +272,6 @@ 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
@@ -378,7 +287,7 @@ guidance.
| `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. |
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). 0 disables the check. |
| `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. |
@@ -395,7 +304,6 @@ guidance.
| `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
@@ -571,7 +479,7 @@ be emitted — the standard OIDC claims plus common provider claims (`email`,
`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
on the list, functions other than `get`/`default`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
@@ -592,21 +500,6 @@ Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
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).
---
## Security Headers
+5 -106
View File
@@ -7,9 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -54,7 +52,6 @@ type DynamicClientRegistrar struct {
registrationResponse *ClientRegistrationResponse
store DCRCredentialsStore // Storage backend for credentials
providerURL string
scopes []string // Runtime auth/scopes from the operator config (merged)
mu sync.RWMutex
}
@@ -106,19 +103,6 @@ func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) {
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 storage if persistence is enabled,
// then registers a new client if no valid credentials exist.
@@ -157,10 +141,8 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
// Validate the endpoint URL
if !strings.HasPrefix(endpoint, "https://") {
// 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) {
// Allow http only for localhost/development
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
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)
@@ -221,19 +203,6 @@ 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
@@ -264,41 +233,11 @@ 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
@@ -353,10 +292,8 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
if metadata.TokenEndpointAuthMethod != "" {
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
} else {
// 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"
// Default to client_secret_basic for confidential clients
reqData["token_endpoint_auth_method"] = "client_secret_basic"
}
if metadata.DefaultMaxAge > 0 {
@@ -373,13 +310,6 @@ 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)
@@ -398,19 +328,6 @@ 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)
@@ -457,7 +374,7 @@ func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, res
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
filePath := r.credentialsFilePath()
data, err := json.MarshalIndent(resp, "", " ") // #nosec G117 -- the registration response, including client_secret, is the credential this function persists
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
@@ -491,21 +408,3 @@ func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse,
return &resp, nil
}
// 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
}
host := u.Hostname()
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return true
}
return false
}
+1 -3
View File
@@ -541,7 +541,6 @@ 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,
@@ -550,7 +549,6 @@ 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,
@@ -600,7 +598,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_post",
"token_endpoint_auth_method": "client_secret_basic",
},
expectError: false,
},
-4
View File
@@ -67,10 +67,6 @@ func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid st
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()
+23 -432
View File
@@ -191,23 +191,12 @@ 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 stay in half-open (after the open timeout)
// before a successful response fully closes the circuit
// resetTimeout is how long to wait before transitioning from open to half-open
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.
@@ -250,32 +239,12 @@ 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 {
// 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()
cb.RecordFailure()
return err
}
@@ -285,37 +254,6 @@ 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 {
@@ -335,9 +273,8 @@ func (cb *CircuitBreaker) allowRequest() bool {
return true
case CircuitBreakerOpen:
if now.Sub(cb.openedAt) > cb.timeout {
if now.Sub(cb.lastFailureTime) > cb.timeout {
cb.state = CircuitBreakerHalfOpen
cb.halfOpenSince = now
cb.logger.Infof("Circuit breaker transitioning to half-open state")
return true
}
@@ -363,13 +300,11 @@ 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")
}
}
@@ -382,15 +317,6 @@ 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")
@@ -422,24 +348,7 @@ 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 {
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
}
return cb.allowRequest()
}
// GetMetrics returns comprehensive metrics about the circuit breaker.
@@ -598,157 +507,12 @@ func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error
}
// Execute runs the given function with retry logic (for backward compatibility)
// Execute runs a function with retry logic (backward compatibility).
// Execute executes 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.
@@ -759,17 +523,6 @@ 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) {
@@ -794,8 +547,7 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
return true
}
@@ -816,19 +568,8 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
}
}
// 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)
if httpErr, ok := err.(*HTTPError); ok {
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
}
return false
@@ -1021,10 +762,6 @@ 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.
@@ -1051,53 +788,6 @@ 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),
@@ -1108,22 +798,6 @@ 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
@@ -1228,54 +902,25 @@ func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{},
return fallback()
}
// 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.
// startHealthCheckRoutine starts the background health check routine
func (gd *GracefulDegradation) startHealthCheckRoutine() {
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().
// Use singleton task registry to prevent multiple instances
registry := GetGlobalTaskRegistry()
task, err := registry.CreateSingletonTask(
"graceful-degradation-health-check",
gd.config.HealthCheckInterval,
globalPerformHealthChecks,
gd.performHealthChecks,
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()
@@ -1327,58 +972,25 @@ 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 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()
// Signal shutdown
select {
case <-gd.stopChan:
// Already closed
default:
close(gd.stopChan)
}
// Stop health check task
gd.mutex.Lock()
task := gd.healthCheckTask
gd.mutex.Unlock()
// 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")
if task != nil {
task.Stop()
// Don't set to nil to avoid race conditions
}
gd.logger.Debug("GracefulDegradation shut down successfully")
@@ -1438,21 +1050,6 @@ 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.
@@ -1475,14 +1072,8 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
cb := erm.GetCircuitBreaker(serviceName)
// 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)
return erm.retryExecutor.Execute(ctx, func() error {
return cb.Execute(fn)
})
}
-47
View File
@@ -1,47 +0,0 @@
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
@@ -1,87 +0,0 @@
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
@@ -1,131 +0,0 @@
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
@@ -1,104 +0,0 @@
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)
}
}
+16 -75
View File
@@ -89,50 +89,18 @@ func TestCircuitBreakerHalfOpenTransition(t *testing.T) {
time.Sleep(150 * time.Millisecond)
// 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()))
allowed := false
_ = cb.Execute(func() error {
allowed = true
return nil
})
if !allowed {
t.Error("Request should be allowed in half-open state")
}
// 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 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()))
t.Errorf("Circuit should be closed after successful half-open request, got %v", cb.GetState())
}
}
@@ -275,13 +243,7 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
time.Sleep(150 * time.Millisecond)
if !cb.IsAvailable() {
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)
t.Error("Circuit should be available in half-open state")
}
}
@@ -1135,7 +1097,6 @@ 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")
@@ -1152,7 +1113,6 @@ 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")
@@ -1173,7 +1133,6 @@ 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 {
@@ -1192,7 +1151,6 @@ func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
func TestErrorRecoveryManagerMetrics(t *testing.T) {
erm := NewErrorRecoveryManager(nil)
t.Cleanup(erm.Close)
_ = erm.GetCircuitBreaker("service1")
_ = erm.GetCircuitBreaker("service2")
@@ -1212,7 +1170,6 @@ 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{
@@ -1314,12 +1271,9 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
defer gd.Close()
t.Run("register health check", func(t *testing.T) {
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.
healthy := true
healthCheck := func() bool {
return healthy.Load()
return healthy
}
gd.RegisterHealthCheck("service1", healthCheck)
@@ -1327,7 +1281,7 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
gd.markServiceDegraded("service1")
assert.True(t, gd.isServiceDegraded("service1"))
healthy.Store(true)
healthy = true
time.Sleep(100 * time.Millisecond)
})
}
@@ -1553,13 +1507,6 @@ 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 {
@@ -1582,9 +1529,6 @@ 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
@@ -1665,12 +1609,9 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
return "fallback data", nil
})
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.
serviceHealthy := false
gd.RegisterHealthCheck("critical-service", func() bool {
return serviceHealthy.Load()
return serviceHealthy
})
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
@@ -1693,7 +1634,7 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
assert.NoError(t, err3)
assert.Equal(t, "fallback data", result3)
serviceHealthy.Store(true)
serviceHealthy = true
time.Sleep(250 * time.Millisecond)
metrics := gd.GetMetrics()
+13 -13
View File
@@ -445,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
# ============================================================================
-226
View File
@@ -1,226 +0,0 @@
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())
}
}
@@ -1,167 +0,0 @@
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
@@ -1,183 +0,0 @@
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
@@ -1,163 +0,0 @@
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
@@ -1,124 +0,0 @@
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
@@ -1,255 +0,0 @@
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
@@ -1,114 +0,0 @@
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
@@ -1,294 +0,0 @@
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
@@ -1,67 +0,0 @@
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
@@ -1,179 +0,0 @@
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
@@ -1,48 +0,0 @@
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)
}
}
@@ -1,83 +0,0 @@
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
@@ -1,132 +0,0 @@
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
@@ -1,20 +0,0 @@
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()
}
-5
View File
@@ -2,11 +2,6 @@ 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/gorilla/sessions v1.3.0
-8
View File
@@ -63,14 +63,6 @@ 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
+9 -8
View File
@@ -45,7 +45,6 @@ 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
@@ -55,7 +54,6 @@ 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))
@@ -372,9 +370,8 @@ func TestGetStatus(t *testing.T) {
status := gm.GetStatus()
// 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 len(status) != 2 {
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
}
if runningStatus, exists := status["running"]; exists {
@@ -397,8 +394,12 @@ func TestGetStatus(t *testing.T) {
t.Error("Expected 'running' goroutine in status")
}
if _, exists := status["quick"]; exists {
t.Error("Expected finished 'quick' goroutine to be pruned from 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")
}
}
@@ -434,7 +435,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)
<-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
time.Sleep(50 * time.Millisecond)
counter.Add(-1)
})
}(i)
-54
View File
@@ -1,54 +0,0 @@
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)
}
}
+26 -168
View File
@@ -6,15 +6,15 @@ 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.
@@ -109,13 +109,10 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
data := url.Values{
"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 t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
data.Set("client_id", t.clientID)
}
if grantType == "authorization_code" {
@@ -160,29 +157,29 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
t.metadataMu.RUnlock()
useBasicAuth := false
if clientAssertion != nil {
assertion, err := clientAssertion.Sign(tokenURL, clientID)
if t.clientAssertion != nil {
assertion, err := t.clientAssertion.Sign(tokenURL, t.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" {
} else if t.clientAuthMethod == "client_secret_basic" {
useBasicAuth = true
} else {
data.Set("client_secret", clientSecret)
data.Set("client_secret", t.clientSecret)
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
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)
setOAuthBasicAuth(req, t.clientID, t.clientSecret)
}
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
}
@@ -194,33 +191,14 @@ 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 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)),
}
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var tokenResponse TokenResponse
// 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 {
if err := json.NewDecoder(resp.Body).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
}
@@ -233,17 +211,6 @@ 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()
@@ -258,8 +225,7 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
t.logger.Debugf("Token response: %+v", tokenResponse)
return tokenResponse, nil
}
@@ -294,37 +260,10 @@ 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 {
@@ -341,7 +280,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 = tc.key(token)
token = "t-" + token
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
}
@@ -353,7 +292,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 = tc.key(token)
token = "t-" + token
value, found := tc.cache.Get(token)
if !found {
return nil, false
@@ -366,7 +305,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 = tc.key(token)
token = "t-" + token
tc.cache.Delete(token)
}
@@ -448,14 +387,6 @@ 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)
@@ -463,37 +394,9 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
return
}
// 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)
}
}
host := utils.DetermineHost(req)
scheme := utils.DetermineScheme(req, t.forceHTTPS)
baseURL := fmt.Sprintf("%s://%s", scheme, host)
postLogoutRedirectURI := t.postLogoutRedirectURI
// localRedirect is used when there is no provider end-session endpoint and
@@ -501,36 +404,14 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
// 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.
if postLogoutRedirectURI == "" {
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
localRedirect = normalizeLogoutPath(postLogoutRedirectURI)
postLogoutRedirectURI = ""
if trustedOrigin != "" {
postLogoutRedirectURI = trustedOrigin + localRedirect
}
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
} else {
localRedirect = postLogoutRedirectURI
}
// Read endSessionURL with RLock
@@ -545,36 +426,13 @@ 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
}
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.
// It includes the ID token hint and post-logout redirect URI according to OIDC specifications.
// Parameters:
-25
View File
@@ -1,25 +0,0 @@
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")
}
}
+11 -30
View File
@@ -61,23 +61,16 @@ 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
// 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.
config.UseCookieJar = true // Enable cookie jar for token operations
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
@@ -85,10 +78,7 @@ 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
// 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.
config.UseCookieJar = true // Enable cookie jar for session management
return config
}
@@ -148,13 +138,10 @@ func (f *HTTPClientFactory) ValidateHTTPClientConfig(config *HTTPClientConfig) e
return nil
}
// 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) {
// 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
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
}
@@ -191,13 +178,6 @@ func applyHTTPClientDefaults(config *HTTPClientConfig) {
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 {
@@ -229,8 +209,9 @@ 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,
},
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
PreferServerCipherSuites: true,
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, //nolint:gosec // opt-in, loud warning emitted at plugin startup
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
+2 -7
View File
@@ -18,10 +18,7 @@ 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")
// 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")
assert.True(t, config.UseCookieJar, "OIDC provider should have cookie jar enabled")
}
// TestCreateDefaultClientUnit tests CreateDefaultClient function
@@ -41,9 +38,7 @@ func TestCreateTokenClientUnit(t *testing.T) {
require.NotNil(t, client)
assert.NotNil(t, client.Transport, "client should have transport")
// 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.NotNil(t, client.Jar, "token client should have cookie jar")
assert.Equal(t, 10*time.Second, client.Timeout, "token client should have 10s timeout")
}
-58
View File
@@ -1,58 +0,0 @@
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)
}
}
+35 -102
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"sync"
"sync/atomic"
"time"
@@ -58,12 +57,6 @@ func GetGlobalTransportPool() *SharedTransportPool {
// GetOrCreateTransport gets or creates a shared transport with the given config
func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *http.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 {
// At the client limit: only reuse a transport that was built for the
@@ -81,21 +74,10 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
return shared.transport
}
}
// 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
// No TLS-compatible transport available; return nil so the caller falls
// back to a default, certificate-verifying transport rather than one
// with a different (possibly verification-disabled) trust store.
return nil
}
p.mu.Lock()
@@ -112,26 +94,8 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
// Increment client count
atomic.AddInt32(&p.clientCount, 1)
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{
// Create new transport with conservative limits
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{
@@ -150,22 +114,32 @@ func newSharedTransport(config HTTPClientConfig) *http.Transport {
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
PreferServerCipherSuites: true,
RootCAs: config.RootCAs,
InsecureSkipVerify: config.InsecureSkipVerify, //nolint:gosec // opt-in, loud warning emitted at plugin startup
},
ForceAttemptHTTP2: config.ForceHTTP2,
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
ExpectContinueTimeout: config.ExpectContinueTimeout,
MaxIdleConns: config.MaxIdleConns,
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
IdleConnTimeout: config.IdleConnTimeout,
MaxIdleConns: 10, // SECURITY FIX: Further reduced
MaxIdleConnsPerHost: 2, // SECURITY FIX: Limited connections
IdleConnTimeout: 30 * time.Second, // Reduced from 5 minutes
DisableKeepAlives: config.DisableKeepAlives,
MaxConnsPerHost: config.MaxConnsPerHost,
MaxConnsPerHost: 5, // SECURITY FIX: Strict limit
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
DisableCompression: config.DisableCompression,
WriteBufferSize: config.WriteBufferSize,
ReadBufferSize: config.ReadBufferSize,
}
p.transports[key] = &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
tlsKey: tlsConfigKey(config),
}
return transport
}
// ReleaseTransport decrements the reference count for a transport
@@ -245,35 +219,20 @@ func (p *SharedTransportPool) performCleanup() {
// configKey generates a unique key for a config
func (p *SharedTransportPool) configKey(config HTTPClientConfig) string {
// Pool transports by every parameter the built *http.Transport actually
// consumes. RootCAs and InsecureSkipVerify MUST be part of the key:
// Pool transports by the parameters that change TLS or connection
// behavior. 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).
// CA configuration.
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",
return fmt.Sprintf("%d|%d|%p|%s",
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,
)
}
@@ -289,38 +248,24 @@ func tlsConfigKey(config HTTPClientConfig) string {
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).
// Cleanup closes all transports and stops the cleanup goroutine
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 {
if shared != nil && shared.transport != nil {
shared.transport.CloseIdleConnections()
}
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)
@@ -329,18 +274,6 @@ 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 {
+5 -74
View File
@@ -63,17 +63,9 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
}
config := DefaultHTTPClientConfig()
config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache
transport := pool.GetOrCreateTransport(config)
// 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")
}
assert.Nil(t, transport, "should return nil when at client limit")
})
t.Run("client limit with existing transport", func(t *testing.T) {
@@ -264,7 +256,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
assert.Len(t, pool.transports, 0, "all transports should be removed")
})
t.Run("cleanup cancels the old context and stays reusable", func(t *testing.T) {
t.Run("cleanup cancels context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport),
@@ -278,16 +270,10 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
pool.Cleanup()
select {
case <-ctx.Done():
// The previous cleanup context was canceled (goroutine stopped).
case <-pool.ctx.Done():
// Context was canceled
case <-time.After(100 * time.Millisecond):
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")
t.Error("context should be canceled")
}
})
@@ -703,58 +689,3 @@ 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
@@ -1,238 +0,0 @@
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
@@ -1,50 +0,0 @@
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")
}
}
+727
View File
@@ -0,0 +1,727 @@
package traefikoidc
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// Pre-compiled regex patterns for validation (const patterns should use MustCompile)
var (
emailRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
urlRegexPattern = regexp.MustCompile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
tokenRegexPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
usernameRegexPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
)
// 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 uses pre-compiled 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 (always nil, kept for API compatibility).
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
return &InputValidator{
maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength,
maxHeaderLength: config.MaxHeaderLength,
maxClaimLength: config.MaxClaimLength,
maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
emailRegex: emailRegexPattern,
urlRegex: urlRegexPattern,
tokenRegex: tokenRegexPattern,
usernameRegex: usernameRegexPattern,
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
@@ -0,0 +1,895 @@
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)
}
})
}
}
+34 -3
View File
@@ -6,10 +6,14 @@ import "time"
type BackendType string
const (
BackendTypeRedis BackendType = "redis"
BackendTypeMemory BackendType = "memory"
BackendTypeRedis BackendType = "redis"
BackendTypeHybrid BackendType = "hybrid"
// TypeRedis is an alias for backward compatibility
TypeRedis BackendType = "redis"
// Aliases for backward compatibility
TypeMemory BackendType = "memory"
TypeRedis BackendType = "redis"
TypeHybrid BackendType = "hybrid"
)
// Config provides common configuration for cache backends
@@ -35,6 +39,17 @@ type Config struct {
TLSSkipVerify bool
}
// DefaultConfig returns a default configuration for in-memory caching
func DefaultConfig() *Config {
return &Config{
Type: BackendTypeMemory,
MaxSize: 1000,
MaxMemoryBytes: 50 * 1024 * 1024, // 50MB
CleanupInterval: 5 * time.Minute,
EnableMetrics: true,
}
}
// DefaultRedisConfig returns a default configuration for Redis caching
func DefaultRedisConfig(addr string) *Config {
return &Config{
@@ -49,3 +64,19 @@ func DefaultRedisConfig(addr string) *Config {
EnableMetrics: true,
}
}
// DefaultHybridConfig returns a default configuration for hybrid caching
func DefaultHybridConfig(redisAddr string) *Config {
return &Config{
Type: BackendTypeHybrid,
L1Config: &Config{
Type: BackendTypeMemory,
MaxSize: 500,
MaxMemoryBytes: 10 * 1024 * 1024, // 10MB for L1
CleanupInterval: 1 * time.Minute,
},
L2Config: DefaultRedisConfig(redisAddr),
AsyncWrites: true,
EnableMetrics: true,
}
}
+59
View File
@@ -0,0 +1,59 @@
//go:build !yaegi
package backends
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDefaultHybridConfig verifies the default hybrid configuration
func TestDefaultHybridConfig(t *testing.T) {
redisAddr := "localhost:6379"
config := DefaultHybridConfig(redisAddr)
require.NotNil(t, config)
// Verify top-level config
assert.Equal(t, BackendTypeHybrid, config.Type)
assert.True(t, config.AsyncWrites)
assert.True(t, config.EnableMetrics)
// Verify L1 (memory) config
require.NotNil(t, config.L1Config)
assert.Equal(t, BackendTypeMemory, config.L1Config.Type)
assert.Equal(t, 500, config.L1Config.MaxSize)
assert.Equal(t, int64(10*1024*1024), config.L1Config.MaxMemoryBytes) // 10MB
assert.Equal(t, 1*time.Minute, config.L1Config.CleanupInterval)
// Verify L2 (Redis) config exists
require.NotNil(t, config.L2Config)
assert.Equal(t, BackendTypeRedis, config.L2Config.Type)
}
func TestDefaultHybridConfig_DifferentRedisAddr(t *testing.T) {
tests := []struct {
name string
redisAddr string
}{
{"localhost", "localhost:6379"},
{"remote host", "redis.example.com:6379"},
{"IP address", "192.168.1.100:6379"},
{"custom port", "localhost:6380"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := DefaultHybridConfig(tt.redisAddr)
require.NotNil(t, config)
assert.Equal(t, BackendTypeHybrid, config.Type)
assert.NotNil(t, config.L1Config)
assert.NotNil(t, config.L2Config)
})
}
}
-17
View File
@@ -35,21 +35,4 @@ var (
// ErrDeserializationFailed is returned when deserialization fails
ErrDeserializationFailed = errors.New("deserialization failed")
// ErrSetNXUnsupported is returned by a CacheBackend wrapper's SetNX when
// the backend it wraps does not itself implement SetNX (FIX-17 round-2).
// Compared with == only: no errors.As, no fmt.Errorf %w — an interpreted
// value wrapped with %w loses its type under yaegi v0.16.1, and
// errors.As with an interpreted target type panics there.
ErrSetNXUnsupported = errors.New("cache backend does not support atomic SetNX")
// ErrSetNXAmbiguous is returned by RedisBackend.SetNX when the SET NX
// command was already written to the connection but its reply could not
// be read (timeout, EOF, connection reset): Redis may or may not have
// applied it. Unlike Set's idempotent SETEX/PSETEX, retrying SET NX
// blindly here would see the caller's own possible write and misreport
// a first-ever claim as already-claimed, so SetNX surfaces the
// ambiguity instead of guessing (FIX-17 round-2). Compared with == only,
// for the same yaegi reason as ErrSetNXUnsupported.
ErrSetNXAmbiguous = errors.New("SET NX outcome unknown: reply lost after the command was sent")
)
+732
View File
@@ -0,0 +1,732 @@
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"context"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
)
// HybridBackend implements a two-tier cache with L1 (memory) and L2 (Redis) backends
// It provides automatic failover, async writes for non-critical data, and optimized read paths
type HybridBackend struct {
lastL2Error atomic.Value
secondary CacheBackend
primary CacheBackend
logger Logger
ctx context.Context
syncWriteCacheTypes map[string]bool
asyncWriteBuffer chan *asyncWriteItem
l1BackfillBuffer chan *l1BackfillItem
cancel context.CancelFunc
wg sync.WaitGroup
l1Hits atomic.Int64
errors atomic.Int64
l2Writes atomic.Int64
l1Writes atomic.Int64
misses atomic.Int64
l2Hits atomic.Int64
l1BackfillDrops atomic.Int64
fallbackMode atomic.Bool
}
// asyncWriteItem represents an async write operation
type asyncWriteItem struct {
ctx context.Context
key string
value []byte
ttl time.Duration
}
// l1BackfillItem represents a deferred write of an L2-resolved value back into
// L1. Backfills run on a single bounded worker so a burst of L2 hits cannot
// detonate the goroutine count (issue: ~1000% CPU under sustained polling).
type l1BackfillItem struct {
key string
value []byte
ttl time.Duration
}
// Logger interface for structured logging
type Logger interface {
Debugf(format string, args ...interface{})
Infof(format string, args ...interface{})
Warnf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// defaultLogger provides a basic logger implementation
type defaultLogger struct {
*log.Logger
}
func (l *defaultLogger) Debugf(format string, args ...interface{}) {
l.Printf("[DEBUG] "+format, args...)
}
func (l *defaultLogger) Infof(format string, args ...interface{}) {
l.Printf("[INFO] "+format, args...)
}
func (l *defaultLogger) Warnf(format string, args ...interface{}) {
l.Printf("[WARN] "+format, args...)
}
func (l *defaultLogger) Errorf(format string, args ...interface{}) {
l.Printf("[ERROR] "+format, args...)
}
// HybridConfig provides configuration for the hybrid backend
type HybridConfig struct {
Primary CacheBackend
Secondary CacheBackend
Logger Logger
SyncWriteCacheTypes map[string]bool
AsyncBufferSize int
}
// NewHybridBackend creates a new hybrid cache backend with L1 (memory) and L2 (Redis) tiers
func NewHybridBackend(config *HybridConfig) (*HybridBackend, error) {
if config == nil {
return nil, fmt.Errorf("config is required")
}
if config.Primary == nil {
return nil, fmt.Errorf("primary (L1) backend is required")
}
if config.Secondary == nil {
return nil, fmt.Errorf("secondary (L2) backend is required")
}
if config.Logger == nil {
config.Logger = &defaultLogger{Logger: log.New(log.Writer(), "[HybridCache] ", log.LstdFlags)}
}
if config.AsyncBufferSize <= 0 {
config.AsyncBufferSize = 1000
}
// Default critical cache types that require synchronous writes
if config.SyncWriteCacheTypes == nil {
config.SyncWriteCacheTypes = map[string]bool{
"blacklist": true, // Token blacklist must be immediately consistent
"token": true, // Token validation is critical
}
}
ctx, cancel := context.WithCancel(context.Background())
h := &HybridBackend{
primary: config.Primary,
secondary: config.Secondary,
syncWriteCacheTypes: config.SyncWriteCacheTypes,
asyncWriteBuffer: make(chan *asyncWriteItem, config.AsyncBufferSize),
l1BackfillBuffer: make(chan *l1BackfillItem, config.AsyncBufferSize),
ctx: ctx,
cancel: cancel,
logger: config.Logger,
}
// Start async write worker
h.wg.Add(1)
go h.asyncWriteWorker()
// Start L1 backfill worker (single goroutine) to bound goroutine growth on
// L2 hits regardless of request rate.
h.wg.Add(1)
go h.l1BackfillWorker()
// Start health monitoring
h.wg.Add(1)
go h.healthMonitor()
h.logger.Infof("HybridBackend initialized with L1 (memory) and L2 (Redis) tiers")
h.logger.Infof("Sync write cache types: %v", config.SyncWriteCacheTypes)
h.logger.Infof("Async write buffer size: %d", config.AsyncBufferSize)
return h, nil
}
// Set stores a value in both L1 and L2 caches
func (h *HybridBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
// Always write to L1 first (synchronous)
if err := h.primary.Set(ctx, key, value, ttl); err != nil {
h.errors.Add(1)
h.logger.Warnf("Failed to write to L1 cache: %v", err)
// Continue to try L2 even if L1 fails
} else {
h.l1Writes.Add(1)
}
// Check if we're in fallback mode
if h.fallbackMode.Load() {
h.logger.Debugf("Operating in fallback mode, skipping L2 write for key: %s", redactKey(key))
return nil // Don't fail the operation if L2 is down
}
// Determine if this should be a sync or async write based on cache type
cacheType := h.extractCacheType(key)
requiresSync := h.syncWriteCacheTypes[cacheType]
if requiresSync {
// Synchronous write for critical cache types
if err := h.secondary.Set(ctx, key, value, ttl); err != nil {
h.errors.Add(1)
h.logger.Warnf("Failed to write to L2 cache (sync) for key %s: %v", redactKey(key), err)
h.recordL2Error()
// Don't fail the operation - L1 write succeeded
return nil
}
h.l2Writes.Add(1)
h.logger.Debugf("Synchronous write to L2 completed for critical key: %s", redactKey(key))
} else {
// Asynchronous write for non-critical cache types
select {
case h.asyncWriteBuffer <- &asyncWriteItem{
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
h.logger.Debugf("Queued async write to L2 for key: %s", redactKey(key))
default:
// Buffer is full, log and continue
h.logger.Warnf("Async write buffer full, dropping L2 write for key: %s", redactKey(key))
h.errors.Add(1)
}
}
return nil
}
// Get retrieves a value from cache, checking L1 first, then L2
func (h *HybridBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
// Try L1 first
value, ttl, exists, err := h.primary.Get(ctx, key)
if err != nil {
h.errors.Add(1)
h.logger.Debugf("L1 get error for key %s: %v", redactKey(key), err)
}
if exists {
h.l1Hits.Add(1)
return value, ttl, true, nil
}
// Check if we're in fallback mode
if h.fallbackMode.Load() {
h.misses.Add(1)
return nil, 0, false, nil
}
// Try L2
value, ttl, exists, err = h.secondary.Get(ctx, key)
if err != nil {
h.errors.Add(1)
h.logger.Debugf("L2 get error for key %s: %v", redactKey(key), err)
h.recordL2Error()
h.misses.Add(1)
return nil, 0, false, nil // Don't propagate L2 errors
}
if !exists {
h.misses.Add(1)
return nil, 0, false, nil
}
h.l2Hits.Add(1)
// Populate L1 cache with value from L2 (write-through on read).
// Hand off to the bounded backfill worker instead of spawning a goroutine
// per read - under burst that would mint thousands of goroutines.
h.queueL1Backfill(key, value, ttl)
return value, ttl, true, nil
}
// Delete removes a key from both L1 and L2 caches
func (h *HybridBackend) Delete(ctx context.Context, key string) (bool, error) {
var deleted bool
// Delete from L1
if d, err := h.primary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L1 cache: %v", err)
} else if d {
deleted = true
}
// Delete from L2 if not in fallback mode
if !h.fallbackMode.Load() {
if d, err := h.secondary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L2 cache: %v", err)
h.recordL2Error()
} else if d {
deleted = true
}
}
return deleted, nil
}
// Exists checks if a key exists in either cache
func (h *HybridBackend) Exists(ctx context.Context, key string) (bool, error) {
// Check L1 first
if exists, err := h.primary.Exists(ctx, key); err == nil && exists {
return true, nil
}
// Check L2 if not in fallback mode
if !h.fallbackMode.Load() {
if exists, err := h.secondary.Exists(ctx, key); err == nil && exists {
return true, nil
}
}
return false, nil
}
// Clear removes all keys from both caches
func (h *HybridBackend) Clear(ctx context.Context) error {
var lastErr error
// Clear L1
if err := h.primary.Clear(ctx); err != nil {
h.logger.Errorf("Failed to clear L1 cache: %v", err)
lastErr = err
}
// Clear L2 if not in fallback mode
if !h.fallbackMode.Load() {
if err := h.secondary.Clear(ctx); err != nil {
h.logger.Errorf("Failed to clear L2 cache: %v", err)
h.recordL2Error()
lastErr = err
}
}
return lastErr
}
// GetStats returns statistics for the hybrid cache
func (h *HybridBackend) GetStats() map[string]interface{} {
l1Hits := h.l1Hits.Load()
l2Hits := h.l2Hits.Load()
misses := h.misses.Load()
total := l1Hits + l2Hits + misses
stats := map[string]interface{}{
"type": TypeHybrid,
"l1_hits": l1Hits,
"l2_hits": l2Hits,
"misses": misses,
"total": total,
"l1_writes": h.l1Writes.Load(),
"l2_writes": h.l2Writes.Load(),
"errors": h.errors.Load(),
"fallback_mode": h.fallbackMode.Load(),
}
if total > 0 {
stats["l1_hit_rate"] = float64(l1Hits) / float64(total)
stats["l2_hit_rate"] = float64(l2Hits) / float64(total)
stats["overall_hit_rate"] = float64(l1Hits+l2Hits) / float64(total)
}
// Add sub-backend stats
stats["l1_stats"] = h.primary.GetStats()
stats["l2_stats"] = h.secondary.GetStats()
// Add last L2 error time if available
if lastErr := h.lastL2Error.Load(); lastErr != nil {
if t, ok := lastErr.(time.Time); ok {
stats["last_l2_error"] = t.Format(time.RFC3339)
stats["seconds_since_l2_error"] = time.Since(t).Seconds()
}
}
return stats
}
// Ping checks if both backends are healthy
func (h *HybridBackend) Ping(ctx context.Context) error {
// Check L1
if err := h.primary.Ping(ctx); err != nil {
return fmt.Errorf("L1 ping failed: %w", err)
}
// Check L2 (but don't fail if it's down)
if err := h.secondary.Ping(ctx); err != nil {
h.logger.Warnf("L2 ping failed: %v", err)
h.recordL2Error()
// Don't return error - we can operate with L1 only
} else {
// L2 is healthy, clear fallback mode if it was set
if h.fallbackMode.CompareAndSwap(true, false) {
h.logger.Infof("L2 backend recovered, exiting fallback mode")
}
}
return nil
}
// Close shuts down the hybrid backend
func (h *HybridBackend) Close() error {
// Cancel context to stop workers
h.cancel()
// Close async write channel
close(h.asyncWriteBuffer)
close(h.l1BackfillBuffer)
// Wait for workers to finish with timeout
done := make(chan struct{})
go func() {
h.wg.Wait()
close(done)
}()
select {
case <-done:
// Workers finished
case <-time.After(5 * time.Second):
h.logger.Warnf("Timeout waiting for workers to finish")
}
var lastErr error
// Close backends
if err := h.primary.Close(); err != nil {
h.logger.Errorf("Failed to close L1 backend: %v", err)
lastErr = err
}
if err := h.secondary.Close(); err != nil {
h.logger.Errorf("Failed to close L2 backend: %v", err)
lastErr = err
}
h.logger.Infof("HybridBackend closed")
return lastErr
}
// GetMany retrieves multiple values efficiently
func (h *HybridBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if len(keys) == 0 {
return make(map[string][]byte), nil
}
results := make(map[string][]byte, len(keys))
missingKeys := make([]string, 0)
// Try L1 first for all keys
for _, key := range keys {
if value, _, exists, _ := h.primary.Get(ctx, key); exists {
results[key] = value
h.l1Hits.Add(1)
} else {
missingKeys = append(missingKeys, key)
}
}
// If all found in L1 or in fallback mode, return
if len(missingKeys) == 0 || h.fallbackMode.Load() {
return results, nil
}
// Try L2 for missing keys using batch operation if available
if batcher, ok := h.secondary.(interface {
GetMany(context.Context, []string) (map[string][]byte, error)
}); ok {
l2Results, err := batcher.GetMany(ctx, missingKeys)
if err != nil {
h.logger.Debugf("L2 batch get error: %v", err)
h.recordL2Error()
} else {
for key, value := range l2Results {
results[key] = value
h.l2Hits.Add(1)
h.queueL1Backfill(key, value, 0) // 0 = primary backend default TTL
}
}
} else {
// Fallback to individual gets
for _, key := range missingKeys {
if value, ttl, exists, err := h.secondary.Get(ctx, key); err == nil && exists {
results[key] = value
h.l2Hits.Add(1)
h.queueL1Backfill(key, value, ttl)
}
}
}
// Count misses for keys not found anywhere
for _, key := range keys {
if _, found := results[key]; !found {
h.misses.Add(1)
}
}
return results, nil
}
// SetMany stores multiple key-value pairs efficiently
func (h *HybridBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
if len(items) == 0 {
return nil
}
// Write to L1 first
for key, value := range items {
if err := h.primary.Set(ctx, key, value, ttl); err != nil {
h.logger.Debugf("Failed to write to L1 in batch: %v", err)
} else {
h.l1Writes.Add(1)
}
}
// Skip L2 if in fallback mode
if h.fallbackMode.Load() {
return nil
}
// Check if L2 supports batch operations
if batcher, ok := h.secondary.(interface {
SetMany(context.Context, map[string][]byte, time.Duration) error
}); ok {
if err := batcher.SetMany(ctx, items, ttl); err != nil {
h.logger.Warnf("Failed to batch write to L2: %v", err)
h.recordL2Error()
} else {
h.l2Writes.Add(int64(len(items)))
}
} else {
// Fallback to individual sets
for key, value := range items {
cacheType := h.extractCacheType(key)
if h.syncWriteCacheTypes[cacheType] {
// Sync write for critical types
if err := h.secondary.Set(ctx, key, value, ttl); err != nil {
h.logger.Debugf("Failed to write to L2: %v", err)
h.recordL2Error()
} else {
h.l2Writes.Add(1)
}
} else {
// Async write for non-critical types
select {
case h.asyncWriteBuffer <- &asyncWriteItem{
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
// Queued
default:
h.logger.Warnf("Async buffer full for batch write")
}
}
}
}
return nil
}
// queueL1Backfill enqueues an L2-resolved value for write-through into L1.
// Drops on full buffer to keep the read path constant-time; the next L2 hit
// for the same key simply re-queues it.
func (h *HybridBackend) queueL1Backfill(key string, value []byte, ttl time.Duration) {
select {
case h.l1BackfillBuffer <- &l1BackfillItem{key: key, value: value, ttl: ttl}:
default:
h.l1BackfillDrops.Add(1)
h.logger.Debugf("L1 backfill buffer full, dropping for key: %s", redactKey(key))
}
}
// l1BackfillWorker drains the backfill queue serially. Single worker is
// intentional - L1 writes are local and cheap, and serializing them keeps
// goroutine count bounded under any read rate.
func (h *HybridBackend) l1BackfillWorker() {
defer h.wg.Done()
for {
select {
case <-h.ctx.Done():
// Drain remaining items best-effort then exit.
for len(h.l1BackfillBuffer) > 0 {
select {
case item := <-h.l1BackfillBuffer:
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
_ = h.primary.Set(writeCtx, item.key, item.value, item.ttl)
cancel()
default:
return
}
}
return
case item, ok := <-h.l1BackfillBuffer:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
if err := h.primary.Set(writeCtx, item.key, item.value, item.ttl); err != nil {
h.logger.Debugf("Failed to populate L1 cache from L2 for key %s: %v", redactKey(item.key), err)
} else {
h.logger.Debugf("Populated L1 cache from L2 for key: %s", redactKey(item.key))
}
cancel()
}
}
}
// asyncWriteWorker processes asynchronous writes to L2
func (h *HybridBackend) asyncWriteWorker() {
defer h.wg.Done()
for {
select {
case <-h.ctx.Done():
// Drain remaining items with best effort
for len(h.asyncWriteBuffer) > 0 {
select {
case item := <-h.asyncWriteBuffer:
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
_ = h.secondary.Set(ctx, item.key, item.value, item.ttl)
cancel()
default:
return
}
}
return
case item, ok := <-h.asyncWriteBuffer:
if !ok {
return
}
// Skip if in fallback mode
if h.fallbackMode.Load() {
continue
}
// Perform the write with a timeout
writeCtx, cancel := context.WithTimeout(item.ctx, 500*time.Millisecond)
if err := h.secondary.Set(writeCtx, item.key, item.value, item.ttl); err != nil {
h.errors.Add(1)
h.logger.Debugf("Async write to L2 failed for key %s: %v", redactKey(item.key), err)
h.recordL2Error()
} else {
h.l2Writes.Add(1)
h.logger.Debugf("Async write to L2 completed for key: %s", redactKey(item.key))
}
cancel()
}
}
}
// healthMonitor periodically checks L2 health and manages fallback mode
func (h *HybridBackend) healthMonitor() {
defer h.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := h.secondary.Ping(ctx); err != nil {
if !h.fallbackMode.Load() {
h.fallbackMode.Store(true)
h.logger.Warnf("L2 backend unhealthy, entering fallback mode: %v", err)
}
} else {
if h.fallbackMode.CompareAndSwap(true, false) {
h.logger.Infof("L2 backend healthy, exiting fallback mode")
}
}
cancel()
}
}
}
// recordL2Error records the timestamp of an L2 error
func (h *HybridBackend) recordL2Error() {
h.lastL2Error.Store(time.Now())
// Check if we should enter fallback mode based on recent errors
if !h.fallbackMode.Load() {
// Simple heuristic: if we've had an error in the last second, consider L2 unhealthy
if lastErr := h.lastL2Error.Load(); lastErr != nil {
if t, ok := lastErr.(time.Time); ok && time.Since(t) < time.Second {
h.fallbackMode.Store(true)
h.logger.Warnf("Multiple L2 errors detected, entering fallback mode")
}
}
}
}
// extractCacheType attempts to determine the cache type from the key
func (h *HybridBackend) extractCacheType(key string) string {
// Simple heuristic based on key prefixes
// This should match the actual cache type strategy in the main application
if len(key) > 10 {
prefix := key[:10]
switch {
case contains(prefix, "blacklist"):
return "blacklist"
case contains(prefix, "token"):
return "token"
case contains(prefix, "metadata"):
return "metadata"
case contains(prefix, "jwk"):
return "jwk"
case contains(prefix, "session"):
return "session"
case contains(prefix, "introspect"):
return "introspection"
}
}
return "general"
}
// contains checks if a string contains a substring (case-insensitive)
func contains(s, substr string) bool {
if len(substr) > len(s) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if toLower(s[i+j]) != toLower(substr[j]) {
match = false
break
}
}
if match {
return true
}
}
return false
}
// toLower converts a byte to lowercase
func toLower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + 32
}
return b
}
+112
View File
@@ -0,0 +1,112 @@
//go:build !yaegi
package backends
import (
"context"
"fmt"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestHybridBackend_L1BackfillBounded verifies that a burst of L2 hits does
// not detonate the goroutine count. Pre-fix the code spawned one goroutine
// per Get() L2 hit; post-fix all backfills funnel through a single worker.
func TestHybridBackend_L1BackfillBounded(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: secondary,
AsyncBufferSize: 256,
})
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
const burst = 1000
// Pre-populate L2 with `burst` distinct keys so each Get triggers a
// fresh L1 backfill enqueue.
for i := 0; i < burst; i++ {
require.NoError(t, secondary.Set(ctx, fmt.Sprintf("k:%d", i), []byte("v"), time.Minute))
}
baseline := runtime.NumGoroutine()
// Issue the burst as fast as possible; the backfill worker MUST be the
// only goroutine doing L1 writes. Allow brief slack for the test runtime
// scheduling but anything north of +20 means goroutine leakage.
peak := baseline
for i := 0; i < burst; i++ {
_, _, exists, err := hybrid.Get(ctx, fmt.Sprintf("k:%d", i))
require.NoError(t, err)
require.True(t, exists)
if g := runtime.NumGoroutine(); g > peak {
peak = g
}
}
delta := peak - baseline
if delta > 20 {
t.Fatalf("goroutine count grew by %d during burst (baseline=%d peak=%d); backfill worker not bounding goroutines",
delta, baseline, peak)
}
// L1 must eventually catch up via the worker. Worker drains serially so
// give it a generous window proportional to the burst size.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
var populated int
for i := 0; i < burst; i++ {
if _, _, ok, _ := primary.Get(ctx, fmt.Sprintf("k:%d", i)); ok {
populated++
}
}
// Be lenient: drops are acceptable under buffer pressure, just want
// most of the keys to make it.
if populated >= burst-int(hybrid.l1BackfillDrops.Load()) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("L1 not backfilled within deadline: l2Hits=%d l1Writes=%d drops=%d",
hybrid.l2Hits.Load(), hybrid.l1Writes.Load(), hybrid.l1BackfillDrops.Load())
}
// TestHybridBackend_L1BackfillFullDrops verifies the drop semantics when the
// buffer is saturated. Drops must be counted, never block, never spawn a
// goroutine.
func TestHybridBackend_L1BackfillFullDrops(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
// Tiny buffer + slow primary writes via failSet so the worker stays
// blocked enough to overflow the buffer.
hybrid, err := NewHybridBackend(&HybridConfig{
Primary: primary,
Secondary: secondary,
AsyncBufferSize: 4,
})
require.NoError(t, err)
defer hybrid.Close()
// Stop the worker from draining: cancel the underlying context so the
// worker bails out, leaving us with a cold buffer and the queue method
// itself responsible for drop accounting.
hybrid.cancel()
// Wait for worker to exit so it can't drain.
time.Sleep(50 * time.Millisecond)
for i := 0; i < 50; i++ {
hybrid.queueL1Backfill(fmt.Sprintf("k:%d", i), []byte("v"), time.Minute)
}
assert.Greater(t, hybrid.l1BackfillDrops.Load(), int64(0),
"expected some drops when buffer is saturated and worker is stopped")
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
// Package backends provides cache backend implementations for the Traefik OIDC plugin.
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
@@ -7,7 +7,7 @@ import (
)
// CacheBackend defines the interface for all cache backend implementations
// Implementations include: RedisBackend
// Implementations include: MemoryBackend, RedisBackend, and HybridBackend
type CacheBackend interface {
// Set stores a value in the cache with the specified TTL
// Returns an error if the operation fails
+60 -29
View File
@@ -11,24 +11,28 @@ import (
"github.com/stretchr/testify/require"
)
// TestCacheBackendContract defines a set of tests that all CacheBackend
// implementations must pass. RedisBackend is the only implementation left
// (FIX-43 deleted MemoryBackend and HybridBackend); it runs against a real
// miniredis-backed instance via setupRedisBackend (R4 cache review — this
// suite used to be a no-op: both subtests were t.Skip stubs).
// TestCacheBackendContract defines a set of tests that all CacheBackend implementations must pass
// This ensures that Memory, Redis, and Hybrid backends all behave consistently
func TestCacheBackendContract(t *testing.T) {
// Test suite will be run against each backend type
t.Run("MemoryBackend", func(t *testing.T) {
backend := setupMemoryBackend(t)
runContractTests(t, backend)
})
t.Run("RedisBackend", func(t *testing.T) {
backend, advanceClock := setupRedisBackend(t)
runContractTests(t, backend, advanceClock)
backend := setupRedisBackend(t)
runContractTests(t, backend)
})
t.Run("HybridBackend", func(t *testing.T) {
backend := setupHybridBackend(t)
runContractTests(t, backend)
})
}
// runContractTests executes all contract tests against a backend.
// advanceClock advances TTL expiration by d: miniredis (see setupRedisBackend)
// only expires keys when its simulated clock is explicitly moved forward, so
// testTTLExpiration cannot rely on a real time.Sleep the way every other
// contract test can.
func runContractTests(t *testing.T, backend CacheBackend, advanceClock func(d time.Duration)) {
// runContractTests executes all contract tests against a backend
func runContractTests(t *testing.T, backend CacheBackend) {
t.Helper()
ctx := context.Background()
@@ -58,7 +62,7 @@ func runContractTests(t *testing.T, backend CacheBackend, advanceClock func(d ti
})
t.Run("TTLExpiration", func(t *testing.T) {
testTTLExpiration(t, ctx, backend, advanceClock)
testTTLExpiration(t, ctx, backend)
})
t.Run("Clear", func(t *testing.T) {
@@ -209,7 +213,7 @@ func testExists(t *testing.T, ctx context.Context, backend CacheBackend) {
}
// testTTLExpiration verifies TTL expiration behavior
func testTTLExpiration(t *testing.T, ctx context.Context, backend CacheBackend, advanceClock func(d time.Duration)) {
func testTTLExpiration(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "ttl-key"
@@ -225,8 +229,8 @@ func testTTLExpiration(t *testing.T, ctx context.Context, backend CacheBackend,
require.NoError(t, err)
assert.True(t, exists, "Key should exist immediately after Set")
// Advance past expiration.
advanceClock(200 * time.Millisecond)
// Wait for expiration
time.Sleep(200 * time.Millisecond)
// Verify expired
exists, err = backend.Exists(ctx, key)
@@ -374,17 +378,44 @@ func testSpecialCharactersInKeys(t *testing.T, ctx context.Context, backend Cach
}
}
// setupRedisBackend wires the contract suite to a real RedisBackend backed
// by miniredis (R4 cache review), matching the pattern redis_test.go and
// singleflight_test.go already use. The returned func advances miniredis's
// own simulated clock (see MiniredisServer.FastForward): miniredis only
// expires a key when that clock is explicitly moved forward, so it never
// expires anything on real elapsed time alone.
func setupRedisBackend(t *testing.T) (CacheBackend, func(d time.Duration)) {
// Helper functions to setup different backend types
// These will be implemented in respective test files
func setupMemoryBackend(t *testing.T) CacheBackend {
t.Helper()
mr := NewMiniredisServer(t)
backend, err := NewRedisBackend(DefaultRedisConfig(mr.GetAddr()))
require.NoError(t, err, "failed to create RedisBackend")
t.Cleanup(func() { _ = backend.Close() })
return backend, mr.FastForward
// This will be implemented in memory_test.go
// For now, return nil to allow compilation
t.Skip("MemoryBackend implementation pending")
return nil
}
func setupRedisBackend(t *testing.T) CacheBackend {
t.Helper()
// This will be implemented in redis_test.go
// For now, return nil to allow compilation
t.Skip("RedisBackend implementation pending")
return nil
}
func setupHybridBackend(t *testing.T) CacheBackend {
t.Helper()
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
AsyncBufferSize: 100,
Logger: NewTestLogger(t),
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
t.Cleanup(func() {
hybrid.Close()
})
return hybrid
}
+26
View File
@@ -0,0 +1,26 @@
// Package backends provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"crypto/sha256"
"encoding/hex"
)
// redactKey returns a short, deterministic hash prefix of a cache key for use
// in debug/info log lines. Cache keys in this plugin can include raw access /
// refresh / id tokens (any caller may pass an arbitrary string), and CodeQL
// flags `key=%s` formatters as a clear-text-logging sink for HTTP-header-
// sourced taint. The hash preserves cache-key uniqueness in logs (same key →
// same hash, useful for correlating a problematic key across log lines) while
// keeping the raw value out of disk-resident log streams.
//
// 8 hex chars (32 bits) is enough to disambiguate at human-debugging scale
// without making the hash itself a useful lookup primitive for an attacker
// who only has the log stream.
func redactKey(key string) string {
if key == "" {
return "(empty)"
}
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:4])
}
+535
View File
@@ -0,0 +1,535 @@
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"context"
"sync"
"sync/atomic"
"time"
)
// Default configuration values
const (
defaultShardCount = 256
defaultMaxSize = int64(10000)
defaultMaxMemory = int64(100 * 1024 * 1024) // 100MB
defaultCleanupInterval = 5 * time.Minute
)
// memoryCacheItem represents an item in the memory cache
type memoryCacheItem struct {
expiresAt time.Time
createdAt time.Time
accessedAt time.Time
value interface{}
element interface{} // *list.Element, using interface{} to avoid import cycle
key string
accessCount int64
size int64
}
// isExpired checks if the item is expired
func (item *memoryCacheItem) isExpired() bool {
if item.expiresAt.IsZero() {
return false
}
return time.Now().After(item.expiresAt)
}
// MemoryCacheBackend implements the CacheBackend interface using sharded in-memory storage
// The sharded design reduces lock contention by partitioning keys across multiple shards,
// each with its own lock.
type MemoryCacheBackend struct {
shards []*cacheShard
startTime time.Time
lastErrorTime time.Time
cleanupDone chan struct{}
cleanupTicker *time.Ticker
lastError string
shardCount uint32
shardMask uint32
maxSize int64
maxMemory int64
cleanupInterval time.Duration
// Global stats (aggregated from shards)
hits atomic.Int64
misses atomic.Int64
sets atomic.Int64
deletes atomic.Int64
evictions atomic.Int64
errors atomic.Int64
// Latency tracking
totalGetTime atomic.Int64
totalSetTime atomic.Int64
getCount atomic.Int64
setCount atomic.Int64
// State
closed atomic.Bool
mu sync.RWMutex // For global operations like stats and error tracking
}
// NewMemoryCacheBackend creates a new sharded memory cache backend
func NewMemoryCacheBackend(maxSize int64, maxMemory int64, cleanupInterval time.Duration) *MemoryCacheBackend {
if maxSize <= 0 {
maxSize = defaultMaxSize
}
if maxMemory <= 0 {
maxMemory = defaultMaxMemory
}
if cleanupInterval <= 0 {
cleanupInterval = defaultCleanupInterval
}
shardCount := uint32(defaultShardCount)
// For very small caches, reduce shard count to maintain sensible per-shard limits
// Ensure each shard can hold at least 2 items for proper LRU behavior
for shardCount > 1 && maxSize/int64(shardCount) < 2 {
shardCount /= 2
}
if shardCount < 1 {
shardCount = 1
}
// Per-shard limits are soft hints; global limits are enforced
// Give shards 2x the average to allow for uneven distribution
shardMaxSize := (maxSize * 2) / int64(shardCount)
if shardMaxSize < 4 {
shardMaxSize = 4
}
shardMaxMemory := (maxMemory * 2) / int64(shardCount)
if shardMaxMemory < 4096 {
shardMaxMemory = 4096 // Minimum 4KB per shard
}
m := &MemoryCacheBackend{
shards: make([]*cacheShard, shardCount),
shardCount: shardCount,
shardMask: shardCount - 1, // For fast modulo with power-of-2
maxSize: maxSize,
maxMemory: maxMemory,
startTime: time.Now(),
cleanupInterval: cleanupInterval,
cleanupDone: make(chan struct{}),
}
// Initialize shards
for i := uint32(0); i < shardCount; i++ {
m.shards[i] = newCacheShard(shardMaxSize, shardMaxMemory)
}
// Start cleanup goroutine
m.cleanupTicker = time.NewTicker(cleanupInterval)
go m.cleanupLoop()
return m
}
// getShard returns the shard for a given key
func (m *MemoryCacheBackend) getShard(key string) *cacheShard {
hash := fnv32(key)
return m.shards[hash&m.shardMask]
}
// cleanupLoop runs periodic cleanup of expired items
func (m *MemoryCacheBackend) cleanupLoop() {
for {
select {
case <-m.cleanupTicker.C:
m.cleanupExpired()
case <-m.cleanupDone:
return
}
}
}
// cleanupExpired removes all expired items from all shards
func (m *MemoryCacheBackend) cleanupExpired() {
if m.closed.Load() {
return
}
totalRemoved := 0
for _, shard := range m.shards {
totalRemoved += shard.cleanup()
}
if totalRemoved > 0 {
m.evictions.Add(int64(totalRemoved))
}
}
// Get retrieves a value from the cache
func (m *MemoryCacheBackend) Get(ctx context.Context, key string) (interface{}, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
start := time.Now()
defer func() {
duration := time.Since(start).Nanoseconds()
m.totalGetTime.Add(duration)
m.getCount.Add(1)
}()
shard := m.getShard(key)
value, exists, expired := shard.get(key)
if expired {
// Clean up expired item
shard.delete(key)
m.misses.Add(1)
return nil, ErrCacheMiss
}
if !exists {
m.misses.Add(1)
return nil, ErrCacheMiss
}
m.hits.Add(1)
return value, nil
}
// Set stores a value in the cache with optional TTL
func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
start := time.Now()
defer func() {
duration := time.Since(start).Nanoseconds()
m.totalSetTime.Add(duration)
m.setCount.Add(1)
}()
// Calculate item size
itemSize := int64(len(key)) + estimateValueSize(value)
// Enforce global limits before adding new item
m.enforceGlobalLimits(itemSize)
var expiresAt time.Time
if ttl > 0 {
expiresAt = time.Now().Add(ttl)
}
shard := m.getShard(key)
shard.set(key, value, expiresAt, itemSize)
m.sets.Add(1)
return nil
}
// enforceGlobalLimits ensures global size and memory limits are respected
// by evicting from shards when necessary
func (m *MemoryCacheBackend) enforceGlobalLimits(newItemSize int64) {
// Check and enforce size limit
for {
totalSize, totalMemory := m.getGlobalStats()
needsSizeEviction := m.maxSize > 0 && totalSize >= m.maxSize
needsMemoryEviction := m.maxMemory > 0 && totalMemory+newItemSize > m.maxMemory
if !needsSizeEviction && !needsMemoryEviction {
break
}
// Find the shard with the most items and evict from it
evicted := m.evictFromLargestShard()
if !evicted {
break // No more items to evict
}
m.evictions.Add(1)
}
}
// getGlobalStats returns the total size and memory usage across all shards
func (m *MemoryCacheBackend) getGlobalStats() (totalSize, totalMemory int64) {
for _, shard := range m.shards {
size, memory := shard.stats()
totalSize += size
totalMemory += memory
}
return
}
// evictFromLargestShard evicts the globally oldest item across all shards
// This provides true LRU behavior even with sharding
func (m *MemoryCacheBackend) evictFromLargestShard() bool {
var oldestShard *cacheShard
var oldestTime time.Time
for _, shard := range m.shards {
accessTime := shard.getOldestAccessTime()
// Skip empty shards
if accessTime.IsZero() {
continue
}
// Find the shard with the oldest (earliest) access time
if oldestShard == nil || accessTime.Before(oldestTime) {
oldestTime = accessTime
oldestShard = shard
}
}
if oldestShard == nil {
return false
}
return oldestShard.evictOne()
}
// Delete removes a key from the cache
func (m *MemoryCacheBackend) Delete(ctx context.Context, key string) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
shard := m.getShard(key)
if shard.delete(key) {
m.deletes.Add(1)
}
return nil
}
// Exists checks if a key exists in the cache
func (m *MemoryCacheBackend) Exists(ctx context.Context, key string) (bool, error) {
if m.closed.Load() {
return false, ErrBackendUnavailable
}
shard := m.getShard(key)
return shard.exists(key), nil
}
// Clear removes all items from the cache
func (m *MemoryCacheBackend) Clear(ctx context.Context) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
for _, shard := range m.shards {
shard.clear()
}
return nil
}
// Keys returns all keys matching the pattern (use "*" for all keys)
func (m *MemoryCacheBackend) Keys(ctx context.Context, pattern string) ([]string, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
var allKeys []string
for _, shard := range m.shards {
keys := shard.keys(pattern)
allKeys = append(allKeys, keys...)
}
return allKeys, nil
}
// Size returns the total number of items in the cache
func (m *MemoryCacheBackend) Size(ctx context.Context) (int64, error) {
if m.closed.Load() {
return 0, ErrBackendUnavailable
}
var total int64
for _, shard := range m.shards {
size, _ := shard.stats()
total += size
}
return total, nil
}
// TTL returns the remaining time-to-live for a key
func (m *MemoryCacheBackend) TTL(ctx context.Context, key string) (time.Duration, error) {
if m.closed.Load() {
return 0, ErrBackendUnavailable
}
shard := m.getShard(key)
ttl, exists := shard.ttl(key)
if !exists {
return 0, ErrCacheMiss
}
return ttl, nil
}
// Expire updates the TTL for an existing key
func (m *MemoryCacheBackend) Expire(ctx context.Context, key string, ttl time.Duration) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
shard := m.getShard(key)
if !shard.expire(key, ttl) {
return ErrCacheMiss
}
return nil
}
// GetStats returns statistics about the cache backend
func (m *MemoryCacheBackend) GetStats(ctx context.Context) (*BackendStats, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
// Aggregate stats from all shards
var totalSize, totalMemory int64
for _, shard := range m.shards {
size, memory := shard.stats()
totalSize += size
totalMemory += memory
}
m.mu.RLock()
lastError := m.lastError
lastErrorTime := m.lastErrorTime
m.mu.RUnlock()
avgGetLatency := time.Duration(0)
if getCount := m.getCount.Load(); getCount > 0 {
avgGetLatency = time.Duration(m.totalGetTime.Load() / getCount)
}
avgSetLatency := time.Duration(0)
if setCount := m.setCount.Load(); setCount > 0 {
avgSetLatency = time.Duration(m.totalSetTime.Load() / setCount)
}
return &BackendStats{
Type: TypeMemory,
Hits: m.hits.Load(),
Misses: m.misses.Load(),
Sets: m.sets.Load(),
Deletes: m.deletes.Load(),
Errors: m.errors.Load(),
Evictions: m.evictions.Load(),
CurrentSize: totalSize,
MaxSize: m.maxSize,
MemoryUsage: totalMemory,
AverageGetLatency: avgGetLatency,
AverageSetLatency: avgSetLatency,
LastError: lastError,
LastErrorTime: lastErrorTime,
Uptime: time.Since(m.startTime),
StartTime: m.startTime,
}, nil
}
// Ping checks if the backend is healthy
func (m *MemoryCacheBackend) Ping(ctx context.Context) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
return nil
}
// Close closes the backend and releases resources
func (m *MemoryCacheBackend) Close() error {
if m.closed.Swap(true) {
return nil // Already closed
}
m.cleanupTicker.Stop()
close(m.cleanupDone)
// Clear all shards
for _, shard := range m.shards {
shard.clear()
}
return nil
}
// IsHealthy returns true if the backend is healthy
func (m *MemoryCacheBackend) IsHealthy() bool {
return !m.closed.Load()
}
// Type returns the backend type
func (m *MemoryCacheBackend) Type() BackendType {
return TypeMemory
}
// Capabilities returns the backend capabilities
func (m *MemoryCacheBackend) Capabilities() *BackendCapabilities {
return &BackendCapabilities{
Distributed: false,
Persistent: false,
Eviction: true,
TTL: true,
MaxKeySize: 1024, // 1KB
MaxValueSize: 10485760, // 10MB
MaxKeys: m.maxSize,
SupportsExpire: true,
SupportsMultiGet: true,
SupportsTransaction: false,
SupportsCompression: false,
RequiresSerialize: false,
}
}
// GetShardCount returns the number of shards (for testing/monitoring)
func (m *MemoryCacheBackend) GetShardCount() uint32 {
return m.shardCount
}
// GetShardStats returns per-shard statistics (for monitoring)
func (m *MemoryCacheBackend) GetShardStats() []map[string]int64 {
stats := make([]map[string]int64, m.shardCount)
for i, shard := range m.shards {
size, memory := shard.stats()
stats[i] = map[string]int64{
"size": size,
"memory": memory,
}
}
return stats
}
// Helper functions
// estimateValueSize estimates the size of a value in bytes
func estimateValueSize(value interface{}) int64 {
switch v := value.(type) {
case string:
return int64(len(v))
case []byte:
return int64(len(v))
case int, int32, int64, uint, uint32, uint64:
return 8
case float32, float64:
return 8
case bool:
return 1
default:
// For complex types, use a default estimate
return 256
}
}
// matchPattern checks if a key matches a pattern (simplified glob matching)
func matchPattern(pattern, key string) bool {
if pattern == "*" {
return true
}
// Simplified pattern matching
if len(pattern) > 0 && pattern[0] == '*' {
suffix := pattern[1:]
return len(key) >= len(suffix) && key[len(key)-len(suffix):] == suffix
}
return key == pattern
}
+182
View File
@@ -0,0 +1,182 @@
package backends
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
)
// setupBenchmarkRedis creates a miniredis instance for benchmarking
func setupBenchmarkRedis(b *testing.B) string {
b.Helper()
mr, err := miniredis.Run()
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() {
mr.Close()
})
return mr.Addr()
}
// BenchmarkRedisOperations_WithPooling benchmarks memory allocations with object pooling
func BenchmarkRedisOperations_WithPooling(b *testing.B) {
addr := setupBenchmarkRedis(b)
config := &PoolConfig{
Address: addr,
MaxConnections: 10,
ConnectTimeout: 5 * time.Second,
}
pool, err := NewConnectionPool(config)
if err != nil {
b.Fatal(err)
}
defer pool.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
conn, err := pool.Get(ctx)
if err != nil {
b.Fatal(err)
}
// Perform various operations
_, _ = conn.Do("SET", "bench-key", "bench-value")
_, _ = conn.Do("GET", "bench-key")
_, _ = conn.Do("EXISTS", "bench-key")
_, _ = conn.Do("DEL", "bench-key")
pool.Put(conn)
}
}
// BenchmarkRedisBackend_SetGet benchmarks the full backend with pooling
func BenchmarkRedisBackend_SetGet(b *testing.B) {
addr := setupBenchmarkRedis(b)
backend, err := NewRedisBackend(&Config{
RedisAddr: addr,
PoolSize: 10,
})
if err != nil {
b.Fatal(err)
}
defer backend.Close()
ctx := context.Background()
testData := []byte("benchmark test data with some content")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Set operation
err := backend.Set(ctx, "bench-key", testData, 0)
if err != nil {
b.Fatal(err)
}
// Get operation
_, _, _, err = backend.Get(ctx, "bench-key")
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkRedisBackend_ConcurrentAccess benchmarks concurrent operations with pooling
func BenchmarkRedisBackend_ConcurrentAccess(b *testing.B) {
addr := setupBenchmarkRedis(b)
backend, err := NewRedisBackend(&Config{
RedisAddr: addr,
PoolSize: 10,
})
if err != nil {
b.Fatal(err)
}
defer backend.Close()
ctx := context.Background()
testData := []byte("concurrent benchmark data")
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = backend.Set(ctx, "concurrent-key", testData, 0)
_, _, _, _ = backend.Get(ctx, "concurrent-key")
}
})
}
// BenchmarkRESPProtocol_WriteRead benchmarks RESP protocol encoding/decoding
func BenchmarkRESPProtocol_WriteRead(b *testing.B) {
addr := setupBenchmarkRedis(b)
config := &PoolConfig{
Address: addr,
MaxConnections: 10,
ConnectTimeout: 5 * time.Second,
}
pool, err := NewConnectionPool(config)
if err != nil {
b.Fatal(err)
}
defer pool.Close()
ctx := context.Background()
conn, err := pool.Get(ctx)
if err != nil {
b.Fatal(err)
}
defer pool.Put(conn)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// This tests the pooling of RESPReader/RESPWriter
_, _ = conn.Do("PING")
}
}
// BenchmarkConnectionPool_GetPut benchmarks connection pool operations
func BenchmarkConnectionPool_GetPut(b *testing.B) {
addr := setupBenchmarkRedis(b)
config := &PoolConfig{
Address: addr,
MaxConnections: 10,
ConnectTimeout: 5 * time.Second,
}
pool, err := NewConnectionPool(config)
if err != nil {
b.Fatal(err)
}
defer pool.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
conn, err := pool.Get(ctx)
if err != nil {
b.Fatal(err)
}
pool.Put(conn)
}
}
+294
View File
@@ -0,0 +1,294 @@
package backends
import (
"container/list"
"sync"
"time"
)
// cacheShard represents a single shard of the sharded cache
// Each shard has its own lock for reduced contention
type cacheShard struct {
items map[string]*memoryCacheItem
lruList *list.List
mu sync.RWMutex
maxSize int64
maxMemory int64
size int64
memoryUsed int64
}
// newCacheShard creates a new cache shard
func newCacheShard(maxSize, maxMemory int64) *cacheShard {
return &cacheShard{
items: make(map[string]*memoryCacheItem),
lruList: list.New(),
maxSize: maxSize,
maxMemory: maxMemory,
}
}
// get retrieves a value from this shard
// Returns: value, exists, expired
func (s *cacheShard) get(key string) (interface{}, bool, bool) {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists {
return nil, false, false
}
if item.isExpired() {
return nil, true, true // exists but expired
}
// Update access time and LRU position under write lock
s.mu.Lock()
// Re-check item exists (could have been deleted)
item, exists = s.items[key]
if exists && !item.isExpired() {
item.accessedAt = time.Now()
item.accessCount++
if elem, ok := item.element.(*list.Element); ok && elem != nil {
s.lruList.MoveToFront(elem)
}
}
s.mu.Unlock()
if !exists || item.isExpired() {
return nil, false, false
}
return item.value, true, false
}
// set stores a value in this shard
func (s *cacheShard) set(key string, value interface{}, expiresAt time.Time, size int64) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if we need to evict items
if s.maxSize > 0 && s.size >= s.maxSize {
s.evictLRULocked()
}
if s.maxMemory > 0 && s.memoryUsed+size > s.maxMemory {
s.evictLRULocked()
}
// Remove old item if exists
if oldItem, exists := s.items[key]; exists {
s.memoryUsed -= oldItem.size
if elem, ok := oldItem.element.(*list.Element); ok && elem != nil {
s.lruList.Remove(elem)
}
s.size--
}
now := time.Now()
item := &memoryCacheItem{
key: key,
value: value,
expiresAt: expiresAt,
createdAt: now,
accessedAt: now,
accessCount: 0,
size: size,
}
item.element = s.lruList.PushFront(item)
s.items[key] = item
s.size++
s.memoryUsed += size
}
// delete removes a key from this shard
// Returns true if the key was deleted
func (s *cacheShard) delete(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
item, exists := s.items[key]
if !exists {
return false
}
s.deleteItemLocked(item)
return true
}
// exists checks if a key exists (and is not expired)
func (s *cacheShard) exists(key string) bool {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists {
return false
}
return !item.isExpired()
}
// ttl returns the remaining TTL for a key
func (s *cacheShard) ttl(key string) (time.Duration, bool) {
s.mu.RLock()
item, exists := s.items[key]
s.mu.RUnlock()
if !exists || item.isExpired() {
return 0, false
}
if item.expiresAt.IsZero() {
return 0, true // No expiration
}
remaining := time.Until(item.expiresAt)
if remaining < 0 {
return 0, false
}
return remaining, true
}
// expire updates the TTL for an existing key
func (s *cacheShard) expire(key string, ttl time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
item, exists := s.items[key]
if !exists || item.isExpired() {
return false
}
if ttl > 0 {
item.expiresAt = time.Now().Add(ttl)
} else {
item.expiresAt = time.Time{} // Remove expiration
}
return true
}
// keys returns all non-expired keys matching the pattern
func (s *cacheShard) keys(pattern string) []string {
s.mu.RLock()
defer s.mu.RUnlock()
var keys []string
for key, item := range s.items {
if !item.isExpired() && matchPattern(pattern, key) {
keys = append(keys, key)
}
}
return keys
}
// clear removes all items from this shard
func (s *cacheShard) clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.items = make(map[string]*memoryCacheItem)
s.lruList.Init()
s.size = 0
s.memoryUsed = 0
}
// cleanup removes expired items
// Returns the number of items removed
func (s *cacheShard) cleanup() int {
s.mu.Lock()
defer s.mu.Unlock()
var toRemove []*memoryCacheItem
for _, item := range s.items {
if item.isExpired() {
toRemove = append(toRemove, item)
}
}
for _, item := range toRemove {
s.deleteItemLocked(item)
}
return len(toRemove)
}
// stats returns statistics for this shard
func (s *cacheShard) stats() (size, memory int64) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.size, s.memoryUsed
}
// deleteItemLocked removes an item (must be called with lock held)
func (s *cacheShard) deleteItemLocked(item *memoryCacheItem) {
if elem, ok := item.element.(*list.Element); ok && elem != nil {
s.lruList.Remove(elem)
}
delete(s.items, item.key)
s.size--
s.memoryUsed -= item.size
}
// evictLRULocked evicts the least recently used item (must be called with lock held)
func (s *cacheShard) evictLRULocked() bool {
if s.lruList.Len() == 0 {
return false
}
element := s.lruList.Back()
if element != nil {
item, ok := element.Value.(*memoryCacheItem)
if ok {
s.deleteItemLocked(item)
return true
}
}
return false
}
// evictOne evicts one item from this shard (for global limit enforcement)
func (s *cacheShard) evictOne() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.evictLRULocked()
}
// getOldestAccessTime returns the access time of the LRU item (oldest) in this shard
// Returns zero time if shard is empty
func (s *cacheShard) getOldestAccessTime() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
if s.lruList.Len() == 0 {
return time.Time{}
}
element := s.lruList.Back()
if element != nil {
item, ok := element.Value.(*memoryCacheItem)
if ok {
return item.accessedAt
}
}
return time.Time{}
}
// fnv32 computes FNV-1a hash of a string
// This is a fast, well-distributed hash function
func fnv32(key string) uint32 {
const (
offset32 = uint32(2166136261)
prime32 = uint32(16777619)
)
hash := offset32
for i := 0; i < len(key); i++ {
hash ^= uint32(key[i])
hash *= prime32
}
return hash
}
+283
View File
@@ -0,0 +1,283 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestShardedCache_ShardDistribution tests that keys are distributed across shards
func TestShardedCache_ShardDistribution(t *testing.T) {
t.Parallel()
// Create a cache with large enough size to have multiple shards
config := DefaultConfig()
config.MaxSize = 10000
config.MaxMemoryBytes = 100 * 1024 * 1024 // 100MB
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add many items to see distribution
numItems := 1000
for i := 0; i < numItems; i++ {
key := fmt.Sprintf("dist-key-%d", i)
value := []byte(fmt.Sprintf("dist-value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Check that items are distributed across multiple shards
shardStats := backend.MemoryCacheBackend.GetShardStats()
nonEmptyShards := 0
for _, stat := range shardStats {
if stat["size"] > 0 {
nonEmptyShards++
}
}
// With good hash distribution, we should have items in multiple shards
assert.Greater(t, nonEmptyShards, 1, "Items should be distributed across multiple shards")
}
// TestShardedCache_ShardCount tests that shard count adapts to cache size
func TestShardedCache_ShardCount(t *testing.T) {
t.Parallel()
tests := []struct {
maxSize int
expectLowShards bool
}{
{5, true}, // Very small cache should have fewer shards
{100, true}, // Small cache should have fewer shards
{10000, false}, // Large cache should have default shards
}
for _, tt := range tests {
t.Run(fmt.Sprintf("MaxSize_%d", tt.maxSize), func(t *testing.T) {
config := DefaultConfig()
config.MaxSize = tt.maxSize
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
shardCount := backend.MemoryCacheBackend.GetShardCount()
if tt.expectLowShards {
assert.Less(t, shardCount, uint32(256), "Small cache should have fewer shards")
} else {
assert.Equal(t, uint32(256), shardCount, "Large cache should have default shard count")
}
})
}
}
// TestShardedCache_ConcurrentSameKey tests concurrent access to the same key
func TestShardedCache_ConcurrentSameKey(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "concurrent-same-key"
initialValue := []byte("initial-value")
err = backend.Set(ctx, key, initialValue, time.Minute)
require.NoError(t, err)
var wg sync.WaitGroup
goroutines := 50
iterations := 100
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
// Mix of reads and writes
if j%3 == 0 {
newValue := []byte(fmt.Sprintf("value-%d-%d", id, j))
err := backend.Set(ctx, key, newValue, time.Minute)
assert.NoError(t, err)
} else {
_, _, _, err := backend.Get(ctx, key)
assert.NoError(t, err)
}
}
}(i)
}
wg.Wait()
// Key should still exist
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
}
// TestShardedCache_GlobalLRUEviction tests that global LRU is maintained
func TestShardedCache_GlobalLRUEviction(t *testing.T) {
t.Parallel()
// Create a small cache to force eviction
config := DefaultConfig()
config.MaxSize = 10
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
value := []byte(fmt.Sprintf("value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
// Small delay to ensure different access times
time.Sleep(time.Millisecond)
}
// Access some items to make them recently used
for i := 5; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
_, _, _, err := backend.Get(ctx, key)
require.NoError(t, err)
}
// Add more items to trigger eviction
for i := 10; i < 15; i++ {
key := fmt.Sprintf("global-lru-%d", i)
value := []byte(fmt.Sprintf("value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Recently accessed items (5-9) should still exist
for i := 5; i < 10; i++ {
key := fmt.Sprintf("global-lru-%d", i)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Recently accessed item %d should exist", i)
}
// Check eviction stats
stats := backend.GetStats()
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have evictions")
}
// TestShardedCache_StatsAggregation tests that stats are aggregated correctly
func TestShardedCache_StatsAggregation(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 10000
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items to multiple shards
numItems := 100
for i := 0; i < numItems; i++ {
key := fmt.Sprintf("stats-key-%d", i)
value := []byte(fmt.Sprintf("stats-value-%d", i))
err := backend.Set(ctx, key, value, time.Minute)
require.NoError(t, err)
}
// Read some items
for i := 0; i < numItems/2; i++ {
key := fmt.Sprintf("stats-key-%d", i)
backend.Get(ctx, key)
}
// Read non-existent items
for i := 0; i < 10; i++ {
backend.Get(ctx, fmt.Sprintf("nonexistent-%d", i))
}
stats := backend.GetStats()
// Verify stats
assert.Equal(t, int64(numItems), stats["sets"].(int64), "Sets should match")
assert.Equal(t, int64(numItems/2), stats["hits"].(int64), "Hits should match")
assert.Equal(t, int64(10), stats["misses"].(int64), "Misses should match")
assert.Equal(t, int64(numItems), stats["size"].(int64), "Size should match")
// Verify hit rate
hitRate := stats["hit_rate"].(float64)
expectedHitRate := float64(numItems/2) / float64(numItems/2+10)
assert.InDelta(t, expectedHitRate, hitRate, 0.01, "Hit rate should match")
}
// BenchmarkShardedCache_Parallel benchmarks parallel access
func BenchmarkShardedCache_Parallel(b *testing.B) {
config := DefaultConfig()
config.MaxSize = 100000
config.MaxMemoryBytes = 100 * 1024 * 1024
backend, _ := NewMemoryBackend(config)
defer backend.Close()
ctx := context.Background()
// Pre-populate cache
for i := 0; i < 10000; i++ {
key := fmt.Sprintf("bench-key-%d", i)
value := []byte(fmt.Sprintf("bench-value-%d", i))
backend.Set(ctx, key, value, time.Hour)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("bench-key-%d", i%10000)
backend.Get(ctx, key)
i++
}
})
}
// BenchmarkShardedCache_MixedOps benchmarks mixed operations
func BenchmarkShardedCache_MixedOps(b *testing.B) {
config := DefaultConfig()
config.MaxSize = 100000
config.MaxMemoryBytes = 100 * 1024 * 1024
backend, _ := NewMemoryBackend(config)
defer backend.Close()
ctx := context.Background()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("mixed-key-%d", i%1000)
if i%3 == 0 {
value := []byte(fmt.Sprintf("mixed-value-%d", i))
backend.Set(ctx, key, value, time.Hour)
} else {
backend.Get(ctx, key)
}
i++
}
})
}
+783
View File
@@ -0,0 +1,783 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMemoryBackend_BasicOperations tests basic CRUD operations
func TestMemoryBackend_BasicOperations(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("SetAndGet", func(t *testing.T) {
key := "test-key"
value := []byte("test-value")
ttl := 1 * time.Minute
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
retrieved, remainingTTL, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
assert.Greater(t, remainingTTL, 50*time.Second)
assert.LessOrEqual(t, remainingTTL, ttl)
})
t.Run("GetNonExistent", func(t *testing.T) {
_, _, exists, err := backend.Get(ctx, "non-existent")
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("Delete", func(t *testing.T) {
key := "delete-key"
value := []byte("delete-value")
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
deleted, err := backend.Delete(ctx, key)
require.NoError(t, err)
assert.True(t, deleted)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("DeleteNonExistent", func(t *testing.T) {
deleted, err := backend.Delete(ctx, "non-existent-delete")
require.NoError(t, err)
assert.False(t, deleted)
})
t.Run("Exists", func(t *testing.T) {
key := "exists-key"
value := []byte("exists-value")
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
})
t.Run("Clear", func(t *testing.T) {
// Add multiple items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("clear-key-%d", i)
value := []byte(fmt.Sprintf("clear-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
err := backend.Clear(ctx)
require.NoError(t, err)
stats := backend.GetStats()
size := stats["size"].(int64)
assert.Equal(t, int64(0), size)
})
}
// TestMemoryBackend_TTLExpiration tests TTL and expiration
func TestMemoryBackend_TTLExpiration(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.CleanupInterval = 50 * time.Millisecond
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("ShortTTL", func(t *testing.T) {
key := "short-ttl-key"
value := []byte("short-ttl-value")
shortTTL := 100 * time.Millisecond
err := backend.Set(ctx, key, value, shortTTL)
require.NoError(t, err)
// Verify exists immediately
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired
_, _, exists, err = backend.Get(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("TTLDecrement", func(t *testing.T) {
key := "ttl-decrement-key"
value := []byte("ttl-decrement-value")
ttl := 2 * time.Second
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
// Check TTL immediately
_, ttl1, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Wait a bit
time.Sleep(500 * time.Millisecond)
// Check TTL again - should be less
_, ttl2, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Less(t, ttl2, ttl1, "TTL should decrease over time")
})
t.Run("CleanupExpiredItems", func(t *testing.T) {
// Set multiple items with short TTL
for i := 0; i < 5; i++ {
key := fmt.Sprintf("cleanup-key-%d", i)
value := []byte(fmt.Sprintf("cleanup-value-%d", i))
err := backend.Set(ctx, key, value, 50*time.Millisecond)
require.NoError(t, err)
}
// Wait for cleanup to run
time.Sleep(200 * time.Millisecond)
// All items should be cleaned up
for i := 0; i < 5; i++ {
key := fmt.Sprintf("cleanup-key-%d", i)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Expired items should be cleaned up")
}
})
}
// TestMemoryBackend_LRUEviction tests LRU eviction
func TestMemoryBackend_LRUEviction(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 5
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Fill cache to max size
for i := 0; i < 5; i++ {
key := fmt.Sprintf("lru-key-%d", i)
value := []byte(fmt.Sprintf("lru-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
// Access first item to make it most recently used
_, _, exists, err := backend.Get(ctx, "lru-key-0")
require.NoError(t, err)
assert.True(t, exists)
// Add a new item - should evict lru-key-1 (least recently used)
err = backend.Set(ctx, "lru-key-new", []byte("new-value"), 1*time.Minute)
require.NoError(t, err)
// lru-key-0 should still exist (was accessed recently)
exists, err = backend.Exists(ctx, "lru-key-0")
require.NoError(t, err)
assert.True(t, exists, "Recently accessed item should not be evicted")
// lru-key-1 should be evicted
exists, err = backend.Exists(ctx, "lru-key-1")
require.NoError(t, err)
assert.False(t, exists, "Least recently used item should be evicted")
// Check eviction count
stats := backend.GetStats()
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have evictions")
}
// TestMemoryBackend_MemoryLimit tests memory-based eviction
func TestMemoryBackend_MemoryLimit(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 100
config.MaxMemoryBytes = 1024 // 1KB limit
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items until memory limit is reached
largeValue := make([]byte, 512) // 512 bytes each
for i := 0; i < 5; i++ {
key := fmt.Sprintf("mem-key-%d", i)
err := backend.Set(ctx, key, largeValue, 1*time.Minute)
require.NoError(t, err)
}
stats := backend.GetStats()
memory := stats["memory"].(int64)
assert.LessOrEqual(t, memory, config.MaxMemoryBytes, "Memory should not exceed limit")
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have memory-based evictions")
}
// TestMemoryBackend_ConcurrentAccess tests thread safety
func TestMemoryBackend_ConcurrentAccess(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
var wg sync.WaitGroup
goroutines := 20
iterations := 50
// Concurrent writes
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("concurrent-key-%d-%d", id, j)
value := []byte(fmt.Sprintf("concurrent-value-%d-%d", id, j))
err := backend.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
// Read back
retrieved, _, exists, err := backend.Get(ctx, key)
assert.NoError(t, err)
if exists {
assert.Equal(t, value, retrieved)
}
// Random deletes
if j%5 == 0 {
backend.Delete(ctx, key)
}
}
}(i)
}
wg.Wait()
// Verify stats are consistent
stats := backend.GetStats()
hits := stats["hits"].(int64)
misses := stats["misses"].(int64)
assert.Greater(t, hits+misses, int64(0), "Should have cache operations")
}
// TestMemoryBackend_UpdateExisting tests updating existing keys
func TestMemoryBackend_UpdateExisting(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "update-key"
value1 := []byte("original-value")
value2 := []byte("updated-value")
// Set original
err = backend.Set(ctx, key, value1, 1*time.Minute)
require.NoError(t, err)
// Update
err = backend.Set(ctx, key, value2, 2*time.Minute)
require.NoError(t, err)
// Verify updated
retrieved, ttl, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value2, retrieved)
assert.Greater(t, ttl, 1*time.Minute, "TTL should be updated")
// Size should not increase (same key)
stats := backend.GetStats()
size := stats["size"].(int64)
assert.Equal(t, int64(1), size, "Size should be 1 for one key")
}
// TestMemoryBackend_Stats tests statistics tracking
func TestMemoryBackend_Stats(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Initial stats
stats := backend.GetStats()
assert.Equal(t, int64(0), stats["hits"].(int64))
assert.Equal(t, int64(0), stats["misses"].(int64))
// Add items and track hits/misses
backend.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
backend.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
// Hit
backend.Get(ctx, "key1")
// Miss
backend.Get(ctx, "non-existent")
stats = backend.GetStats()
assert.Equal(t, int64(1), stats["hits"].(int64))
assert.Equal(t, int64(1), stats["misses"].(int64))
hitRate := stats["hit_rate"].(float64)
assert.InDelta(t, 0.5, hitRate, 0.01)
}
// TestMemoryBackend_EmptyValues tests handling of empty values
func TestMemoryBackend_EmptyValues(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "empty-key"
emptyValue := []byte{}
err = backend.Set(ctx, key, emptyValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, 0, len(retrieved))
}
// TestMemoryBackend_LargeValues tests handling of large values
func TestMemoryBackend_LargeValues(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxMemoryBytes = 10 * 1024 * 1024 // 10MB
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "large-key"
largeValue := make([]byte, 1024*1024) // 1MB
err = backend.Set(ctx, key, largeValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, len(largeValue), len(retrieved))
}
// TestMemoryBackend_Close tests proper cleanup on close
func TestMemoryBackend_Close(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
ctx := context.Background()
// Add some items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("close-key-%d", i)
value := []byte(fmt.Sprintf("close-value-%d", i))
backend.Set(ctx, key, value, 1*time.Minute)
}
// Close
err = backend.Close()
require.NoError(t, err)
// Operations after close should fail
err = backend.Set(ctx, "after-close", []byte("value"), 1*time.Minute)
assert.Error(t, err)
assert.Equal(t, ErrBackendClosed, err)
_, _, _, err = backend.Get(ctx, "close-key-0")
assert.Error(t, err)
assert.Equal(t, ErrBackendClosed, err)
// Closing again should be safe
err = backend.Close()
assert.NoError(t, err)
}
// TestMemoryBackend_Ping tests ping operation
func TestMemoryBackend_Ping(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
err = backend.Ping(ctx)
assert.NoError(t, err)
// Close and ping should fail
backend.Close()
err = backend.Ping(ctx)
assert.Error(t, err)
}
// TestMemoryBackend_ValueIsolation tests that returned values are isolated
func TestMemoryBackend_ValueIsolation(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "isolation-key"
originalValue := []byte("original-value")
err = backend.Set(ctx, key, originalValue, 1*time.Minute)
require.NoError(t, err)
// Get value and modify it
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Modify retrieved value
if len(retrieved) > 0 {
retrieved[0] = 'X'
}
// Get again - should be unchanged
retrieved2, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, originalValue, retrieved2, "Original value should not be modified")
}
// TestMemoryBackend_Keys tests the Keys method with pattern matching
func TestMemoryBackend_Keys(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add test data
testKeys := []string{"user:1", "user:2", "session:abc", "session:def", "token:xyz"}
for _, key := range testKeys {
err := backend.Set(ctx, key, []byte("value"), 1*time.Minute)
require.NoError(t, err)
}
t.Run("AllKeys", func(t *testing.T) {
keys, err := backend.Keys(ctx, "*")
require.NoError(t, err)
assert.Len(t, keys, 5)
})
t.Run("SpecificPattern", func(t *testing.T) {
// Simple exact match
keys, err := backend.Keys(ctx, "user:1")
require.NoError(t, err)
assert.Len(t, keys, 1)
assert.Contains(t, keys, "user:1")
})
t.Run("ExcludesExpired", func(t *testing.T) {
// Add an expired key
expiredKey := "expired:key"
err := backend.Set(ctx, expiredKey, []byte("value"), 1*time.Millisecond)
require.NoError(t, err)
// Wait for expiration
time.Sleep(10 * time.Millisecond)
keys, err := backend.Keys(ctx, "*")
require.NoError(t, err)
assert.NotContains(t, keys, expiredKey, "Expired keys should not be returned")
})
t.Run("AfterClose", func(t *testing.T) {
closedBackend, _ := NewMemoryBackend(DefaultConfig())
closedBackend.Close()
_, err := closedBackend.Keys(ctx, "*")
assert.Error(t, err)
assert.Equal(t, ErrBackendUnavailable, err)
})
}
// TestMemoryBackend_Size tests the Size method
func TestMemoryBackend_Size(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Initially empty
size, err := backend.Size(ctx)
require.NoError(t, err)
assert.Equal(t, int64(0), size)
// Add items
for i := 0; i < 5; i++ {
key := fmt.Sprintf("key-%d", i)
err := backend.Set(ctx, key, []byte("value"), 1*time.Minute)
require.NoError(t, err)
}
size, err = backend.Size(ctx)
require.NoError(t, err)
assert.Equal(t, int64(5), size)
// Delete one
backend.Delete(ctx, "key-0")
size, err = backend.Size(ctx)
require.NoError(t, err)
assert.Equal(t, int64(4), size)
// After close
backend.Close()
_, err = backend.Size(ctx)
assert.Error(t, err)
assert.Equal(t, ErrBackendUnavailable, err)
}
// TestMemoryBackend_TTL tests the TTL method
func TestMemoryBackend_TTL(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("ExistingKey", func(t *testing.T) {
key := "ttl-key"
ttl := 1 * time.Minute
err := backend.Set(ctx, key, []byte("value"), ttl)
require.NoError(t, err)
remaining, err := backend.TTL(ctx, key)
require.NoError(t, err)
assert.Greater(t, remaining, 50*time.Second)
assert.LessOrEqual(t, remaining, ttl)
})
t.Run("NonExistentKey", func(t *testing.T) {
_, err := backend.TTL(ctx, "non-existent")
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
})
t.Run("NoExpiration", func(t *testing.T) {
key := "no-expiry"
// TTL of 0 typically means no expiration
err := backend.Set(ctx, key, []byte("value"), 0)
require.NoError(t, err)
remaining, err := backend.TTL(ctx, key)
require.NoError(t, err)
// No expiration returns 0
assert.Equal(t, time.Duration(0), remaining)
})
t.Run("AfterClose", func(t *testing.T) {
closedBackend, _ := NewMemoryBackend(DefaultConfig())
closedBackend.Close()
_, err := closedBackend.TTL(ctx, "key")
assert.Error(t, err)
assert.Equal(t, ErrBackendUnavailable, err)
})
}
// TestMemoryBackend_Expire tests the Expire method
func TestMemoryBackend_Expire(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("UpdateTTL", func(t *testing.T) {
key := "expire-key"
err := backend.Set(ctx, key, []byte("value"), 1*time.Minute)
require.NoError(t, err)
// Update to shorter TTL
err = backend.Expire(ctx, key, 5*time.Second)
require.NoError(t, err)
// Check new TTL
remaining, err := backend.TTL(ctx, key)
require.NoError(t, err)
assert.LessOrEqual(t, remaining, 5*time.Second)
})
t.Run("NonExistentKey", func(t *testing.T) {
err := backend.Expire(ctx, "non-existent", 1*time.Minute)
assert.Error(t, err)
assert.Equal(t, ErrCacheMiss, err)
})
t.Run("RemoveExpiration", func(t *testing.T) {
key := "no-expire-key"
err := backend.Set(ctx, key, []byte("value"), 1*time.Minute)
require.NoError(t, err)
// Set TTL to 0 to remove expiration
err = backend.Expire(ctx, key, 0)
require.NoError(t, err)
// TTL should now be 0
remaining, err := backend.TTL(ctx, key)
require.NoError(t, err)
assert.Equal(t, time.Duration(0), remaining)
})
t.Run("AfterClose", func(t *testing.T) {
closedBackend, _ := NewMemoryBackend(DefaultConfig())
closedBackend.Close()
err := closedBackend.Expire(ctx, "key", 1*time.Minute)
assert.Error(t, err)
assert.Equal(t, ErrBackendUnavailable, err)
})
}
// TestMemoryBackend_IsHealthy tests the IsHealthy method
func TestMemoryBackend_IsHealthy(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
// Should be healthy when open
assert.True(t, backend.IsHealthy())
// Should be unhealthy after close
backend.Close()
assert.False(t, backend.IsHealthy())
}
// TestMemoryBackend_Type tests the Type method
func TestMemoryBackend_Type(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
backendType := backend.Type()
assert.Equal(t, TypeMemory, backendType)
}
// TestMemoryBackend_Capabilities tests the Capabilities method
func TestMemoryBackend_Capabilities(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
caps := backend.Capabilities()
require.NotNil(t, caps)
// Memory backend should not be distributed or persistent
assert.False(t, caps.Distributed)
assert.False(t, caps.Persistent)
// Should support eviction and TTL
assert.True(t, caps.Eviction)
assert.True(t, caps.TTL)
assert.True(t, caps.SupportsExpire)
assert.True(t, caps.SupportsMultiGet)
// Check limits
assert.Greater(t, caps.MaxKeySize, int64(0))
assert.Greater(t, caps.MaxValueSize, int64(0))
}
// TestMatchPattern tests the matchPattern helper function
func TestMatchPattern(t *testing.T) {
t.Parallel()
tests := []struct {
pattern string
key string
matches bool
}{
{"*", "any-key", true},
{"*", "another", true},
{"user:1", "user:1", true},
{"user:1", "user:2", false},
{"*:suffix", "prefix:suffix", true},
{"*suffix", "prefix-suffix", true},
{"*abc", "xyzabc", true},
{"*abc", "xyz", false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.pattern, tt.key), func(t *testing.T) {
result := matchPattern(tt.pattern, tt.key)
assert.Equal(t, tt.matches, result)
})
}
}
+143
View File
@@ -0,0 +1,143 @@
package backends
import (
"context"
"time"
)
// MemoryBackend wraps MemoryCacheBackend to implement the CacheBackend interface
type MemoryBackend struct {
*MemoryCacheBackend
}
// NewMemoryBackend creates a new memory backend from a config
func NewMemoryBackend(config *Config) (*MemoryBackend, error) {
maxSize := int64(config.MaxSize)
if maxSize <= 0 {
maxSize = 1000
}
cacheBackend := NewMemoryCacheBackend(maxSize, config.MaxMemoryBytes, config.CleanupInterval)
return &MemoryBackend{
MemoryCacheBackend: cacheBackend,
}, nil
}
// Set stores a value in the cache with the specified TTL
func (m *MemoryBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
err := m.MemoryCacheBackend.Set(ctx, key, value, ttl)
if err == ErrBackendUnavailable {
return ErrBackendClosed
}
return err
}
// Get retrieves a value from the cache
func (m *MemoryBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
val, err := m.MemoryCacheBackend.Get(ctx, key)
if err != nil {
if err == ErrCacheMiss {
return nil, 0, false, nil
}
if err == ErrBackendUnavailable {
return nil, 0, false, ErrBackendClosed
}
return nil, 0, false, err
}
// Get TTL using the TTL method
ttl, ttlErr := m.MemoryCacheBackend.TTL(ctx, key)
if ttlErr != nil {
// If we can't get TTL, still return the value with 0 TTL
ttl = 0
}
// Convert interface{} to []byte
var valueBytes []byte
if val != nil {
if bytes, ok := val.([]byte); ok {
valueBytes = bytes
} else {
// If it's not already []byte, return an error
return nil, 0, false, ErrInvalidValue
}
}
return valueBytes, ttl, true, nil
}
// Delete removes a key from the cache
func (m *MemoryBackend) Delete(ctx context.Context, key string) (bool, error) {
// Check if key exists first
exists, err := m.MemoryCacheBackend.Exists(ctx, key)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
err = m.MemoryCacheBackend.Delete(ctx, key)
if err != nil {
return false, err
}
return true, nil
}
// Exists checks if a key exists in the cache
func (m *MemoryBackend) Exists(ctx context.Context, key string) (bool, error) {
return m.MemoryCacheBackend.Exists(ctx, key)
}
// Clear removes all keys from the cache
func (m *MemoryBackend) Clear(ctx context.Context) error {
return m.MemoryCacheBackend.Clear(ctx)
}
// GetStats returns cache statistics
func (m *MemoryBackend) GetStats() map[string]interface{} {
stats, err := m.MemoryCacheBackend.GetStats(context.Background())
if err != nil {
return map[string]interface{}{
"error": err.Error(),
}
}
// Convert BackendStats to map
hitRate := float64(0)
total := stats.Hits + stats.Misses
if total > 0 {
hitRate = float64(stats.Hits) / float64(total)
}
return map[string]interface{}{
"type": stats.Type,
"hits": stats.Hits,
"misses": stats.Misses,
"sets": stats.Sets,
"deletes": stats.Deletes,
"errors": stats.Errors,
"evictions": stats.Evictions,
"size": stats.CurrentSize,
"max_size": stats.MaxSize,
"memory": stats.MemoryUsage,
"hit_rate": hitRate,
"uptime": stats.Uptime,
"start_time": stats.StartTime,
"shard_count": m.MemoryCacheBackend.GetShardCount(),
}
}
// Close shuts down the cache backend and releases resources
func (m *MemoryBackend) Close() error {
return m.MemoryCacheBackend.Close()
}
// Ping checks if the backend is healthy and responsive
func (m *MemoryBackend) Ping(ctx context.Context) error {
return m.MemoryCacheBackend.Ping(ctx)
}
// Ensure MemoryBackend implements CacheBackend
var _ CacheBackend = (*MemoryBackend)(nil)
+32 -247
View File
@@ -17,15 +17,6 @@ var (
ErrPoolExhausted = errors.New("connection pool exhausted")
)
// NoExpiryTTL is the sentinel RedisBackend.Get reports for a key that has
// no associated expiry (Redis PTTL -1). It is distinct from a returned
// ttl of 0, which means the key has under a millisecond left (or vanished
// between GET and PTTL): the caller must not treat those two cases the
// same way — folding "no expiry" into 0 made a caller that repopulates a
// local copy on ttl<=0 re-cache a dying entry for its full DefaultTTL
// (FIX-31).
const NoExpiryTTL time.Duration = -1
// RedisBackend implements a Redis-based cache backend using pure Go
type RedisBackend struct {
config *Config
@@ -104,15 +95,7 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
return ErrBackendClosed
}
// A NEGATIVE TTL means "already expired" (the stack's convention for a
// value whose validity has passed). Previously ttl<=0 fell through to a
// bare SET with no expiry, silently making the entry permanent. Zero here
// is the documented "no expiry" contract (see SetManyNoTTL), so only
// strictly-negative TTLs are skipped.
prefixedKey := r.prefixKey(key)
if ttl < 0 {
return nil
}
// Execute with retry logic
return r.executeWithRetry(ctx, func(conn *RedisConn) error {
@@ -120,8 +103,15 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
// Use PSETEX for millisecond precision, SETEX for second precision
if ttl > 0 {
cmd, arg := ttlCommand(ttl)
_, err = conn.Do(cmd, prefixedKey, arg, string(value))
ttlMillis := ttl.Milliseconds()
if ttlMillis < 1000 {
// Use PSETEX for sub-second TTLs (millisecond precision)
_, err = conn.Do("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
} else {
// Use SETEX for larger TTLs (second precision)
ttlSeconds := int(ttl.Seconds())
_, err = conn.Do("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
}
} else {
_, err = conn.Do("SET", prefixedKey, string(value))
}
@@ -130,157 +120,6 @@ func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl ti
})
}
// SetNX stores a value in Redis only if the key does not already exist,
// atomically, using Redis SET key value NX PX <ttl-ms> — one server-side
// command rather than a Get followed by a Set, so two callers racing the
// same key (e.g. two Traefik replicas racing the same backchannel-logout
// jti) can never both observe "absent". Backs UniversalCache.SetIfAbsent's
// distributed case (FIX-17): CacheBackend has no such primitive, and
// widening it would force every implementer to add an operation only this
// one caller needs, so UniversalCache reaches this through an optional
// interface type assertion instead.
//
// Returns (true, nil) when this call claimed the key, (false, nil) when the
// key already existed (someone else claimed it first — not an error),
// (false, ErrSetNXAmbiguous) when the command reached the wire but its
// reply could not be read at all (timeout, EOF, connection reset) — Redis
// may or may not have applied it — and (false, err) on any other genuine
// backend failure, INCLUDING a definitive RESP '-' command-error reply
// (-READONLY, -OOM, -MISCONF, ...): that proves Redis read and rejected the
// command, so it is never ambiguous (R4 cache review).
//
// SetNX runs its own retry loop rather than executeWithRetry (FIX-17
// round-2): Set's SETEX/PSETEX are idempotent, so retrying one after a lost
// reply just repeats the same unconditional write. SET NX is not — retrying
// it after a lost reply would see this call's OWN possible write and
// misreport a first-ever claim as already-claimed. So SetNX retries a
// failed connection acquisition (nothing was sent yet, safe to retry) but
// never re-sends SET NX once doTracked reports the command was written.
func (r *RedisBackend) SetNX(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
if r.closed.Load() {
return false, ErrBackendClosed
}
// Mirrors Set's TTL convention: negative means "already expired", so
// there is nothing to claim.
prefixedKey := r.prefixKey(key)
if ttl < 0 {
return false, nil
}
var args []string
if ttl > 0 {
ttlMillis := ttl.Milliseconds()
if ttlMillis < 1 {
ttlMillis = 1
}
args = []string{prefixedKey, string(value), "NX", "PX", fmt.Sprintf("%d", ttlMillis)}
} else {
args = []string{prefixedKey, string(value), "NX"}
}
maxRetries := 3
baseDelay := 50 * time.Millisecond
for attempt := 0; attempt < maxRetries; attempt++ {
if ctx.Err() != nil {
return false, ctx.Err()
}
conn, err := r.pool.Get(ctx)
if err != nil {
if attempt == maxRetries-1 {
return false, fmt.Errorf("failed to get connection after %d attempts: %w", maxRetries, err)
}
delay := baseDelay * time.Duration(1<<uint(attempt))
select {
case <-ctx.Done():
return false, ctx.Err()
case <-time.After(delay):
continue
}
}
var (
resp interface{}
sent bool
doErr error
)
func() {
defer func() { r.pool.Put(conn) }()
resp, sent, doErr = conn.doTracked("SET", args...)
}()
if doErr == nil {
if _, strErr := RESPString(resp); strErr != nil {
return false, strErr
}
return true, nil
}
if errors.Is(doErr, ErrNilResponse) {
// NX condition failed: the key already existed before this
// call. A valid protocol outcome, not an error — the
// connection is healthy and nothing here should be retried.
return false, nil
}
if errors.Is(doErr, ErrCommandReply) {
// A RESP '-' reply (e.g. -READONLY, -OOM, -MISCONF) proves
// Redis read the command and refused it — unlike a lost reply,
// this is a DEFINITIVE outcome: the SET NX was never applied.
// Reporting it as ErrSetNXAmbiguous would make
// checkAndMarkLogoutJTIProcessed accept the caller's claim
// outright (the ambiguous branch exists precisely to avoid a
// false replay report for a write that may have landed), which
// here would let a captured logout token replay forever while
// Redis rejects writes. Surface it as a plain error instead, so
// the caller falls through to its own local fallback.
return false, doErr
}
if sent {
// The command reached the wire before reading its reply
// failed (timeout, EOF, connection reset): Redis may have
// applied it. This is checked BEFORE ctx.Err() (FIX-17
// round-3): UniversalCache.setIfAbsentBackend gives SetNX a
// 500ms context, and the pool's read deadline on this same
// write is also 500ms, started strictly after the ctx
// deadline began ticking — so when a reply is lost, ctx has
// almost always already expired by the time this line runs.
// Checking ctx.Err() first therefore reported
// context.DeadlineExceeded for what is actually an ambiguous
// outcome, and checkAndMarkLogoutJTIProcessed does not
// special-case a plain deadline error: it fell through to the
// mutex-guarded Get+Set fallback, whose Get saw this call's
// own possible write and misreported a first-ever logout
// token as a replay. Retrying here (instead of returning) would
// see this call's own possible write and misreport a
// first-ever claim as already-claimed, same reasoning as
// above — surface the ambiguity instead of guessing.
return false, ErrSetNXAmbiguous
}
if ctx.Err() != nil {
return false, ctx.Err()
}
if attempt == maxRetries-1 || !isRetryableError(doErr) {
return false, doErr
}
delay := baseDelay * time.Duration(1<<uint(attempt))
select {
case <-ctx.Done():
return false, ctx.Err()
case <-time.After(delay):
continue
}
}
return false, fmt.Errorf("operation failed after %d attempts", maxRetries)
}
// Get retrieves a value from Redis
func (r *RedisBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
if r.closed.Load() {
@@ -310,14 +149,10 @@ func (r *RedisBackend) Get(ctx context.Context, key string) ([]byte, time.Durati
return err
}
// Get TTL with millisecond precision (PTTL, not TTL). TTL's second
// precision maps both "no expiry" (-1) and "under one second left"
// to a reported ttl of 0 once truncated to whole seconds, so a
// caller cannot tell them apart (FIX-31). PTTL keeps "no expiry"
// distinct via NoExpiryTTL.
ttlResp, err := conn.Do("PTTL", prefixedKey)
// Get TTL
ttlResp, err := conn.Do("TTL", prefixedKey)
if err != nil {
// If PTTL fails, still return the value; report no TTL info.
// If TTL fails, still return the value
r.hits.Add(1)
resultValue = []byte(value)
resultTTL = 0
@@ -325,18 +160,10 @@ func (r *RedisBackend) Get(ctx context.Context, key string) ([]byte, time.Durati
return nil
}
ttlMillis, _ := RESPInt(ttlResp)
ttlSeconds, _ := RESPInt(ttlResp)
var ttl time.Duration
switch {
case ttlMillis == -1:
// Key exists with no associated expiry.
ttl = NoExpiryTTL
case ttlMillis > 0:
ttl = time.Duration(ttlMillis) * time.Millisecond
default:
// -2 (key vanished between GET and PTTL) or 0 (under 1ms
// left): report "expire now", not "no expiry".
ttl = 0
if ttlSeconds > 0 {
ttl = time.Duration(ttlSeconds) * time.Second
}
r.hits.Add(1)
@@ -549,30 +376,20 @@ func (r *RedisBackend) executeWithRetry(ctx context.Context, operation func(*Red
}
}
// Execute the operation, guaranteeing the borrowed connection is
// returned to the pool even if the operation panics (previously a
// panic here leaked the connection and desynced the pool counters;
// other pool call sites use defer for this).
func() {
defer func() { r.pool.Put(conn) }()
err = operation(conn)
}()
// Check err == nil BEFORE ctx.Err(): the operation already completed
// against the connection (op returned), so a nil error means Redis
// applied it. Reporting ctx.Err() first would tell the caller (e.g.
// UniversalCache.Set) that a write failed when it actually landed,
// which previously triggered a Delete that erased the just-applied
// value (FIX-04).
if err == nil {
return nil
}
// Execute the operation
err = operation(conn)
r.pool.Put(conn)
// Check context after operation - if canceled, don't bother retrying
if ctx.Err() != nil {
return ctx.Err()
}
// If successful, return
if err == nil {
return nil
}
// If error is not retryable or last attempt, fail
if attempt == maxRetries-1 || !isRetryableError(err) {
return err
@@ -617,19 +434,6 @@ func isRetryableError(err error) bool {
return false
}
// ttlCommand picks SETEX for whole seconds and PSETEX otherwise. The expiry is
// rounded up to a millisecond and is never zero, so a key never dies early.
func ttlCommand(ttl time.Duration) (cmd, arg string) {
ms := (ttl + time.Millisecond - 1).Milliseconds()
if ms < 1 {
ms = 1
}
if ms >= 1000 && ms%1000 == 0 {
return "SETEX", fmt.Sprintf("%d", ms/1000)
}
return "PSETEX", fmt.Sprintf("%d", ms)
}
// SetMany stores multiple values in Redis using pipelining for efficiency
// This reduces N round-trips to a single round-trip
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
@@ -641,13 +445,6 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
return nil
}
if ttl < 0 {
// Already-expired TTL: nothing to persist (see Set's guard). Avoids
// creating permanent entries for past-dated values via the multi path.
// Zero retains the "no expiry" contract (see SetManyNoTTL).
return nil
}
// For single items, use regular Set
if len(items) == 1 {
for key, value := range items {
@@ -664,16 +461,20 @@ func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl
pipeline := conn.NewPipeline()
// Queue all SET commands
var ttlCmd, ttlArg string
if ttl > 0 {
ttlCmd, ttlArg = ttlCommand(ttl)
}
ttlSeconds := int(ttl.Seconds())
ttlMillis := ttl.Milliseconds()
for key, value := range items {
prefixedKey := r.prefixKey(key)
if ttl > 0 {
pipeline.Queue(ttlCmd, prefixedKey, ttlArg, string(value))
if ttlMillis < 1000 {
// Use PSETEX for sub-second TTLs
pipeline.Queue("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
} else {
// Use SETEX for larger TTLs
pipeline.Queue("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
}
} else {
pipeline.Queue("SET", prefixedKey, string(value))
}
@@ -746,7 +547,6 @@ func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][
// Process responses
result := make(map[string][]byte)
var firstErr error
for i, resp := range responses {
if resp == nil {
// Key doesn't exist
@@ -754,17 +554,6 @@ func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][
continue
}
// A Redis command-error reply (e.g. LOADING, WRONGTYPE) is stored
// as an error value by Pipeline.Execute. It means the key lookup
// failed, not that the key is missing, so it must not be counted
// as a miss.
if respErr, ok := resp.(error); ok {
if firstErr == nil {
firstErr = fmt.Errorf("GetMany: command error for key %q: %w", keys[i], respErr)
}
continue
}
value, err := RESPString(resp)
if err != nil {
// Invalid response, skip this key
@@ -776,9 +565,5 @@ func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][
result[keys[i]] = []byte(value)
}
if firstErr != nil {
return result, firstErr
}
return result, nil
}
@@ -1,46 +0,0 @@
package backends
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRedisBackend_GetMany_CommandErrorReplyIsNotAMiss is a regression test
// for the review finding that RedisBackend.GetMany silently counted a Redis
// command-error reply (for example WRONGTYPE) as a cache miss instead of
// surfacing it as an error. Pipeline.Execute stores such a reply as an
// error value in the responses slice; GetMany must recognize that value and
// report it, not swallow it as "key not found". Per the todo acceptance
// criteria, this must also not increment the misses counter.
func TestRedisBackend_GetMany_CommandErrorReplyIsNotAMiss(t *testing.T) {
t.Parallel()
mr, backend := setupTestRedis(t)
ctx := context.Background()
require.NoError(t, backend.Set(ctx, "good-key", []byte("good-value"), time.Minute))
// Create a key holding a list, so a GET against it returns a RESP
// command-error reply (WRONGTYPE), not a nil/missing response.
_, err := mr.Lpush("test:bad-key", "listval")
require.NoError(t, err)
missesBefore := backend.GetStats()["misses"].(int64)
hitsBefore := backend.GetStats()["hits"].(int64)
results, err := backend.GetMany(ctx, []string{"good-key", "bad-key"})
require.Error(t, err, "GetMany must surface the WRONGTYPE command-error reply instead of masking it as a miss")
assert.Contains(t, err.Error(), "bad-key")
assert.Equal(t, []byte("good-value"), results["good-key"], "a command error on one key must not drop a good key's result")
assert.NotContains(t, results, "bad-key")
missesAfter := backend.GetStats()["misses"].(int64)
hitsAfter := backend.GetStats()["hits"].(int64)
assert.Equal(t, missesBefore, missesAfter, "a command-error reply must not increment the misses counter")
assert.Equal(t, hitsBefore+1, hitsAfter, "the good key must still count as exactly one hit")
}
+13 -76
View File
@@ -67,24 +67,6 @@ func NewConnectionPool(config *PoolConfig) (*ConnectionPool, error) {
return pool, nil
}
// reserveConnectionSlot atomically reserves capacity for one new connection,
// returning false when the pool is already at MaxConnections. The
// reservation is released via totalConns.Add(-1) if the dial fails.
func (p *ConnectionPool) reserveConnectionSlot() bool {
// Compare in int64: narrowing MaxConnections to int32 could wrap a
// large configured value to a negative limit and refuse every connection.
maxConns := int64(p.config.MaxConnections)
for {
cur := p.totalConns.Load()
if int64(cur) >= maxConns {
return false
}
if p.totalConns.CompareAndSwap(cur, cur+1) {
return true
}
}
}
// Get retrieves a connection from the pool or creates a new one
func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
if p.closed.Load() {
@@ -101,11 +83,6 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
select {
case conn = <-p.connections:
if conn == nil {
// Pool channel was closed concurrently (Close) - receiving from a
// closed channel yields the zero value.
return nil, ErrBackendClosed
}
// Reuse existing connection - validate if health check enabled
if p.config.EnableHealthCheck && !p.isConnectionHealthy(conn) {
// Connection is stale, close it and try again
@@ -120,15 +97,11 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
return nil, ctx.Err()
default:
// No available connection, create new one if under limit.
// Reserve capacity atomically before dialing: checking
// totalConns then dialing is not atomic, so concurrent Gets
// could each pass the check and spike the live socket count
// far above MaxConnections (FD / Redis maxclients exhaustion).
if p.reserveConnectionSlot() {
// No available connection, create new one if under limit
// #nosec G115 -- MaxConnections is a small config value that fits in int32
if p.totalConns.Load() < int32(p.config.MaxConnections) {
conn, err = p.createConnection(ctx)
if err != nil {
p.totalConns.Add(-1) // release the reserved slot
// If this is the last attempt, return error
if attempt == maxAttempts-1 {
return nil, err
@@ -138,17 +111,13 @@ func (p *ConnectionPool) Get(ctx context.Context) (*RedisConn, error) {
continue
}
p.activeConns.Add(1)
p.totalConns.Add(1)
return conn, nil
}
// Pool exhausted, wait for a connection with timeout
select {
case conn = <-p.connections:
if conn == nil {
// Pool channel was closed concurrently (Close) - receiving from a
// closed channel yields the zero value.
return nil, ErrBackendClosed
}
// Validate connection if health check enabled
if p.config.EnableHealthCheck && !p.isConnectionHealthy(conn) {
_ = conn.Close()
@@ -179,13 +148,6 @@ func (p *ConnectionPool) Put(conn *RedisConn) {
p.puts.Add(1)
p.activeConns.Add(-1)
// Hold p.mu so this is mutually exclusive with Close (which closes
// p.connections under the same lock). Without it, a Put that passed
// the closed check could send on p.connections just after Close
// closed it, panicking "send on closed channel" at shutdown.
p.mu.Lock()
defer p.mu.Unlock()
if p.closed.Load() || conn.closed.Load() {
_ = conn.Close()
p.totalConns.Add(-1)
@@ -298,23 +260,10 @@ type RedisConn struct {
mu sync.Mutex
}
// Do executes a Redis command and returns the response.
// Do executes a Redis command and returns the response
func (c *RedisConn) Do(command string, args ...string) (interface{}, error) {
resp, _, err := c.doTracked(command, args...)
return resp, err
}
// doTracked is Do's implementation. It additionally reports whether the
// command was already written to the connection when it failed: SetNX uses
// this to tell "definitely not applied" (the write itself failed) from
// "outcome unknown, may have applied" (the write succeeded but reading the
// reply failed). SET NX is not safe to retry blindly the way Set's
// idempotent SETEX/PSETEX is — a retried SET NX after a lost reply sees its
// own possible write and reports "already claimed" for what may have been a
// first-ever claim (FIX-17 round-2).
func (c *RedisConn) doTracked(command string, args ...string) (interface{}, bool, error) {
if c.closed.Load() {
return nil, false, ErrBackendClosed
return nil, ErrBackendClosed
}
c.mu.Lock()
@@ -324,7 +273,7 @@ func (c *RedisConn) doTracked(command string, args ...string) (interface{}, bool
// maxSafeArgs is set to (1<<20)-1 = 1,048,575 which is more than any reasonable Redis command
const maxSafeArgs = (1 << 20) - 1
if len(args) > maxSafeArgs {
return nil, false, errors.New("too many arguments: exceeds maximum safe count")
return nil, errors.New("too many arguments: exceeds maximum safe count")
}
// Build command arguments
@@ -334,11 +283,11 @@ func (c *RedisConn) doTracked(command string, args ...string) (interface{}, bool
for _, s := range args {
// Protect against possible overflow
if len(s) > maxTotalArgBytes-totalBytes {
return nil, false, errors.New("arguments too large (would overflow maximum allowed total size)")
return nil, errors.New("arguments too large (would overflow maximum allowed total size)")
}
totalBytes += len(s)
if totalBytes > maxTotalArgBytes {
return nil, false, errors.New("total argument size exceeds maximum allowed")
return nil, errors.New("total argument size exceeds maximum allowed")
}
}
// Build command slice: prepend command to args
@@ -356,7 +305,7 @@ func (c *RedisConn) doTracked(command string, args ...string) (interface{}, bool
writer.Release() // Return to pool immediately after use
if err != nil {
c.closed.Store(true)
return nil, false, err
return nil, err
}
// Set read timeout
@@ -369,17 +318,13 @@ func (c *RedisConn) doTracked(command string, args ...string) (interface{}, bool
resp, err := reader.ReadResponse()
reader.Release() // Return to pool immediately after use
if err != nil {
// A nil response or a Redis command error reply leaves the
// connection healthy — only an IO/parse error closes it.
if !errors.Is(err, ErrNilResponse) && !errors.Is(err, ErrCommandReply) {
if !errors.Is(err, ErrNilResponse) {
c.closed.Store(true)
}
// The write above already succeeded, so the command reached the
// wire even though its reply did not come back.
return nil, true, err
return nil, err
}
return resp, true, nil
return resp, nil
}
// Close closes the connection
@@ -508,14 +453,6 @@ func (p *Pipeline) Execute() ([]interface{}, error) {
responses[i] = nil
continue
}
if errors.Is(err, ErrCommandReply) {
// A Redis command error reply: the connection is healthy
// and the remaining replies are still readable. Store the
// error value so per-command validation upstream can
// report it without aborting the batch.
responses[i] = err
continue
}
p.conn.closed.Store(true)
return responses[:i], fmt.Errorf("pipeline read error at command %d: %w", i, err)
}
-37
View File
@@ -1,37 +0,0 @@
package backends
import (
"math"
"strconv"
"testing"
)
// TestReserveConnectionSlotLargeMaxConnections pins the gosec G115 fix: the
// limit was narrowed with int32(MaxConnections), so a value above MaxInt32
// wrapped to a negative limit and every connection was refused.
func TestReserveConnectionSlotLargeMaxConnections(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("int cannot exceed MaxInt32 on this platform")
}
p := &ConnectionPool{config: &PoolConfig{MaxConnections: math.MaxInt32 + 1}}
if !p.reserveConnectionSlot() {
t.Fatal("reserveConnectionSlot refused a connection although MaxConnections is far above the current count")
}
if got := p.totalConns.Load(); got != 1 {
t.Fatalf("totalConns = %d, want 1", got)
}
}
// TestReserveConnectionSlotRespectsLimit checks that the int64 comparison
// still enforces a normal limit.
func TestReserveConnectionSlotRespectsLimit(t *testing.T) {
p := &ConnectionPool{config: &PoolConfig{MaxConnections: 2}}
for i := 0; i < 2; i++ {
if !p.reserveConnectionSlot() {
t.Fatalf("reservation %d refused below the limit", i+1)
}
}
if p.reserveConnectionSlot() {
t.Fatal("reservation above MaxConnections was accepted")
}
}
+1 -85
View File
@@ -367,47 +367,7 @@ func TestConnectionPool_Close(t *testing.T) {
require.NoError(t, err)
}
// TestConnectionPool_GetDuringClose_NoNilRegression ensures a Get racing with
// Close never yields a nil *RedisConn with a nil error (which previously
// happened when the receive from the closed p.connections channel returned
// the zero value), and never panics by calling Close on a nil receiver.
func TestConnectionPool_GetDuringClose_NoNilRegression(t *testing.T) {
mr := NewMiniredisServer(t)
config := &PoolConfig{
Address: mr.GetAddr(),
MaxConnections: 1,
ConnectTimeout: 1 * time.Second,
}
pool, err := NewConnectionPool(config)
require.NoError(t, err)
// Fill the pool so a concurrent Get blocks waiting for a connection.
held, err := pool.Get(context.Background())
require.NoError(t, err)
defer pool.Put(held)
type result struct {
conn *RedisConn
err error
}
resCh := make(chan result, 1)
go func() {
c, e := pool.Get(context.Background())
resCh <- result{c, e}
}()
// Give the goroutine time to reach the blocking receive on p.connections,
// then close the pool. Closing the channel unblocks the receive with the
// zero value; Get must surface ErrBackendClosed rather than (nil, nil).
time.Sleep(50 * time.Millisecond)
require.NoError(t, pool.Close())
res := <-resCh
require.Error(t, res.err, "Get after Close must return an error, not (nil, nil)")
assert.True(t, errors.Is(res.err, ErrBackendClosed), "want ErrBackendClosed, got %v", res.err)
}
// TestConnectionPool_Timeouts tests various timeout scenarios
func TestConnectionPool_Timeouts(t *testing.T) {
mr := NewMiniredisServer(t)
@@ -688,47 +648,3 @@ func TestRedisConn_RejectOversizedArgumentBytes(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "arguments too large")
}
// TestConnectionPool_PutDuringClose_NoPanic hammers Put/Get concurrently with
// Close. Before the fix, Put checked closed then sent on p.connections
// without holding p.mu; if Close closed the channel in between, the send
// panicked ("send on closed channel") at shutdown. Run under -race.
func TestConnectionPool_PutDuringClose_NoPanic(t *testing.T) {
mr := NewMiniredisServer(t)
config := &PoolConfig{
Address: mr.GetAddr(),
MaxConnections: 5,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
}
pool, err := NewConnectionPool(config)
require.NoError(t, err)
ctx := context.Background()
stop := make(chan struct{})
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if conn, err := pool.Get(ctx); err == nil {
pool.Put(conn) // may race Close
}
}
}()
}
time.Sleep(5 * time.Millisecond)
_ = pool.Close()
close(stop)
wg.Wait()
}
-45
View File
@@ -1,45 +0,0 @@
package backends
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRedisSetTTLNeverZeroOrEarly(t *testing.T) {
mr := NewMiniredisServer(t)
b, err := NewRedisBackend(DefaultRedisConfig(mr.GetAddr()))
require.NoError(t, err)
defer b.Close()
ctx := context.Background()
t.Run("sub-millisecond ttl is accepted", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "sub", []byte("v"), 500*time.Microsecond))
})
t.Run("sub-millisecond ttl accepted by SetMany", func(t *testing.T) {
require.NoError(t, b.SetMany(ctx, map[string][]byte{"sub-many": []byte("v")}, 500*time.Microsecond))
})
t.Run("fractional seconds are not rounded down", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "frac", []byte("v"), 1999*time.Millisecond))
_, ttl, ok, err := b.Get(ctx, "frac")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, time.Second, "1999ms ttl must not be stored as 1s")
})
t.Run("fractional seconds SetMany", func(t *testing.T) {
require.NoError(t, b.SetMany(ctx, map[string][]byte{"frac-many": []byte("v")}, 1999*time.Millisecond))
_, ttl, ok, err := b.Get(ctx, "frac-many")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, time.Second)
})
t.Run("whole seconds still work", func(t *testing.T) {
require.NoError(t, b.Set(ctx, "whole", []byte("v"), 5*time.Second))
_, ttl, ok, err := b.Get(ctx, "whole")
require.NoError(t, err)
require.True(t, ok)
require.Greater(t, ttl, 3*time.Second)
})
}

Some files were not shown because too many files have changed in this diff Show More