mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-02 07:34:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6521f6ac6 | ||
|
|
9ab7220828 | ||
|
|
f0af6282dc | ||
|
|
7c5a86f4e1 | ||
|
|
94a8664403 | ||
|
|
74decb21c2 | ||
|
|
7ae75d2523 | ||
|
|
937776516d | ||
|
|
c9844d0ff2 | ||
|
|
45e99551f9 | ||
|
|
d3012d5169 | ||
|
|
61e60733a5 | ||
|
|
7e44725edc | ||
|
|
15dba0e8d3 | ||
|
|
f728f61777 | ||
|
|
78b6f193b3 | ||
|
|
edb7174cc8 | ||
|
|
e51bd9cdbe | ||
|
|
f320d0a316 | ||
|
|
48a19e04f8 | ||
|
|
546ceb949c | ||
|
|
f75b2f20e0 | ||
|
|
cf6ed1da55 | ||
|
|
f821b8829b | ||
|
|
5f9c574f95 | ||
|
|
7c6f09fb20 | ||
|
|
68e1c4319c | ||
|
|
17e3f8ef62 | ||
|
|
827926bc3a | ||
|
|
abbfdb02a7 | ||
|
|
72e2b682bb | ||
|
|
ae4ccaa89d | ||
|
|
984fd1c08f | ||
|
|
99bdd23986 | ||
|
|
a548665edb | ||
|
|
8c5df82dcf | ||
|
|
aa96e9dbee | ||
|
|
1e33bb0a4d | ||
|
|
bfd702a447 | ||
|
|
68c150eba4 | ||
|
|
9cbca4c4fb | ||
|
|
684a990f59 | ||
|
|
1b6c8616fd | ||
|
|
4d28fa01ab | ||
|
|
2d1b04c637 | ||
|
|
ccbb98b9dd |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: lukaszraczylo
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: https://monzo.me/lukaszraczylo
|
||||
@@ -1,52 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
reviewers:
|
||||
- "lukaszraczylo"
|
||||
|
||||
# Maintain Go module dependencies
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
reviewers:
|
||||
- "lukaszraczylo"
|
||||
# Group patch updates together
|
||||
groups:
|
||||
patch-updates:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
# Ignore certain dependencies if needed
|
||||
ignore:
|
||||
# Example: ignore specific versions
|
||||
# - dependency-name: "github.com/example/package"
|
||||
# versions: ["1.x", "2.x"]
|
||||
@@ -18,6 +18,43 @@ jobs:
|
||||
pr-checks:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||
with:
|
||||
go-version: "1.24.11"
|
||||
go-version: "1.26.x"
|
||||
coverage-threshold: 70
|
||||
secrets: inherit
|
||||
|
||||
yaegi-validate:
|
||||
name: Yaegi interpreter compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
|
||||
# Pinned to the yaegi version Traefik v3.7.1 vendors (Makefile
|
||||
# YAEGI_VERSION), so this tests the real interpreter a release runs
|
||||
# under, not a newer one that may tolerate more.
|
||||
- name: Install yaegi
|
||||
run: go install github.com/traefik/yaegi/cmd/[email protected]
|
||||
|
||||
# yaegi resolves the plugin's own import path
|
||||
# (github.com/lukaszraczylo/traefikoidc) through GOPATH/src, same as
|
||||
# `make yaegi-validate` does locally.
|
||||
- name: Symlink checkout into GOPATH
|
||||
run: |
|
||||
mkdir -p "$(go env GOPATH)/src/github.com/lukaszraczylo"
|
||||
ln -sfn "$GITHUB_WORKSPACE" "$(go env GOPATH)/src/github.com/lukaszraczylo/traefikoidc"
|
||||
|
||||
# Runs cmd/yaegicheck under the real interpreter: imports the plugin
|
||||
# package, exercises CreateConfig + New, and drives every yaegi-only
|
||||
# regression check (errors.As-on-an-interpreted-target panics,
|
||||
# SSE Flush across the interpreter boundary, etc.) that native
|
||||
# `go build`/`go test` cannot see.
|
||||
- name: Run yaegi regression checks
|
||||
run: DO_NOT_TRACK=1 GOFLAGS=-mod=vendor yaegi run ./cmd/yaegicheck/main.go
|
||||
|
||||
@@ -19,5 +19,5 @@ jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
with:
|
||||
go-version: "1.24.11"
|
||||
go-version: "1.26.x"
|
||||
secrets: inherit
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
docker/
|
||||
.claude/*.out
|
||||
.claude/
|
||||
*.test
|
||||
.leann/
|
||||
|
||||
+98
-1913
File diff suppressed because it is too large
Load Diff
+146
@@ -0,0 +1,146 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`allowUnauthenticatedPreflight` (default `false`).** Every `OPTIONS`
|
||||
request needs authentication by default, as in the last release. Set the
|
||||
option to `true` to let a genuine CORS preflight reach the backend
|
||||
without a session. Only an `OPTIONS` request that carries both `Origin`
|
||||
and `Access-Control-Request-Method` bypasses authentication, and the
|
||||
plugin discards the backend's response body for it, because a browser
|
||||
never reads a preflight body (`settings.go`, `middleware.go`, FIX-02).
|
||||
See the option table in [CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
- **`streamingRefresh` (default `false`).** SSE and WebSocket requests do
|
||||
not check the token expiry, so a session stays usable for streaming until
|
||||
the session cookie expires. Set the option to `true` to refresh an expired
|
||||
token on these requests. If the IdP rejects the refresh with
|
||||
`invalid_grant`, the request gets `401`. If the IdP cannot be reached, the
|
||||
request is forwarded as before. The request waits for the full refresh, so
|
||||
a rotated refresh token is never lost (`middleware.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Opaque bearer tokens when all three bearer flags are set.** A deployment
|
||||
that sets `enableBearerAuth`, `requireTokenIntrospection` and
|
||||
`allowOpaqueTokens` now accepts opaque bearer tokens after RFC 7662
|
||||
introspection. The last release rejected every non-JWT bearer token. The
|
||||
plugin binds an accepted opaque token to the client through `client_id` or
|
||||
`aud`. It also applies the identifier, token-age, logout, revocation and
|
||||
`nbf` checks (`bearer_auth.go`).
|
||||
|
||||
- **Sessions created before this upgrade can omit `post_logout_redirect_uri`
|
||||
on their first logout.** The plugin now builds that parameter only from the
|
||||
redirect-URI origin recorded at login, never from the logout request's
|
||||
host. Older sessions have no recorded origin, so those users stay on the
|
||||
IdP's logout page once (`helpers.go`).
|
||||
|
||||
- **Shutdown waits up to 5 seconds for a token refresh in progress.** On
|
||||
shutdown, for example a Traefik reload, the refresh coordinator lets a
|
||||
refresh that already reached the IdP deliver its tokens, then returns
|
||||
(`shutdownRefreshDrainTimeout`, `refresh_coordinator.go`). A refresh that
|
||||
has not started does not start after shutdown.
|
||||
|
||||
- **A panic after the request reaches the backend now aborts the
|
||||
connection.** The plugin re-panics it, for example the reverse proxy's
|
||||
`http.ErrAbortHandler` when an upstream drops the connection mid-body. Go's
|
||||
HTTP server then aborts the connection. Before, the plugin recovered the
|
||||
panic, so a truncated response could reach the client as a complete 200. A
|
||||
panic before the request reaches the backend still returns 500
|
||||
(`middleware.go`).
|
||||
|
||||
- **private_key_jwt now rejects RSA signing keys under 2048 bits.**
|
||||
`validateAlgKeyMatch` stops `New()` with an error when a configured
|
||||
`private_key_jwt` RS\*/PS\* key is smaller than 2048 bits (RFC 7518 §3.3).
|
||||
A smaller key signs locally, but a conformant IdP rejects it at token
|
||||
exchange with `invalid_client`, so the plugin now fails at construction
|
||||
instead of at every runtime exchange. There is no override: deployments
|
||||
using an RSA key under 2048 bits must generate a 2048-bit-or-larger key
|
||||
before upgrading. The rejection returns a matchable `ErrRSAKeyTooSmall`
|
||||
sentinel (`client_assertion.go`).
|
||||
|
||||
- **A discovered `http://` endpoint is now dropped when `providerURL` is
|
||||
`https://`.** `validateDiscoveredEndpoint` refuses to accept an
|
||||
authorization, token, jwks_uri, revocation, end_session, introspection, or
|
||||
registration endpoint served over plaintext HTTP when the operator
|
||||
configured an HTTPS provider (R146): using it as-is would send a client
|
||||
secret or token over an unauthenticated channel. There is no configuration
|
||||
flag to disable this check. A dropped endpoint logs an `ERROR` line; a
|
||||
dropped `token`, `jwks_uri`, or `authorization` endpoint — the three
|
||||
every login needs — additionally logs a `SECURITY:`-prefixed line naming
|
||||
the endpoint, the dropped URL, and the fact that there is no override, so
|
||||
the failure is diagnosable instead of surfacing only as a broken login.
|
||||
See [Discovered Endpoint
|
||||
Validation](docs/CONFIGURATION.md#discovered-endpoint-validation)
|
||||
(`url_helpers.go`, `main.go`).
|
||||
|
||||
- **Blacklist entries in Redis use the `blacklist:` key prefix.** The
|
||||
blacklist cache now has its own cache type (`CacheTypeBlacklist`), so its
|
||||
Redis keys move from the shared `token:` prefix to `blacklist:` (R128).
|
||||
A cached token entry and a revocation marker for the same raw token can
|
||||
no longer overwrite each other. For one release, a blacklist miss also
|
||||
reads the legacy `token:<key>` entry and treats it as revoked only when
|
||||
the stored value is boolean `true` (`checkLegacyBlacklistMarker` in
|
||||
`universal_cache.go`, FIX-16). The fallback works in one direction only:
|
||||
during a rolling deploy, a replica that runs the previous version does
|
||||
not see revocations that an upgraded replica writes under `blacklist:`.
|
||||
A blacklist miss checks the legacy `token:` entry at most once per key in
|
||||
each 30 seconds, so the fallback adds little Redis traffic.
|
||||
|
||||
- **Claim validation accepts four previously-rejected or silently-dropped
|
||||
claim shapes.** Each change below is intentional, matches a production
|
||||
change, and is pinned by an existing regression test.
|
||||
- `iat` (issued-at) is optional in `jwt.Verify`, per RFC 7519 §4.1.6. A
|
||||
token that omits it is validated on `iss`/`aud`/`exp`/`nbf` alone,
|
||||
instead of being rejected (`jwt.go`, R126). The lenient-audience
|
||||
access-token path (`accessTokenUnexpired` in `token_validation_rs.go`)
|
||||
applies the same rule, because both paths call `verifyTimeClaims`
|
||||
(`jwt.go`, FIX-27). Bearer-token auth still rejects a token with no
|
||||
`iat`: it returns `"missing iat claim"` (`enforceIatAge` in
|
||||
`bearer_auth.go`), because `maxTokenAgeSeconds` always applies — `0`
|
||||
falls back to a 24h default (`main.go`).
|
||||
- A numeric `sub` (bearer-auth identifier) claim is stringified instead of
|
||||
rejected, so an IdP that emits a numeric subject still authenticates
|
||||
(`bearer_auth.go` `resolveBearerIdentifier`, R102).
|
||||
- A `groups`/`roles` claim that is neither a string nor an array (for
|
||||
example a bare number or `null`) is treated as an empty list instead of
|
||||
failing claim extraction outright, so a malformed claim on one side does
|
||||
not suppress a valid claim on the other (`token_manager.go`
|
||||
`extractGroupsAndRolesFromClaims`, R96).
|
||||
- A numeric element inside a `groups`/`roles` array is stringified into
|
||||
the list instead of being silently dropped, so a numeric group ID
|
||||
survives extraction and can match an `allowedRolesAndGroups` entry
|
||||
(`token_manager.go` `stringListFromClaim`, `utilities.go`
|
||||
`claimScalarString`, R105).
|
||||
|
||||
### Fixed
|
||||
|
||||
- SSE and WebSocket requests now get the configured `headers`, as normal
|
||||
requests do. The last release sent only `X-Forwarded-User` on these
|
||||
requests, so a backend that checks a shared-secret header answered `401`
|
||||
on WebSocket upgrades. The same requests now also honor back-channel and
|
||||
front-channel logout (`middleware.go`, #162).
|
||||
- The opaque bearer path rejects a token that `RevokeToken` added to the
|
||||
blacklist, for example at logout. It also rejects an opaque token whose
|
||||
`nbf` is in the future (`bearer_auth.go`).
|
||||
- The backchannel-logout replay check treats a Redis error reply such as
|
||||
`READONLY`, `OOM` or `MISCONF` as a failure, not as an unknown outcome. A
|
||||
replayed logout token is now rejected while Redis refuses writes
|
||||
(`internal/cache/backends/redis.go`).
|
||||
- After a failed cache write, a replica no longer ignores a newer value that
|
||||
another replica wrote, such as a later logout. The preference for the local
|
||||
value expires after 5 seconds (`backendStaleMarkTTL`, `universal_cache.go`).
|
||||
- The token circuit breaker treats HTTP 408 from the token endpoint as a
|
||||
transient failure, the same as 429 (`error_recovery.go`).
|
||||
- With `allowUnauthenticatedPreflight` enabled, a preflight response no longer
|
||||
carries a stale `Content-Length` when the backend writes a body without
|
||||
calling `WriteHeader` (`middleware.go`).
|
||||
- A dropped `http://` revocation, end-session or introspection endpoint logs
|
||||
the `SECURITY:` line when its override (`revocationURL`, `oidcEndSessionURL`
|
||||
or `introspectionURL`) is not configured (`main.go`).
|
||||
@@ -0,0 +1,61 @@
|
||||
# traefikoidc — Makefile
|
||||
# Run `make help` for available targets.
|
||||
|
||||
GO ?= go
|
||||
GOPATH := $(shell $(GO) env GOPATH)
|
||||
# Pin to the yaegi version bundled by the deployed Traefik so yaegi-validate
|
||||
# tests the real interpreter, not a newer one that may support more. Traefik
|
||||
# v3.7.1 vendors yaegi v0.16.1 (Go ~1.22 stdlib surface). Bump when Traefik is.
|
||||
YAEGI_VERSION ?= v0.16.1
|
||||
TEST_TIMEOUT ?= 480s
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: build
|
||||
build: ## Compile all packages (native toolchain)
|
||||
$(GO) build ./...
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format sources with gofmt
|
||||
gofmt -w $$(git ls-files '*.go' | grep -v '^vendor/')
|
||||
|
||||
.PHONY: vet
|
||||
vet: ## Run go vet
|
||||
$(GO) vet ./...
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run golangci-lint if available
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; else echo "golangci-lint not installed; skipping"; fi
|
||||
|
||||
.PHONY: staticcheck
|
||||
staticcheck: ## Run staticcheck (matches the CI "Static Analysis" job; catches U1000 unused, etc.)
|
||||
@command -v staticcheck >/dev/null 2>&1 || { echo ">> installing staticcheck"; $(GO) install honnef.co/go/tools/cmd/staticcheck@latest; }
|
||||
@GOFLAGS=-buildvcs=false $$(command -v staticcheck || echo "$(GOPATH)/bin/staticcheck") ./...
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the test suite (race detector on: several regression pins, e.g. R44/R77/R111, only fail under -race)
|
||||
$(GO) test -race ./... -count=1 -timeout $(TEST_TIMEOUT)
|
||||
|
||||
.PHONY: vendor
|
||||
vendor: ## Refresh and vendor dependencies
|
||||
$(GO) mod tidy && $(GO) mod vendor
|
||||
|
||||
# yaegi-validate interprets the plugin under the yaegi interpreter the same way
|
||||
# Traefik loads it. Native `go build`/`go test` use the standard compiler and do
|
||||
# NOT catch yaegi-only incompatibilities (unsupported stdlib symbols, reflection
|
||||
# edge cases). This target does. Importing the package forces yaegi to interpret
|
||||
# every file in it plus its vendored deps; CreateConfig + New exercise the
|
||||
# instantiation path. Pin YAEGI_VERSION to match Traefik's bundled yaegi if you
|
||||
# need exact parity.
|
||||
.PHONY: yaegi-validate
|
||||
yaegi-validate: ## Verify the plugin loads under Traefik's yaegi interpreter
|
||||
@command -v yaegi >/dev/null 2>&1 || { echo ">> installing yaegi@$(YAEGI_VERSION)"; $(GO) install github.com/traefik/yaegi/cmd/yaegi@$(YAEGI_VERSION); }
|
||||
@echo ">> interpreting plugin under yaegi (as Traefik does)"
|
||||
@DO_NOT_TRACK=1 GOFLAGS=-mod=vendor $$(command -v yaegi || echo "$(GOPATH)/bin/yaegi") run ./cmd/yaegicheck/main.go
|
||||
|
||||
.PHONY: check
|
||||
check: vet staticcheck test yaegi-validate ## vet + staticcheck + tests + yaegi load validation
|
||||
@@ -0,0 +1,49 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-27 regression test.
|
||||
//
|
||||
// accessTokenUnexpired (the lenient-audience-path time-claims re-check)
|
||||
// returned false whenever iat was absent, even though 8640451 made iat
|
||||
// OPTIONAL in jwt.Verify itself (R126: "Hard-requiring it here also
|
||||
// rejected otherwise-valid access ... tokens whose provider omits iat").
|
||||
// The two paths had drifted onto different contracts for the same claim.
|
||||
// A lenient (wrong-audience) access token without iat, in a session with
|
||||
// no ID token to corroborate it, was always forced to refresh or
|
||||
// re-authenticate, while the identical token with a matching audience
|
||||
// authenticated fine via the main jwt.Verify path.
|
||||
//
|
||||
// Fail-on-old: a syntactically valid, unexpired token with no iat is
|
||||
// rejected by accessTokenUnexpired (returns false).
|
||||
func TestAccessTokenUnexpired_IatOptional(t *testing.T) {
|
||||
tm := &TraefikOidc{}
|
||||
now := time.Now()
|
||||
|
||||
noIat := makeTestJWT(t, map[string]interface{}{
|
||||
"exp": float64(now.Add(time.Hour).Unix()),
|
||||
// intentionally no "iat", matching jwt.Verify's R126 contract
|
||||
})
|
||||
if !tm.accessTokenUnexpired(noIat) {
|
||||
t.Error("a token with a valid exp and no iat must be accepted, matching jwt.Verify's iat-optional contract (R126/FIX-27)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessTokenUnexpired_IatStillBoundedWhenPresent is the positive
|
||||
// control: an iat far in the future (used-before-issued) must still be
|
||||
// rejected when iat IS present — the fix makes iat optional, not ignored.
|
||||
func TestAccessTokenUnexpired_IatStillBoundedWhenPresent(t *testing.T) {
|
||||
tm := &TraefikOidc{}
|
||||
now := time.Now()
|
||||
|
||||
futureIat := makeTestJWT(t, map[string]interface{}{
|
||||
"exp": float64(now.Add(time.Hour).Unix()),
|
||||
"iat": float64(now.Add(time.Hour).Unix()), // far in the future
|
||||
})
|
||||
if tm.accessTokenUnexpired(futureIat) {
|
||||
t.Error("a present iat far in the future (used-before-issued) must still be rejected")
|
||||
}
|
||||
}
|
||||
+9
-21
@@ -288,6 +288,10 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
||||
|
||||
customAudience := "https://my-api.example.com"
|
||||
ts.tOidc.audience = customAudience
|
||||
// buildAuthURL sources the outbound `audience` param from
|
||||
// explicitAudience, not audience (see url_helpers.go); an explicitly
|
||||
// configured custom audience must set both to behave like production.
|
||||
ts.tOidc.explicitAudience = customAudience
|
||||
|
||||
// Create ID token with aud = client_id (OIDC standard)
|
||||
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||
@@ -322,16 +326,12 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify ID token validates against client_id
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(idToken)
|
||||
if err != nil {
|
||||
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
|
||||
}
|
||||
|
||||
// Verify access token validates against custom audience
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(accessToken)
|
||||
if err != nil {
|
||||
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
|
||||
@@ -395,8 +395,6 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify ID token validates
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(idToken)
|
||||
if err != nil {
|
||||
t.Errorf("ID token validation failed: %v", err)
|
||||
@@ -404,8 +402,6 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
|
||||
|
||||
// Access token won't have client_id in aud, so it will fail validation
|
||||
// This is expected for scenario 2 - the session validation relies on ID token
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(accessToken)
|
||||
if err == nil {
|
||||
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
|
||||
@@ -456,8 +452,6 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
|
||||
opaqueAccessToken := "opaque_access_token_random_string_12345"
|
||||
|
||||
// Verify ID token validates
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(idToken)
|
||||
if err != nil {
|
||||
t.Errorf("ID token validation failed: %v", err)
|
||||
@@ -484,7 +478,8 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
|
||||
session.SetAccessToken(opaqueAccessToken)
|
||||
session.SetIDToken(idToken)
|
||||
|
||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
|
||||
if !authenticated || needsRefresh || expired {
|
||||
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
|
||||
authenticated, needsRefresh, expired)
|
||||
@@ -519,8 +514,6 @@ func TestAuth0AudienceArrayValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should validate successfully - custom audience is in the array
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(accessToken)
|
||||
if err != nil {
|
||||
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
|
||||
@@ -553,8 +546,6 @@ func TestAuth0MismatchedAudience(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should fail validation - audience doesn't match
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(accessToken)
|
||||
if err == nil {
|
||||
t.Error("Access token with wrong audience should fail validation")
|
||||
@@ -623,7 +614,8 @@ func TestAuth0Scenario2StrictMode(t *testing.T) {
|
||||
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
|
||||
|
||||
// In strict mode, this should FAIL (no fallback to ID token)
|
||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
|
||||
if authenticated {
|
||||
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
|
||||
}
|
||||
@@ -663,8 +655,6 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should validate successfully - ID tokens are checked against client_id
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(idToken)
|
||||
if err != nil {
|
||||
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
|
||||
@@ -686,8 +676,6 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should fail - ID tokens must have client_id as audience
|
||||
cleanupReplayCache()
|
||||
initReplayCache()
|
||||
err = ts.tOidc.VerifyToken(wrongIDToken)
|
||||
if err == nil {
|
||||
t.Error("ID token with custom audience (not client_id) should fail validation")
|
||||
@@ -1491,7 +1479,7 @@ func TestAudienceEndToEndScenario(t *testing.T) {
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("Failed to set authenticated: %v", err)
|
||||
}
|
||||
session.SetEmail("[email protected]")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetIDToken(validJWT)
|
||||
session.SetAccessToken(validJWT)
|
||||
|
||||
|
||||
+271
-48
@@ -1,13 +1,37 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
// redactAuthorizeURLForLog removes the remaining security-sensitive query
|
||||
// values from the provider authorize URL before it is logged. It builds on
|
||||
// redactCallbackURL (url_helpers.go, R153), which strips "code" and
|
||||
// "state" but was written for the callback URL and does not know about
|
||||
// the nonce or PKCE code_challenge the authorize URL also carries — so
|
||||
// logging redactCallbackURL(authURL) alone would still leak the nonce
|
||||
// (FIX-29 correction of the R153 claim).
|
||||
func redactAuthorizeURLForLog(rawURL string) string {
|
||||
redacted := redactCallbackURL(rawURL)
|
||||
u, err := url.Parse(redacted)
|
||||
if err != nil {
|
||||
return redacted
|
||||
}
|
||||
q := u.Query()
|
||||
for _, k := range []string{"nonce", "code_challenge"} {
|
||||
if q.Get(k) != "" {
|
||||
q.Set(k, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// validateRedirectCount checks if redirect limit is exceeded and handles the error
|
||||
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
|
||||
const maxRedirects = 5
|
||||
@@ -15,6 +39,16 @@ func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.Respon
|
||||
if redirectCount >= maxRedirects {
|
||||
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
|
||||
session.ResetRedirectCount()
|
||||
// Persist the reset so the NEXT request starts at 0 and can
|
||||
// attempt auth again. Without Save, the reset only touched the
|
||||
// in-memory session value (dirty flag set but never flushed): the
|
||||
// cookie still carried the old count, so every subsequent
|
||||
// request re-observed redirect_count==max and the user was
|
||||
// hard-locked on 508 for the whole session with no recovery
|
||||
// (R123).
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
t.logger.Errorf("Failed to save session after redirect reset: %v", err)
|
||||
}
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected)
|
||||
return fmt.Errorf("redirect limit exceeded")
|
||||
}
|
||||
@@ -44,7 +78,7 @@ func (t *TraefikOidc) generatePKCEParameters() (string, string, error) {
|
||||
func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrfToken, nonce, codeVerifier, incomingPath string) {
|
||||
// Clear all existing session data
|
||||
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication state on new flow
|
||||
session.SetEmail("")
|
||||
session.SetUserIdentifier("")
|
||||
session.SetAccessToken("")
|
||||
session.SetRefreshToken("")
|
||||
session.SetIDToken("")
|
||||
@@ -69,15 +103,45 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
|
||||
// - req: The HTTP request initiating authentication.
|
||||
// - session: The session data to prepare for authentication.
|
||||
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
|
||||
//
|
||||
// rateLimitPerSourceAuth applies the (optional) per-external-source auth
|
||||
// throttle at an OIDC gate. Returns true if the request was already
|
||||
// answered with 429 and the caller must return. No-op when the limiter is
|
||||
// disabled (PerSourceLoginRateLimit <= 0) or the source is internal.
|
||||
func (t *TraefikOidc) rateLimitPerSourceAuth(rw http.ResponseWriter, req *http.Request) bool {
|
||||
if t.perSourceLimiter == nil {
|
||||
return false
|
||||
}
|
||||
if t.perSourceLimiter.allow(req) {
|
||||
return false
|
||||
}
|
||||
rw.Header().Set("Retry-After", "1")
|
||||
t.sendErrorResponse(rw, req, "Too many authentication attempts, please try again shortly", http.StatusTooManyRequests)
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
||||
|
||||
// Per-external-source throttle on login initiation: a single host
|
||||
// flooding the route with auth starts shouldn't be able to spin up an
|
||||
// unbounded number of IdP round-trips or wear the shared token-origin
|
||||
// bucket down for everyone (R185).
|
||||
if t.rateLimitPerSourceAuth(rw, req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check and handle redirect limits
|
||||
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken := uuid.NewString()
|
||||
csrfToken, err := newUUIDv4()
|
||||
if err != nil {
|
||||
t.logger.Errorf("Failed to generate CSRF token: %v", err)
|
||||
http.Error(rw, "Failed to generate CSRF token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
nonce, err := generateNonce()
|
||||
if err != nil {
|
||||
t.logger.Errorf("Failed to generate nonce: %v", err)
|
||||
@@ -93,9 +157,42 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
||||
return
|
||||
}
|
||||
|
||||
// Build the provider authorize URL BEFORE mutating the session. A URL-
|
||||
// build failure must not clear a valid existing session, and must not
|
||||
// emit an empty-Location redirect (browsers reload the current page,
|
||||
// re-entering this flow and bumping the redirect count until the hard
|
||||
// 508). Fail closed with an explicit status instead (R142).
|
||||
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
||||
if authURL == "" {
|
||||
t.logger.Errorf("Failed to build provider authorize URL; aborting login flow")
|
||||
http.Error(rw, "Failed to build provider authorize URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// When dynamic client registration is enabled but has not yet produced a
|
||||
// client_id (registration pending or failed), forging an authorize URL
|
||||
// with client_id="" would send the user to the provider on a broken
|
||||
// flow. Fail with a clear status instead of redirecting. This guard
|
||||
// must run BEFORE prepareSessionForAuthentication re-seeds the session:
|
||||
// re-seeding (and the save that follows) would otherwise mutate and
|
||||
// persist the session even though we are about to answer 503 with no
|
||||
// redirect — discarding any existing session state for no reason (R163).
|
||||
// Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137).
|
||||
clientID, _, _, _, _ := t.clientCredentials()
|
||||
if t.dcrConfig != nil && t.dcrConfig.Enabled && clientID == "" {
|
||||
t.logger.Errorf("OIDC dynamic client registration has not produced a client_id; refusing to redirect to provider")
|
||||
http.Error(rw, "Identity provider registration pending", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing session data and set new authentication state
|
||||
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
|
||||
|
||||
// Persist the redirect_uri so the callback reuses the exact value sent
|
||||
// to the IdP, instead of rebuilding it from the (client-affectable)
|
||||
// callback request's X-Forwarded-Host (R136).
|
||||
session.SetRedirectURL(redirectURL)
|
||||
|
||||
session.MarkDirty()
|
||||
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
@@ -104,12 +201,17 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
||||
return
|
||||
}
|
||||
|
||||
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
|
||||
csrfToken, nonce)
|
||||
// CSRF/state and nonce are single-use security credentials; do not log
|
||||
// them in the clear (FIX-29 correction of the R153 redaction claim).
|
||||
t.logger.Debug("Session saved before redirect")
|
||||
|
||||
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
||||
t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
|
||||
t.logger.Debugf("Redirecting user to OIDC provider: %s", redactAuthorizeURLForLog(authURL))
|
||||
|
||||
// 302s into the IdP must not be heuristically cacheable (RFC 7234):
|
||||
// a stale, cached redirect could replay the pre-re-authentication URL.
|
||||
// Every other auth response sets no-store; these redirects were missed
|
||||
// (R147).
|
||||
rw.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(rw, req, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -120,7 +222,31 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
||||
// - rw: The HTTP response writer.
|
||||
// - req: The callback request containing authorization code and state.
|
||||
// - redirectURL: The fully qualified callback URL (used in the token exchange request).
|
||||
//
|
||||
// clearOneTimeAuthState clears the one-time CSRF/nonce/PKCE-verifier after a
|
||||
// callback that consumed the authorization code but then failed, and persists
|
||||
// the cleared state. Without it the failed session stays in a pending-auth
|
||||
// state and a retried callback would re-exchange a fresh code before failing
|
||||
// again. Mirrors the cleanup on the exchange-failure / empty-access-token
|
||||
// paths (R134).
|
||||
func (t *TraefikOidc) clearOneTimeAuthState(session *SessionData, req *http.Request, rw http.ResponseWriter) {
|
||||
session.SetCSRF("")
|
||||
session.SetNonce("")
|
||||
session.SetCodeVerifier("")
|
||||
if saveErr := session.Save(req, rw); saveErr != nil {
|
||||
t.logger.Errorf("Failed to save session after callback failure: %v", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
|
||||
// Per-external-source throttle on the authorization-code callback: this
|
||||
// is where the one-time code exchange (server round-trip + token
|
||||
// cache write) happens, so a single source can't brute-force
|
||||
// repeated code exchanges or drown the shared limiter (R185).
|
||||
if t.rateLimitPerSourceAuth(rw, req) {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Session error during callback: %v", err)
|
||||
@@ -129,15 +255,28 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
}
|
||||
defer session.returnToPoolSafely()
|
||||
|
||||
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
|
||||
t.logger.Debugf("Handling callback, URL: %s", redactCallbackURL(req.URL.String()))
|
||||
|
||||
if req.URL.Query().Get("error") != "" {
|
||||
errorDescription := req.URL.Query().Get("error_description")
|
||||
if errorDescription == "" {
|
||||
errorDescription = req.URL.Query().Get("error")
|
||||
}
|
||||
t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
|
||||
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
|
||||
// error / error_description are attacker-controlled query params.
|
||||
// Sanitize before logging and before echoing back to the client:
|
||||
// an embedded newline would inject a forged line into (especially
|
||||
// structured) logs, and unbounded length would bloat the log and
|
||||
// the error response (R145).
|
||||
sanitizeErr := func(s string) string {
|
||||
s = strings.NewReplacer("\n", " ", "\r", " ").Replace(s)
|
||||
const maxErrLen = 512
|
||||
if len(s) > maxErrLen {
|
||||
s = s[:maxErrLen]
|
||||
}
|
||||
return s
|
||||
}
|
||||
t.logger.Errorf("Authentication error from provider during callback: %s - %s", sanitizeErr(req.URL.Query().Get("error")), sanitizeErr(errorDescription))
|
||||
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", sanitizeErr(errorDescription)), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,7 +290,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
csrfToken := session.GetCSRF()
|
||||
if csrfToken == "" {
|
||||
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
|
||||
session.GetAuthenticated(), req.URL.String())
|
||||
session.GetAuthenticated(), redactCallbackURL(req.URL.String()))
|
||||
|
||||
cookie, err := req.Cookie("_oidc_raczylo_m")
|
||||
if err != nil {
|
||||
@@ -164,7 +303,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
return
|
||||
}
|
||||
|
||||
if state != csrfToken {
|
||||
if !constantTimeStringCompare(state, csrfToken) {
|
||||
t.logger.Error("State parameter does not match CSRF token in session during callback")
|
||||
t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
|
||||
return
|
||||
@@ -178,16 +317,55 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
}
|
||||
|
||||
codeVerifier := session.GetCodeVerifier()
|
||||
if t.enablePKCE && codeVerifier == "" {
|
||||
t.logger.Error("PKCE is enabled but code verifier is missing from session during callback")
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: PKCE verifier missing", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the redirect_uri persisted at initiate time (must match what was
|
||||
// sent to the IdP's authorize endpoint). Fall back to the rebuilt one
|
||||
// only for pre-existing sessions that predate R136 (R136).
|
||||
if stored := session.GetRedirectURL(); stored != "" {
|
||||
redirectURL = stored
|
||||
}
|
||||
|
||||
tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Failed to exchange code for token during callback: %v", err)
|
||||
// Invalidate the one-time auth state and persist it so a
|
||||
// partially-failed flow cannot be continued: the authorization code
|
||||
// may still be unconsumed at the provider, so a replayed callback
|
||||
// must re-authenticate rather than revalidate a stale
|
||||
// state/nonce/code_verifier. Mirrors the cleanup on success.
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenResponse.AccessToken == "" {
|
||||
t.logger.Errorf("Token endpoint returned no access token during callback")
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Token endpoint returned no access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = t.verifyToken(tokenResponse.IDToken); err != nil {
|
||||
t.logger.Errorf("Failed to verify id_token during callback: %v", err)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
if errors.Is(err, ErrRateLimitExceeded) {
|
||||
// Rate-limit exhaustion must be a 429, not a generic 500: a
|
||||
// single source's burst currently degrades every user's
|
||||
// callback to "server error" (R179). No Retry-After: by this
|
||||
// point clearOneTimeAuthState has already destroyed the
|
||||
// csrf/nonce/code_verifier and the authorization code has
|
||||
// already been redeemed at the IdP, so a client that honors
|
||||
// Retry-After and retries this exact callback URL is
|
||||
// guaranteed to hit "CSRF token missing in session" instead
|
||||
// of a successful retry (FIX-32). The user must restart login.
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Too many requests, please retry", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -195,6 +373,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Failed to extract claims during callback: %v", err)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -202,41 +381,57 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
nonceClaim, ok := claims["nonce"].(string)
|
||||
if !ok || nonceClaim == "" {
|
||||
t.logger.Error("Nonce claim missing in id_token during callback")
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionNonce := session.GetNonce()
|
||||
if sessionNonce == "" {
|
||||
t.logger.Error("Nonce not found in session during callback")
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if nonceClaim != sessionNonce {
|
||||
if !constantTimeStringCompare(nonceClaim, sessionNonce) {
|
||||
t.logger.Error("Nonce claim does not match session nonce during callback")
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
||||
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
|
||||
userIdentifier, _ := claimScalarString(claims[t.userIdentifierClaim])
|
||||
if userIdentifier == "" {
|
||||
// Try "sub" as fallback since it's required by OIDC spec
|
||||
if t.userIdentifierClaim != "sub" {
|
||||
userIdentifier, _ = claims["sub"].(string)
|
||||
userIdentifier, _ = claimScalarString(claims["sub"])
|
||||
}
|
||||
if userIdentifier == "" {
|
||||
t.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", t.userIdentifierClaim)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", t.userIdentifierClaim)
|
||||
}
|
||||
|
||||
// Validate user authorization. When the identity is an email, require the
|
||||
// IdP not to have explicitly flagged it as unverified — an unverified
|
||||
// address must not satisfy an email/domain allowlist (see
|
||||
// emailIdentityPermitted).
|
||||
if !emailIdentityPermitted(userIdentifier, claims) {
|
||||
t.logger.Errorf("User email not verified during callback, rejecting email-based authorization: %s", userIdentifier)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: Email not verified", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate user authorization
|
||||
if !t.isAllowedUser(userIdentifier) {
|
||||
t.logger.Errorf("User not authorized during callback: %s", userIdentifier)
|
||||
t.clearOneTimeAuthState(session, req, rw)
|
||||
t.sendErrorResponse(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -246,7 +441,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
|
||||
session.SetUserIdentifier(userIdentifier)
|
||||
session.SetIDToken(tokenResponse.IDToken)
|
||||
session.SetAccessToken(tokenResponse.AccessToken)
|
||||
session.SetRefreshToken(tokenResponse.RefreshToken)
|
||||
@@ -259,7 +454,10 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
|
||||
redirectPath := "/"
|
||||
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
|
||||
redirectPath = incomingPath
|
||||
// Neutralize open-redirect payloads (e.g. //evil.com, /\evil.com) stored
|
||||
// from the original request target before using it as the post-login
|
||||
// redirect target. normalizeLogoutPath forces a host-relative path.
|
||||
redirectPath = normalizeLogoutPath(incomingPath)
|
||||
}
|
||||
session.SetIncomingPath("")
|
||||
|
||||
@@ -270,6 +468,8 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
||||
}
|
||||
|
||||
t.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
|
||||
// no-store so the post-auth redirect isn't cached and replayed (R147).
|
||||
rw.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -286,7 +486,7 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
|
||||
session.SetIDToken("")
|
||||
session.SetAccessToken("")
|
||||
session.SetRefreshToken("")
|
||||
session.SetEmail("")
|
||||
session.SetUserIdentifier("")
|
||||
// Clear CSRF tokens to prevent replay attacks
|
||||
session.SetCSRF("")
|
||||
session.SetNonce("")
|
||||
@@ -301,28 +501,6 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
|
||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
||||
}
|
||||
|
||||
// isUserAuthenticated determines the authentication status and refresh requirements.
|
||||
// It delegates to provider-specific validation methods that handle different token types
|
||||
// and expiration behaviors.
|
||||
// Parameters:
|
||||
// - session: The session data containing authentication tokens.
|
||||
//
|
||||
// Returns:
|
||||
// - authenticated (bool): True if the user has valid tokens.
|
||||
// - needsRefresh (bool): True if tokens are valid but nearing expiration.
|
||||
// - expired (bool): True if the session is unauthenticated, the token is missing,
|
||||
// or the token verification failed for reasons other than nearing/actual expiration.
|
||||
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) {
|
||||
if t.isAzureProvider() {
|
||||
return t.validateAzureTokens(session)
|
||||
} else if t.isGoogleProvider() {
|
||||
return t.validateGoogleTokens(session)
|
||||
}
|
||||
// Auth0 and other providers can now use standard validation
|
||||
// which handles opaque tokens generically
|
||||
return t.validateStandardTokens(session)
|
||||
}
|
||||
|
||||
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
|
||||
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
|
||||
xhr := req.Header.Get("X-Requested-With")
|
||||
@@ -334,9 +512,54 @@ func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
|
||||
strings.Contains(accept, "application/json")
|
||||
}
|
||||
|
||||
// isRefreshTokenExpired checks if refresh token is likely expired (older than 6 hours)
|
||||
// isNonNavigationRequest reports whether the request is a browser
|
||||
// sub-resource (script, image, stylesheet, fetch, serviceWorker) rather than
|
||||
// a top-level HTML navigation. Non-navigation requests MUST NOT trigger an
|
||||
// OIDC redirect flow: several sub-resource loads happening in parallel would
|
||||
// each call defaultInitiateAuthentication, each overwriting the session's
|
||||
// CSRF/nonce, breaking the eventual callback (issue #129).
|
||||
//
|
||||
// Detection prefers Sec-Fetch-Mode, which all modern browsers send
|
||||
// (Chrome/Edge/Firefox/Safari). For older or non-browser clients we fall
|
||||
// back to Accept: if Accept is present and does not list text/html, treat
|
||||
// it as a sub-resource. An empty/missing Accept is assumed to be navigation
|
||||
// (safer to redirect than 401 on an ambiguous request).
|
||||
func (t *TraefikOidc) isNonNavigationRequest(req *http.Request) bool {
|
||||
if mode := req.Header.Get("Sec-Fetch-Mode"); mode != "" {
|
||||
return mode != "navigate"
|
||||
}
|
||||
accept := req.Header.Get("Accept")
|
||||
if accept == "" || accept == "*/*" {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(accept, "text/html")
|
||||
}
|
||||
|
||||
// isRefreshTokenExpired checks whether the stored refresh token is likely
|
||||
// past its useful lifetime, using the cookie-side issued_at timestamp set by
|
||||
// SetRefreshToken. IdPs do not expose RT TTL on the wire, so this is a
|
||||
// conservative heuristic gated by t.maxRefreshTokenAge (default 6h, set via
|
||||
// MaxRefreshTokenAgeSeconds; 0 disables the check).
|
||||
//
|
||||
// The point of this check is to short-circuit the refresh path BEFORE the
|
||||
// thundering herd hits the IdP for a token the provider has almost certainly
|
||||
// revoked. Together with the RefreshCoordinator wireup, it keeps Grafana-
|
||||
// style polling clients from looping on invalid_grant after a long pause.
|
||||
func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool {
|
||||
// This is a heuristic check - actual implementation would depend on
|
||||
// the specific provider and token metadata
|
||||
return false // Placeholder implementation
|
||||
if t == nil || session == nil {
|
||||
return false
|
||||
}
|
||||
if t.maxRefreshTokenAge <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
issuedAt := session.GetRefreshTokenIssuedAt()
|
||||
if issuedAt.IsZero() {
|
||||
// No timestamp recorded (legacy session pre-dating the issued_at
|
||||
// field). Don't force a re-auth - attempt refresh once and let the
|
||||
// IdP be the source of truth.
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(issuedAt) > t.maxRefreshTokenAge
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func (s *AuthFlowBehaviourSuite) TestPrepareSessionForAuthentication() {
|
||||
|
||||
// Pre-populate session with old data
|
||||
_ = session.SetAuthenticated(true)
|
||||
session.SetEmail("[email protected]")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetAccessToken("old-access-token-with-many-characters")
|
||||
session.SetRefreshToken("old-refresh-token-with-many-characters")
|
||||
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.signature")
|
||||
@@ -207,7 +207,7 @@ func (s *AuthFlowBehaviourSuite) TestPrepareSessionForAuthentication() {
|
||||
|
||||
// Verify old data is cleared
|
||||
s.False(session.GetAuthenticated())
|
||||
s.Empty(session.GetEmail())
|
||||
s.Empty(session.GetUserIdentifier())
|
||||
|
||||
// Verify new data is set
|
||||
s.Equal(csrfToken, session.GetCSRF())
|
||||
@@ -305,6 +305,90 @@ func (s *AuthFlowBehaviourSuite) TestIsAjaxRequest() {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsNonNavigationRequest verifies browser sub-resource detection used to
|
||||
// suppress OIDC redirects on parallel static-asset loads (issue #129).
|
||||
func (s *AuthFlowBehaviourSuite) TestIsNonNavigationRequest() {
|
||||
testCases := []struct {
|
||||
headers map[string]string
|
||||
name string
|
||||
expectNonNavigation bool
|
||||
}{
|
||||
{
|
||||
name: "Sec-Fetch-Mode navigate",
|
||||
headers: map[string]string{"Sec-Fetch-Mode": "navigate"},
|
||||
expectNonNavigation: false,
|
||||
},
|
||||
{
|
||||
name: "Sec-Fetch-Mode no-cors",
|
||||
headers: map[string]string{"Sec-Fetch-Mode": "no-cors"},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
{
|
||||
name: "Sec-Fetch-Mode cors",
|
||||
headers: map[string]string{"Sec-Fetch-Mode": "cors"},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
{
|
||||
name: "Sec-Fetch-Mode same-origin (fetch in page)",
|
||||
headers: map[string]string{"Sec-Fetch-Mode": "same-origin"},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
{
|
||||
name: "Accept text/html (fallback)",
|
||||
headers: map[string]string{"Accept": "text/html,application/xhtml+xml"},
|
||||
expectNonNavigation: false,
|
||||
},
|
||||
{
|
||||
name: "Accept image/png (fallback)",
|
||||
headers: map[string]string{"Accept": "image/png,image/*;q=0.8"},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
{
|
||||
name: "Accept application/javascript (fallback)",
|
||||
headers: map[string]string{"Accept": "application/javascript"},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
{
|
||||
name: "Accept */* treated as navigation",
|
||||
headers: map[string]string{"Accept": "*/*"},
|
||||
expectNonNavigation: false,
|
||||
},
|
||||
{
|
||||
name: "No Accept header assumed navigation",
|
||||
headers: map[string]string{},
|
||||
expectNonNavigation: false,
|
||||
},
|
||||
{
|
||||
name: "Sec-Fetch-Mode beats Accept (navigate wins)",
|
||||
headers: map[string]string{
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Accept": "application/javascript",
|
||||
},
|
||||
expectNonNavigation: false,
|
||||
},
|
||||
{
|
||||
name: "Sec-Fetch-Mode beats Accept (no-cors wins)",
|
||||
headers: map[string]string{
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Accept": "text/html",
|
||||
},
|
||||
expectNonNavigation: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/_static/asset.js", nil)
|
||||
for key, value := range tc.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
result := s.tOidc.isNonNavigationRequest(req)
|
||||
s.Equal(tc.expectNonNavigation, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCallback_MissingState tests callback with missing state parameter
|
||||
func (s *AuthFlowBehaviourSuite) TestHandleCallback_MissingState() {
|
||||
sessionManager, err := NewSessionManager(
|
||||
@@ -627,7 +711,7 @@ func (s *AuthFlowBehaviourSuite) TestHandleExpiredToken() {
|
||||
session, err := sessionManager.GetSession(req)
|
||||
s.Require().NoError(err)
|
||||
_ = session.SetAuthenticated(true)
|
||||
session.SetEmail("[email protected]")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetIDToken("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.signature")
|
||||
session.mainSession.Values["redirect_count"] = 3
|
||||
|
||||
@@ -636,7 +720,7 @@ func (s *AuthFlowBehaviourSuite) TestHandleExpiredToken() {
|
||||
|
||||
// Session should be cleared
|
||||
s.False(session.GetAuthenticated())
|
||||
s.Empty(session.GetEmail())
|
||||
s.Empty(session.GetUserIdentifier())
|
||||
s.Empty(session.GetIDToken())
|
||||
|
||||
// Redirect count should be reset to 0 and then incremented by defaultInitiateAuthentication
|
||||
@@ -873,8 +957,10 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() {
|
||||
// Call handleCallback
|
||||
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
|
||||
|
||||
// Should return internal server error due to nonce mismatch
|
||||
s.Equal(http.StatusInternalServerError, rw2.Code)
|
||||
// R161: nonce mismatch is a client-side authorization-request
|
||||
// integrity failure (same class as state/CSRF mismatch), so it returns
|
||||
// 400, not 500.
|
||||
s.Equal(http.StatusBadRequest, rw2.Code)
|
||||
}
|
||||
|
||||
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
|
||||
|
||||
+118
-24
@@ -28,6 +28,12 @@ type BackgroundTask struct {
|
||||
stopped int32 // 1 = stopped, 0 = not stopped
|
||||
started int32 // 1 = started, 0 = not started
|
||||
doneClosed int32 // 1 = doneChan closed, 0 = not closed
|
||||
// startRefused is set when Start's circuit-breaker or concurrency-limit
|
||||
// early-return fires (FIX-19). started stays 0 and stopped stays 0 in
|
||||
// that case, which is indistinguishable from "registered but Start has
|
||||
// not been called yet" — RegisterBackgroundTask needs the distinction to
|
||||
// know this task's slot is actually free to reuse.
|
||||
startRefused int32 // 1 = a Start call was refused, 0 = never refused
|
||||
}
|
||||
|
||||
// NewBackgroundTask creates a new background task with the specified configuration.
|
||||
@@ -46,6 +52,12 @@ func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), log
|
||||
if len(wg) > 0 {
|
||||
externalWG = wg[0]
|
||||
}
|
||||
// run() uses time.NewTicker(interval), which panics for d <= 0 inside
|
||||
// the started goroutine (autocleanup.go:186), crashing the whole
|
||||
// process on a misconfigured interval. Normalize here (R149).
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
return &BackgroundTask{
|
||||
name: name,
|
||||
interval: interval,
|
||||
@@ -81,6 +93,7 @@ func (bt *BackgroundTask) Start() {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Debugf("Cannot start task %s: %v (circuit breaker protection working as expected)", bt.name, err)
|
||||
}
|
||||
atomic.StoreInt32(&bt.startRefused, 1)
|
||||
// Close doneChan since the task won't run
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
@@ -88,8 +101,21 @@ func (bt *BackgroundTask) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
// Reserve the task slot immediately when starting
|
||||
registry.cb.OnTaskStart(bt.name)
|
||||
// Reserve the task slot immediately when starting. OnTaskStart
|
||||
// is the authoritative concurrency gate (R165): it returns false
|
||||
// if maxConcurrent is already reached, in which case this task
|
||||
// must not launch run() (its slot was not taken).
|
||||
if !registry.cb.OnTaskStart(bt.name) {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Debugf("Cannot start task %s: concurrency limit reached, slot not acquired", bt.name)
|
||||
}
|
||||
atomic.StoreInt32(&bt.startRefused, 1)
|
||||
// Close doneChan since the task won't run
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
atomic.StoreInt32(&bt.started, 1)
|
||||
bt.internalWG.Add(1)
|
||||
@@ -114,6 +140,14 @@ func (bt *BackgroundTask) Stop() {
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
}
|
||||
// Also close stopChan (R117): a concurrent Start may be in-flight
|
||||
// and about to launch run(). Signaling stopChan lets it exit at
|
||||
// the next stop check instead of leaking the goroutine and its
|
||||
// circuit-breaker slot (taken by OnTaskStart but never released).
|
||||
func() {
|
||||
defer func() { _ = recover() }() // safe if already closed
|
||||
close(bt.stopChan)
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,18 +168,37 @@ func (bt *BackgroundTask) Stop() {
|
||||
// This avoids the race condition with WaitGroup
|
||||
select {
|
||||
case <-bt.doneChan:
|
||||
// Normal completion
|
||||
// Normal completion: the goroutine has returned, so it is safe
|
||||
// to wait on the WaitGroup.
|
||||
bt.internalWG.Wait()
|
||||
case <-time.After(5 * time.Second):
|
||||
// The task goroutine is still running (its taskFunc is blocked or
|
||||
// long-lived). Do NOT wait on the WaitGroup here — that would
|
||||
// block Stop() (and shutdown) for as long as the task runs.
|
||||
// Return now; the goroutine is reaped when it completes.
|
||||
if bt.logger != nil {
|
||||
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the internal WaitGroup synchronously after doneChan signals
|
||||
bt.internalWG.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// safeRunTask executes the task function, recovering from any panic so that a
|
||||
// single panicking run does not permanently kill the background goroutine and its
|
||||
// periodic loop. Under yaegi (where this plugin runs) unexpected panics are more
|
||||
// likely, and a silently-dead background task (metadata refresh, autocleanup,
|
||||
// memory monitor) would otherwise stop forever with no log or restart.
|
||||
func (bt *BackgroundTask) safeRunTask() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Errorf("Background task %s panicked (recovered): %v", bt.name, r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
bt.taskFunc()
|
||||
}
|
||||
|
||||
// run is the main loop for the background task.
|
||||
// It executes the task function immediately, then periodically
|
||||
// until the stop signal is received.
|
||||
@@ -187,7 +240,7 @@ func (bt *BackgroundTask) run() {
|
||||
}
|
||||
return
|
||||
default:
|
||||
bt.taskFunc()
|
||||
bt.safeRunTask()
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -206,7 +259,7 @@ func (bt *BackgroundTask) run() {
|
||||
}
|
||||
return
|
||||
default:
|
||||
bt.taskFunc()
|
||||
bt.safeRunTask()
|
||||
}
|
||||
case <-bt.stopChan:
|
||||
if bt.logger != nil {
|
||||
@@ -329,9 +382,30 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// OnTaskStart records a task starting execution
|
||||
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
|
||||
atomic.AddInt32(&cb.concurrentTasks, 1)
|
||||
// OnTaskStart records a task starting execution. It atomically reserves a
|
||||
// concurrency slot and returns whether one was available. The reservation
|
||||
// is fundamental (R165): CanCreateTask's limit check is only advisory —
|
||||
// it is also called by RegisterTask without starting the task — so a
|
||||
// check-then-act TOCTOU meant two concurrently starting tasks could both
|
||||
// read current < limit and then both start, letting live tasks exceed
|
||||
// maxConcurrent and defeating the DoS backstop. OnTaskStart is the
|
||||
// single commit point, so it enforces maxConcurrent here via a CAS loop:
|
||||
// only maxConcurrent tasks can hold slots at once. BackgroundTask.Start
|
||||
// aborts (without launching run) when this returns false.
|
||||
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
|
||||
max := atomic.LoadInt32(&cb.maxConcurrent)
|
||||
for {
|
||||
current := atomic.LoadInt32(&cb.concurrentTasks)
|
||||
if current >= max {
|
||||
if cb.logger != nil {
|
||||
cb.logger.Debug("Concurrent task limit reached, refusing slot for: %s", taskName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if atomic.CompareAndSwapInt32(&cb.concurrentTasks, current, current+1) {
|
||||
break
|
||||
}
|
||||
}
|
||||
cb.tasksMu.Lock()
|
||||
cb.activeTasks[taskName] = struct{}{}
|
||||
cb.tasksMu.Unlock()
|
||||
@@ -342,6 +416,7 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
|
||||
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
|
||||
atomic.LoadInt32(&cb.concurrentTasks), taskName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OnTaskComplete records a task completing execution
|
||||
@@ -463,10 +538,13 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
|
||||
// Task execution tracking is now handled in the run() method
|
||||
|
||||
tr.tasks[name] = task
|
||||
tr.cb.OnTaskSuccess(name)
|
||||
// Execution tracking (the circuit-breaker concurrent count) is handled by
|
||||
// Start()/run(): Start() reserves a slot via OnTaskStart and run() releases
|
||||
// it via OnTaskComplete. Do NOT also OnTaskSuccess here — that would
|
||||
// double-count every registered task (+1 on register, +1 on Start, -1 on
|
||||
// stop), leaking one slot per task and eventually blocking all background
|
||||
// task creation at the concurrency limit.
|
||||
|
||||
if tr.logger != nil {
|
||||
tr.logger.Debug("Registered background task: %s", name)
|
||||
@@ -499,9 +577,18 @@ func (tr *TaskRegistry) GetTask(name string) (*BackgroundTask, bool) {
|
||||
return task, exists
|
||||
}
|
||||
|
||||
// StopAllTasks stops all registered background tasks
|
||||
// StopAllTasks stops all registered background tasks.
|
||||
//
|
||||
// Tasks can live in two places: those registered directly via
|
||||
// TaskRegistry.RegisterTask (stored in tr.tasks) and process-global
|
||||
// singletons created via TaskRegistry.CreateSingletonTask, which delegate to
|
||||
// GetResourceManager().RegisterBackgroundTask and store the task in the
|
||||
// ResourceManager's registry (see CreateSingletonTask below) — NOT in
|
||||
// tr.tasks. Stop both so neither a direct task nor a singleton task leaks its
|
||||
// ticker goroutine past the last Close.
|
||||
func (tr *TaskRegistry) StopAllTasks() {
|
||||
// First, copy the tasks map to avoid deadlock with GetTaskCount()
|
||||
// First, stop tasks registered directly in this registry.
|
||||
// Copy the tasks map to avoid deadlock with GetTaskCount().
|
||||
tr.mu.Lock()
|
||||
tasksCopy := make(map[string]*BackgroundTask, len(tr.tasks))
|
||||
for name, task := range tr.tasks {
|
||||
@@ -511,13 +598,15 @@ func (tr *TaskRegistry) StopAllTasks() {
|
||||
tr.tasks = make(map[string]*BackgroundTask)
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Now stop all tasks without holding the lock
|
||||
for name, task := range tasksCopy {
|
||||
for _, task := range tasksCopy {
|
||||
task.Stop()
|
||||
if tr.logger != nil {
|
||||
tr.logger.Debug("Stopped background task during shutdown: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Then stop process-global singleton tasks, which live in the
|
||||
// ResourceManager's registry, not in tr.tasks (otherwise the singleton
|
||||
// task teardown would be a silent no-op and leak every singleton task's
|
||||
// ticker goroutine past the last Close).
|
||||
GetResourceManager().StopAllTasks()
|
||||
}
|
||||
|
||||
// GetTaskCount returns the number of active tasks
|
||||
@@ -721,9 +810,14 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
|
||||
historyLen := len(mm.statsHistory)
|
||||
if historyLen >= 2 {
|
||||
prev := mm.statsHistory[historyLen-2]
|
||||
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
|
||||
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
|
||||
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
|
||||
// Guard against div-by-zero (prev.HeapAlloc == 0) which would make
|
||||
// heapGrowth == +Inf and fire a spurious "memory leak" alert for a
|
||||
// first-ever measurement (R103).
|
||||
if prev.HeapAlloc > 0 {
|
||||
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
|
||||
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
|
||||
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
|
||||
}
|
||||
}
|
||||
}
|
||||
mm.mu.RUnlock()
|
||||
|
||||
+17
-13
@@ -262,16 +262,17 @@ func TestAzureOIDCRegression(t *testing.T) {
|
||||
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
|
||||
|
||||
// Test that CSRF is preserved during Azure validation failures
|
||||
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
|
||||
|
||||
// Should not be authenticated due to validation failure
|
||||
// Should not be authenticated due to validation failure, and with no
|
||||
// refresh token and no tokens to probe, should not claim an expiry
|
||||
// state (matches the standard path: unauthenticated, no refresh).
|
||||
if authenticated {
|
||||
t.Error("Should not be authenticated when token validation fails")
|
||||
}
|
||||
|
||||
// Should be marked as expired since no tokens work
|
||||
if !expired && !needsRefresh {
|
||||
t.Error("Should be marked as needing refresh or expired when validation fails")
|
||||
if needsRefresh || expired {
|
||||
t.Error("With no refresh token and no tokens to probe, should be (false,false,false)")
|
||||
}
|
||||
|
||||
// Verify CSRF token is still preserved in session
|
||||
@@ -453,7 +454,8 @@ func TestValidateGoogleTokens(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := tt.setupSession()
|
||||
|
||||
auth, refresh, expired := ts.tOidc.validateGoogleTokens(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
auth, refresh, expired := ts.tOidc.validateGoogleTokensRS(rs)
|
||||
|
||||
if auth != tt.expectedAuth {
|
||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||
@@ -637,7 +639,8 @@ func TestIsUserAuthenticated(t *testing.T) {
|
||||
defer func() { ts.tOidc.issuerURL = originalIssuer }()
|
||||
|
||||
session := tt.setupSession()
|
||||
auth, refresh, expired := ts.tOidc.isUserAuthenticated(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
auth, refresh, expired := ts.tOidc.isUserAuthenticatedRS(rs)
|
||||
|
||||
if auth != tt.expectedAuth {
|
||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||
@@ -688,7 +691,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
||||
return session
|
||||
},
|
||||
expectedAuth: false,
|
||||
expectedRefresh: true,
|
||||
expectedRefresh: false,
|
||||
expectedExpired: false,
|
||||
description: "Unauthenticated Azure session without refresh token",
|
||||
},
|
||||
@@ -722,10 +725,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
||||
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
|
||||
return session
|
||||
},
|
||||
expectedAuth: true,
|
||||
expectedAuth: false,
|
||||
expectedRefresh: false,
|
||||
expectedExpired: false,
|
||||
description: "Azure session with opaque access token",
|
||||
expectedExpired: true,
|
||||
description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
|
||||
},
|
||||
{
|
||||
name: "AuthenticatedWithBothTokensInvalid",
|
||||
@@ -762,7 +765,8 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
session := tt.setupSession()
|
||||
|
||||
auth, refresh, expired := ts.tOidc.validateAzureTokens(session)
|
||||
rs := (&requestState{}).captureSession(session)
|
||||
auth, refresh, expired := ts.tOidc.validateAzureTokensRS(rs)
|
||||
|
||||
if auth != tt.expectedAuth {
|
||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
|
||||
pressure := monitor.GetMemoryPressure()
|
||||
assert.Equal(t, MemoryPressureNone, pressure)
|
||||
|
||||
// Collect stats to populate lastStats
|
||||
monitor.GetCurrentStats()
|
||||
// Explicitly sample to populate lastStats; GetCurrentStats is now a
|
||||
// cached read and no longer forces a runtime.ReadMemStats.
|
||||
monitor.Refresh()
|
||||
|
||||
// Now should return a valid pressure level
|
||||
pressure = monitor.GetMemoryPressure()
|
||||
@@ -46,11 +47,13 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
|
||||
thresholds := DefaultMemoryAlertThresholds()
|
||||
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
|
||||
|
||||
// Start monitoring should not panic
|
||||
// Start monitoring should not panic. Interval is clamped to the
|
||||
// minimum (30s); we rely on Refresh() when we need a synchronous
|
||||
// sample instead of waiting for a tick.
|
||||
assert.NotPanics(t, func() {
|
||||
ctx := context.Background()
|
||||
monitor.StartMonitoring(ctx, 100*time.Millisecond)
|
||||
time.Sleep(GetTestDuration(50 * time.Millisecond))
|
||||
monitor.StartMonitoring(ctx, 0)
|
||||
monitor.Refresh()
|
||||
})
|
||||
|
||||
// Clean up
|
||||
@@ -117,6 +120,9 @@ func TestMemoryMonitorComprehensive(t *testing.T) {
|
||||
thresholds := DefaultMemoryAlertThresholds()
|
||||
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
|
||||
|
||||
// Refresh forces a synchronous sample; GetCurrentStats is a cached
|
||||
// read, so we sample first to guarantee fresh data.
|
||||
monitor.Refresh()
|
||||
stats := monitor.GetCurrentStats()
|
||||
assert.NotNil(t, stats)
|
||||
assert.Greater(t, stats.HeapAllocBytes, uint64(0))
|
||||
@@ -450,12 +456,12 @@ func TestMemoryMonitorIntegration(t *testing.T) {
|
||||
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
|
||||
defer monitor.StopMonitoring()
|
||||
|
||||
// Start monitoring
|
||||
// Start monitoring. The interval is clamped to the minimum (30s) so
|
||||
// the ticker won't fire during the test; drive the sample manually via
|
||||
// Refresh() instead.
|
||||
ctx := context.Background()
|
||||
monitor.StartMonitoring(ctx, 50*time.Millisecond)
|
||||
|
||||
// Wait for at least one check
|
||||
time.Sleep(GetTestDuration(150 * time.Millisecond))
|
||||
monitor.StartMonitoring(ctx, 0)
|
||||
monitor.Refresh()
|
||||
|
||||
// Get pressure (should be a valid pressure level)
|
||||
pressure := monitor.GetMemoryPressure()
|
||||
@@ -488,6 +494,7 @@ func TestMemoryStatsCollection(t *testing.T) {
|
||||
thresholds := DefaultMemoryAlertThresholds()
|
||||
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
|
||||
|
||||
monitor.Refresh()
|
||||
stats := monitor.GetCurrentStats()
|
||||
|
||||
assert.NotNil(t, stats)
|
||||
@@ -501,6 +508,7 @@ func TestMemoryStatsCollection(t *testing.T) {
|
||||
thresholds := DefaultMemoryAlertThresholds()
|
||||
monitor := NewMemoryMonitor(newNoOpLogger(), thresholds)
|
||||
|
||||
monitor.Refresh()
|
||||
stats := monitor.GetCurrentStats()
|
||||
|
||||
// Should calculate and include pressure level
|
||||
@@ -521,13 +529,14 @@ func TestMemoryStatsCollection(t *testing.T) {
|
||||
// Allocate some memory
|
||||
_ = make([]byte, 1024*1024) // 1MB
|
||||
|
||||
// Get stats before GC
|
||||
beforeStats := monitor.GetCurrentStats()
|
||||
// Get stats before GC (explicit Refresh so we have a fresh pre-GC
|
||||
// snapshot to compare against, not the constructor baseline).
|
||||
beforeStats := monitor.Refresh()
|
||||
|
||||
// Trigger GC
|
||||
// Trigger GC (internally Refresh()es before and after)
|
||||
monitor.TriggerGC()
|
||||
|
||||
// Get stats after GC
|
||||
// Get stats after GC from cache (TriggerGC already refreshed it)
|
||||
afterStats := monitor.GetCurrentStats()
|
||||
|
||||
// After GC should have different stats
|
||||
|
||||
+1099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
package traefikoidc
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzParseBearerJOSEHeader verifies the attacker-controlled JWT header parser
|
||||
// never panics on arbitrary input (no slice OOB, no unbounded alloc, no
|
||||
// JSON-engine surprise) regardless of token contents.
|
||||
func FuzzParseBearerJOSEHeader(f *testing.F) {
|
||||
seeds := []string{
|
||||
"",
|
||||
"...",
|
||||
"abc.def.ghi",
|
||||
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEifQ.sig.sig", // alg=RS256 kid=kid1
|
||||
"::",
|
||||
"snapshot=",
|
||||
"a,b.zzz",
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, token string) {
|
||||
parseBearerJOSEHeader(token) // must not panic
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzSanitizeBearerIdentifier verifies the identifier sanitizer never panics on
|
||||
// arbitrary strings and always returns either a clean value or a *bearerError.
|
||||
func FuzzSanitizeBearerIdentifier(f *testing.F) {
|
||||
seeds := []string{
|
||||
"",
|
||||
"alice",
|
||||
"user,group=admin",
|
||||
"a\u202eb\u202c",
|
||||
"\t \n",
|
||||
"verylongidentifier-padding-padding-padding-padding",
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
id, berr := sanitizeBearerIdentifier(raw, 256)
|
||||
if berr == nil && id == "" {
|
||||
t.Fatalf("sanitize ok but empty identifier for %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Helper builders
|
||||
// =============================================================================
|
||||
|
||||
// makeBearerJWT constructs a JWT with explicit header + claims for tests.
|
||||
// Signature is opaque (b64("signature")) — bearer tests don't exercise the
|
||||
// real cryptographic verifier; verification is bypassed via tokenCache pre-
|
||||
// seed so the bearer pipeline under test sees a "verified" token.
|
||||
func makeBearerJWT(t *testing.T, header, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal header: %v", err)
|
||||
}
|
||||
cb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s",
|
||||
base64.RawURLEncoding.EncodeToString(hb),
|
||||
base64.RawURLEncoding.EncodeToString(cb),
|
||||
base64.RawURLEncoding.EncodeToString([]byte("signature")),
|
||||
)
|
||||
}
|
||||
|
||||
// defaultBearerHeader produces the standard RS256+kid header used in tests.
|
||||
func defaultBearerHeader() map[string]interface{} {
|
||||
return map[string]interface{}{"alg": "RS256", "kid": "test-kid"}
|
||||
}
|
||||
|
||||
// defaultBearerClaims produces a baseline access-token claim set. Tests
|
||||
// shallow-clone and override fields as needed.
|
||||
func defaultBearerClaims() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"iss": "https://issuer.example.com",
|
||||
"aud": "https://api.example.com",
|
||||
"sub": "service-account-1",
|
||||
"scope": "api:read api:write",
|
||||
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
||||
"iat": float64(time.Now().Unix()),
|
||||
}
|
||||
}
|
||||
|
||||
// makeBearerOIDC constructs a TraefikOidc wired for bearer auth tests. The
|
||||
// real verifyTokenWithOpts pipeline is short-circuited via tokenCache pre-
|
||||
// seed: any token Set into t.tokenCache returns nil from VerifyToken,
|
||||
// letting tests exercise the post-verify bearer logic (classifier, identifier,
|
||||
// throttle, header forwarding) without standing up JWKs.
|
||||
func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
|
||||
t.Helper()
|
||||
sm := createTestSessionManager(t)
|
||||
oidc := &TraefikOidc{
|
||||
next: next,
|
||||
logger: NewLogger("error"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: sm,
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://issuer.example.com",
|
||||
audience: "https://api.example.com",
|
||||
clientID: "https://api.example.com",
|
||||
tokenCache: NewTokenCache(),
|
||||
excludedURLs: map[string]struct{}{"/favicon.ico": {}},
|
||||
allowedRolesAndGroups: map[string]struct{}{},
|
||||
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
|
||||
ctx: context.Background(),
|
||||
enableBearerAuth: true,
|
||||
stripAuthorizationHeader: true,
|
||||
bearerEmitWWWAuthenticate: true,
|
||||
bearerOverridesCookie: false,
|
||||
bearerIdentifierClaim: "sub",
|
||||
maxIdentifierLength: 256,
|
||||
maxTokenAge: 24 * time.Hour,
|
||||
bearerFailureThreshold: 20,
|
||||
bearerFailureWindow: 60 * time.Second,
|
||||
bearerFailurePenalty: 60 * time.Second,
|
||||
bearerFailureTracker: newBearerFailureTracker(20, 60*time.Second, 60*time.Second),
|
||||
}
|
||||
oidc.extractClaimsFunc = extractClaims
|
||||
close(oidc.initComplete)
|
||||
return oidc
|
||||
}
|
||||
|
||||
// seedVerified pre-populates the tokenCache so verifyTokenWithOpts short-
|
||||
// circuits to nil for the given token. Mirrors the production fast-return
|
||||
// path at token_manager.go for previously-verified tokens.
|
||||
func seedVerified(t *testing.T, oidc *TraefikOidc, token string, claims map[string]interface{}) {
|
||||
t.Helper()
|
||||
if oidc.tokenCache == nil {
|
||||
oidc.tokenCache = NewTokenCache()
|
||||
}
|
||||
oidc.tokenCache.Set(token, claims, time.Hour)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Unit tests — small helpers
|
||||
// =============================================================================
|
||||
|
||||
func TestDetectBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"missing header", "", "", false},
|
||||
{"basic auth", "Basic abc", "", false},
|
||||
{"bearer with token", "Bearer abc.def.ghi", "abc.def.ghi", true},
|
||||
{"lowercase bearer", "bearer abc.def.ghi", "abc.def.ghi", true},
|
||||
{"mixed case", "BeArEr abc.def.ghi", "abc.def.ghi", true},
|
||||
{"empty token after prefix", "Bearer ", "", false},
|
||||
{"bearer no space", "Bearerabc", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
got, ok := detectBearerToken(req)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("got=(%q, %v), want=(%q, %v)", got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBearerJOSEHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(t *testing.T, h map[string]interface{}) string {
|
||||
return makeBearerJWT(t, h, map[string]interface{}{"sub": "x"})
|
||||
}
|
||||
cases := []struct {
|
||||
header map[string]interface{}
|
||||
name string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid RS256", header: map[string]interface{}{"alg": "RS256", "kid": "k1"}, wantErr: false},
|
||||
{name: "valid ES512", header: map[string]interface{}{"alg": "ES512", "kid": "abc-_.="}, wantErr: false},
|
||||
{name: "alg=none rejected", header: map[string]interface{}{"alg": "none", "kid": "k1"}, wantErr: true},
|
||||
{name: "alg=HS256 rejected", header: map[string]interface{}{"alg": "HS256", "kid": "k1"}, wantErr: true},
|
||||
{name: "missing kid", header: map[string]interface{}{"alg": "RS256"}, wantErr: true},
|
||||
{name: "kid too long", header: map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}, wantErr: true},
|
||||
{name: "kid bad chars", header: map[string]interface{}{"alg": "RS256", "kid": "evil/../etc/passwd"}, wantErr: true},
|
||||
{name: "kid with space", header: map[string]interface{}{"alg": "RS256", "kid": "key one"}, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
token := mk(t, tc.header)
|
||||
err := parseBearerJOSEHeader(token)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseBearerIdentifier(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"normal sub", "service-account-1", "service-account-1", false},
|
||||
{"email-like", "[email protected]", "[email protected]", false},
|
||||
{"trim whitespace", " abc ", "abc", false},
|
||||
{"empty", "", "", true},
|
||||
{"only whitespace", " ", "", true},
|
||||
{"control char (newline)", "alice\nbob", "", true},
|
||||
{"control char (CR)", "alice\rbob", "", true},
|
||||
{"control char (NUL)", "alice\x00bob", "", true},
|
||||
{"bidi override", "alice\u202ebob", "", true},
|
||||
{"bidi isolate", "alice\u2066bob", "", true},
|
||||
{"comma delimiter", "alice,bob", "", true},
|
||||
{"semicolon delimiter", "alice;bob", "", true},
|
||||
{"equals delimiter", "alice=bob", "", true},
|
||||
{"over length", strings.Repeat("a", 257), "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitizeBearerIdentifier(tc.in, 256)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
if !tc.wantErr && got != tc.want {
|
||||
t.Fatalf("got=%q want=%q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBearerIdentifier(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
claims map[string]interface{}
|
||||
name string
|
||||
claim string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default sub", claims: map[string]interface{}{"sub": "abc"}, claim: "", want: "abc"},
|
||||
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
|
||||
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
|
||||
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
|
||||
{name: "numeric claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", want: "123"}, // R102: scalar numbers stringified, not rejected
|
||||
{name: "object claim", claims: map[string]interface{}{"sub": map[string]interface{}{"a": 1}}, claim: "sub", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveBearerIdentifier(tc.claims, tc.claim)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
if !tc.wantErr && got != tc.want {
|
||||
t.Fatalf("got=%q want=%q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceMultiAudienceAzp(t *testing.T) {
|
||||
t.Parallel()
|
||||
const cid = "https://api.example.com"
|
||||
cases := []struct {
|
||||
claims map[string]interface{}
|
||||
name string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "single string aud", claims: map[string]interface{}{"aud": "x"}, wantErr: false},
|
||||
{name: "single element array", claims: map[string]interface{}{"aud": []interface{}{"x"}}, wantErr: false},
|
||||
{name: "multi-aud with matching azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": cid}, wantErr: false},
|
||||
{name: "multi-aud missing azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}}, wantErr: true},
|
||||
{name: "multi-aud empty azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": ""}, wantErr: true},
|
||||
{name: "multi-aud wrong azp", claims: map[string]interface{}{"aud": []interface{}{"a", "b"}, "azp": "other"}, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := enforceMultiAudienceAzp(tc.claims, cid)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceIatAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
iat float64
|
||||
maxAge time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "fresh", iat: float64(now.Unix()), maxAge: time.Hour, wantErr: false},
|
||||
{name: "23h59m old, max 24h", iat: float64(now.Add(-23*time.Hour - 59*time.Minute).Unix()), maxAge: 24 * time.Hour, wantErr: false},
|
||||
{name: "25h old, max 24h", iat: float64(now.Add(-25 * time.Hour).Unix()), maxAge: 24 * time.Hour, wantErr: true},
|
||||
{name: "1970 token", iat: float64(0), maxAge: 24 * time.Hour, wantErr: true},
|
||||
{name: "maxAge disabled (0)", iat: float64(0), maxAge: 0, wantErr: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := enforceIatAge(map[string]interface{}{"iat": tc.iat}, tc.maxAge)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerFailureTracker(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
||||
const ip = "10.0.0.1"
|
||||
// Below threshold: not blocked.
|
||||
for i := 0; i < 2; i++ {
|
||||
tr.recordFailure(ip)
|
||||
if b, _ := tr.blocked(ip); b {
|
||||
t.Fatalf("blocked too early after %d failures", i+1)
|
||||
}
|
||||
}
|
||||
// Threshold reached: blocked.
|
||||
tr.recordFailure(ip)
|
||||
if b, retry := tr.blocked(ip); !b || retry <= 0 {
|
||||
t.Fatalf("expected blocked with positive retry, got=%v retry=%v", b, retry)
|
||||
}
|
||||
// A success while a penalty is active must NOT wipe the in-effect lockout
|
||||
// (otherwise a single success could clear an attacker's penalty).
|
||||
tr.recordSuccess(ip)
|
||||
if b, _ := tr.blocked(ip); !b {
|
||||
t.Fatalf("expected still blocked after success while penalty active")
|
||||
}
|
||||
// Other IPs are unaffected.
|
||||
if b, _ := tr.blocked("10.0.0.2"); b {
|
||||
t.Fatalf("unrelated IP should not be blocked")
|
||||
}
|
||||
|
||||
// With an expired penalty, a success resets the counter so a subsequent
|
||||
// sub-threshold failure does not immediately re-block.
|
||||
tr2 := newBearerFailureTracker(3, 60*time.Second, 1*time.Millisecond)
|
||||
const ip2 = "10.0.0.3"
|
||||
for i := 0; i < 3; i++ {
|
||||
tr2.recordFailure(ip2)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond) // let the short penalty expire
|
||||
if b, _ := tr2.blocked(ip2); b {
|
||||
t.Fatalf("expected unblocked after penalty expiry")
|
||||
}
|
||||
tr2.recordSuccess(ip2) // resets count since penalty has passed
|
||||
tr2.recordFailure(ip2) // single failure, well below threshold
|
||||
if b, _ := tr2.blocked(ip2); b {
|
||||
t.Fatalf("expected unblocked: counter should have reset after success")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Integration tests — full ServeHTTP via the bearer pipeline
|
||||
// =============================================================================
|
||||
|
||||
func TestServeHTTP_Bearer_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
var nextCalled atomic.Bool
|
||||
var capturedHeaders http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled.Store(true)
|
||||
capturedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if !nextCalled.Load() {
|
||||
t.Fatalf("expected next handler to run; got status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200", rw.Code)
|
||||
}
|
||||
if got := capturedHeaders.Get("X-Forwarded-User"); got != "service-account-1" {
|
||||
t.Fatalf("X-Forwarded-User=%q, want service-account-1", got)
|
||||
}
|
||||
if got := capturedHeaders.Get("Authorization"); got != "" {
|
||||
t.Fatalf("Authorization should be stripped, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_StripAuthDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedAuth string
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.stripAuthorizationHeader = false
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if !strings.HasPrefix(capturedAuth, "Bearer ") {
|
||||
t.Fatalf("expected Authorization to be forwarded, got=%q", capturedAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_RejectIDToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for ID token rejection")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
// ID-token shape: nonce claim present and no scope. detectTokenType
|
||||
// returns true.
|
||||
claims := map[string]interface{}{
|
||||
"iss": "https://issuer.example.com",
|
||||
"aud": "https://api.example.com",
|
||||
"sub": "user-1",
|
||||
"nonce": "n-0S6_WzA2Mj",
|
||||
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
||||
"iat": float64(time.Now().Unix()),
|
||||
}
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
if wa := rw.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) {
|
||||
t.Fatalf("expected WWW-Authenticate invalid_token, got=%q", wa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_AlgNoneRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for alg=none")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
header := map[string]interface{}{"alg": "none", "kid": "k1"}
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, header, claims)
|
||||
// Even if we pre-seeded the cache, the early alg pin runs FIRST.
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_KidTooLongRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for oversized kid")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
header := map[string]interface{}{"alg": "RS256", "kid": strings.Repeat("a", bearerKidMaxLen+1)}
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, header, claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_MultiAudRequiresAzp(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for multi-aud without azp")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
|
||||
delete(claims, "azp")
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_MultiAudWithAzpAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
var nextCalled atomic.Bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
claims["aud"] = []interface{}{"https://api.example.com", "https://other.example.com"}
|
||||
claims["azp"] = oidc.clientID
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusOK || !nextCalled.Load() {
|
||||
t.Fatalf("expected 200 + next called; got status=%d called=%v", rw.Code, nextCalled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_IatTooOldRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for old iat")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
claims["iat"] = float64(time.Now().Add(-25 * time.Hour).Unix())
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_IdentifierWithBidiRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for bidi identifier")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
claims["sub"] = "alice\u202ebob"
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_ReplayRegression(t *testing.T) {
|
||||
t.Parallel()
|
||||
var successCount atomic.Int32
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
successCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
claims["jti"] = "regression-jti"
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("iteration %d: status=%d, want 200", i, rw.Code)
|
||||
}
|
||||
}
|
||||
if successCount.Load() != 100 {
|
||||
t.Fatalf("successCount=%d, want 100", successCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run during throttle test")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
||||
|
||||
// Send malformed bearers from the same RemoteAddr until threshold trips.
|
||||
send := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.RemoteAddr = "10.0.0.5:1234"
|
||||
req.Header.Set("Authorization", "Bearer not-a-jwt")
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
return rw
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
rw := send()
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("pre-throttle iteration %d: status=%d, want 401", i, rw.Code)
|
||||
}
|
||||
}
|
||||
// 4th request: throttled.
|
||||
rw := send()
|
||||
if rw.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 after threshold, got %d", rw.Code)
|
||||
}
|
||||
if ra := rw.Header().Get("Retry-After"); ra == "" {
|
||||
t.Fatalf("expected Retry-After header on 429")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_Introspection503_NotThrottleFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run: introspection 503 should reject request")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.requireTokenIntrospection = true
|
||||
// Unreachable introspection endpoint -> every request returns 503.
|
||||
oidc.introspectionURL = "http://127.0.0.1:1/"
|
||||
oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
|
||||
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
||||
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
send := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.RemoteAddr = "10.0.0.7:1234"
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// Four requests during an introspection outage. Each is a 503 and the
|
||||
// per-IP failure count must stay at 0 so the threshold (3) never trips.
|
||||
// On OLD code each 503 was counted as a throttle failure, so the 4th
|
||||
// request would come back 429 (penalty box) instead of 503.
|
||||
for i := 0; i < 4; i++ {
|
||||
rw := send()
|
||||
if rw.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("iteration %d: status=%d, want %d", i, rw.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedAuth string
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.excludedURLs = map[string]struct{}{"/favicon.ico": {}}
|
||||
|
||||
req := httptest.NewRequest("GET", "/favicon.ico", nil)
|
||||
req.Header.Set("Authorization", "Bearer abc.def.ghi")
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("excluded path should pass; got %d", rw.Code)
|
||||
}
|
||||
if capturedAuth != "" {
|
||||
t.Fatalf("Authorization must be stripped on excluded paths, got=%q", capturedAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_RolesGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
rolesClaim []interface{}
|
||||
want int
|
||||
}{
|
||||
{name: "matching role", rolesClaim: []interface{}{"admin"}, want: http.StatusOK},
|
||||
{name: "no matching role", rolesClaim: []interface{}{"viewer"}, want: http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.allowedRolesAndGroups = map[string]struct{}{"admin": {}}
|
||||
oidc.roleClaimName = "roles"
|
||||
claims := defaultBearerClaims()
|
||||
claims["roles"] = tc.rolesClaim
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
if rw.Code != tc.want {
|
||||
t.Fatalf("status=%d, want %d", rw.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_CookieWinsByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both cookie and bearer present: cookie path runs (which will redirect
|
||||
// to /authorize since the cookie is empty/unauthenticated).
|
||||
var nextCalled atomic.Bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
prefix := oidc.sessionManager.GetCookiePrefix()
|
||||
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
// Cookie path consumed the request; bearer was ignored. Since the
|
||||
// cookie is empty, the cookie path will either 302 to /authorize or
|
||||
// return 401 — in either case, next must NOT be called.
|
||||
if nextCalled.Load() {
|
||||
t.Fatalf("next must not be called when bearer is ignored due to cookie precedence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_BearerOverridesCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
var nextCalled atomic.Bool
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.bearerOverridesCookie = true
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
prefix := oidc.sessionManager.GetCookiePrefix()
|
||||
req.AddCookie(&http.Cookie{Name: prefix + "main", Value: "irrelevant"})
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if !nextCalled.Load() || rw.Code != http.StatusOK {
|
||||
t.Fatalf("expected bearer to win with override; status=%d called=%v", rw.Code, nextCalled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_OversizedToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for oversized token")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
huge := strings.Repeat("a", AccessTokenConfig.MaxLength+1)
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+huge)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_MalformedJWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("next must not run for malformed JWT")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer not.jwt") // 1 dot
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_Bearer_FeatureOffPassesThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should not be reached: cookie path runs and (with no session)
|
||||
// will redirect or 401. We assert no panic / next not called.
|
||||
t.Fatalf("next must not run when bearer is off and no valid session exists")
|
||||
})
|
||||
oidc := makeBearerOIDC(t, next)
|
||||
oidc.enableBearerAuth = false
|
||||
claims := defaultBearerClaims()
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
// Expect non-200: either 302 to /authorize or 401. The point is the
|
||||
// bearer pipeline didn't run.
|
||||
if rw.Code == http.StatusOK {
|
||||
t.Fatalf("expected non-200 when bearer is off; got %d", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup validation tests
|
||||
// =============================================================================
|
||||
|
||||
func TestStartupValidation_BearerRequiresAudience(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := CreateConfig()
|
||||
cfg.ProviderURL = "https://issuer.example.com"
|
||||
cfg.ClientID = "id"
|
||||
cfg.ClientSecret = "secret"
|
||||
cfg.CallbackURL = "/oauth/callback"
|
||||
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
cfg.EnableBearerAuth = true
|
||||
cfg.Audience = ""
|
||||
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires Audience") {
|
||||
t.Fatalf("expected audience-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupValidation_BearerAcceptsResourceInPlaceOfAudience(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := CreateConfig()
|
||||
cfg.ProviderURL = "https://issuer.example.com"
|
||||
cfg.ClientID = "id"
|
||||
cfg.ClientSecret = "secret"
|
||||
cfg.CallbackURL = "/oauth/callback"
|
||||
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
cfg.EnableBearerAuth = true
|
||||
cfg.Audience = ""
|
||||
cfg.Resource = "https://api.example.com"
|
||||
mw, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected Resource to satisfy the bearer-auth audience requirement, got %v", err)
|
||||
}
|
||||
defer mw.(*TraefikOidc).Close()
|
||||
}
|
||||
|
||||
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := CreateConfig()
|
||||
cfg.ProviderURL = "https://issuer.example.com"
|
||||
cfg.ClientID = "id"
|
||||
cfg.ClientSecret = "secret"
|
||||
cfg.CallbackURL = "/oauth/callback"
|
||||
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
cfg.EnableBearerAuth = true
|
||||
cfg.Audience = "https://api.example.com"
|
||||
cfg.BearerIdentifierClaim = "email"
|
||||
_, err := New(context.Background(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cfg, "bearer-test")
|
||||
if err == nil || !strings.Contains(err.Error(), "bearerIdentifierClaim=\"email\"") {
|
||||
t.Fatalf("expected email-identifier rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Principal invariants
|
||||
// =============================================================================
|
||||
|
||||
func TestBuildPrincipalFromSession_NoIdentifier(t *testing.T) {
|
||||
t.Parallel()
|
||||
oidc := &TraefikOidc{logger: NewLogger("error")}
|
||||
if p := oidc.buildPrincipalFromSession(nil); p != nil {
|
||||
t.Fatalf("nil session must produce nil principal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-28 regression tests.
|
||||
//
|
||||
// R117's map-growth sweep in recordFailure deleted every entry whose
|
||||
// penaltyUntil was before the cutoff. An entry that has not tripped the
|
||||
// penalty box yet has a ZERO penaltyUntil, which is always before any
|
||||
// cutoff — so once the map passed defaultBearerEntrySweepThreshold, every
|
||||
// accumulating (not-yet-tripped) counter was discarded regardless of how
|
||||
// recently it started, no matter how recent firstFailureAt was. A source
|
||||
// whose failures interleave with sweeps could never reach the threshold.
|
||||
|
||||
// TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters guards the
|
||||
// exact scenario from the finding: threshold 3, 2 failures for a target
|
||||
// IP, then 1025 other IPs failing once each (crossing the sweep
|
||||
// threshold), then a 3rd failure for the target IP. The target's count
|
||||
// must survive the sweep and trip the penalty box.
|
||||
// Fail-on-old: the sweep discards the target's in-progress counter, so the
|
||||
// 3rd failure restarts it at count=1 and blocked() reports false.
|
||||
func TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters(t *testing.T) {
|
||||
b := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
||||
|
||||
b.recordFailure("target-ip")
|
||||
b.recordFailure("target-ip")
|
||||
|
||||
// Push the map past defaultBearerEntrySweepThreshold with distinct,
|
||||
// equally-fresh IPs so recordFailure's sweep branch runs.
|
||||
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
|
||||
b.recordFailure(fmt.Sprintf("other-ip-%d", i))
|
||||
}
|
||||
|
||||
b.recordFailure("target-ip")
|
||||
|
||||
blocked, _ := b.blocked("target-ip")
|
||||
if !blocked {
|
||||
t.Fatal("a source whose failures are recent (within the window) must not lose its count to the map-growth sweep, and must trip the penalty box on its 3rd failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerFailureTracker_SweepRemovesTrulyStaleEntries is the positive
|
||||
// control: an entry that is BOTH untripped AND outside the window (a
|
||||
// source that failed once, long ago, and never returned) must still be
|
||||
// swept once the map grows past the threshold — otherwise the fix would
|
||||
// defeat the sweep's original memory-bound purpose entirely.
|
||||
func TestBearerFailureTracker_SweepRemovesTrulyStaleEntries(t *testing.T) {
|
||||
b := newBearerFailureTracker(20, 60*time.Second, 60*time.Second)
|
||||
|
||||
b.mu.Lock()
|
||||
b.entries["stale-ip"] = &bearerFailureEntry{
|
||||
firstFailureAt: time.Now().Add(-time.Hour), // well outside the 60s window
|
||||
count: 1,
|
||||
// penaltyUntil left zero: never tripped.
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
|
||||
b.recordFailure(fmt.Sprintf("filler-ip-%d", i))
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
_, stillPresent := b.entries["stale-ip"]
|
||||
b.mu.Unlock()
|
||||
if stillPresent {
|
||||
t.Fatal("an untripped entry whose firstFailureAt is outside the window must still be swept once the map grows past the threshold")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-24 regression test.
|
||||
//
|
||||
// buildPrincipalFromBearerToken derived the IdP-logout-invalidation
|
||||
// "createdAt" from the token's iat claim, falling back to time.Now() when
|
||||
// iat was absent. Since 8640451 made iat OPTIONAL in jwt.Verify (R126), an
|
||||
// access token without iat now reaches this check. time.Now() makes such a
|
||||
// token always look newer than any logout event (isSessionInvalidated only
|
||||
// matches when invalidationTime >= createdAt), so IdP-initiated
|
||||
// (backchannel) logout never revokes it — reintroducing the R98 defect the
|
||||
// cookie path already fixed by falling back to zero time instead.
|
||||
//
|
||||
// Fail-on-old: a bearer token without iat, for a sid invalidated by
|
||||
// backchannel logout, still authenticates (200) after the logout.
|
||||
func TestBearerLogoutInvalidation_NoIatFallsClosedNotOpen(t *testing.T) {
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
oidc := makeBearerOIDC(t, nil)
|
||||
oidc.sessionInvalidationCache = cache
|
||||
// Disable the unrelated maxTokenAge/enforceIatAge bound (a DIFFERENT
|
||||
// check that also rejects a missing iat, and that New() always turns on
|
||||
// in production — 0/unset becomes 24h, main.go:357-362 — this override
|
||||
// only exists because this test constructs *TraefikOidc directly,
|
||||
// bypassing New()) so this test isolates the isSessionInvalidated
|
||||
// createdAt fallback under test. That fallback is what actually matters
|
||||
// once maxTokenAge is 0: with maxTokenAge > 0 (the production default),
|
||||
// enforceIatAge rejects an iat-less token before this logout check is
|
||||
// even reached.
|
||||
oidc.maxTokenAge = 0
|
||||
|
||||
claims := defaultBearerClaims()
|
||||
delete(claims, "iat") // provider omits iat (now legal per R126)
|
||||
claims["sub"] = "user-1"
|
||||
claims["sid"] = "session-1"
|
||||
|
||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||
seedVerified(t, oidc, token, claims)
|
||||
|
||||
// Backchannel logout invalidates the session a full hour before this
|
||||
// request (set directly rather than via invalidateSession's real-clock
|
||||
// Unix() call, so the comparison is deterministic regardless of
|
||||
// test-execution timing rather than depending on two time.Now() calls
|
||||
// landing in the same second). The buggy time.Now() fallback for
|
||||
// createdAt makes the token look newer than this invalidation
|
||||
// (invalidationTime.Before(createdAt) == true -> NOT invalidated); the
|
||||
// fix's zero-time fallback is never after any invalidation timestamp.
|
||||
invalidatedAt := time.Now().Add(-time.Hour).Unix()
|
||||
cache.Set(oidc.buildSessionInvalidationKey("sid", "session-1"), invalidatedAt, time.Hour)
|
||||
|
||||
_, bErr := oidc.buildPrincipalFromBearerToken(token)
|
||||
if bErr == nil {
|
||||
t.Fatal("a bearer token without iat, whose sid was invalidated by backchannel logout, must be rejected (fail closed), not authenticated as if issued after the logout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerLogoutInvalidation_WithIatStillHonored is the positive control:
|
||||
// a token WITH iat issued before the logout must still be invalidated (the
|
||||
// existing R146 behavior), and one issued after must still pass.
|
||||
func TestBearerLogoutInvalidation_WithIatStillHonored(t *testing.T) {
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
oidc := makeBearerOIDC(t, nil)
|
||||
oidc.sessionInvalidationCache = cache
|
||||
|
||||
if err := oidc.invalidateSession("session-2", ""); err != nil {
|
||||
t.Fatalf("invalidateSession: %v", err)
|
||||
}
|
||||
|
||||
// Issued well before the logout above.
|
||||
staleClaims := defaultBearerClaims()
|
||||
staleClaims["sub"] = "user-2"
|
||||
staleClaims["sid"] = "session-2"
|
||||
staleClaims["iat"] = float64(time.Now().Add(-time.Hour).Unix())
|
||||
staleToken := makeBearerJWT(t, defaultBearerHeader(), staleClaims)
|
||||
seedVerified(t, oidc, staleToken, staleClaims)
|
||||
|
||||
if _, bErr := oidc.buildPrincipalFromBearerToken(staleToken); bErr == nil {
|
||||
t.Fatal("a token issued before the logout must still be invalidated")
|
||||
}
|
||||
|
||||
// Freshly issued after the logout, for an unrelated sid.
|
||||
freshClaims := defaultBearerClaims()
|
||||
freshClaims["sub"] = "user-3"
|
||||
freshClaims["sid"] = "session-3"
|
||||
freshClaims["iat"] = float64(time.Now().Unix())
|
||||
freshToken := makeBearerJWT(t, defaultBearerHeader(), freshClaims)
|
||||
seedVerified(t, oidc, freshToken, freshClaims)
|
||||
|
||||
if _, bErr := oidc.buildPrincipalFromBearerToken(freshToken); bErr != nil {
|
||||
t.Fatalf("a freshly issued token for an uninvalidated sid must pass, got: %v", bErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-03 regression tests.
|
||||
//
|
||||
// R159 sent every opaque (non-JWT) bearer token to
|
||||
// buildPrincipalFromOpaqueIntrospection whenever requireTokenIntrospection
|
||||
// was enabled, with none of the JWT-path gates: allowOpaqueTokens was never
|
||||
// checked, the introspected client_id/audience was never bound to this
|
||||
// client when the operator left audience==clientID (the common default),
|
||||
// and IdP-initiated logout (isSessionInvalidated) was never consulted. Any
|
||||
// introspected active=true token from the same IdP therefore authenticated,
|
||||
// including one issued to a completely different client.
|
||||
|
||||
// TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens guards the
|
||||
// allowOpaqueTokens gate: with it left at its default (false), an opaque
|
||||
// bearer token must be rejected even when requireTokenIntrospection is on
|
||||
// and the introspection endpoint reports the token active.
|
||||
func TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens(t *testing.T) {
|
||||
active := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: active},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: false, // default
|
||||
clientID: "my-client",
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("opaque bearer token must be rejected when allowOpaqueTokens is false, even with requireTokenIntrospection on")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_RejectsForeignClient guards the client
|
||||
// binding: when audience==clientID (no distinct API audience configured),
|
||||
// an introspected token whose client_id names another client, and whose
|
||||
// aud does not contain this client either, must be rejected.
|
||||
func TestBearerOpaqueIntrospection_RejectsForeignClient(t *testing.T) {
|
||||
foreign := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "other-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: foreign},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected token issued to a different client_id must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID is the positive
|
||||
// control: client_id matching this client must still authenticate.
|
||||
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID(t *testing.T) {
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
}
|
||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("a token issued to this client_id must be accepted, got: %v", bErr)
|
||||
}
|
||||
if p.Identifier != "user-1" {
|
||||
t.Fatalf("expected identifier %q, got %q", "user-1", p.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud is the positive
|
||||
// control for providers that omit client_id from the introspection
|
||||
// response but carry it in aud instead (RFC 7662 leaves client_id
|
||||
// optional).
|
||||
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud(t *testing.T) {
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", Aud: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("a token whose aud contains this client_id must be accepted, got: %v", bErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_InvalidatedSessionRejected guards the
|
||||
// IdP-initiated-logout check: a subject invalidated via backchannel logout
|
||||
// must be rejected even though the introspected token itself reports
|
||||
// active=true. The introspection response carries no sid (RFC 7662 does
|
||||
// not define one), so this checks invalidation by sub, mirroring the JWT
|
||||
// bearer path's isSessionInvalidated(sid, sub, createdAt) call with an
|
||||
// empty sid.
|
||||
func TestBearerOpaqueIntrospection_InvalidatedSessionRejected(t *testing.T) {
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
sessionInvalidationCache: cache,
|
||||
}
|
||||
if err := tObj.invalidateSession("", "user-1"); err != nil {
|
||||
t.Fatalf("invalidateSession: %v", err)
|
||||
}
|
||||
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected token for a subject invalidated by backchannel logout must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted is the positive
|
||||
// control: a subject with no invalidation entry must still authenticate.
|
||||
func TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted(t *testing.T) {
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
sessionInvalidationCache: cache,
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("a subject with no invalidation entry must authenticate, got: %v", bErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim guards that the
|
||||
// identifier is resolved through bearerIdentifierClaim + sanitizeBearerIdentifier
|
||||
// like the JWT bearer path, instead of always preferring sub.
|
||||
func TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim(t *testing.T) {
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", Username: "svc-account", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
bearerIdentifierClaim: "username",
|
||||
}
|
||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("unexpected error: %v", bErr)
|
||||
}
|
||||
if p.Identifier != "svc-account" {
|
||||
t.Fatalf("expected identifier from configured claim %q, got %q", "username", p.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_MaxTokenAgeEnforced guards that maxTokenAge
|
||||
// bounds the introspected token's iat like enforceIatAge does for the JWT
|
||||
// path, when the AS returns one.
|
||||
func TestBearerOpaqueIntrospection_MaxTokenAgeEnforced(t *testing.T) {
|
||||
stale := &IntrospectionResponse{
|
||||
Active: true,
|
||||
Sub: "user-1",
|
||||
ClientID: "my-client",
|
||||
Iat: time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: stale},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
maxTokenAge: time.Hour,
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected token whose iat is older than maxTokenAge must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge guards
|
||||
// that an opaque token whose introspection response carries no iat is
|
||||
// bound by maxTokenAge exactly like enforceIatAge binds the JWT path: when
|
||||
// maxTokenAge > 0 (New() always sets it so — 0/unset becomes 24h,
|
||||
// main.go's config-default closure), a missing iat gives nothing to bound
|
||||
// the token's age against, so it must fail closed rather than bypass the
|
||||
// age check entirely.
|
||||
func TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge(t *testing.T) {
|
||||
noIat := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"} // Iat left zero
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: noIat},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
maxTokenAge: 24 * time.Hour,
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected opaque token with no iat must be rejected when maxTokenAge is set, matching enforceIatAge's fail-closed contract on the JWT path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_SidOnlyLogoutRejected guards front-channel
|
||||
// (sid-only) logout reaching an opaque bearer token. isSessionInvalidated
|
||||
// used to be called with a hardcoded empty sid, so a logout recorded only
|
||||
// by sid (front-channel logout always records this way; so does a
|
||||
// back-channel logout token carrying only sid) never revoked an opaque
|
||||
// token even when the introspection response itself carried a sid.
|
||||
func TestBearerOpaqueIntrospection_SidOnlyLogoutRejected(t *testing.T) {
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
withSid := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Sid: "session-1"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: withSid},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
sessionInvalidationCache: cache,
|
||||
}
|
||||
if err := tObj.invalidateSession("session-1", ""); err != nil {
|
||||
t.Fatalf("invalidateSession: %v", err)
|
||||
}
|
||||
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected token whose sid was invalidated by a sid-only (front-channel) logout must be rejected, when the introspection response carries that sid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_RevokedTokenRejected guards the local
|
||||
// revocation blacklist: the JWT bearer path rejects a raw token that
|
||||
// RevokeToken blacklisted (verifyTokenWithOpts, token_manager.go:71-75), and
|
||||
// handleLogout calls RevokeToken on the session's access token specifically
|
||||
// so "a token captured before logout ... cannot be reused" (helpers.go).
|
||||
// buildPrincipalFromOpaqueIntrospection never consulted t.tokenBlacklist, so
|
||||
// a captured opaque bearer token kept authenticating as the victim after
|
||||
// logout for as long as the IdP still reported it active.
|
||||
func TestBearerOpaqueIntrospection_RevokedTokenRejected(t *testing.T) {
|
||||
blacklist := NewCache()
|
||||
defer blacklist.Close()
|
||||
|
||||
active := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: active},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
tokenBlacklist: blacklist,
|
||||
tokenCache: NewTokenCache(),
|
||||
}
|
||||
defer tObj.tokenCache.Close()
|
||||
|
||||
const token = "opaque-victim-token"
|
||||
tObj.RevokeToken(token)
|
||||
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection(token)
|
||||
if bErr == nil {
|
||||
t.Fatal("an opaque bearer token blacklisted by RevokeToken (logout) must be rejected, even though introspection still reports it active")
|
||||
}
|
||||
if bErr.kind != bearerErrTokenInactive {
|
||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_BlacklistedJtiRejected guards the jti side
|
||||
// of the same gate: when the introspection response carries a jti and that
|
||||
// jti is blacklisted (e.g. the IdP echoes the same jti it minted for a JWT
|
||||
// RevokeToken already blacklisted by jti), the opaque path must reject it
|
||||
// too, unless disableReplayDetection is set — mirroring the JWT path's
|
||||
// tokenCache-hit jti check (token_manager.go:90-96).
|
||||
func TestBearerOpaqueIntrospection_BlacklistedJtiRejected(t *testing.T) {
|
||||
blacklist := NewCache()
|
||||
defer blacklist.Close()
|
||||
blacklist.Set("revoked-jti-1", true, time.Hour)
|
||||
|
||||
withJti := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client", Jti: "revoked-jti-1"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: withJti},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
tokenBlacklist: blacklist,
|
||||
}
|
||||
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an opaque bearer token whose introspection jti is blacklisted must be rejected")
|
||||
}
|
||||
if bErr.kind != bearerErrTokenInactive {
|
||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled
|
||||
// guards the disableReplayDetection escape hatch on the jti-blacklist gate
|
||||
// (bearer_auth.go:928): an operator who has turned off replay/JTI tracking
|
||||
// must not be surprised by this gate rejecting an otherwise-valid token
|
||||
// whose jti happens to collide with a blacklisted one. The raw token itself
|
||||
// is NOT blacklisted here — only its jti is — so this isolates the jti
|
||||
// branch from the raw-token blacklist check covered by
|
||||
// _RevokedTokenRejected above.
|
||||
func TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled(t *testing.T) {
|
||||
blacklist := NewCache()
|
||||
defer blacklist.Close()
|
||||
blacklist.Set("revoked-jti-1", true, time.Hour)
|
||||
|
||||
withJti := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Jti: "revoked-jti-1"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: withJti},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
tokenBlacklist: blacklist,
|
||||
disableReplayDetection: true,
|
||||
}
|
||||
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("a blacklisted jti must be ignored when disableReplayDetection is true, got: %v", bErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_NbfFutureRejected guards the not-before
|
||||
// check the JWT path applies through verifyTimeClaims and the session path
|
||||
// applies through validateOpaqueToken (token_introspection.go:258-263): an
|
||||
// introspection response reporting a future nbf must be rejected on the
|
||||
// bearer path too.
|
||||
func TestBearerOpaqueIntrospection_NbfFutureRejected(t *testing.T) {
|
||||
notYetValid := &IntrospectionResponse{
|
||||
Active: true,
|
||||
Sub: "user-1",
|
||||
ClientID: "my-client",
|
||||
Nbf: time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: notYetValid},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
}
|
||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr == nil {
|
||||
t.Fatal("an introspected token with nbf in the future must be rejected")
|
||||
}
|
||||
if bErr.kind != bearerErrTokenInactive {
|
||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerOpaqueIntrospection_UsesClientIDClaim guards that
|
||||
// bearerIdentifierClaim can resolve against any IntrospectionResponse
|
||||
// member, not just sub/username — client_id, scope, iss, jti and aud must
|
||||
// all be available too.
|
||||
func TestBearerOpaqueIntrospection_UsesClientIDClaim(t *testing.T) {
|
||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
||||
tObj := &TraefikOidc{
|
||||
logger: newNoOpLogger(),
|
||||
introspectionCache: &stubIntrospectionCache{v: own},
|
||||
requireTokenIntrospection: true,
|
||||
allowOpaqueTokens: true,
|
||||
clientID: "my-client",
|
||||
bearerIdentifierClaim: "client_id",
|
||||
}
|
||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
||||
if bErr != nil {
|
||||
t.Fatalf("unexpected error: %v", bErr)
|
||||
}
|
||||
if p.Identifier != "my-client" {
|
||||
t.Fatalf("expected identifier from configured claim %q, got %q", "client_id", p.Identifier)
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testCertPEM returns a valid PEM-encoded certificate harvested from an
|
||||
// httptest.NewTLSServer. Using httptest keeps the test free of any
|
||||
// handwritten static cert that could expire.
|
||||
func testCertPEM(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cert := srv.Certificate()
|
||||
if cert == nil {
|
||||
t.Fatal("httptest.NewTLSServer did not expose a certificate")
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_Empty(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pool != nil {
|
||||
t.Errorf("expected nil pool when no CA source configured, got %v", pool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_InlinePEM(t *testing.T) {
|
||||
cfg := &Config{CACertPEM: testCertPEM(t)}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pool == nil {
|
||||
t.Fatal("expected non-nil pool for valid CACertPEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_InlinePEM_Garbage(t *testing.T) {
|
||||
cfg := &Config{CACertPEM: "not a pem"}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for garbage CACertPEM, got nil")
|
||||
}
|
||||
if pool != nil {
|
||||
t.Errorf("expected nil pool on error, got %v", pool)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "caCertPEM") {
|
||||
t.Errorf("error should name the failing field, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_FilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ca.pem")
|
||||
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
|
||||
t.Fatalf("writing temp PEM: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{CACertPath: path}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pool == nil {
|
||||
t.Fatal("expected non-nil pool for valid CACertPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_FilePath_Missing(t *testing.T) {
|
||||
cfg := &Config{CACertPath: "/does/not/exist/ca.pem"}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CACertPath, got nil")
|
||||
}
|
||||
if pool != nil {
|
||||
t.Errorf("expected nil pool on error, got %v", pool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCACertPool_Combined(t *testing.T) {
|
||||
// Both inline and file sources populated — certificates from both should
|
||||
// be accepted into the same pool.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ca.pem")
|
||||
if err := os.WriteFile(path, []byte(testCertPEM(t)), 0o600); err != nil {
|
||||
t.Fatalf("writing temp PEM: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{CACertPath: path, CACertPEM: testCertPEM(t)}
|
||||
pool, err := cfg.loadCACertPool()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pool == nil {
|
||||
t.Fatal("expected non-nil pool when both sources set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransportPool_ConfigKeyDistinguishesCAAndSkipVerify(t *testing.T) {
|
||||
p := GetGlobalTransportPool()
|
||||
cfgSystem := DefaultHTTPClientConfig()
|
||||
|
||||
cfgSkip := DefaultHTTPClientConfig()
|
||||
cfgSkip.InsecureSkipVerify = true
|
||||
|
||||
cfgCustomCA := DefaultHTTPClientConfig()
|
||||
pool, err := (&Config{CACertPEM: testCertPEM(t)}).loadCACertPool()
|
||||
if err != nil {
|
||||
t.Fatalf("loadCACertPool: %v", err)
|
||||
}
|
||||
cfgCustomCA.RootCAs = pool
|
||||
|
||||
keys := map[string]string{
|
||||
"system": p.configKey(cfgSystem),
|
||||
"skip": p.configKey(cfgSkip),
|
||||
"customCA": p.configKey(cfgCustomCA),
|
||||
}
|
||||
seen := make(map[string]string, len(keys))
|
||||
for name, key := range keys {
|
||||
if dup, ok := seen[key]; ok {
|
||||
t.Errorf("configKey collision: %s and %s share key %q", name, dup, key)
|
||||
}
|
||||
seen[key] = name
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package traefikoidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -161,81 +160,3 @@ func BenchmarkLRUStrategy_EstimateSize(b *testing.B) {
|
||||
strategy.EstimateSize(item)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHARDED CACHE BENCHMARKS
|
||||
// =============================================================================
|
||||
|
||||
func BenchmarkShardedCache(b *testing.B) {
|
||||
b.Run("Set", func(b *testing.B) {
|
||||
cache := NewShardedCache(64, 100000)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Get", func(b *testing.B) {
|
||||
cache := NewShardedCache(64, 100000)
|
||||
for i := 0; i < 10000; i++ {
|
||||
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cache.Get(fmt.Sprintf("key-%d", i%10000))
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ParallelSetGet", func(b *testing.B) {
|
||||
cache := NewShardedCache(64, 100000)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
cache.Set(key, i, 5*time.Minute)
|
||||
cache.Get(key)
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkShardedVsGlobalMutex compares sharded cache with global mutex approach
|
||||
func BenchmarkShardedVsGlobalMutex(b *testing.B) {
|
||||
b.Run("ShardedCache64", func(b *testing.B) {
|
||||
cache := NewShardedCache(64, 100000)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
key := fmt.Sprintf("jti-%d", i%10000)
|
||||
if !cache.Exists(key) {
|
||||
cache.Set(key, true, 5*time.Minute)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
b.Run("GlobalMutexCache", func(b *testing.B) {
|
||||
var mu sync.RWMutex
|
||||
data := make(map[string]bool)
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
key := fmt.Sprintf("jti-%d", i%10000)
|
||||
|
||||
mu.RLock()
|
||||
_, exists := data[key]
|
||||
mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
mu.Lock()
|
||||
data[key] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+39
-7
@@ -5,10 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBlacklistDuration = 24 * time.Hour
|
||||
)
|
||||
|
||||
// CacheManager manages all caching components using the universal cache
|
||||
type CacheManager struct {
|
||||
manager *UniversalCacheManager
|
||||
@@ -16,8 +12,9 @@ type CacheManager struct {
|
||||
}
|
||||
|
||||
var (
|
||||
globalCacheManagerInstance *CacheManager
|
||||
cacheManagerInitOnce sync.Once
|
||||
globalCacheManagerInstance *CacheManager
|
||||
cacheManagerInitOnce sync.Once
|
||||
cacheManagerActiveFingerprint string
|
||||
)
|
||||
|
||||
// GetGlobalCacheManager returns a singleton CacheManager instance.
|
||||
@@ -29,7 +26,9 @@ func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
|
||||
|
||||
// GetGlobalCacheManagerWithConfig returns a singleton CacheManager instance with optional Redis configuration
|
||||
func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheManager {
|
||||
fp := redisFingerprint(config)
|
||||
cacheManagerInitOnce.Do(func() {
|
||||
cacheManagerActiveFingerprint = fp
|
||||
var redisConfig *RedisConfig
|
||||
var logger *Logger
|
||||
|
||||
@@ -55,9 +54,27 @@ func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheM
|
||||
manager: GetUniversalCacheManagerWithConfig(logger, redisConfig),
|
||||
}
|
||||
})
|
||||
// Warn loudly if a later instance asks for a DIFFERENT explicit Redis
|
||||
// backend than the one that won initialization: the cache manager is a
|
||||
// process-global singleton shared across plugin instances (yaegi), so this
|
||||
// instance's divergent configuration is silently ignored, which would
|
||||
// otherwise collapse cache/state isolation between routes (rank 9).
|
||||
if fp != "" && cacheManagerActiveFingerprint != "" && fp != cacheManagerActiveFingerprint {
|
||||
NewLogger(config.LogLevel).Errorf("cache manager already initialized with Redis backend %q; this instance's Redis backend %q is IGNORED (process-global singleton). Use a single consistent cache configuration across all routes.", cacheManagerActiveFingerprint, fp)
|
||||
}
|
||||
return globalCacheManagerInstance
|
||||
}
|
||||
|
||||
// redisFingerprint returns a stable identifier for an explicitly-enabled Redis
|
||||
// backend (address + key prefix), or "" when Redis is not explicitly enabled.
|
||||
// Used to detect divergent cache configurations across plugin instances.
|
||||
func redisFingerprint(config *Config) string {
|
||||
if config == nil || config.Redis == nil || !config.Redis.Enabled {
|
||||
return ""
|
||||
}
|
||||
return config.Redis.Address + "|" + config.Redis.KeyPrefix
|
||||
}
|
||||
|
||||
// GetSharedTokenBlacklist returns the shared token blacklist cache
|
||||
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
@@ -86,7 +103,7 @@ func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
|
||||
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &JWKCache{cache: cm.manager.GetJWKCache()}
|
||||
return &JWKCache{cache: cm.manager.GetJWKCache(), lastForceRefresh: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
// GetSharedIntrospectionCache returns the shared token introspection cache
|
||||
@@ -113,6 +130,14 @@ func (cm *CacheManager) GetSharedSessionInvalidationCache() CacheInterface {
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetSessionInvalidationCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedRefreshResultCache returns the short-lived refresh-result cache used
|
||||
// by the refresh path to coalesce grants across Traefik replicas via Redis.
|
||||
func (cm *CacheManager) GetSharedRefreshResultCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetRefreshResultCache(), managed: true}
|
||||
}
|
||||
|
||||
// Close gracefully shuts down all cache components
|
||||
func (cm *CacheManager) Close() error {
|
||||
cm.mu.Lock()
|
||||
@@ -144,6 +169,13 @@ func (c *CacheInterfaceWrapper) Get(key string) (interface{}, bool) {
|
||||
return c.cache.Get(key)
|
||||
}
|
||||
|
||||
// SetIfAbsent atomically stores a value only if the key is not already
|
||||
// present. See UniversalCache.SetIfAbsent and AtomicSetIfAbsentCache
|
||||
// (FIX-17).
|
||||
func (c *CacheInterfaceWrapper) SetIfAbsent(key string, value interface{}, ttl time.Duration) (bool, error) {
|
||||
return c.cache.SetIfAbsent(key, value, ttl)
|
||||
}
|
||||
|
||||
// Delete removes a key
|
||||
func (c *CacheInterfaceWrapper) Delete(key string) {
|
||||
c.cache.Delete(key)
|
||||
|
||||
+5
-308
@@ -178,6 +178,11 @@ func TestCacheInterfaceWrapper_Clear(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
cache := cm.GetSharedTokenBlacklist()
|
||||
|
||||
// The shared token blacklist is process-global across tests; under shuffled
|
||||
// execution a prior test may have left items behind. Clear first so the
|
||||
// size assertions are stable regardless of test order.
|
||||
cache.Clear()
|
||||
|
||||
cache.Set("key1", "value1", time.Hour)
|
||||
cache.Set("key2", "value2", time.Hour)
|
||||
|
||||
@@ -465,314 +470,6 @@ func TestConcurrentCacheAccess(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHARDED CACHE TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestShardedCacheBasicOperations(t *testing.T) {
|
||||
t.Run("SetAndGet", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
cache.Set("key2", 42, 5*time.Minute)
|
||||
cache.Set("key3", true, 5*time.Minute)
|
||||
|
||||
val1, ok := cache.Get("key1")
|
||||
if !ok || val1 != "value1" {
|
||||
t.Errorf("Expected 'value1', got %v, ok=%v", val1, ok)
|
||||
}
|
||||
|
||||
val2, ok := cache.Get("key2")
|
||||
if !ok || val2 != 42 {
|
||||
t.Errorf("Expected 42, got %v, ok=%v", val2, ok)
|
||||
}
|
||||
|
||||
val3, ok := cache.Get("key3")
|
||||
if !ok || val3 != true {
|
||||
t.Errorf("Expected true, got %v, ok=%v", val3, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetNonExistent", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
val, ok := cache.Get("nonexistent")
|
||||
if ok || val != nil {
|
||||
t.Errorf("Expected nil/false for nonexistent key, got %v/%v", val, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
cache.Delete("key1")
|
||||
|
||||
val, ok := cache.Get("key1")
|
||||
if ok || val != nil {
|
||||
t.Errorf("Expected nil/false after delete, got %v/%v", val, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Exists", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
|
||||
if !cache.Exists("key1") {
|
||||
t.Error("Expected Exists to return true for existing key")
|
||||
}
|
||||
|
||||
if cache.Exists("nonexistent") {
|
||||
t.Error("Expected Exists to return false for nonexistent key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Size", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
if cache.Size() != 0 {
|
||||
t.Errorf("Expected size 0, got %d", cache.Size())
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
if cache.Size() != 100 {
|
||||
t.Errorf("Expected size 100, got %d", cache.Size())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Clear", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
cache.Clear()
|
||||
|
||||
if cache.Size() != 0 {
|
||||
t.Errorf("Expected size 0 after clear, got %d", cache.Size())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheExpiration(t *testing.T) {
|
||||
t.Run("ItemExpires", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 50*time.Millisecond)
|
||||
|
||||
if !cache.Exists("key1") {
|
||||
t.Error("Item should exist immediately after set")
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if cache.Exists("key1") {
|
||||
t.Error("Item should have expired")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CleanupRemovesExpired", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("expired%d", i), i, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
cache.Cleanup()
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
if cache.Exists(fmt.Sprintf("expired%d", i)) {
|
||||
t.Errorf("Expired item %d should not exist after cleanup", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
if !cache.Exists(fmt.Sprintf("valid%d", i)) {
|
||||
t.Errorf("Valid item %d should still exist after cleanup", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZeroTTLNeverExpires", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("permanent", "value", 0)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if !cache.Exists("permanent") {
|
||||
t.Error("Item with 0 TTL should never expire")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheConcurrency(t *testing.T) {
|
||||
t.Run("ConcurrentSetGet", func(t *testing.T) {
|
||||
cache := NewShardedCache(64, 10000)
|
||||
const numGoroutines = 100
|
||||
const numOperations = 1000
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errors int32
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", id, j)
|
||||
cache.Set(key, j, 5*time.Minute)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", id, j)
|
||||
cache.Get(key)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if atomic.LoadInt32(&errors) > 0 {
|
||||
t.Errorf("Encountered %d errors during concurrent access", errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentMixedOperations", func(t *testing.T) {
|
||||
cache := NewShardedCache(64, 10000)
|
||||
const numGoroutines = 50
|
||||
const numOperations = 500
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d", j%100)
|
||||
switch j % 4 {
|
||||
case 0:
|
||||
cache.Set(key, j, 5*time.Minute)
|
||||
case 1:
|
||||
cache.Get(key)
|
||||
case 2:
|
||||
cache.Exists(key)
|
||||
case 3:
|
||||
cache.Delete(key)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("NoConcurrentPanics", func(t *testing.T) {
|
||||
cache := NewShardedCache(32, 5000)
|
||||
const numGoroutines = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Panic in goroutine %d: %v", id, r)
|
||||
}
|
||||
}()
|
||||
|
||||
for j := 0; j < 100; j++ {
|
||||
cache.Set(fmt.Sprintf("k%d", j), j, time.Millisecond)
|
||||
cache.Get(fmt.Sprintf("k%d", j))
|
||||
cache.Cleanup()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheEviction(t *testing.T) {
|
||||
t.Run("EvictsWhenFull", func(t *testing.T) {
|
||||
cache := NewShardedCache(4, 100)
|
||||
|
||||
for i := 0; i < 600; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
size := cache.Size()
|
||||
if size >= 600 {
|
||||
t.Errorf("Expected eviction to reduce size below 600, got %d", size)
|
||||
}
|
||||
t.Logf("Cache size after adding 600 items: %d", size)
|
||||
})
|
||||
|
||||
t.Run("EvictsExpiredFirst", func(t *testing.T) {
|
||||
cache := NewShardedCache(4, 100)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("expired%d", i), i, 1*time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
validCount := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
if cache.Exists(fmt.Sprintf("valid%d", i)) {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if validCount < 80 {
|
||||
t.Errorf("Expected at least 80 valid items, got %d", validCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheShardDistribution(t *testing.T) {
|
||||
t.Run("EvenDistribution", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 16000)
|
||||
|
||||
for i := 0; i < 10000; i++ {
|
||||
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
stats := cache.ShardStats()
|
||||
|
||||
average := 10000 / 16
|
||||
for i, count := range stats {
|
||||
if count > average*3 || count < average/3 {
|
||||
t.Errorf("Shard %d has uneven distribution: %d items (expected ~%d)", i, count, average)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CACHE COMPATIBILITY TESTS
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package traefikoidc
|
||||
|
||||
// Shared CacheInterface / cache-backend test doubles for round-regression
|
||||
// tests.
|
||||
//
|
||||
// Moved here for FIX-41: NewMemoryBackendForTest, mapCache/newMapCache and
|
||||
// serializingCache were each declared inside a single round file (review_r124,
|
||||
// review_r146, review_r159) but consumed by other round files, so deleting or
|
||||
// renaming the declaring file silently broke the consumer. Every
|
||||
// review_rNN*_test.go file must depend only on shared helpers like this one,
|
||||
// never on another round file.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
||||
)
|
||||
|
||||
// NewMemoryBackendForTest returns a cache backend backed by an in-process
|
||||
// miniredis instance, for white-box tests that need a real CacheBackend
|
||||
// without depending on a live Redis server. internal/cache's in-memory
|
||||
// backend was removed as unreachable dead code (FIX-43); RedisBackend
|
||||
// against miniredis is the project's standard lightweight substitute.
|
||||
//
|
||||
// The miniredis server is registered against t.Cleanup so it (and its
|
||||
// listener goroutine) is closed when t ends. Callers still defer
|
||||
// backend.Close() to close the RedisBackend connection pool.
|
||||
func NewMemoryBackendForTest(t *testing.T) (backends.CacheBackend, error) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
return backends.NewRedisBackend(backends.DefaultRedisConfig(mr.Addr()))
|
||||
}
|
||||
|
||||
// mapCache is a minimal thread-safe CacheInterface for seeding caches in
|
||||
// bearer-invalidation tests.
|
||||
type mapCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]any
|
||||
}
|
||||
|
||||
func newMapCache() *mapCache { return &mapCache{m: map[string]any{}} }
|
||||
func (c *mapCache) Set(key string, value any, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.m[key] = value
|
||||
c.mu.Unlock()
|
||||
}
|
||||
func (c *mapCache) Get(key string) (any, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.m[key]
|
||||
return v, ok
|
||||
}
|
||||
func (c *mapCache) Delete(key string) { c.mu.Lock(); delete(c.m, key); c.mu.Unlock() }
|
||||
func (c *mapCache) SetMaxSize(size int) {}
|
||||
func (c *mapCache) Size() int { return 0 }
|
||||
func (c *mapCache) Clear() {}
|
||||
func (c *mapCache) Cleanup() {}
|
||||
func (c *mapCache) Close() {}
|
||||
func (c *mapCache) GetStats() map[string]any { return nil }
|
||||
|
||||
// serializingCache simulates a UniversalCache backed by a serializing
|
||||
// store (e.g. Redis): Set JSON-round-trips the value, so Get returns a
|
||||
// generic map, never the original concrete type. This is exactly what
|
||||
// production sees on the distributed path.
|
||||
type serializingCache struct {
|
||||
m map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *serializingCache) Set(key string, value any, ttl time.Duration) {
|
||||
b, _ := json.Marshal(value)
|
||||
var v interface{}
|
||||
_ = json.Unmarshal(b, &v)
|
||||
c.m[key] = v
|
||||
}
|
||||
func (c *serializingCache) Get(key string) (any, bool) {
|
||||
v, ok := c.m[key]
|
||||
return v, ok
|
||||
}
|
||||
func (c *serializingCache) Delete(key string) { delete(c.m, key) }
|
||||
func (c *serializingCache) SetMaxSize(int) {}
|
||||
func (c *serializingCache) Size() int { return len(c.m) }
|
||||
func (c *serializingCache) Clear() { c.m = map[string]interface{}{} }
|
||||
func (c *serializingCache) Cleanup() {}
|
||||
func (c *serializingCache) Close() {}
|
||||
func (c *serializingCache) GetStats() map[string]any { return nil }
|
||||
@@ -0,0 +1,94 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These tests pin the token-content validators' results, including for
|
||||
// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where
|
||||
// ranging over a string allocates O(n²)) without changing behavior.
|
||||
|
||||
func TestDetectRepeatedCharactersPinned(t *testing.T) {
|
||||
cm := NewChunkManager(NewLogger(""))
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
wantErr string
|
||||
}{
|
||||
{"short token skipped", "aaaaaaaaa", ""},
|
||||
{"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""},
|
||||
{"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""},
|
||||
{"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
|
||||
{"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
|
||||
// Rune counts are divided by the byte length, so a multi-byte rune
|
||||
// cannot reach the 70% threshold. Pinned as-is.
|
||||
{"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""},
|
||||
{"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"},
|
||||
{"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig)
|
||||
assertPinnedErr(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTFormatPinned(t *testing.T) {
|
||||
cm := NewChunkManager(NewLogger(""))
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
wantErr string
|
||||
}{
|
||||
{"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""},
|
||||
{"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""},
|
||||
{"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
|
||||
{"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
|
||||
{"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := cm.validateJWTFormat(tc.token, "access")
|
||||
assertPinnedErr(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenSanitizationPinned(t *testing.T) {
|
||||
cm := NewChunkManager(NewLogger(""))
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
wantErr string
|
||||
}{
|
||||
{"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""},
|
||||
{"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""},
|
||||
{"control char byte offset", "abcé\x01def", "control character at position 5"},
|
||||
{"delete char", "abcdef\x7fgh", "control character at position 6"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := cm.validateTokenSanitization(tc.token, AccessTokenConfig)
|
||||
assertPinnedErr(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the
|
||||
// pre-commit secret scanner.
|
||||
func jwtLike(parts ...string) string { return strings.Join(parts, ".") }
|
||||
|
||||
func assertPinnedErr(t *testing.T, err error, want string) {
|
||||
t.Helper()
|
||||
if want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRSAKeyTooSmall is returned (wrapped) by validateAlgKeyMatch when a
|
||||
// private_key_jwt RS*/PS* signing key is smaller than the RFC 7518 §3.3
|
||||
// minimum of 2048 bits. A named sentinel lets callers detect this specific
|
||||
// rejection with errors.Is instead of matching the formatted message text
|
||||
// (FIX-42; see CHANGELOG.md).
|
||||
var ErrRSAKeyTooSmall = errors.New("RSA key too small for RS*/PS* signing (RFC 7518 §3.3 requires >= 2048 bits)")
|
||||
|
||||
// isSupportedClientAssertionAlg reports whether alg is a recognized JWS
|
||||
// algorithm for private_key_jwt (RFC 7523 §2.2).
|
||||
func isSupportedClientAssertionAlg(alg string) bool {
|
||||
switch alg {
|
||||
case "RS256", "RS384", "RS512",
|
||||
"PS256", "PS384", "PS512",
|
||||
"ES256", "ES384", "ES512":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientAssertionSigner builds and signs client_assertion JWTs (RFC 7523 §2.2).
|
||||
type ClientAssertionSigner struct {
|
||||
key crypto.PrivateKey
|
||||
alg string
|
||||
kid string
|
||||
// rand is the entropy source for jti generation and PSS/ECDSA signing.
|
||||
// Defaults to crypto/rand.Reader when nil.
|
||||
rand io.Reader
|
||||
// now returns the current time. Defaults to time.Now when nil.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewClientAssertionSigner parses pemBytes as a private key, validates that
|
||||
// alg is consistent with the key type, and returns a ready-to-use signer.
|
||||
// kid is placed verbatim in the JWS header.
|
||||
//
|
||||
// PEM block types understood:
|
||||
// - "PRIVATE KEY" → PKCS#8 (tried first for all types)
|
||||
// - "RSA PRIVATE KEY" → PKCS#1
|
||||
// - "EC PRIVATE KEY" → SEC1
|
||||
func NewClientAssertionSigner(pemBytes []byte, alg, kid string) (*ClientAssertionSigner, error) {
|
||||
if !isSupportedClientAssertionAlg(alg) {
|
||||
return nil, fmt.Errorf("unsupported client assertion alg %q", alg)
|
||||
}
|
||||
if kid == "" {
|
||||
return nil, fmt.Errorf("kid must not be empty")
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found in private key material")
|
||||
}
|
||||
|
||||
var key crypto.PrivateKey
|
||||
var parseErr error
|
||||
|
||||
switch block.Type {
|
||||
case "PRIVATE KEY":
|
||||
key, parseErr = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
case "RSA PRIVATE KEY":
|
||||
key, parseErr = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
key, parseErr = x509.ParseECPrivateKey(block.Bytes)
|
||||
default:
|
||||
// Best-effort fallback for unknown block types.
|
||||
key, parseErr = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key (block type %q): %w", block.Type, parseErr)
|
||||
}
|
||||
|
||||
if err := validateAlgKeyMatch(alg, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ClientAssertionSigner{key: key, alg: alg, kid: kid}, nil
|
||||
}
|
||||
|
||||
// validateAlgKeyMatch returns an error when alg implies a key type that does
|
||||
// not match the actual key.
|
||||
func validateAlgKeyMatch(alg string, key crypto.PrivateKey) error {
|
||||
switch alg[0] {
|
||||
case 'R', 'P': // RS* or PS*
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("alg %q requires an RSA key, got %T", alg, key)
|
||||
}
|
||||
// RFC 7518 §3.3: RS*/PS* MUST use a key of 2048 bits or larger.
|
||||
// A smaller key signs locally but conformant IdPs (Entra, Okta,
|
||||
// Keycloak) reject it, so every exchange/refresh/revocation fails
|
||||
// invalid_client at runtime — better to fail at construction
|
||||
// (R135).
|
||||
if rsaKey.N.BitLen() < 2048 {
|
||||
return fmt.Errorf("alg %q requires an RSA key of at least 2048 bits, got %d: %w", alg, rsaKey.N.BitLen(), ErrRSAKeyTooSmall)
|
||||
}
|
||||
case 'E': // ES*
|
||||
ecKey, ok := key.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("alg %q requires an EC key, got %T", alg, key)
|
||||
}
|
||||
// ES* maps 1:1 to a NIST curve (ES256→P-256, ES384→P-384,
|
||||
// ES512→P-521). A key on the wrong curve would otherwise pass this
|
||||
// check yet always produce a signature the IdP rejects.
|
||||
if want := ecdsaAlgorithmCurve(alg); want != nil && ecKey.Curve != want {
|
||||
return fmt.Errorf("alg %q requires a key on curve %s, got %s", alg, want.Params().Name, ecKey.Curve.Params().Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ecdsaAlgorithmCurve returns the NIST curve required by an ES* algorithm, or
|
||||
// nil for any non-ES algorithm.
|
||||
func ecdsaAlgorithmCurve(alg string) elliptic.Curve {
|
||||
switch alg {
|
||||
case "ES256":
|
||||
return elliptic.P256()
|
||||
case "ES384":
|
||||
return elliptic.P384()
|
||||
case "ES512":
|
||||
return elliptic.P521()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sign constructs and returns a signed client_assertion JWT.
|
||||
// audience is typically the token endpoint URL (RFC 7523 §3).
|
||||
// clientID is used as both iss and sub per RFC 7523 §2.2.
|
||||
func (s *ClientAssertionSigner) Sign(audience, clientID string) (string, error) {
|
||||
rander := s.rand
|
||||
if rander == nil {
|
||||
rander = rand.Reader
|
||||
}
|
||||
nowFn := s.now
|
||||
if nowFn == nil {
|
||||
nowFn = time.Now
|
||||
}
|
||||
|
||||
now := nowFn()
|
||||
|
||||
// RFC 7523 §3: aud must be the token (or introspection/revocation)
|
||||
// endpoint URL. An empty audience yields a client_assertion with
|
||||
// "aud":"" that the IdP will reject, so fail fast with a clear
|
||||
// error rather than minting a useless token (R150).
|
||||
if audience == "" {
|
||||
return "", fmt.Errorf("client assertion audience must not be empty")
|
||||
}
|
||||
|
||||
// 16 random bytes as lowercase hex for jti uniqueness.
|
||||
jtiBytes := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rander, jtiBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate jti: %w", err)
|
||||
}
|
||||
jti := hex.EncodeToString(jtiBytes)
|
||||
|
||||
header := map[string]string{
|
||||
"alg": s.alg,
|
||||
"typ": "JWT",
|
||||
"kid": s.kid,
|
||||
}
|
||||
hdrJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal JWT header: %w", err)
|
||||
}
|
||||
|
||||
claims := map[string]any{
|
||||
"iss": clientID,
|
||||
"sub": clientID,
|
||||
"aud": audience,
|
||||
"jti": jti,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(60 * time.Second).Unix(),
|
||||
}
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
|
||||
}
|
||||
|
||||
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
signingInput := hdrB64 + "." + claimsB64
|
||||
|
||||
sig, err := s.sign(rander, []byte(signingInput))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// sign computes raw signature bytes for signingInput per s.alg.
|
||||
// validateAlgKeyMatch in NewClientAssertionSigner guarantees the key type
|
||||
// matches s.alg, but the comma-ok asserts here keep errcheck happy and
|
||||
// surface internal misuse loudly instead of via panic.
|
||||
func (s *ClientAssertionSigner) sign(rander io.Reader, input []byte) ([]byte, error) {
|
||||
switch s.alg {
|
||||
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
|
||||
rsaKey, ok := s.key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("internal: alg %q requires *rsa.PrivateKey, got %T", s.alg, s.key)
|
||||
}
|
||||
hash := rsaHashForAlg(s.alg)
|
||||
digest := hashSum(hash, input)
|
||||
if s.alg[0] == 'R' {
|
||||
return signRSAPKCS1v15(rander, rsaKey, hash, digest)
|
||||
}
|
||||
return signRSAPSS(rander, rsaKey, hash, digest)
|
||||
case "ES256", "ES384", "ES512":
|
||||
ecKey, ok := s.key.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("internal: alg %q requires *ecdsa.PrivateKey, got %T", s.alg, s.key)
|
||||
}
|
||||
hash := ecHashForAlg(s.alg)
|
||||
digest := hashSum(hash, input)
|
||||
return signECDSA(rander, ecKey, digest)
|
||||
}
|
||||
return nil, fmt.Errorf("unhandled alg %q", s.alg)
|
||||
}
|
||||
|
||||
func rsaHashForAlg(alg string) crypto.Hash {
|
||||
switch alg {
|
||||
case "RS256", "PS256":
|
||||
return crypto.SHA256
|
||||
case "RS384", "PS384":
|
||||
return crypto.SHA384
|
||||
case "RS512", "PS512":
|
||||
return crypto.SHA512
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ecHashForAlg(alg string) crypto.Hash {
|
||||
switch alg {
|
||||
case "ES256":
|
||||
return crypto.SHA256
|
||||
case "ES384":
|
||||
return crypto.SHA384
|
||||
case "ES512":
|
||||
return crypto.SHA512
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hashSum(h crypto.Hash, input []byte) []byte {
|
||||
switch h {
|
||||
case crypto.SHA256:
|
||||
sum := sha256.Sum256(input)
|
||||
return sum[:]
|
||||
case crypto.SHA384:
|
||||
sum := sha512.Sum384(input)
|
||||
return sum[:]
|
||||
case crypto.SHA512:
|
||||
sum := sha512.Sum512(input)
|
||||
return sum[:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signRSAPKCS1v15(rander io.Reader, key *rsa.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) {
|
||||
sig, err := rsa.SignPKCS1v15(rander, key, hash, digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSA PKCS1v15 signing failed: %w", err)
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func signRSAPSS(rander io.Reader, key *rsa.PrivateKey, hash crypto.Hash, digest []byte) ([]byte, error) {
|
||||
opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: hash}
|
||||
sig, err := rsa.SignPSS(rander, key, hash, digest, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RSA PSS signing failed: %w", err)
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// signECDSA produces the JWS raw r||s signature (RFC 7515 App. A.3).
|
||||
// Each scalar is zero-padded to (curve.BitSize+7)/8 bytes.
|
||||
func signECDSA(rander io.Reader, key *ecdsa.PrivateKey, digest []byte) ([]byte, error) {
|
||||
r, ss, err := ecdsa.Sign(rander, key, digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ECDSA signing failed: %w", err)
|
||||
}
|
||||
byteLen := (key.Curve.Params().BitSize + 7) / 8
|
||||
sig := make([]byte, 2*byteLen)
|
||||
padBigInt(sig[0:byteLen], r)
|
||||
padBigInt(sig[byteLen:], ss)
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// padBigInt writes n as a fixed-width big-endian integer into buf.
|
||||
func padBigInt(buf []byte, n *big.Int) {
|
||||
b := n.Bytes()
|
||||
copy(buf[len(buf)-len(b):], b)
|
||||
}
|
||||
|
||||
// buildClientAssertionSignerFromConfig loads key material and constructs a
|
||||
// ClientAssertionSigner. Called from NewWithContext when
|
||||
// ClientAuthMethod == "private_key_jwt".
|
||||
func buildClientAssertionSignerFromConfig(config *Config) (*ClientAssertionSigner, error) {
|
||||
var pemBytes []byte
|
||||
|
||||
if config.ClientAssertionPrivateKey != "" {
|
||||
pemBytes = []byte(config.ClientAssertionPrivateKey)
|
||||
} else {
|
||||
data, err := os.ReadFile(config.ClientAssertionKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read clientAssertionKeyPath %q: %w", config.ClientAssertionKeyPath, err)
|
||||
}
|
||||
pemBytes = data
|
||||
}
|
||||
|
||||
alg := config.ClientAssertionAlg
|
||||
if alg == "" {
|
||||
alg = "RS256"
|
||||
}
|
||||
|
||||
return NewClientAssertionSigner(pemBytes, alg, config.ClientAssertionKeyID)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecompressCombinedPayloadReusesReaders(t *testing.T) {
|
||||
var readers sync.Pool
|
||||
first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
|
||||
if err != nil {
|
||||
t.Fatalf("compress first: %v", err)
|
||||
}
|
||||
second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
|
||||
if err != nil {
|
||||
t.Fatalf("compress second: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantUser string
|
||||
wantErr bool
|
||||
}{
|
||||
{"first payload", first, "[email protected]", false},
|
||||
{"corrupt payload rejected", "bm90LWd6aXA=", "", true},
|
||||
{"second payload after corrupt one", second, "[email protected]", false},
|
||||
{"first payload again", first, "[email protected]", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := decompressCombinedPayload(tc.input, &readers)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error", tc.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if got.Ui != tc.wantUser {
|
||||
t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConcurrentServeHTTP_SharedInstance drives the full request path
|
||||
// (bootstraps, auth-bypass evaluation, session lookup, redirect) across many
|
||||
// goroutines against ONE shared plugin instance. Traefik runs the middleware
|
||||
// concurrently for every request on a router, so this is the production
|
||||
// concurrency shape. Holds under -race only if the request path touches
|
||||
// shared mutable state via atomics/locks, never plain field writes.
|
||||
func TestConcurrentServeHTTP_SharedInstance(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
next: next,
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
issuerURL: "https://provider.example.com",
|
||||
redirURLPath: "/callback",
|
||||
logoutURLPath: "/logout",
|
||||
clientID: "test-client",
|
||||
audience: "test-client",
|
||||
authURL: "https://provider.example.com/auth",
|
||||
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
|
||||
metadataRefreshStartedAtomic: 0,
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
const goroutines = 40
|
||||
const perGoroutine = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for g := 0; g < goroutines; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perGoroutine; i++ {
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/g%d/", g), nil)
|
||||
w := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(w, req)
|
||||
// Unauthenticated request must bounce to the IdP (3xx), not
|
||||
// forward to the backend (200) or error.
|
||||
if w.Code < 300 || w.Code >= 400 {
|
||||
t.Errorf("expected auth redirect (3xx), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestConcurrentServeHTTP_ExcludedBypass drives the auth-bypass path
|
||||
// concurrently (shouldBypassAuth read-path) on a shared instance.
|
||||
func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
next: next,
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
authURL: "https://provider.example.com/auth",
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
const goroutines = 40
|
||||
const perGoroutine = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for g := 0; g < goroutines; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perGoroutine; i++ {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(w, req)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
+101
-42
@@ -7,10 +7,32 @@ import (
|
||||
// REDACTED is the placeholder value for sensitive information
|
||||
const REDACTED = "[REDACTED]"
|
||||
|
||||
// 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
|
||||
// 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{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Copy public fields
|
||||
@@ -28,11 +50,66 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
result["allowedUsers"] = c.AllowedUsers
|
||||
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
||||
|
||||
// Redact sensitive fields
|
||||
// Fields that the legacy hand-written list dropped (R150): important
|
||||
// non-secret config that must survive any JSON/YAML round-trip.
|
||||
result["audience"] = c.Audience
|
||||
result["resource"] = c.Resource
|
||||
result["cookiePrefix"] = c.CookiePrefix
|
||||
result["cookieDomain"] = c.CookieDomain
|
||||
result["userIdentifierClaim"] = c.UserIdentifierClaim
|
||||
result["groupClaimName"] = c.GroupClaimName
|
||||
result["roleClaimName"] = c.RoleClaimName
|
||||
result["oidcEndSessionURL"] = c.OIDCEndSessionURL
|
||||
result["revocationURL"] = c.RevocationURL
|
||||
result["introspectionURL"] = c.IntrospectionURL
|
||||
result["headers"] = c.Headers
|
||||
result["allowedClaims"] = c.AllowedClaims
|
||||
result["extraAuthParams"] = c.ExtraAuthParams
|
||||
result["refreshGracePeriodSeconds"] = c.RefreshGracePeriodSeconds
|
||||
result["maxRefreshTokenAgeSeconds"] = c.MaxRefreshTokenAgeSeconds
|
||||
result["sessionMaxAge"] = c.SessionMaxAge
|
||||
result["overrideScopes"] = c.OverrideScopes
|
||||
result["disableReplayDetection"] = c.DisableReplayDetection
|
||||
result["requireTokenIntrospection"] = c.RequireTokenIntrospection
|
||||
result["allowOpaqueTokens"] = c.AllowOpaqueTokens
|
||||
result["strictAudienceValidation"] = c.StrictAudienceValidation
|
||||
result["enablePKCE"] = c.EnablePKCE
|
||||
result["allowPrivateIPAddresses"] = c.AllowPrivateIPAddresses
|
||||
result["allowUnauthenticatedPreflight"] = c.AllowUnauthenticatedPreflight
|
||||
result["streamingRefresh"] = c.StreamingRefresh
|
||||
result["minimalHeaders"] = c.MinimalHeaders
|
||||
result["stripAuthCookies"] = c.StripAuthCookies
|
||||
result["cookiePath"] = c.CookiePath
|
||||
result["enableBackchannelLogout"] = c.EnableBackchannelLogout
|
||||
result["enableFrontchannelLogout"] = c.EnableFrontchannelLogout
|
||||
result["backchannelLogoutURL"] = c.BackchannelLogoutURL
|
||||
result["frontchannelLogoutURL"] = c.FrontchannelLogoutURL
|
||||
result["caCertPath"] = c.CACertPath
|
||||
result["caCertPEM"] = c.CACertPEM
|
||||
result["insecureSkipVerify"] = c.InsecureSkipVerify
|
||||
result["clientAuthMethod"] = c.ClientAuthMethod
|
||||
result["clientAssertionKeyID"] = c.ClientAssertionKeyID
|
||||
result["clientAssertionAlg"] = c.ClientAssertionAlg
|
||||
result["clientAssertionKeyPath"] = c.ClientAssertionKeyPath
|
||||
result["perSourceLoginRateLimit"] = c.PerSourceLoginRateLimit
|
||||
result["enableBearerAuth"] = c.EnableBearerAuth
|
||||
result["bearerIdentifierClaim"] = c.BearerIdentifierClaim
|
||||
result["stripAuthorizationHeader"] = c.StripAuthorizationHeader
|
||||
result["bearerEmitWWWAuthenticate"] = c.BearerEmitWWWAuthenticate
|
||||
result["bearerOverridesCookie"] = c.BearerOverridesCookie
|
||||
result["maxTokenAgeSeconds"] = c.MaxTokenAgeSeconds
|
||||
result["maxIdentifierLength"] = c.MaxIdentifierLength
|
||||
result["bearerFailureThreshold"] = c.BearerFailureThreshold
|
||||
result["bearerFailureWindowSeconds"] = c.BearerFailureWindowSeconds
|
||||
result["bearerFailurePenaltySeconds"] = c.BearerFailurePenaltySeconds
|
||||
|
||||
// Redact sensitive fields (existing secrets plus the previously-leaked
|
||||
// client assertion private key, R150).
|
||||
result["clientSecret"] = REDACTED
|
||||
result["sessionEncryptionKey"] = REDACTED
|
||||
result["clientAssertionPrivateKey"] = REDACTED
|
||||
|
||||
// Handle Redis config
|
||||
// Handle Redis config (redacts password)
|
||||
if c.Redis != nil {
|
||||
redisMap := make(map[string]interface{})
|
||||
redisMap["enabled"] = c.Redis.Enabled
|
||||
@@ -44,47 +121,29 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
result["redis"] = redisMap
|
||||
}
|
||||
|
||||
return json.Marshal(result)
|
||||
// Handle dynamic client registration (redacts the initial access token)
|
||||
if c.DynamicClientRegistration != nil {
|
||||
result["dynamicClientRegistration"] = marshalDCR(c.DynamicClientRegistration)
|
||||
}
|
||||
|
||||
// Handle security headers
|
||||
if c.SecurityHeaders != nil {
|
||||
result["securityHeaders"] = c.SecurityHeaders
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(c.configToMap())
|
||||
}
|
||||
|
||||
// MarshalYAML implements custom YAML marshaling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalYAML() (interface{}, error) {
|
||||
// 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
|
||||
return c.configToMap(), nil
|
||||
}
|
||||
|
||||
// MarshalJSON for RedisConfig to redact sensitive fields
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigToMap_IncludesAllJSONFields pins FIX-40: configToMap is the
|
||||
// single field/redaction list shared by MarshalJSON and MarshalYAML (R150),
|
||||
// so every json-tagged Config field must appear as a configToMap key or the
|
||||
// marshaled/logged config silently drops it again. Fields tagged
|
||||
// `json:"-"` are intentionally excluded from marshaling and are skipped.
|
||||
func TestConfigToMap_IncludesAllJSONFields(t *testing.T) {
|
||||
// Non-nil pointer fields so their conditional branches in configToMap
|
||||
// run and populate a map key, matching how a real, non-empty Config is
|
||||
// built.
|
||||
cfg := Config{
|
||||
Redis: &RedisConfig{},
|
||||
DynamicClientRegistration: &DynamicClientRegistrationConfig{},
|
||||
SecurityHeaders: &SecurityHeadersConfig{},
|
||||
}
|
||||
result := cfg.configToMap()
|
||||
|
||||
typ := reflect.TypeOf(Config{})
|
||||
var missing []string
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(tag, ",")[0]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := result[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("configToMap is missing json-tagged Config fields: %v", missing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package traefikoidc
|
||||
|
||||
// Shared Config-fixture helpers for round-regression tests.
|
||||
//
|
||||
// Moved here for FIX-41: r134ValidConfig was declared inside review_r134_test.go
|
||||
// but consumed by review_r135_test.go, so deleting or renaming the declaring
|
||||
// file silently broke the consumer. Every review_rNN*_test.go file must
|
||||
// depend only on shared helpers like this one, never on another round file.
|
||||
|
||||
// r134ValidConfig returns a minimal Config that passes Validate(), for tests
|
||||
// that mutate one field and assert the resulting accept/reject outcome.
|
||||
func r134ValidConfig() *Config {
|
||||
return &Config{
|
||||
ProviderURL: "https://provider.example.com",
|
||||
CallbackURL: "/callback",
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
SessionEncryptionKey: "0123456789abcdef0123456789abcdef",
|
||||
RateLimit: CreateConfig().RateLimit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContentValidCache(t *testing.T) {
|
||||
cm := NewChunkManager(NewLogger(""))
|
||||
t.Cleanup(cm.Shutdown)
|
||||
now := time.Now()
|
||||
valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
|
||||
futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())})
|
||||
badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl")
|
||||
|
||||
t.Run("valid token passes twice and is cached", func(t *testing.T) {
|
||||
for i := 0; i < 2; i++ {
|
||||
if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil {
|
||||
t.Fatalf("pass %d: %v", i, res.Error)
|
||||
}
|
||||
}
|
||||
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok {
|
||||
t.Fatal("valid token was not cached")
|
||||
}
|
||||
})
|
||||
t.Run("cache is per token type", func(t *testing.T) {
|
||||
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok {
|
||||
t.Fatal("access-token entry leaked into the ID-token key")
|
||||
}
|
||||
})
|
||||
t.Run("time checks still run on a cache hit", func(t *testing.T) {
|
||||
cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat))
|
||||
res := cm.validateToken(futureIat, AccessTokenConfig)
|
||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
|
||||
t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error)
|
||||
}
|
||||
})
|
||||
t.Run("cache hit uses the stored claims", func(t *testing.T) {
|
||||
other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
|
||||
future := now.Add(time.Hour)
|
||||
cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future})
|
||||
res := cm.validateToken(other, AccessTokenConfig)
|
||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
|
||||
t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error)
|
||||
}
|
||||
})
|
||||
t.Run("invalid content never cached", func(t *testing.T) {
|
||||
if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil {
|
||||
t.Fatal("control character accepted")
|
||||
}
|
||||
if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok {
|
||||
t.Fatal("invalid token was cached")
|
||||
}
|
||||
})
|
||||
t.Run("cache stays bounded", func(t *testing.T) {
|
||||
for i := 0; i < contentValidCacheMax+10; i++ {
|
||||
cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{})
|
||||
}
|
||||
cm.contentMu.Lock()
|
||||
n := len(cm.contentValid)
|
||||
cm.contentMu.Unlock()
|
||||
if n > contentValidCacheMax {
|
||||
t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanupOldCookies_UsesConfiguredCookiePath pins the fix for cookie
|
||||
// cleanup under cookiePath. Session cookies are written at the configured
|
||||
// cookiePath (EnhanceSessionSecurity), but CleanupOldCookies wrote its
|
||||
// deletion cookies at a hardcoded "/". A browser deletes a cookie only when
|
||||
// name, domain and path all match, so the cleanup removed nothing when
|
||||
// cookiePath was set.
|
||||
func TestCleanupOldCookies_UsesConfiguredCookiePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cookiePath string
|
||||
wantPath string
|
||||
}{
|
||||
{name: "configured path", cookiePath: "/app", wantPath: "/app"},
|
||||
{name: "default path", cookiePath: "", wantPath: "/"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sm, err := NewSessionManager(
|
||||
"test-encryption-key-32-bytes-long!!",
|
||||
false, "app.example.com", "testpref", 0,
|
||||
newNoOpLogger(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("session manager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
sm.cookiePath = tc.cookiePath
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/page", nil)
|
||||
req.Host = "app.example.com"
|
||||
req.AddCookie(&http.Cookie{Name: "testpref_sess", Value: "x"})
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
sm.CleanupOldCookies(rw, req)
|
||||
|
||||
deleted := rw.Result().Cookies()
|
||||
if len(deleted) == 0 {
|
||||
t.Fatal("expected deletion cookies for the other domains")
|
||||
}
|
||||
for _, c := range deleted {
|
||||
if c.Path != tc.wantPath {
|
||||
t.Errorf("deletion cookie %s for domain %s has Path %q, want %q", c.Name, c.Domain, c.Path, tc.wantPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -41,24 +41,6 @@ func TestLRUStrategy_OnRemove_CoverageBoost(t *testing.T) {
|
||||
strategy.OnRemove("")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JWT REPLAY CACHE TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestGetReplayCacheStats_CoverageBoost(t *testing.T) {
|
||||
// Test the function - it should return valid stats
|
||||
size, maxSize := getReplayCacheStats()
|
||||
|
||||
if maxSize != 10000 {
|
||||
t.Errorf("Expected maxSize to be 10000, got %d", maxSize)
|
||||
}
|
||||
|
||||
// Size should be >= 0
|
||||
if size < 0 {
|
||||
t.Errorf("Expected size to be >= 0, got %d", size)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROFILING MANAGER TESTS
|
||||
// =============================================================================
|
||||
@@ -278,82 +260,6 @@ func TestHTTPClientProfiler_Methods_CoverageBoost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECURITY MONITORING TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestSecurityMonitor_StopCleanupRoutine_CoverageBoost(t *testing.T) {
|
||||
logger := NewLogger("info")
|
||||
config := SecurityMonitorConfig{
|
||||
MaxFailuresPerIP: 5,
|
||||
FailureWindowMinutes: 15,
|
||||
BlockDurationMinutes: 30,
|
||||
RapidFailureThreshold: 3,
|
||||
CleanupIntervalMinutes: 60,
|
||||
RetentionHours: 24,
|
||||
EnablePatternDetection: true,
|
||||
EnableDetailedLogging: false,
|
||||
LogSuspiciousOnly: false,
|
||||
}
|
||||
|
||||
sm := NewSecurityMonitor(config, logger)
|
||||
if sm == nil {
|
||||
t.Fatal("Expected non-nil SecurityMonitor")
|
||||
}
|
||||
|
||||
// Start cleanup routine first (lowercase method)
|
||||
sm.startCleanupRoutine()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Stop cleanup routine (public method)
|
||||
sm.StopCleanupRoutine()
|
||||
|
||||
// Stop again should be safe
|
||||
sm.StopCleanupRoutine()
|
||||
}
|
||||
|
||||
func TestSecurityMonitor_MultipleHandlers_CoverageBoost(t *testing.T) {
|
||||
logger := NewLogger("info")
|
||||
config := SecurityMonitorConfig{
|
||||
MaxFailuresPerIP: 5,
|
||||
FailureWindowMinutes: 15,
|
||||
BlockDurationMinutes: 30,
|
||||
RapidFailureThreshold: 3,
|
||||
CleanupIntervalMinutes: 60,
|
||||
RetentionHours: 24,
|
||||
}
|
||||
|
||||
sm := NewSecurityMonitor(config, logger)
|
||||
|
||||
// Create handler
|
||||
handler := &LoggingSecurityEventHandler{logger: logger}
|
||||
|
||||
// Register handler using AddEventHandler
|
||||
sm.AddEventHandler(handler)
|
||||
|
||||
// Record a failure to trigger events
|
||||
sm.RecordAuthenticationFailure("192.168.1.100", "test-agent", "/test", "test_failure", nil)
|
||||
}
|
||||
|
||||
func TestLoggingSecurityEventHandler_HandleSecurityEvent_AllSeverities_CoverageBoost(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
handler := &LoggingSecurityEventHandler{logger: logger}
|
||||
|
||||
// Severity is a string in this implementation
|
||||
events := []SecurityEvent{
|
||||
{Type: "test", Severity: "low", Message: "low severity"},
|
||||
{Type: "test", Severity: "medium", Message: "medium severity"},
|
||||
{Type: "test", Severity: "high", Message: "high severity"},
|
||||
{Type: "test", Severity: "critical", Message: "critical severity"},
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
handler.HandleSecurityEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SESSION MANAGER TESTS
|
||||
// =============================================================================
|
||||
@@ -826,11 +732,53 @@ func TestValidateTemplateSecure_CoverageBoost(t *testing.T) {
|
||||
template: "{{.Unknown}}",
|
||||
shouldError: true,
|
||||
},
|
||||
// Issue #148: whitespace after {{ and before }} must be tolerated
|
||||
{
|
||||
name: "get with leading space (issue #148)",
|
||||
template: "{{ get .Claims \"email\" }}",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "get with multiple leading spaces",
|
||||
template: "{{ get .Claims \"email\"}}",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "default with spaces",
|
||||
template: "{{ default \"unknown\" .Claims.email }}",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "claims with spaces",
|
||||
template: "{{ .Claims.email }}",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "access token with spaces",
|
||||
template: "{{ .AccessToken }}",
|
||||
shouldError: false,
|
||||
},
|
||||
// Issue #148: space must NOT let dangerous patterns bypass detection
|
||||
{
|
||||
name: "range with leading space stays blocked",
|
||||
template: "{{ range .Items }}{{.}}{{end}}",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "call with leading space stays blocked",
|
||||
template: "{{ call .Func }}",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "disallowed claim with spaces stays blocked",
|
||||
template: "{{ .Claims.password }}",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateTemplateSecure(tt.template)
|
||||
err := validateTemplateSecure(tt.template, nil)
|
||||
if tt.shouldError && err == nil {
|
||||
t.Errorf("Expected error for template: %s", tt.template)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
session.SetCSRF(csrfToken)
|
||||
session.SetNonce("test-nonce")
|
||||
session.SetAuthenticated(true)
|
||||
session.SetEmail("[email protected]")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetAccessToken("old-access-token")
|
||||
session.SetRefreshToken("old-refresh-token")
|
||||
session.SetIDToken("old-id-token")
|
||||
@@ -61,7 +61,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
|
||||
// Now perform selective clearing (as done in the fix)
|
||||
session2.SetAuthenticated(false)
|
||||
session2.SetEmail("")
|
||||
session2.SetUserIdentifier("")
|
||||
session2.SetAccessToken("")
|
||||
session2.SetRefreshToken("")
|
||||
session2.SetIDToken("")
|
||||
@@ -303,7 +303,7 @@ func TestRegressionLoginLoop(t *testing.T) {
|
||||
|
||||
// Set initial session data
|
||||
session.SetAuthenticated(true)
|
||||
session.SetEmail("[email protected]")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetAccessToken("old-token")
|
||||
session.SetCSRF("existing-csrf")
|
||||
|
||||
@@ -325,7 +325,7 @@ func TestRegressionLoginLoop(t *testing.T) {
|
||||
// OLD BEHAVIOR: session.Clear() would have been called here, losing CSRF
|
||||
// NEW BEHAVIOR: Selective clearing
|
||||
session2.SetAuthenticated(false)
|
||||
session2.SetEmail("")
|
||||
session2.SetUserIdentifier("")
|
||||
session2.SetAccessToken("")
|
||||
session2.SetRefreshToken("")
|
||||
session2.SetIDToken("")
|
||||
|
||||
+22
-24
@@ -151,9 +151,11 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
|
||||
// Configure custom claim names
|
||||
ts.tOidc.roleClaimName = "custom_roles"
|
||||
|
||||
// Create token with malformed role claim (not an array)
|
||||
// Create token with malformed role claim (non-string, non-array scalar).
|
||||
// A single-string role claim is now accepted as a 1-element list, so
|
||||
// "malformed" here means a scalar that is neither.
|
||||
claims := map[string]interface{}{
|
||||
"custom_roles": "this-should-be-an-array",
|
||||
"custom_roles": 12345,
|
||||
}
|
||||
|
||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
|
||||
@@ -161,15 +163,12 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
||||
if err == nil {
|
||||
t.Error("Expected error for malformed role claim, got nil")
|
||||
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for malformed role claim, got %v", err)
|
||||
}
|
||||
|
||||
// Check error message contains the custom claim name
|
||||
expectedError := "custom_roles claim is not an array"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
||||
if len(groups) != 0 || len(roles) != 0 {
|
||||
t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,15 +190,12 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
|
||||
t.Fatalf("Failed to create test token: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
||||
if err == nil {
|
||||
t.Error("Expected error for malformed group claim, got nil")
|
||||
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for malformed group claim, got %v", err)
|
||||
}
|
||||
|
||||
// Check error message contains the custom claim name
|
||||
expectedError := "custom_groups claim is not an array"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
||||
if len(groups) != 0 || len(roles) != 0 {
|
||||
t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,9 +324,10 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should only extract string elements
|
||||
if !stringSliceEqual(roles, []string{"role1", "role2"}) {
|
||||
t.Errorf("Expected roles [role1 role2], got %v", roles)
|
||||
// String elements kept, numeric scalars stringified (R105, matching R102
|
||||
// numeric-tolerance), only non-scalars (here: bool) dropped.
|
||||
if !stringSliceEqual(roles, []string{"role1", "12345", "role2"}) {
|
||||
t.Errorf("Expected roles [role1 12345 role2], got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,8 +354,9 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should only extract string elements
|
||||
if !stringSliceEqual(groups, []string{"group1", "group2"}) {
|
||||
t.Errorf("Expected groups [group1 group2], got %v", groups)
|
||||
// String elements kept, numeric scalars stringified (R105); only
|
||||
// non-scalars (here: nil) dropped.
|
||||
if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) {
|
||||
t.Errorf("Expected groups [group1 group2 3.14], got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ The **audience** (`aud`) claim in a JWT identifies the intended recipient of the
|
||||
|
||||
### Why Does This Matter?
|
||||
|
||||
Proper audience validation prevents **token confusion attacks** where a token intended for one API is used to access another API.
|
||||
Audience validation rejects access tokens whose `aud` claim does not match the
|
||||
expected audience, blocking the trivial form of token confusion where a token
|
||||
issued for API A is presented to API B. (Defence in depth — pair with
|
||||
short-lived tokens, rotation, and per-API client credentials.)
|
||||
|
||||
---
|
||||
|
||||
@@ -137,8 +140,8 @@ http:
|
||||
**Recommended:** `true` for production
|
||||
|
||||
**What it does:**
|
||||
- When `true`: Rejects sessions if access token audience doesn't match (prevents Scenario 2)
|
||||
- When `false`: Logs warnings but allows fallback to ID token (backward compatible)
|
||||
- When `true`: On audience mismatch, the middleware does **not** silently fall back to ID-token validation. It tries to refresh the access token first; if no refresh token is present (or refresh fails), the user is re-authenticated.
|
||||
- When `false`: Logs warnings and falls back to ID-token validation (backward compatible).
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
@@ -295,13 +298,20 @@ strictAudienceValidation: true
|
||||
```
|
||||
Look for `introspection_endpoint`
|
||||
|
||||
2. **Disable required introspection (less secure):**
|
||||
2. **Configure the endpoint manually:**
|
||||
```yaml
|
||||
introspectionURL: https://YOUR_DOMAIN/oauth/introspect
|
||||
```
|
||||
Set this when Auth0 omits `introspection_endpoint` from discovery. The
|
||||
manual value takes precedence over the discovered value.
|
||||
|
||||
3. **Disable required introspection (less secure):**
|
||||
```yaml
|
||||
allowOpaqueTokens: true
|
||||
requireTokenIntrospection: false # Falls back to ID token
|
||||
```
|
||||
|
||||
3. **Use JWT access tokens instead** (recommended)
|
||||
4. **Use JWT access tokens instead** (recommended)
|
||||
|
||||
---
|
||||
|
||||
@@ -349,7 +359,7 @@ When opaque tokens are detected:
|
||||
|
||||
**Cache behavior:**
|
||||
- Cache key: Token hash
|
||||
- TTL: 5 minutes or token expiry (whichever is shorter)
|
||||
- TTL: 5 minutes; if the token's `exp` is sooner, the cache entry expires at `exp` instead. Tokens without `exp` use the flat 5-minute TTL.
|
||||
- Reduces introspection requests for frequently used tokens
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# Bearer Token (M2M) Authentication
|
||||
|
||||
Opt-in path that lets API clients present `Authorization: Bearer <jwt>` to
|
||||
authenticate without going through the cookie-based OIDC redirect flow.
|
||||
Designed for machine-to-machine (M2M) traffic — services calling other
|
||||
services with tokens minted by your OIDC provider.
|
||||
|
||||
The bearer path lives next to the cookie path: both go through the same
|
||||
post-auth pipeline (`forwardAuthorized`) that injects identity headers,
|
||||
checks `allowedRolesAndGroups`, applies security headers, and forwards to
|
||||
the backend. The only thing that differs is how the principal is established
|
||||
for that single request.
|
||||
|
||||
## Quick start
|
||||
|
||||
```yaml
|
||||
enableBearerAuth: true
|
||||
audience: https://api.example.com # REQUIRED when bearer is enabled
|
||||
clientID: my-api-client-id
|
||||
providerURL: https://issuer.example.com
|
||||
sessionEncryptionKey: <32+-byte secret>
|
||||
callbackURL: /oauth2/callback
|
||||
```
|
||||
|
||||
That is the minimum. Everything else has a secure default.
|
||||
|
||||
## Obtaining bearer tokens from your OIDC provider
|
||||
|
||||
The middleware only **validates** bearer tokens — minting them is the IdP's job. For M2M traffic the canonical mint flow is OAuth 2.0 **`client_credentials`** (RFC 6749 §4.4); some providers require **JWT bearer assertion** (RFC 7523) instead.
|
||||
|
||||
```
|
||||
┌────────────┐ POST /token ┌──────────┐
|
||||
│ client │ ───────────────────────────────►│ IdP │
|
||||
│ (service) │ grant_type=client_credentials │ /token │
|
||||
│ │ client_id=… │ │
|
||||
│ │ client_secret=… (or JWT) │ │
|
||||
│ │ audience=https://api.… ←── critical │
|
||||
│ │ scope=api:read … │
|
||||
│ │ ◄───────────────────────────────│ │
|
||||
│ │ access_token (JWT) │ │
|
||||
└────────────┘ └──────────┘
|
||||
│
|
||||
│ GET /protected
|
||||
│ Authorization: Bearer <access_token>
|
||||
▼
|
||||
Your service (behind Traefik + this plugin)
|
||||
```
|
||||
|
||||
The IdP returns a JWT signed by the same JWKs the middleware already trusts (it discovers them from `providerURL`/.well-known). On the first protected request, the middleware verifies signature + issuer + **audience** + `exp` + identifier claim, then forwards downstream with `X-Forwarded-User` set.
|
||||
|
||||
### Minimal worked example (Auth0-shape)
|
||||
|
||||
```bash
|
||||
# 1. Mint a token
|
||||
curl -s -X POST https://issuer.example.com/oauth/token \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "your-m2m-client-id",
|
||||
"client_secret": "your-m2m-client-secret",
|
||||
"audience": "https://api.example.com",
|
||||
"scope": "api:read api:write"
|
||||
}'
|
||||
# → {"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":86400,…}
|
||||
|
||||
# 2. Use it
|
||||
curl -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs…' https://api.example.com/protected
|
||||
```
|
||||
|
||||
The `audience` field in the token request **must match** the `audience` you configured on the middleware. Mismatch → 401 with `Bearer error="invalid_token"`.
|
||||
|
||||
### Per-provider quick reference
|
||||
|
||||
| Provider | Grant | Token endpoint | Audience parameter | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Auth0** | `client_credentials` | `https://TENANT.auth0.com/oauth/token` | `audience=<your API identifier>` | Register an "API" + "Machine to Machine Application" authorised against that API. Without `audience` you get an opaque /userinfo token, which the bearer path rejects. See `docs/AUTH0_AUDIENCE_GUIDE.md`. |
|
||||
| **Okta** | `client_credentials` | `https://TENANT.okta.com/oauth2/default/v1/token` | Configured in the authorization server; default `aud` is the auth-server URL | Service app must enable the `client_credentials` flow and be granted the requested scopes. |
|
||||
| **Keycloak** | `client_credentials` | `https://kc/realms/REALM/protocol/openid-connect/token` | Configure an "Audience" mapper on a client scope, or use `client_id` as the audience | Client must have `serviceAccountsEnabled: true` plus role mappings. |
|
||||
| **Entra ID / Azure AD** | `client_credentials` (v2.0 endpoint) | `https://login.microsoftonline.com/TENANT/oauth2/v2.0/token` | Pass `scope=<App ID URI>/.default`; `aud` ends up being the API's App ID URI | Requires an App Registration + API permissions + admin consent. **Use the v2.0 endpoint** — v1 issues Microsoft-proprietary access tokens that are opaque to non-Microsoft clients. |
|
||||
| **AWS Cognito** | `client_credentials` | `https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/token` | Scopes from a "Resource Server" attached to your User Pool | App client must have `client_credentials` flow enabled. Use HTTP **Basic** auth header for `client_id:client_secret`. |
|
||||
| **GitLab** | `client_credentials` | `https://gitlab.com/oauth/token` | Audience matches the GitLab issuer | Rarely used for protecting external APIs; better suited for GitLab's own resources. |
|
||||
| **Google** | **JWT bearer (RFC 7523)** — *not* `client_credentials` | `https://oauth2.googleapis.com/token` | Signed assertion JWT carries `aud=https://oauth2.googleapis.com/token`; resulting access token is **opaque** unless you specifically request a Google-issued JWT for your API | Google service-account flow is not the best fit for this middleware (opaque tokens are rejected on the bearer path). Run Auth0 / Okta / Keycloak in front, or use ID-token-based flows on the cookie path. |
|
||||
|
||||
### RFC 7523 (JWT bearer assertion) — secretless alternative
|
||||
|
||||
When shared secrets are forbidden (FAPI, internal compliance), swap `client_secret` for a signed JWT assertion:
|
||||
|
||||
```
|
||||
POST /token
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion=<JWT signed by the client's private key>
|
||||
```
|
||||
|
||||
The assertion JWT carries `iss=<client_id>`, `sub=<client_id>`, `aud=<token endpoint>`, `exp`. The IdP verifies the signature against a public key you've pre-registered and returns an access token.
|
||||
|
||||
This middleware already supports JWT assertions on the *middleware → IdP* hop via `clientAuthMethod: private_key_jwt` (see `docs/CONFIGURATION.md`). For the *client → IdP* hop, the same pattern applies — the client signs its own assertion.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Token TTL is typically 1–24 hours.** Clients should refresh on `401`, not on a polling timer — saves the IdP.
|
||||
- **Cache and reuse tokens.** The middleware caches verified tokens too, so repeated presentations are cheap. Clients SHOULD reuse a token until ~80 % of `expires_in`.
|
||||
- **JWKS rotation is transparent.** The middleware auto-refreshes its JWKS cache when the IdP rotates keys. Clients don't need to do anything.
|
||||
- **Revocation is generally not per-token** with `client_credentials`. If you need real-time revocation, set `requireTokenIntrospection: true` on the middleware and the IdP is consulted on every cache miss.
|
||||
- **`scope` vs `audience`.** Scope says *what the client may do*; audience says *which service the token is for*. The middleware enforces audience; the backend service should enforce scope.
|
||||
- **Secret hygiene.** Store `client_secret` in a secrets manager (Vault, AWS Secrets Manager, Kubernetes `Secret`). For higher assurance, switch the client to `private_key_jwt` (no shared secret at all).
|
||||
|
||||
### Quickest validation loop
|
||||
|
||||
```bash
|
||||
# 1. Mint
|
||||
TOKEN=$(curl -s -X POST https://issuer.example.com/oauth/token \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"grant_type":"client_credentials","client_id":"…","client_secret":"…","audience":"https://api.example.com"}' \
|
||||
| jq -r .access_token)
|
||||
|
||||
# 2. Inspect claims to confirm aud/iss/exp match the middleware config
|
||||
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
|
||||
|
||||
# 3. Hit the protected route
|
||||
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/protected
|
||||
```
|
||||
|
||||
`HTTP/1.1 200` with `X-Forwarded-User` on the backend confirms the loop works end-to-end. `401` with `WWW-Authenticate: Bearer error="invalid_token"` plus a middleware debug log explaining the rejection (audience mismatch, ID token presented, `iat` outside the 24h window, etc.) confirms the hardening is firing as designed.
|
||||
|
||||
## Threat model and design rules
|
||||
|
||||
Bearer authentication has materially different security properties from
|
||||
cookie sessions: no `HttpOnly`/`Secure`/`SameSite` shielding, the token is
|
||||
visible in headers and logs, and it's easier to exfiltrate. The bearer path
|
||||
treats every one of these as a first-class concern.
|
||||
|
||||
| Property | Behaviour | Why |
|
||||
|---|---|---|
|
||||
| Default state | `enableBearerAuth=false` | Bearer is opt-in; existing deployments observe no change. |
|
||||
| Audience | **Mandatory.** Startup fails if `audience` is empty when bearer is enabled. | Eliminates the "token issued for service B accepted by service A" confusion attack. |
|
||||
| Token format | JWT by default. An opaque (non-JWT) token is accepted only when both `requireTokenIntrospection=true` and `allowOpaqueTokens=true`. | JWTs carry their own signature, audience and issuer; an opaque token has no local verification, so introspection must be turned on explicitly for both purposes before one is trusted. |
|
||||
| Opaque token client binding | When `audience` equals `clientID` (the common single-app case), the introspection response must carry a matching `client_id`, or an `aud` that contains `clientID`. | RFC 7662 §2.2 leaves client scoping to the authorization server's own policy. Without this check, any active token from the same IdP — including one issued to a different client — would authenticate. |
|
||||
| Opaque token `iat` requirement | The introspection response for an opaque token must carry `iat`. A response with no `iat` is always rejected: `maxTokenAgeSeconds` has no "disabled" value — `0` or unset maps to a 24h default (see the config-reference row below) — so the age bound is always in effect. | Mirrors `enforceIatAge` on the JWT path: with an age bound configured, a token with nothing to bound its age against must fail closed, not skip the check. |
|
||||
| Opaque token logout scope | A `sid`-only (front-channel) logout revokes an opaque bearer token only when the introspection response itself carries a `sid`. RFC 7662 does not define `sid`; when the IdP's response omits it, only a `sub`-based (backchannel) logout reaches the opaque token. | The middleware can only match invalidation against fields the introspection response actually returns. |
|
||||
| `alg` allowlist | Hard-pinned asymmetric: `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. Checked **before** any JWKS fetch. | Denies `alg=none` and `alg=HS*` probes; prevents attacker noise from amplifying into JWKS round-trips. |
|
||||
| `kid` hardening | Max 256 bytes; charset `[A-Za-z0-9._\-=]`. Checked **before** JWKS fetch. | Prevents cache-key explosion / pathological-`kid` JWKS amplification. |
|
||||
| Token type | ID tokens are explicitly rejected (`nonce` claim, `typ: at+jwt`, `token_use=id`, scope/aud heuristics — reuses the existing `detectTokenType` helper). | ID tokens are not API credentials; treating them as such is classic token confusion. |
|
||||
| Multi-audience | When `aud` is an array of length > 1, the token must carry `azp == clientID`. | OIDC §2 hardening against tokens minted for one client being replayed by another. |
|
||||
| `iat` upper-age | Rejects tokens older than `maxTokenAgeSeconds` (default 24h). | Bounds clock-manipulation / forever-token abuse, even if `exp` is far in the future. |
|
||||
| Identifier claim | `bearerIdentifierClaim` (default `"sub"`). Resolved value drives `X-Forwarded-User`. | Decoupled from the cookie path's `UserIdentifierClaim` (default `email`) so the M2M flow can never accidentally trust an unverified email. |
|
||||
| Identifier sanitisation | Length cap (`maxIdentifierLength`, default 256). Rejects control chars, Unicode bidi-overrides (U+202A–U+202E, U+2066–U+2069), and the delimiters `, ; =`. | Defence in depth against downstream header injection / log injection / admin-UI spoofing. |
|
||||
| JTI replay marking | No request path writes an access-token JTI to a replay cache (the only JTI replay check left is for backchannel logout tokens). Revocation still works without it: `RevokeToken` (called from logout) blacklists the raw token and, for a JWT, its `jti`; both the JWT and the opaque bearer path check that blacklist — raw token always, plus `jti` when the introspection response carries one — before accepting the token. | Revocation without replay-detection overhead: a legitimately reused, non-revoked bearer token is never falsely flagged as a replay. |
|
||||
| Mixed bearer + cookie | **Cookie wins by default.** Flip to bearer-wins with `bearerOverridesCookie=true`. | Safer against browser/extension/proxy bearer injection scenarios. The cookie is the authoritative authenticator when present. |
|
||||
| `Authorization` strip | `stripAuthorizationHeader=true` by default. | Keeps the raw token out of downstream services and their logs. |
|
||||
| Excluded URLs | `Authorization` is stripped on excluded paths when `enableBearerAuth=true`. | Prevents bearer leakage into public health/metrics endpoint logs and prevents recon via excluded paths. |
|
||||
| Per-IP throttle | After `bearerFailureThreshold` consecutive 401s from one source IP within `bearerFailureWindowSeconds`, further bearer requests from that IP return `429 Too Many Requests` + `Retry-After` for `bearerFailurePenaltySeconds`. | Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. |
|
||||
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Only an HTTP 200 response with `active=false` returns `401`; any other endpoint failure (network error, or any other status including 401/403/408/429) returns `503`. | RFC 7662 §2.3 defines a 401/403 from the introspection endpoint as the resource's own client credentials being rejected, not a statement about the presented token, so it must never be read as "token revoked". |
|
||||
| Response shape | `401 Unauthorized` with generic body. `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3 (toggleable via `bearerEmitWWWAuthenticate`). `403` for roles/groups denial. `429` for throttle. `503` for introspection-endpoint outage. | Auditable from spec to code; reason categories never leak into the response body. |
|
||||
| Logging | Failure reason + identifier hash (SHA-256 truncated to 8 hex chars) logged at debug. Raw tokens are never logged. | Audit trail without secrets-in-logs. |
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `enableBearerAuth` | `false` | Master switch for the bearer path. |
|
||||
| `audience` | (unset) | **Required** when `enableBearerAuth=true`. Reuses the existing global `audience` field. |
|
||||
| `bearerIdentifierClaim` | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
||||
| `stripAuthorizationHeader` | `true` | Remove the `Authorization` header before forwarding to the backend. Disable only when a downstream needs to re-verify the bearer. |
|
||||
| `bearerEmitWWWAuthenticate` | `true` | Include `WWW-Authenticate: Bearer error="..."` on 401 responses (RFC 6750 §3). Disable to reduce recon signal. |
|
||||
| `bearerOverridesCookie` | `false` | Cookie wins when both are present (default). Set `true` for the AWS/GCP/Kubernetes bearer-wins convention. |
|
||||
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age. `0` and unset both map to the 24h default — there is no value that disables the check. A JWT or opaque-introspection response with no `iat` is rejected once this bound is in effect, which it always is. |
|
||||
| `maxIdentifierLength` | `256` | Length cap for the post-sanitisation identifier. |
|
||||
| `bearerFailureThreshold` | `20` | Consecutive 401s from one IP that trip the throttle. |
|
||||
| `bearerFailureWindowSeconds` | `60` | Rolling window over which 401s are counted. |
|
||||
| `bearerFailurePenaltySeconds` | `60` | Duration of the 429 penalty box after the threshold trips. |
|
||||
| `requireTokenIntrospection` | `false` | Call RFC 7662 introspection on every cache miss. Adds per-request IdP latency. |
|
||||
| `allowOpaqueTokens` | `false` | Accept an opaque (non-JWT) bearer token, introspected via `requireTokenIntrospection`. Both flags are required together; this flag alone has no effect on the bearer path. |
|
||||
|
||||
## What the bearer path does NOT do
|
||||
|
||||
- **Human-user / browser flows.** The bearer path is M2M-only in this
|
||||
iteration. Browser SPAs that want to attach a bearer to fetch calls work
|
||||
if your backend treats them as machine clients, but the spec defaults are
|
||||
tuned for service-to-service traffic.
|
||||
- **Opaque access tokens by default.** Tokens must be JWTs unless both
|
||||
`requireTokenIntrospection=true` and `allowOpaqueTokens=true` are set. For a
|
||||
JWT, introspection stays a revocation overlay on top of local verification,
|
||||
not a substitute for it; an opaque token has no local verification, so
|
||||
introspection is its only check.
|
||||
- **`email_verified` enforcement.** The bearer path rejects `email` as the
|
||||
identifier claim at startup precisely because `email_verified` is not
|
||||
enforced in this iteration. Adding human-user bearer support is a
|
||||
follow-up that must include this check.
|
||||
- **mTLS / API keys.** Out of scope. The `principal` abstraction enables
|
||||
adding these later as additional auth methods that produce a principal
|
||||
for the shared `forwardAuthorized` pipeline.
|
||||
- **SSE / WebSocket bypass with bearer.** Bypass paths keep their existing
|
||||
cookie-only behaviour; bearer headers are ignored on those endpoints.
|
||||
Documented limitation; widen by removing the bypass if you need bearer on
|
||||
streaming endpoints.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
- **Always set `strictAudienceValidation: true` when bearer is enabled.**
|
||||
Startup logs a recommendation if you don't.
|
||||
- **Set a tight `maxTokenAgeSeconds`** for environments where tokens are
|
||||
expected to be minted frequently — the default 24h is conservative.
|
||||
- **Enable `requireTokenIntrospection`** if your IdP supports it and
|
||||
revocation latency matters. Bearer-path introspection caches results for
|
||||
a short window per token.
|
||||
- **Also set `allowOpaqueTokens=true`** if your clients present opaque
|
||||
(non-JWT) access tokens. `requireTokenIntrospection` alone does not admit
|
||||
an opaque token; both flags are required together. Confirm your IdP's
|
||||
introspection response carries `client_id` or `aud` so the client-binding
|
||||
check above can pass.
|
||||
- **Monitor 429s.** Sustained 429 traffic indicates either a buggy client
|
||||
loop or an active credential-stuffing attempt. The throttle is your
|
||||
primary signal for both.
|
||||
- **`stripAuthorizationHeader=false` extends the token's blast radius** to
|
||||
every downstream service that sees the request. Treat those services'
|
||||
logs as token stores.
|
||||
- **Bearer reuse is normal.** Don't enable per-token rate limiting; that's
|
||||
what `bearerFailureThreshold` is for (per-IP, not per-token).
|
||||
- **Cookie-wins is the safer default.** Only flip `bearerOverridesCookie`
|
||||
if you control all clients and have audited that none of them present a
|
||||
cookie alongside a bearer they don't intend to authenticate with.
|
||||
|
||||
## Failure response matrix
|
||||
|
||||
| Trigger | Status | Body | `WWW-Authenticate` |
|
||||
|---|---|---|---|
|
||||
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` |
|
||||
| Token over `MaxLength` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Missing / oversized / bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Signature / issuer / audience / `exp` failure | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| `iat` older than `maxTokenAgeSeconds` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Multi-audience token without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Introspection returns HTTP 200 with `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Introspection endpoint HTTP failure (network error, or any non-200 status including 401/403/408/429) | 503 | `Service Unavailable` | (none) |
|
||||
| Identifier claim missing / empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <bearerFailurePenaltySeconds>` |
|
||||
| Roles / groups not allowed | 403 | `Access denied` | (none) |
|
||||
|
||||
## Known follow-ups (deferred)
|
||||
|
||||
These are documented as future work, not blockers:
|
||||
|
||||
- **Human-user bearer with `email_verified` enforcement.** Requires
|
||||
decoupling the email-claim guard from the startup rejection and adding a
|
||||
per-request `email_verified=true` check.
|
||||
- **Introspection respects `client_assertion`.** The existing introspection
|
||||
helper uses `client_secret_basic` only; operators on `private_key_jwt`
|
||||
will see introspection silently use basic auth.
|
||||
- **Per-route bearer configuration.** Single middleware-wide setting in this
|
||||
iteration.
|
||||
|
||||
## References
|
||||
|
||||
- [PR design spec](superpowers/specs/2026-05-18-bearer-token-auth-design.md) — full design rationale, alternatives considered, and per-section sign-off history.
|
||||
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage.
|
||||
- [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) — OAuth 2.0 Token Introspection.
|
||||
- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) — JWT Profile for OAuth 2.0 Access Tokens.
|
||||
+354
-18
@@ -5,6 +5,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
||||
## Table of Contents
|
||||
|
||||
- [Required Parameters](#required-parameters)
|
||||
- [Client Authentication](#client-authentication)
|
||||
- [Optional Parameters](#optional-parameters)
|
||||
- [Security Options](#security-options)
|
||||
- [Session Management](#session-management)
|
||||
@@ -22,7 +23,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
||||
|-----------|------|-------------|---------|
|
||||
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
|
||||
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
|
||||
| `clientSecret` | string | OAuth 2.0 client secret | `your-client-secret` |
|
||||
| `clientSecret` | string | OAuth 2.0 client secret. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`. Optional when `clientAuthMethod: private_key_jwt`. | `your-client-secret` |
|
||||
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
|
||||
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
|
||||
|
||||
@@ -45,6 +46,134 @@ spec:
|
||||
|
||||
---
|
||||
|
||||
## Client Authentication
|
||||
|
||||
The middleware supports three client authentication methods at the token and
|
||||
revocation endpoints. The default is `client_secret_post` (current behavior);
|
||||
`private_key_jwt` is opt-in and backwards compatible.
|
||||
|
||||
| Method | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `client_secret_post` | yes | `client_id` + `client_secret` in the request body. |
|
||||
| `client_secret_basic` | no | RFC 6749 §2.3.1 — `client_id` + `client_secret` in the `Authorization: Basic` header (form-urlencoded then base64); not in the body. |
|
||||
| `private_key_jwt` | no | RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as `client_assertion`. |
|
||||
|
||||
Select via `clientAuthMethod`:
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: private_key_jwt
|
||||
```
|
||||
|
||||
### client_secret_post
|
||||
|
||||
Default. The plugin sends `client_id` and `client_secret` as form parameters
|
||||
in the token / revocation request body. No additional configuration required.
|
||||
|
||||
### private_key_jwt
|
||||
|
||||
Asymmetric client authentication per
|
||||
[RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523). Use this when your
|
||||
IdP enforces short secret TTLs, when policy mandates secretless clients, or
|
||||
when you want to avoid distributing a shared secret to the proxy.
|
||||
|
||||
For each token / revocation request the plugin builds a JWS with:
|
||||
|
||||
- `iss` = `sub` = `clientID`
|
||||
- `aud` = token endpoint URL
|
||||
- `iat` = now, `exp` = now + 60s
|
||||
- `jti` = random hex per request
|
||||
- `kid` header = `clientAssertionKeyID`
|
||||
|
||||
**Required fields:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `clientAuthMethod` | string | `client_secret_post` | Set to `private_key_jwt`. |
|
||||
| `clientAssertionPrivateKey` | string | none | Inline PEM private key. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8, PKCS#1, and SEC1 formats accepted. |
|
||||
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk. Mutually exclusive with `clientAssertionPrivateKey`. |
|
||||
| `clientAssertionKeyID` | string | none | `kid` header inserted in the JWS. Must match the public key registered with the IdP. |
|
||||
| `clientAssertionAlg` | string | `RS256` | One of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`. |
|
||||
|
||||
When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
|
||||
|
||||
With `clientAssertionAlg` set to `RS256`, `RS384`, `RS512`, `PS256`, `PS384`,
|
||||
or `PS512`, the RSA key must be 2048 bits or larger (RFC 7518 §3.3). The
|
||||
plugin rejects a smaller key at startup instead of at every token exchange.
|
||||
There is no override for this check.
|
||||
|
||||
**Example — inline PEM:**
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-auth
|
||||
spec:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: https://idp.example.com
|
||||
clientID: my-client-id
|
||||
sessionEncryptionKey: your-32-byte-encryption-key-here
|
||||
callbackURL: /oauth2/callback
|
||||
clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyID: key-2026-01
|
||||
clientAssertionAlg: RS256
|
||||
clientAssertionPrivateKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
|
||||
MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
|
||||
... (truncated) ...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
**Example — key on disk:**
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
|
||||
clientAssertionKeyID: key-2026-01
|
||||
clientAssertionAlg: RS256
|
||||
```
|
||||
|
||||
**Generating an RS256 key with OpenSSL:**
|
||||
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
-out client-key.pem
|
||||
openssl rsa -in client-key.pem -pubout -out client-pub.pem
|
||||
```
|
||||
|
||||
Register `client-pub.pem` (or its JWK form) with your IdP under the same
|
||||
`kid` you set in `clientAssertionKeyID`.
|
||||
|
||||
**Notes:**
|
||||
|
||||
- The private key is parsed once at plugin startup. Key rotation requires a
|
||||
Traefik reload.
|
||||
- Assertion lifetime is fixed at 60 seconds.
|
||||
- A fresh random `jti` is generated per request.
|
||||
- The `aud` claim is the token endpoint URL (from discovery).
|
||||
- Tracking issue:
|
||||
[#135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
|
||||
|
||||
### client_secret_basic
|
||||
|
||||
Per [RFC 6749 §2.3.1][rfc6749-2-3-1], the plugin sends the client credentials
|
||||
in an `Authorization: Basic` header instead of the body. Both halves
|
||||
(`client_id`, `client_secret`) are form-urlencoded individually, joined with
|
||||
a colon, then base64-encoded. Use this when your IdP requires Basic auth at
|
||||
the token endpoint and rejects credentials in the body.
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: client_secret_basic
|
||||
clientID: your-client-id
|
||||
clientSecret: your-client-secret
|
||||
```
|
||||
|
||||
[rfc6749-2-3-1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
||||
|
||||
---
|
||||
|
||||
## Optional Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
@@ -52,23 +181,91 @@ spec:
|
||||
| `logoutURL` | string | `callbackURL + "/logout"` | Path for logout requests |
|
||||
| `postLogoutRedirectURI` | string | `/` | Redirect URL after logout |
|
||||
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
|
||||
| `forceHTTPS` | bool | `false` | Force HTTPS for redirect URIs |
|
||||
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
|
||||
| `rateLimit` | int | `100` | Maximum requests per second |
|
||||
| `excludedURLs` | []string | none | Paths that bypass authentication |
|
||||
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
|
||||
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
|
||||
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
|
||||
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. See [Logout Behavior](#logout-behavior) for what happens at logout when this is set. |
|
||||
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
|
||||
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
|
||||
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
|
||||
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
|
||||
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
|
||||
| `clientAssertionPrivateKey` | string | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8 / PKCS#1 / SEC1. |
|
||||
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
|
||||
| `clientAssertionKeyID` | string | none | `kid` header for `private_key_jwt` assertions. Required when `clientAuthMethod: private_key_jwt`. |
|
||||
| `clientAssertionAlg` | string | `RS256` | Signing algorithm for `private_key_jwt`. One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
|
||||
| `extraAuthParams` | map | none | Extra query parameters appended to the authorization request (e.g. re-selective prompt). |
|
||||
| `perSourceLoginRateLimit` | int | `0` (off) | Throttle OIDC auth events (callback + login initiation) per external client source, in auth events per minute. Keys and classifies the source by RemoteAddr only, never `X-Forwarded-For`, so a client cannot spoof it. Internal/loopback sources are never throttled. RemoteAddr is the TCP peer. Behind a load balancer or CDN, the limiter keys on the proxy address: a private address is never throttled, and a public address puts every client in one shared bucket. |
|
||||
| `stripAuthCookies` | bool | `false` | Strip OIDC session cookies from the hop to the backend (mitigates HTTP 431). |
|
||||
| `enableBackchannelLogout` | bool | `false` | Enable OIDC back-channel logout (IdP-initiated, server-to-server). |
|
||||
| `backchannelLogoutURL` | string | derived | Back-channel logout endpoint path. |
|
||||
| `enableFrontchannelLogout` | bool | `false` | Enable OIDC front-channel logout (browser iframe logout). |
|
||||
| `frontchannelLogoutURL` | string | derived | Front-channel logout endpoint path. |
|
||||
| `caCertPath` / `caCertPEM` | string | none | Custom CA bundle for OIDC TLS verification (filesystem path, or inline PEM). Mutually exclusive with `insecureSkipVerify`. |
|
||||
| `insecureSkipVerify` | bool | `false` | Disable TLS verification for the OIDC client (load balancer / mTLS edge). Emits a loud warning at startup. |
|
||||
|
||||
### Logout Behavior
|
||||
|
||||
A request to `logoutURL` clears the session, redirects the browser, and
|
||||
also revokes the session's tokens.
|
||||
|
||||
1. The plugin blacklists the access, ID, and refresh tokens locally, so this
|
||||
instance rejects them immediately.
|
||||
2. If `revocationURL` is set or discovered, the plugin also revokes the
|
||||
access token and the refresh token at the provider (RFC 7009). Each
|
||||
revocation call times out after 3 seconds. The two calls run one after
|
||||
the other, so together they can delay the logout redirect by up to about
|
||||
6 seconds. A revocation failure logs at error level and does not stop
|
||||
the redirect. Deployments with no revocation endpoint log nothing for
|
||||
this step.
|
||||
3. The plugin redirects to `oidcEndSessionURL` (RP-initiated logout) only
|
||||
when `oidcEndSessionURL` is set or discovered and the session still
|
||||
holds an ID token. Otherwise it redirects to `postLogoutRedirectURI`.
|
||||
4. When the plugin uses `oidcEndSessionURL`, it also sends
|
||||
`post_logout_redirect_uri`. An absolute `postLogoutRedirectURI` value is
|
||||
sent unchanged. A relative value, or the default `/`, is joined to the
|
||||
origin of the redirect URI recorded at login. The plugin does not use
|
||||
the logout request's `Host` header for this, because a client can spoof
|
||||
it. The plugin omits the parameter only for a session created before
|
||||
that origin was recorded. The provider then decides where to send the
|
||||
user.
|
||||
|
||||
### TLS Termination at Load Balancer
|
||||
|
||||
If running Traefik behind a load balancer (AWS ALB, Google Cloud LB, Azure App Gateway) that terminates TLS:
|
||||
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
|
||||
the correct default behind any TLS-terminating load balancer (AWS ALB, Google
|
||||
Cloud LB, Azure App Gateway) — `X-Forwarded-Proto` cannot be trusted (ALB may
|
||||
overwrite it).
|
||||
|
||||
```yaml
|
||||
forceHTTPS: true # Required for correct redirect URIs
|
||||
```
|
||||
Set `forceHTTPS: false` only when you serve OIDC over plaintext HTTP (local
|
||||
dev). Otherwise leave it at default.
|
||||
|
||||
Without this setting, redirect URIs will use `http://` instead of `https://`, causing OAuth callback failures.
|
||||
### Streaming Endpoints (SSE and WebSocket)
|
||||
|
||||
The middleware automatically bypasses the OIDC redirect for two request kinds
|
||||
that browsers cannot follow a 302 on:
|
||||
|
||||
| Bypass | Triggered by |
|
||||
|--------|--------------|
|
||||
| Server-Sent Events (SSE) | `Accept: text/event-stream` |
|
||||
| WebSocket upgrade | `Upgrade: websocket` + `Connection: upgrade` (RFC 6455) |
|
||||
|
||||
These requests do **not** require any explicit configuration — they are
|
||||
handled implicitly. However, the bypass is **not** unauthenticated:
|
||||
|
||||
- A valid, encrypted session cookie is required. Requests without one are
|
||||
rejected (the connection cannot proceed to the backend).
|
||||
- The session cookie is sealed with `sessionEncryptionKey`, so the
|
||||
`authenticated` flag cannot be forged.
|
||||
- Validation is cookie-only — no JWK fetch / signature verification — so
|
||||
streaming endpoints keep working when the OIDC provider is briefly
|
||||
unavailable.
|
||||
- The user identifier from the session is forwarded as `X-Forwarded-User`
|
||||
(and `X-Auth-Request-User` unless `minimalHeaders: true`).
|
||||
|
||||
For browser clients, the user must complete the normal OIDC flow on a
|
||||
regular HTTP page first; the resulting session cookie is then reused on the
|
||||
SSE / WebSocket connection.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +275,8 @@ Without this setting, redirect URIs will use `http://` instead of `https://`, ca
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `audience` | string | `clientID` | Expected audience for access token validation |
|
||||
| `audience` | string | `clientID`, or `resource` if set | Expected audience for access token validation |
|
||||
| `resource` | string | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs — see [docs/RFC8707.md](RFC8707.md) |
|
||||
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
|
||||
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
||||
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
||||
@@ -90,6 +288,16 @@ audience: "https://my-api.example.com"
|
||||
strictAudienceValidation: true
|
||||
```
|
||||
|
||||
#### RFC 8707 Resource Indicators
|
||||
|
||||
```yaml
|
||||
resource: "https://api.example.com"
|
||||
```
|
||||
|
||||
Alternative to `audience` for IdPs that implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707).
|
||||
See [docs/RFC8707.md](RFC8707.md) for exactly what request is made, why only a single
|
||||
resource is supported, and current per-provider support.
|
||||
|
||||
#### Opaque Token Support
|
||||
|
||||
```yaml
|
||||
@@ -104,6 +312,77 @@ strictAudienceValidation: true
|
||||
|-----------|------|---------|-------------|
|
||||
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
||||
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
||||
| `allowUnauthenticatedPreflight` | bool | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` with `Origin` + `Access-Control-Request-Method`); response body is still discarded. Default requires auth for every `OPTIONS` request. |
|
||||
| `streamingRefresh` | bool | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request with `401` when the IdP revokes the grant (`invalid_grant`). Forward it as before when the IdP cannot be reached. |
|
||||
|
||||
### Discovered Endpoint Validation
|
||||
|
||||
When `providerURL` is `https://`, the plugin drops any endpoint the
|
||||
`.well-known/openid-configuration` document advertises as plain `http://`
|
||||
(authorization, token, jwks_uri, revocation, end_session, introspection,
|
||||
registration). A dropped credential-bearing endpoint would otherwise send
|
||||
the client secret or a token over an unauthenticated channel. There is no
|
||||
configuration flag to disable this check.
|
||||
|
||||
A dropped endpoint logs an `ERROR`-level line naming it, and a dropped
|
||||
`token`, `jwks_uri`, or `authorization` endpoint — the three logins cannot
|
||||
function without — additionally logs a `SECURITY:`-prefixed line, because a
|
||||
blank endpoint otherwise fails every login with no other signal:
|
||||
|
||||
```
|
||||
SECURITY: dropped the discovered token endpoint "http://idp.example.com/token": it is plaintext http while providerURL "https://idp.example.com" is https, and this check has no override; requests needing the token endpoint will fail until the provider serves it over https
|
||||
```
|
||||
|
||||
The `revocationURL`, `oidcEndSessionURL` and `introspectionURL` config
|
||||
fields can supply those three endpoints directly, and when you set one of
|
||||
them, a plaintext-`http://` drop of the matching discovered endpoint does
|
||||
not break anything — the override replaces the endpoint right after
|
||||
sanitize runs and the `SECURITY:` line does not fire for it.
|
||||
|
||||
Leave the override unset, though, and a drop is exactly as terminal as it is
|
||||
for `token`, `jwks_uri` and `authorization`: RP-initiated logout falls back
|
||||
to a local-only redirect (the IdP session stays alive), provider-side
|
||||
revocation is skipped, or `requireTokenIntrospection` breaks — all with no
|
||||
signal beyond the generic `ERROR` line. So `revocation`, `end_session` and
|
||||
`introspection` log the same `SECURITY:` line too, whenever the matching
|
||||
override is not set. `registration` never logs the `SECURITY:` line, under
|
||||
any configuration — it is not covered by this check. Dynamic Client
|
||||
Registration reads its own, separate override
|
||||
(`dynamicClientRegistration.registrationEndpoint`), independently of this
|
||||
discovery step, but only when you set it: leave it unset and a
|
||||
plaintext-`http://` drop of the discovered registration endpoint makes
|
||||
Dynamic Client Registration fail with only the generic `ERROR` line above —
|
||||
no `SECURITY:`-tagged signal, unlike `revocation`, `end_session` and
|
||||
`introspection`.
|
||||
|
||||
`token`, `jwks_uri` and `authorization` log the `SECURITY:` line
|
||||
unconditionally — these three have no config field that can supply them at
|
||||
all, so a drop always breaks login regardless of any other setting.
|
||||
|
||||
If your IdP's discovery document ever advertises an `http://` endpoint under
|
||||
an `https://` `providerURL`, fix the discovery document (for example, a
|
||||
TLS-terminating proxy in front of the IdP that does not forward
|
||||
`X-Forwarded-Proto`) rather than relying on the plugin to relax the check.
|
||||
|
||||
### Bearer-token (M2M) authentication
|
||||
|
||||
Opt-in path that accepts `Authorization: Bearer <jwt>` instead of the cookie
|
||||
session flow. M2M-only, default off, audience-mandatory. See
|
||||
[docs/BEARER_AUTH.md](BEARER_AUTH.md) for the threat model and operational
|
||||
guidance.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `enableBearerAuth` | bool | `false` | Master switch. Startup fails if true with empty `audience` or with `bearerIdentifierClaim=email`. |
|
||||
| `bearerIdentifierClaim` | string | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
||||
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
|
||||
| `bearerEmitWWWAuthenticate` | bool | `true` | Emit RFC 6750 `WWW-Authenticate: Bearer error="..."` hints on 401. |
|
||||
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
|
||||
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). `0` and unset both map to the 24h default; no value disables the check, and a bearer token or opaque-introspection response without `iat` is rejected. |
|
||||
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
||||
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
||||
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
||||
| `bearerFailurePenaltySeconds` | int | `60` | 429 + `Retry-After` duration after the threshold trips. |
|
||||
|
||||
---
|
||||
|
||||
@@ -113,8 +392,10 @@ strictAudienceValidation: true
|
||||
|-----------|------|---------|-------------|
|
||||
| `sessionMaxAge` | int | `86400` (24h) | Maximum session age in seconds |
|
||||
| `refreshGracePeriodSeconds` | int | `60` | Seconds before expiry to attempt refresh |
|
||||
| `maxRefreshTokenAgeSeconds` | int | `21600` | Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with `invalid_grant` retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set `0` to disable. Default `21600` (6h). |
|
||||
| `cookieDomain` | string | auto-detected | Domain for session cookies |
|
||||
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
|
||||
| `cookiePath` | string | `/` | Path prefix for session cookies. Set it to the middleware's path (for example `/app`) so the browser does not send OIDC cookies to unprotected paths (avoids HTTP 431). |
|
||||
|
||||
### Multi-Subdomain Setup
|
||||
|
||||
@@ -254,22 +535,77 @@ minimalHeaders: true # Only forwards X-Forwarded-User
|
||||
```yaml
|
||||
headers:
|
||||
- name: "X-User-Email"
|
||||
value: "{{{{.Claims.email}}}}"
|
||||
value: "{{.Claims.email}}"
|
||||
- name: "X-User-ID"
|
||||
value: "{{{{.Claims.sub}}}}"
|
||||
value: "{{.Claims.sub}}"
|
||||
- name: "Authorization"
|
||||
value: "Bearer {{{{.AccessToken}}}}"
|
||||
value: "Bearer {{.AccessToken}}"
|
||||
- name: "X-User-Roles"
|
||||
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
|
||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
- `{{.Claims.field}}` - ID token claims
|
||||
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
|
||||
- `{{.AccessToken}}` - Raw access token
|
||||
- `{{.IdToken}}` - Raw ID token
|
||||
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
|
||||
- `{{.RefreshToken}}` - Raw refresh token
|
||||
|
||||
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
|
||||
**Important — file provider only:** Traefik's file provider runs every dynamic
|
||||
configuration file through Go templating before the plugin sees it, so plain
|
||||
`{{ }}` fails there with `can't evaluate field AccessToken in type bool`.
|
||||
Escape with a raw string: ``value: "{{`{{.Claims.email}}`}}"``. All other
|
||||
providers (Kubernetes CRD, Docker labels, Consul, ...) pass the value through
|
||||
untouched — use the plain form shown above. Do not use quadruple braces
|
||||
(`{{{{ }}}}`); no configuration path collapses them, and template validation
|
||||
rejects them (issues #149, #151).
|
||||
|
||||
**Validation and the claims whitelist.** Header templates are parsed and
|
||||
validated when the middleware loads; an invalid or disallowed template stops the
|
||||
middleware from starting (the route then returns Traefik's
|
||||
`invalid handler type: <nil>`). To prevent a template from accidentally
|
||||
forwarding raw tokens or sensitive claims, only a fixed set of claim fields may
|
||||
be emitted — the standard OIDC claims plus common provider claims (`email`,
|
||||
`name`, `given_name`, `family_name`, `preferred_username`, `sub`, `groups`,
|
||||
`roles`, `internal_role`, `role`, `department`, `organization`, `realm_access`,
|
||||
`resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`, `locale`, `zoneinfo`,
|
||||
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
|
||||
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
|
||||
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
|
||||
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
|
||||
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
|
||||
.Claims.groups}}` is allowed).
|
||||
|
||||
**`allowedClaims`** extends the whitelist for claims your provider issues that
|
||||
are not built in:
|
||||
|
||||
```yaml
|
||||
allowedClaims:
|
||||
- employee_id
|
||||
- cost_center
|
||||
headers:
|
||||
- name: "X-Employee-Id"
|
||||
value: "{{.Claims.employee_id}}"
|
||||
```
|
||||
|
||||
Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
|
||||
`get` (`{{get .Claims "employee_id"}}`). Subfields of a whitelisted map-valued
|
||||
claim (e.g. `realm_access.roles`) are already accessible without listing them
|
||||
individually.
|
||||
|
||||
**`toJson`** forwards an object or array claim as one JSON header:
|
||||
|
||||
```yaml
|
||||
allowedClaims:
|
||||
- tenant
|
||||
headers:
|
||||
- name: "X-Tenant"
|
||||
value: "{{get .Claims \"tenant\" | toJson}}"
|
||||
```
|
||||
|
||||
It only serializes values the template can already reach, so the whitelist
|
||||
still applies; `{{toJson .Claims}}` is rejected. Control characters are
|
||||
JSON-escaped, and values over 8192 bytes or with bidi-override characters are
|
||||
dropped; an oversize value is logged at error level (header name only).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# Dynamic Client Registration (RFC 7591)
|
||||
|
||||
The middleware can register itself with an OIDC provider at startup instead of
|
||||
using a pre-provisioned `clientID` / `clientSecret`. Useful for multi-tenant
|
||||
deployments, self-service integrations, and ephemeral environments.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Middleware reads `registration_endpoint` from `.well-known/openid-configuration`.
|
||||
2. If `clientID` is empty, it `POST`s `clientMetadata` to the registration endpoint.
|
||||
3. Returned `client_id` / `client_secret` are cached, optionally persisted.
|
||||
4. Subsequent requests use the registered credentials.
|
||||
|
||||
For multi-replica deployments, set `storageBackend: redis` so all replicas
|
||||
share one client and avoid registration races.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-dcr
|
||||
namespace: traefik
|
||||
spec:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: https://your-oidc-provider.com
|
||||
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||
callbackURL: /oauth2/callback
|
||||
dynamicClientRegistration:
|
||||
enabled: true
|
||||
persistCredentials: true
|
||||
storageBackend: redis # file | redis | auto
|
||||
initialAccessToken: "" # optional, for protected endpoints
|
||||
registrationEndpoint: "" # optional, override discovery
|
||||
credentialsFile: /tmp/oidc-client-credentials.json
|
||||
redisKeyPrefix: "dcr:creds:"
|
||||
clientMetadata:
|
||||
redirect_uris:
|
||||
- https://app.example.com/oauth2/callback
|
||||
client_name: My Application
|
||||
application_type: web
|
||||
grant_types: [authorization_code, refresh_token]
|
||||
response_types: [code]
|
||||
token_endpoint_auth_method: client_secret_basic
|
||||
contacts: [admin@example.com]
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `enabled` | `false` | Enable DCR. |
|
||||
| `persistCredentials` | `false` | Save returned credentials for reuse across restarts. |
|
||||
| `storageBackend` | `auto` | `file`, `redis`, or `auto` (Redis if available, else file). |
|
||||
| `credentialsFile` | `/tmp/oidc-client-credentials.json` | Path for file-backed storage. Mode `0600`. |
|
||||
| `redisKeyPrefix` | (none — set explicitly) | Key prefix for Redis-backed storage. The code does not inject a default; if unset, keys have no prefix. `dcr:creds:` is a sensible convention. |
|
||||
| `registrationEndpoint` | discovered | Override the discovered endpoint. |
|
||||
| `initialAccessToken` | none | Bearer token for protected registration endpoints. |
|
||||
| `clientMetadata.redirect_uris` | required | Callback URIs for the OAuth flow. |
|
||||
| `clientMetadata.client_name` | none | Human-readable client name. |
|
||||
| `clientMetadata.application_type` | `web` | `web` or `native`. |
|
||||
| `clientMetadata.grant_types` | `[authorization_code, refresh_token]` | OAuth grant types. |
|
||||
| `clientMetadata.response_types` | `[code]` | OAuth response types. |
|
||||
| `clientMetadata.token_endpoint_auth_method` | `client_secret_basic` | `client_secret_basic`, `client_secret_post`, or `none`. |
|
||||
| `clientMetadata.scope` | none | Space-separated scopes. |
|
||||
| `clientMetadata.contacts` | none | Admin email addresses. |
|
||||
| `clientMetadata.logo_uri` | none | Logo URL for consent screens. |
|
||||
| `clientMetadata.client_uri` | none | Client homepage URL. |
|
||||
| `clientMetadata.policy_uri` | none | Privacy policy URL. |
|
||||
| `clientMetadata.tos_uri` | none | Terms of service URL. |
|
||||
|
||||
## Provider support
|
||||
|
||||
The middleware does not gate DCR by provider — if the provider exposes a
|
||||
`registration_endpoint` in its discovery document (or you set
|
||||
`registrationEndpoint` explicitly), DCR will attempt registration. The table
|
||||
below is informational guidance based on each provider's published support.
|
||||
|
||||
| Provider | DCR | Notes |
|
||||
|----------|-----|-------|
|
||||
| Keycloak | Yes | Enable in realm settings. |
|
||||
| Auth0 | Yes | Requires Management API token. |
|
||||
| Okta | Yes | Enable Dynamic Client Registration in admin console. |
|
||||
| Azure AD | Limited | Use App Registration API instead. |
|
||||
| Google | No | Manual registration required. |
|
||||
| AWS Cognito | No | Manual registration required. |
|
||||
|
||||
## Security notes
|
||||
|
||||
- Registration endpoints must be HTTPS (loopback excepted for local dev).
|
||||
- Use `initialAccessToken` in production to gate registration.
|
||||
- File-backed credentials use `0600`; protect the mount path.
|
||||
- The plugin marks credentials invalid when within ~5 min of `client_secret_expires_at` but does **not** automatically re-register. If your provider sets a non-zero expiry, schedule manual rotation (delete the credentials file or Redis entry, restart) before that time.
|
||||
+20
-99
@@ -16,9 +16,8 @@ Guide for local development, testing, and contributing to the Traefik OIDC middl
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.23+** for plugin compilation
|
||||
- **Docker & Docker Compose** for local testing
|
||||
- **OIDC Provider** credentials (Google, Azure, etc.)
|
||||
- **Go 1.24+** (matches `go.mod`; CI runs Go 1.24.11)
|
||||
- **OIDC Provider** credentials (Google, Azure, etc.) for any end-to-end test against a real provider
|
||||
|
||||
### Required Development Tools
|
||||
|
||||
@@ -40,110 +39,32 @@ go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Docker Compose Environment
|
||||
|
||||
The repository includes a Docker Compose setup for testing the plugin locally.
|
||||
|
||||
#### 1. Host Configuration
|
||||
|
||||
Add to `/etc/hosts`:
|
||||
### Build and unit tests
|
||||
|
||||
```bash
|
||||
127.0.0.1 hello.localhost
|
||||
127.0.0.1 traefik.localhost
|
||||
go mod tidy
|
||||
go build ./...
|
||||
go test ./... -short # fast loop, < 30 s
|
||||
go test -race -timeout=15m ./...
|
||||
```
|
||||
|
||||
#### 2. Plugin Configuration
|
||||
### Sample plugin configurations
|
||||
|
||||
The plugin is loaded using Traefik's **local plugins mode**:
|
||||
Working middleware/Traefik configs live in [`examples/`](../examples/):
|
||||
|
||||
- Plugin source: Parent directory (`../`)
|
||||
- Mount path: `/plugins-local/src/github.com/lukaszraczylo/traefikoidc`
|
||||
- Configuration: `experimental.localPlugins` in `traefik.yml`
|
||||
- `complete-traefik-config.yaml` — full middleware example
|
||||
- `redis-config.yaml` — Redis cache configuration
|
||||
|
||||
#### 3. OIDC Provider Setup
|
||||
To run the plugin against a real Traefik instance, drop the project on disk
|
||||
and load it via `experimental.localPlugins` in your Traefik static config —
|
||||
see the [README install section](../README.md#install).
|
||||
|
||||
Edit `docker/dynamic.yml` with your provider details:
|
||||
### Integration tests
|
||||
|
||||
**Google:**
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
oidc-auth:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: "https://accounts.google.com"
|
||||
clientID: "your-client-id.apps.googleusercontent.com"
|
||||
clientSecret: "your-google-client-secret"
|
||||
sessionEncryptionKey: "your-32-character-encryption-key"
|
||||
callbackURL: "/oauth2/callback"
|
||||
logoutURL: "/oauth2/logout"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
- "profile"
|
||||
```
|
||||
|
||||
**Azure AD:**
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
oidc-auth:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: "https://login.microsoftonline.com/your-tenant-id/v2.0"
|
||||
clientID: "your-azure-client-id"
|
||||
clientSecret: "your-azure-client-secret"
|
||||
sessionEncryptionKey: "your-32-character-encryption-key"
|
||||
callbackURL: "/oauth2/callback"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
- "profile"
|
||||
```
|
||||
|
||||
#### 4. Start Environment
|
||||
Integration tests live in `integration/`. Run them explicitly:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 5. Test Plugin
|
||||
|
||||
- **Protected App**: http://hello.localhost (redirects to OIDC)
|
||||
- **Traefik Dashboard**: http://traefik.localhost:8080
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Edit plugin code** in the project root
|
||||
2. **Build and test** (optional syntax check):
|
||||
```bash
|
||||
go mod tidy
|
||||
go build .
|
||||
go test ./...
|
||||
```
|
||||
3. **Restart Traefik** to reload plugin:
|
||||
```bash
|
||||
docker-compose restart traefik
|
||||
```
|
||||
4. **Test changes** at http://hello.localhost
|
||||
|
||||
### Debugging
|
||||
|
||||
**View plugin logs:**
|
||||
```bash
|
||||
docker-compose logs -f traefik | grep traefikoidc
|
||||
```
|
||||
|
||||
**Check plugin loading:**
|
||||
```bash
|
||||
docker-compose logs traefik | grep -i plugin
|
||||
```
|
||||
|
||||
**Verify plugin directory:**
|
||||
```bash
|
||||
docker-compose exec traefik ls -la /plugins-local/src/github.com/lukaszraczylo/traefikoidc/
|
||||
go test ./integration/... -run Integration -v
|
||||
```
|
||||
|
||||
---
|
||||
@@ -299,7 +220,7 @@ The repository uses GitHub Actions for comprehensive validation with 20+ paralle
|
||||
|
||||
#### Testing (9 suites)
|
||||
- Race Detector
|
||||
- Coverage (75% threshold)
|
||||
- Coverage (70% threshold, enforced in `pr.yaml`)
|
||||
- Memory Leaks
|
||||
- Integration Tests
|
||||
- Regression Tests
|
||||
@@ -323,13 +244,13 @@ Tests run in parallel for:
|
||||
#### Performance & Build (3 checks)
|
||||
- Benchmarks
|
||||
- Multi-platform Build (linux/darwin x amd64/arm64)
|
||||
- Go Version Compatibility (Go 1.23 & 1.24)
|
||||
- Go Version Compatibility (currently Go 1.24.11 in CI)
|
||||
|
||||
### Quality Gates
|
||||
|
||||
All PRs must pass:
|
||||
- All parallel checks
|
||||
- 75% test coverage minimum
|
||||
- 70% test coverage minimum
|
||||
- Zero security vulnerabilities
|
||||
- No race conditions
|
||||
- No memory leaks
|
||||
|
||||
+3
-3
@@ -23,10 +23,10 @@ Configuration reference for each supported OIDC provider.
|
||||
| Provider | OIDC Support | Refresh Tokens | Auto-Detection | ID Tokens |
|
||||
|----------|-------------|----------------|----------------|-----------|
|
||||
| Google | Full | Yes | `accounts.google.com` | Yes |
|
||||
| Azure AD | Full | Yes | `login.microsoftonline.com` | Yes |
|
||||
| Azure AD | Full | Yes | `login.microsoftonline.com`, `sts.windows.net` | Yes |
|
||||
| Auth0 | Full | Yes | `*.auth0.com` | Yes |
|
||||
| Okta | Full | Yes | `*.okta.com` | Yes |
|
||||
| Keycloak | Full | Yes | `/auth/realms/` path | Yes |
|
||||
| Okta | Full | Yes | `*.okta.com`, `*.oktapreview.com`, `*.okta-emea.com` | Yes |
|
||||
| Keycloak | Full | Yes | host containing `keycloak`, or `/realms/` in path (matches both `/auth/realms/` legacy and `/realms/` modern) | Yes |
|
||||
| AWS Cognito | Full | Yes | `cognito-idp.*.amazonaws.com` | Yes |
|
||||
| GitLab | Full | Yes | `gitlab.com` | Yes |
|
||||
| GitHub | OAuth 2.0 Only | No | `github.com` | No |
|
||||
|
||||
+14
-6
@@ -109,11 +109,11 @@ redis:
|
||||
| `writeTimeout` | int | `3` | Write timeout (seconds) |
|
||||
| `enableTLS` | bool | `false` | Enable TLS for connections |
|
||||
| `tlsSkipVerify` | bool | `false` | Skip TLS certificate verification |
|
||||
| `enableCircuitBreaker` | bool | `true` | Enable circuit breaker |
|
||||
| `circuitBreakerThreshold` | int | `5` | Failures before circuit opens |
|
||||
| `circuitBreakerTimeout` | int | `60` | Circuit reset timeout (seconds) |
|
||||
| `enableHealthCheck` | bool | `true` | Enable periodic health checks |
|
||||
| `healthCheckInterval` | int | `30` | Health check interval (seconds) |
|
||||
| `enableCircuitBreaker` | bool | `false` | Wrap the Redis backend with a circuit breaker. **Recommended `true` in production.** |
|
||||
| `circuitBreakerThreshold` | int | `5` | Consecutive failures before the circuit opens (only when `enableCircuitBreaker: true`). |
|
||||
| `circuitBreakerTimeout` | int | `60` | Seconds the circuit stays open before allowing a probe (only when `enableCircuitBreaker: true`). |
|
||||
| `enableHealthCheck` | bool | `false` | Wrap the Redis backend with periodic health checks. **Recommended `true` in production.** |
|
||||
| `healthCheckInterval` | int | `30` | Health check interval in seconds (only when `enableHealthCheck: true`). |
|
||||
| `hybridL1Size` | int | `500` | Max items in L1 cache (hybrid mode) |
|
||||
| `hybridL1MemoryMB` | int64 | `10` | Max memory for L1 cache in MB |
|
||||
|
||||
@@ -134,13 +134,21 @@ REDIS_READ_TIMEOUT=3
|
||||
REDIS_WRITE_TIMEOUT=3
|
||||
REDIS_ENABLE_TLS=false
|
||||
REDIS_TLS_SKIP_VERIFY=false
|
||||
REDIS_HYBRID_L1_SIZE=500
|
||||
REDIS_HYBRID_L1_MEMORY_MB=10
|
||||
```
|
||||
|
||||
> Resilience fields (`enableCircuitBreaker`, `enableHealthCheck`,
|
||||
> `circuitBreakerThreshold`, `circuitBreakerTimeout`, `healthCheckInterval`)
|
||||
> have no environment variable fallback — set them in plugin configuration.
|
||||
|
||||
Invalid `cacheMode` values are rejected at plugin startup.
|
||||
|
||||
---
|
||||
|
||||
## Cache Modes
|
||||
|
||||
### Memory Mode (Default without Redis)
|
||||
### Memory Mode (used when Redis is disabled)
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# RFC 8707 Resource Indicators
|
||||
|
||||
`resource` ([Config.Resource](../settings.go)) is an alternative to `audience` for IdPs that
|
||||
implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). See
|
||||
[docs/CONFIGURATION.md](CONFIGURATION.md#audience-validation) for the config reference; this
|
||||
document covers exactly what request is made and which providers support it.
|
||||
|
||||
## What the plugin sends
|
||||
|
||||
- One `resource` parameter on the authorization request (`buildAuthURL`), added before
|
||||
`extraAuthParams` so an operator-configured `resource` key there cannot override or
|
||||
duplicate it.
|
||||
- The same `resource` parameter on both the `authorization_code` and `refresh_token` grants
|
||||
(`exchangeTokens`), per RFC 8707 §2.2 — so a refreshed token keeps the same audience binding
|
||||
as the original grant.
|
||||
|
||||
Only a single resource is supported, not a list. RFC 8707 allows a server to accept multiple
|
||||
`resource` values and scope the issued token to a subset of them, but no provider found to
|
||||
support the parameter at all handles that usefully: one provider tested rejects a request
|
||||
naming more than one resource with HTTP 400 `invalid_target`; node-oidc-provider accepts
|
||||
multiple resources but issues an opaque access token bound to none of them. A single value
|
||||
avoids both failure modes.
|
||||
|
||||
## Relationship to `audience`
|
||||
|
||||
`resource` and `audience` both feed the same access-token audience check —
|
||||
`strictAudienceValidation` applies identically to either — but they produce different requests
|
||||
on the wire:
|
||||
|
||||
- `audience`, when explicitly configured, is sent as a non-standard `audience` request
|
||||
parameter (see [docs/AUTH0\_AUDIENCE\_GUIDE.md](AUTH0_AUDIENCE_GUIDE.md)).
|
||||
- `resource` is sent as the RFC 8707 `resource` parameter instead.
|
||||
- If `resource` is set and `audience` is not, the expected-audience check defaults to the
|
||||
resource value, but this is validation-only: it does not cause an `audience` parameter to be
|
||||
sent. Setting both is allowed — e.g. as a hedge against a provider that ignores `resource` —
|
||||
in which case both parameters are sent.
|
||||
|
||||
## Provider support
|
||||
|
||||
Verified 2026-09-02 (Zitadel reverified separately against a live instance). RFC 8707 requires
|
||||
a server that does not support `resource` to *ignore* it, but not every implementation does —
|
||||
verify against your specific provider and version before relying on it.
|
||||
|
||||
| Provider | Support | Notes |
|
||||
|---|---|---|
|
||||
| node-oidc-provider | Supported | Via its `resourceIndicators` feature. |
|
||||
| Zitadel | Ignored, not rejected | `oidc.AuthRequest` has no `resource` field; confirmed against a live instance — `/oauth/v2/authorize` returns the same `302` with or without `resource`, including a spec-invalid value. |
|
||||
| Authentik | Rejected | Returns `invalid_target` ("Resource indicators are not supported"). |
|
||||
| Keycloak | Not supported (through 26.6) | Its own guidance is an audience mapper plus `scope` instead ([keycloak/keycloak#41526](https://github.com/keycloak/keycloak/issues/41526)). |
|
||||
| Ory Hydra / Fosite | Not implemented | Uses its own non-standard `audience` request parameter instead. |
|
||||
| Azure AD / Entra ID v2.0 (MSAL) | Reported to reject | When combined with v2-style `scope`, rather than ignoring it ([writeup](https://www.groff.dev/blog/azure-entra-id-mcp-server-authentication-incompatibilities)). Do not enable against an Entra ID v2 tenant without confirming. The v1.0 endpoint has a separate, pre-RFC `resource` parameter with similar but not identical semantics. |
|
||||
| Auth0 | Not verified | Documents a Resource Parameter Compatibility Profile mapping `resource` → `audience` ([Auth0 docs](https://auth0.com/ai/docs/mcp/guides/resource-param-compatibility-profile)). |
|
||||
| AWS Cognito | Not verified | Announced Oct 2025 for user-pool authorization-code/implicit grants ([announcement](https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-cognito-resource-indicators-protection-oauth-2-0-resources)). |
|
||||
| Okta, Google, GitLab | Unverified | No confirming documentation found either way. |
|
||||
+2
-2
@@ -6,8 +6,8 @@ Comprehensive testing infrastructure for traefikoidc.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Test files | 99 |
|
||||
| Lines of test code | ~65,500 |
|
||||
| Test files | 110 |
|
||||
| Lines of test code | ~72,000 |
|
||||
| Code coverage | 71.0% |
|
||||
| Race conditions | None (all pass with `-race`) |
|
||||
|
||||
|
||||
+52
-4
@@ -642,7 +642,7 @@ spec:
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientSecret</code></td>
|
||||
<td class="py-2 px-3">OAuth 2.0 client secret</td>
|
||||
<td class="py-2 px-3">OAuth 2.0 client secret. Only required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is unset or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">sessionEncryptionKey</code></td>
|
||||
@@ -718,6 +718,11 @@ spec:
|
||||
<td class="py-2 px-3">86400</td>
|
||||
<td class="py-2 px-3">Maximum session age in seconds (24 hours default)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">maxRefreshTokenAgeSeconds</code></td>
|
||||
<td class="py-2 px-3">21600</td>
|
||||
<td class="py-2 px-3">Heuristic upper bound on stored refresh-token lifetime (6 hours default). Past this, the plugin treats the RT as expired without contacting the IdP. Set <code>0</code> to disable.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">cookiePrefix</code></td>
|
||||
<td class="py-2 px-3">_oidc_raczylo_</td>
|
||||
@@ -730,9 +735,14 @@ spec:
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code></td>
|
||||
<td class="py-2 px-3">clientID</td>
|
||||
<td class="py-2 px-3">clientID, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code> if set</td>
|
||||
<td class="py-2 px-3">Custom audience for access token validation</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">RFC 8707 resource indicator, alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code> for supporting IdPs — see docs/RFC8707.md.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
@@ -748,15 +758,48 @@ spec:
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Require RFC 7662 introspection for opaque tokens</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">disableReplayDetection</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Disable JTI replay detection (for multi-replica without Redis)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code></td>
|
||||
<td class="py-2 px-3">client_secret_post</td>
|
||||
<td class="py-2 px-3">Selects how the plugin authenticates to the token endpoint. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">Inline PEM private key used to sign client assertions for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyPath</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">Path to a PEM private key file. Alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyID</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">JWS <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">kid</code> header value. Required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionAlg</code></td>
|
||||
<td class="py-2 px-3">RS256</td>
|
||||
<td class="py-2 px-3">Signing algorithm for the client assertion. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES512</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Private Key JWT (RFC 7523)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Use this when your IdP (Entra ID, Okta, Auth0, Keycloak) pressures short-lived secrets, or when policy mandates secretless service-to-service authentication. The plugin signs a 60-second assertion with the configured private key and sends it as <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_assertion</code> instead of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret</code>. Public-key registration on the IdP replaces shared-secret rotation. See <a href="https://www.rfc-editor.org/rfc/rfc7523" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">RFC 7523</a> and <a href="https://github.com/lukaszraczylo/traefikoidc/issues/135" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">issue #135</a>.</p>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyPath: /etc/traefik/oidc-client.pem
|
||||
clientAssertionKeyID: my-client-key-2026
|
||||
# clientSecret no longer required</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Google Workspace with Domain Restriction</h3>
|
||||
|
||||
@@ -858,7 +901,12 @@ spec:
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.enableTLS</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Enable TLS for Redis connections</td>
|
||||
<td class="py-2 px-3">Enable TLS for Redis connections (e.g. AWS ElastiCache in-transit encryption)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.tlsSkipVerify</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Skip TLS server certificate verification (testing only; not recommended in production)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# Bearer Token Authentication — Design Spec
|
||||
|
||||
- **Date**: 2026-05-18
|
||||
- **Status**: Design — pending implementation plan
|
||||
- **Supersedes**: PR #93 (broken implementation; recommended to close in favour of this design)
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Add an opt-in path that lets API clients (machine-to-machine) authenticate by presenting a signed access token in the `Authorization: Bearer <token>` header, bypassing the cookie-based OIDC redirect flow. Identity, roles, and authorization checks remain consistent with the existing cookie path; the only thing that changes is how the principal is established for that single request.
|
||||
|
||||
The feature is implemented by extracting a shared `forwardAuthorized` pipeline from the existing `processAuthorizedRequest`, introducing a `principal` value type, and adding a small bearer-specific entrypoint that builds a principal directly from a verified JWT — without synthesising a fake `SessionData`.
|
||||
|
||||
## 2. Motivation
|
||||
|
||||
PR #93 attempted this feature by building an in-memory `SessionData` from JWT claims and reusing `processAuthorizedRequest`. The approach has three latent defects:
|
||||
|
||||
1. The synthetic session omits `mainSession.Values["user_identifier"]`. `processAuthorizedRequest` reads it via `GetUserIdentifier()`; when empty it bails to `defaultInitiateAuthentication` and issues an OIDC redirect. The feature is non-functional in practice despite the unit test passing.
|
||||
2. `verifyToken` accepts both ID tokens (audience match against `clientID`) and access tokens. ID tokens are not API credentials; treating them as such is a classic token-confusion vector.
|
||||
3. `verifyToken` adds JTI to the replay blacklist on first verify. Once the verified-token cache evicts, subsequent reuse of the same bearer token triggers a false-positive replay rejection.
|
||||
|
||||
Rather than patch a synthetic-session approach that will keep generating bugs as `SessionData` evolves, this spec replaces it with a cleaner abstraction where session lifecycle and post-auth header injection live in separate units.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Accept `Authorization: Bearer <jwt>` from M2M clients, validate the token, and forward the request downstream with identity headers populated.
|
||||
- Enforce the same `allowedRolesAndGroups` policy as the cookie path.
|
||||
- Default-off; safe defaults when enabled (audience required, ID tokens rejected, identifier sanitised).
|
||||
- No behavioural change to the cookie path. Existing tests must continue to pass without modification.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Human-user / browser flows. Bearer is M2M-only in this iteration.
|
||||
- Pure opaque access tokens on the bearer path. Tokens must be JWTs; introspection (RFC 7662) is supported *on top of* JWT verification for revocation state, not as a substitute for it.
|
||||
- mTLS, API keys, or any other auth method. The `principal` abstraction enables them later, but they are not delivered here.
|
||||
- Per-route bearer configuration. Single middleware-wide setting.
|
||||
|
||||
## 5. Decided Requirements
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Consumer type | Machine-to-machine (M2M) only |
|
||||
| Token format | JWT only (signature, issuer, audience, exp) |
|
||||
| Audience | Mandatory when feature enabled; startup fails if `Audience == ""` |
|
||||
| Token type | Access tokens only; ID tokens explicitly rejected |
|
||||
| Revocation | JWT-only verification by default; introspection (RFC 7662) opt-in via existing `RequireTokenIntrospection` |
|
||||
| Identity claim | New `BearerIdentifierClaim` config (string, default `"sub"`). Bearer path reads this claim exclusively; does NOT use `UserIdentifierClaim` (which defaults to `"email"` and drives the cookie path). Resolved value must be a non-empty string. `sub` is mandatory per `jwt.go:416` regardless, so even with a different `BearerIdentifierClaim` the token must still carry a valid `sub`. Decoupling avoids the M2M-vs-human-user identity-claim conflict and the email-spoofing footgun. |
|
||||
| Identifier sanitisation | Reject value containing any `unicode.IsControl` char, any Unicode bidi-override (U+202A–U+202E, U+2066–U+2069), leading/trailing whitespace, commas, semicolons, equals signs. Max length 256 bytes. |
|
||||
| Token classifier | **Reuse existing `detectTokenType(jwt, token)` at `token_manager.go:187-303`** which already handles `nonce`, `typ: at+jwt`, `token_use`, `scope`, and aud-vs-clientID priority. Bearer path rejects any token where `detectTokenType == true` (ID token). Do not invent a parallel classifier. |
|
||||
| Algorithm pinning | Hard-pin `alg ∈ {RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512}`, enforced **before** JWKS lookup on the bearer path. Prevents wasted JWKS fetches for `alg=none`/HS attacker probes. |
|
||||
| `kid` hardening | `kid` ≤ 256 bytes, charset `[A-Za-z0-9._\-=]`. Reject before JWKS lookup. |
|
||||
| Token age | Bearer path enforces `now - iat <= MaxTokenAgeSeconds` (default 86400 / 24h, configurable). Cookie path unchanged. |
|
||||
| Multi-audience policy | If `aud` is an array (length > 1), require `azp` claim to be present and equal to `clientID`. Single-string `aud` unaffected. |
|
||||
| Mixed bearer + cookie precedence | **Cookie wins by default** when both are presented (safer for browser scenarios). Operator opt-in: `BearerOverridesCookie=true` to flip. Either way, a warning is logged on the request. |
|
||||
| Bearer + excluded URL | `Authorization` header is **stripped** before forwarding when the request hits an excluded URL. Prevents bearer leaking into public endpoints' downstream logs and prevents recon via excluded paths. |
|
||||
| Per-source bearer 401 throttle | New sharded cache `failedBearerAttempts` keyed by client IP. After N (default 20) consecutive 401s from one IP within 1 minute, reject further bearer requests from that IP with 429 for 60s. Applied BEFORE `verifyToken` to deny JWKS amplification. |
|
||||
| `Authorization` header passthrough | New `StripAuthorizationHeader` config, default `true` |
|
||||
| Roles/groups gating | Same `allowedRolesAndGroups` rules as cookie path |
|
||||
| Default state | `EnableBearerAuth` = `false` |
|
||||
| JTI replay marking | Suppressed on bearer path; cookie path unchanged |
|
||||
| Failure response shape | 401 with generic body; `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 |
|
||||
| Introspection endpoint outage | 503 (distinguishes infra outage from token rejection) |
|
||||
| Mixed bearer + cookie | Bearer wins; cookie ignored on that request |
|
||||
| SSE/WS bypass + bearer | Bypass paths keep cookie-only check; bearer header ignored on SSE/WS |
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
HTTP req ──► │ ServeHTTP │ (existing entry; adds bearer detection)
|
||||
└─────────┬────────┘
|
||||
┌───────────┴────────────┐
|
||||
▼ ▼
|
||||
cookie / session bearer (Authorization: Bearer …)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────────┐
|
||||
│ buildPrincipal │ │ buildPrincipal │
|
||||
│ FromSession() │ │ FromBearerToken() │
|
||||
└────────┬───────┘ └─────────┬──────────┘
|
||||
│ produces *principal │
|
||||
└──────────────┬───────────┘
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ forwardAuthorized(rw,req,p)│ (shared pipeline)
|
||||
│ • roles/groups gate │
|
||||
│ • header injection │
|
||||
│ • header templates │
|
||||
│ • security headers │
|
||||
│ • cookie stripping │
|
||||
│ • next.ServeHTTP │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**Invariant**: `forwardAuthorized` never touches session storage. Session-specific concerns (Save, IsDirty, backchannel-logout invalidation) stay inside `processAuthorizedRequest` around the call to `forwardAuthorized`.
|
||||
|
||||
**Feature gate**: when `EnableBearerAuth == false`, the bearer-detection check in `ServeHTTP` is a no-op. Existing deployments observe byte-identical behaviour.
|
||||
|
||||
## 7. Components
|
||||
|
||||
### 7.1 `principal` type (new file `principal.go`)
|
||||
|
||||
```go
|
||||
type principalSource int
|
||||
|
||||
const (
|
||||
sourceSession principalSource = iota
|
||||
sourceBearer
|
||||
)
|
||||
|
||||
type principal struct {
|
||||
Identifier string // drives X-Forwarded-User
|
||||
Email string // optional, "" for M2M
|
||||
Subject string // sub claim
|
||||
ClientID string // azp / client_id, M2M caller
|
||||
Claims map[string]interface{} // raw claims for templates / groups
|
||||
AccessToken string // for X-Auth-Request-Token (gated by minimalHeaders)
|
||||
IDToken string // "" on bearer path
|
||||
RefreshToken string // "" on bearer path
|
||||
Source principalSource
|
||||
}
|
||||
```
|
||||
|
||||
Pure data. No methods that mutate it. No I/O. No manager pointer.
|
||||
|
||||
### 7.2 `buildPrincipalFromSession(*SessionData) *principal` (new in `principal.go`)
|
||||
|
||||
Read-only adapter over existing `SessionData` getters: `GetUserIdentifier`, `GetEmail`, `GetAccessToken`, `GetIDToken`, `GetRefreshToken`, cached claims via `GetIDTokenClaims`. Does not write back to the session. This is the only function that still knows about `SessionData`.
|
||||
|
||||
### 7.3 `buildPrincipalFromBearerToken(token string) (*principal, error)` (new in `bearer_auth.go`)
|
||||
|
||||
1. **Length / format guards**: `len(token) <= AccessTokenConfig.MaxLength`, exactly two dots, non-empty after trim.
|
||||
2. **Parse header for early alg/kid pinning** (without trusting payload): decode JOSE header; reject if `alg` ∉ asymmetric allowlist; reject if `kid` missing, > 256 bytes, or contains chars outside `[A-Za-z0-9._\-=]`. This happens **before** JWKS lookup so attacker noise doesn't amplify into JWKS fetches.
|
||||
3. **Per-IP 401 throttle check**: if this IP is in the `failedBearerAttempts` penalty box, return 429 immediately.
|
||||
4. `t.verifyToken(token, verifyOpts{skipReplayMarking: true})` — reuses signature, issuer, audience, expiration, JTI Get (replay detection). The `skipReplayMarking` flag gates ONLY the JTI Set at `token_manager.go:108-143`; the JTI Get at `token_manager.go:44-47, 80-89` remains active so revoked tokens (via `RevokeToken` adding to blacklist) are still rejected.
|
||||
5. **Re-parse claims** (`parseJWT(token)` is cheap and already done internally; reuse via a single decode if practical).
|
||||
6. **Token-type guard**: call existing `detectTokenType(jwt, token)` (`token_manager.go:187-303`). Reject when it returns `true` (ID token). Belt-and-braces: also reject if `claims["nonce"]` is a non-empty string or `claims["token_use"] == "id"`.
|
||||
7. **Multi-audience hardening**: if `claims["aud"]` is a `[]interface{}` with length > 1, require `claims["azp"]` to be a non-empty string equal to `t.clientID`; reject otherwise.
|
||||
8. **`iat` upper-age bound**: reject when `time.Now().Unix() - int64(claims["iat"].(float64)) > MaxTokenAgeSeconds` (default 86400).
|
||||
9. **Optional introspection**: if `requireTokenIntrospection` is set, call `introspectToken`; reject if `active == false` (401); surface 503 on transport failure. Bearer-path introspection cache TTL is capped at 60s (not 5min) to keep the "real-time revocation" promise close to true.
|
||||
10. **Identifier resolution**: read `t.bearerIdentifierClaim` (defaults to `"sub"`); do NOT use `t.userIdentifierClaim` (cookie path's setting, default `email`). The bearer path does NOT fall back to other claims because `jwt.Verify` already enforces non-empty `sub` (`jwt.go:416-419`). Empty/missing identifier → 401.
|
||||
11. **Identifier sanitisation**: trim, then reject if length > 256 OR contains any of: `unicode.IsControl`, bidi-override (U+202A–U+202E, U+2066–U+2069), `,`, `;`, `=`.
|
||||
12. Return `&principal{ Source: sourceBearer, … }`.
|
||||
|
||||
On any failure path: increment the per-IP `failedBearerAttempts` counter; return the appropriate HTTP status (401 / 403 / 429 / 503) without revealing the failure reason in the response body. Reason is logged at debug only, with the identifier (if resolved) hashed via SHA-256 truncated to 8 hex chars.
|
||||
|
||||
### 7.4 `forwardAuthorized(rw, req, *principal)` (new in `middleware.go`, extracted)
|
||||
|
||||
The shared post-auth pipeline. Lifted verbatim from the existing `processAuthorizedRequest`:
|
||||
|
||||
1. Roles/groups extraction via existing `extractGroupsAndRolesFromClaims`.
|
||||
2. `allowedRolesAndGroups` gate (existing logic).
|
||||
3. Inject `X-Forwarded-User`, `X-User-Groups`, `X-User-Roles`.
|
||||
4. Inject `X-Auth-Request-*` (gated by `minimalHeaders`).
|
||||
5. Header templates.
|
||||
6. Security headers.
|
||||
7. Cookie strip when `stripAuthCookies`.
|
||||
8. **New**: `Authorization` header strip when `stripAuthorizationHeader` AND `principal.Source == sourceBearer`.
|
||||
9. `t.next.ServeHTTP(rw, req)`.
|
||||
|
||||
Does not call `Save`, does not check `IsDirty`. Session persistence stays with the cookie-path caller.
|
||||
|
||||
### 7.5 `handleBearerRequest(rw, req)` (new in `bearer_auth.go`)
|
||||
|
||||
```
|
||||
1. Detect "Authorization: Bearer <token>" (case-insensitive prefix).
|
||||
2. token = TrimSpace(authHeader[7:]); reject empty.
|
||||
3. p, err := buildPrincipalFromBearerToken(token).
|
||||
On err → 401 with WWW-Authenticate, log reason at debug.
|
||||
4. forwardAuthorized(rw, req, p).
|
||||
```
|
||||
|
||||
Target: ~40 lines.
|
||||
|
||||
### 7.6 Refactor of `processAuthorizedRequest` (modify `middleware.go`)
|
||||
|
||||
Splits along the principal boundary:
|
||||
- Session-specific part (backchannel-logout invalidation, `IsDirty` / `Save`) stays in `processAuthorizedRequest`.
|
||||
- Everything else moves to `forwardAuthorized`.
|
||||
- `processAuthorizedRequest` ends with `forwardAuthorized(rw, req, buildPrincipalFromSession(session))`.
|
||||
|
||||
### 7.7 `verifyOpts` extension to `verifyToken` (modify `token_manager.go`)
|
||||
|
||||
Add a parameter struct:
|
||||
```go
|
||||
type verifyOpts struct {
|
||||
skipReplayMarking bool // suppress JTI Set (token_manager.go:108-143); blacklist Get stays active
|
||||
}
|
||||
```
|
||||
|
||||
Both the type and field are unexported (internal-only knob). Signature change: `verifyToken(token string)` becomes `verifyToken(token string, opts verifyOpts)`. Existing callers pass `verifyOpts{}` (zero value = current behaviour). Bearer path passes `verifyOpts{skipReplayMarking: true}`.
|
||||
|
||||
**Critical semantics — must be reflected in implementation and tests:**
|
||||
- `skipReplayMarking` only gates the **Set** at `token_manager.go:108-143` (the call adding the JTI to the blacklist and replay cache).
|
||||
- The blacklist **Get** at `token_manager.go:44-47, 80-89` stays unconditionally active on the bearer path. Tokens revoked via `RevokeToken` (which adds the JTI to the blacklist) MUST still be rejected on the bearer path.
|
||||
- Must NOT be implemented by mutating `t.disableReplayDetection` (struct field) — that would create a cross-request race that disables replay protection globally.
|
||||
|
||||
A targeted regression test exercises: bearer token verified once → admin calls `RevokeToken` adding the JTI to the blacklist → same token replayed → 401.
|
||||
|
||||
### 7.8 Config additions (modify `settings.go`)
|
||||
|
||||
```go
|
||||
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
|
||||
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
|
||||
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
|
||||
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
|
||||
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
|
||||
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
|
||||
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
|
||||
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
|
||||
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
|
||||
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
|
||||
```
|
||||
|
||||
Defaults (applied in `CreateConfig` for the bearer-related fields; values >0 only honoured when `EnableBearerAuth=true`):
|
||||
- `EnableBearerAuth`: `false`.
|
||||
- `BearerIdentifierClaim`: `"sub"`.
|
||||
- `StripAuthorizationHeader`: `true`.
|
||||
- `BearerEmitWWWAuthenticate`: `true` (RFC 6750 hint enabled by default; flip to false if recon-exposure is a concern).
|
||||
- `BearerOverridesCookie`: `false` (cookie wins when both present; flip to `true` for the legacy/industry-default behaviour).
|
||||
- `MaxTokenAgeSeconds`: `86400` (24h upper bound on `iat`).
|
||||
- `MaxIdentifierLength`: `256`.
|
||||
- `BearerFailureThreshold`: `20` (consecutive 401s per IP before throttle).
|
||||
- `BearerFailureWindowSeconds`: `60`.
|
||||
- `BearerFailurePenaltySeconds`: `60` (429 reply for this long after threshold tripped).
|
||||
|
||||
### 7.9 Startup validation (modify `main.go` `New()`)
|
||||
|
||||
- `EnableBearerAuth && Audience == ""` → fatal error.
|
||||
- `EnableBearerAuth && !StrictAudienceValidation` → warning log (recommended hardening).
|
||||
- `EnableBearerAuth && BearerIdentifierClaim == "email"` → fatal error (the bearer path is M2M and an `email` identifier without `email_verified` enforcement is a spoofing vector; default `BearerIdentifierClaim=sub` avoids this; explicit override to `email` is rejected).
|
||||
- `EnableBearerAuth && MaxTokenAgeSeconds <= 0` → reset to default 86400 with info log.
|
||||
- `EnableBearerAuth && BearerFailureThreshold <= 0` → reset to default 20 with info log.
|
||||
|
||||
## 8. Data Flow
|
||||
|
||||
### 8.1 Bearer path
|
||||
|
||||
```
|
||||
ServeHTTP entry (pre-init paths unchanged: logout, backchannel, frontchannel, excluded URLs, SSE/WS bypass)
|
||||
│
|
||||
├─ enableBearerAuth == false? → fall through to cookie path
|
||||
│
|
||||
└─ enableBearerAuth == true AND Authorization starts with "Bearer "
|
||||
│
|
||||
▼
|
||||
handleBearerRequest
|
||||
│
|
||||
├─ format guards (empty, length, segment count)
|
||||
│
|
||||
▼
|
||||
verifyToken(token, verifyOpts{SkipReplayMarking: true})
|
||||
│ signature, issuer, audience (strict), exp
|
||||
│
|
||||
▼
|
||||
classifyToken(claims) → reject ID tokens
|
||||
│
|
||||
▼
|
||||
if requireTokenIntrospection: introspectToken → active check
|
||||
│
|
||||
▼
|
||||
resolveIdentifier(claims) → sanitiseIdentifier
|
||||
│
|
||||
▼
|
||||
principal{Source: sourceBearer, …}
|
||||
│
|
||||
▼
|
||||
forwardAuthorized(rw, req, principal)
|
||||
│
|
||||
├─ roles/groups gate (403 on deny)
|
||||
├─ header injection
|
||||
├─ header templates
|
||||
├─ security headers
|
||||
├─ strip OIDC cookies (existing)
|
||||
├─ strip Authorization header (new, when configured)
|
||||
└─ next.ServeHTTP(rw, req)
|
||||
```
|
||||
|
||||
### 8.2 Cookie path (refactored, semantically unchanged)
|
||||
|
||||
```
|
||||
processAuthorizedRequest
|
||||
1. Session validity / backchannel-logout invalidation (unchanged).
|
||||
2. principal := buildPrincipalFromSession(session).
|
||||
3. forwardAuthorized(rw, req, principal).
|
||||
4. if session.IsDirty(): session.Save().
|
||||
```
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
| Trigger | Status | Body | WWW-Authenticate | Debug log reason |
|
||||
|---|---|---|---|---|
|
||||
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` | empty bearer token |
|
||||
| Token over MaxLength | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token exceeds max length |
|
||||
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` | malformed JWT |
|
||||
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | unsupported alg |
|
||||
| Missing/oversized/bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid kid |
|
||||
| Signature / issuer / aud / exp fail | 401 | `Unauthorized` | `Bearer error="invalid_token"` | reason from verifyToken (category only) |
|
||||
| `iat` older than MaxTokenAgeSeconds | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token too old (iat outside age bound) |
|
||||
| Multi-aud without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | multi-aud token without azp match |
|
||||
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` | ID tokens not accepted on bearer path |
|
||||
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token JTI in blacklist |
|
||||
| Introspection `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token inactive at IdP |
|
||||
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) | introspection unavailable |
|
||||
| Identifier claim missing/empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` | no identifier claim |
|
||||
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid identifier characters |
|
||||
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <BearerFailurePenaltySeconds>` | source IP in penalty box |
|
||||
| Roles/groups not allowed | 403 | `Access denied` | (none) | user not in allowedRolesAndGroups |
|
||||
|
||||
Responses never include token contents, never include the raw failure reason, and never set `Location` headers (API clients cannot follow redirects).
|
||||
|
||||
## 10. Edge Cases
|
||||
|
||||
1. **Both bearer header and cookie session present.** Cookie wins by default (safer against browser/extension/proxy bearer injection). `BearerOverridesCookie=true` flips to bearer-wins. Either way: WARN log includes both source markers so operators can audit.
|
||||
2. **`Authorization: Basic …`.** Not bearer; cookie path runs as today.
|
||||
3. **`Authorization: Bearer ` (trailing space, no value).** Empty after trim → 401.
|
||||
4. **Mixed-case prefix (`bearer`, `BEARER`, `BeArEr`).** Case-insensitive prefix check; token value preserved verbatim.
|
||||
5. **Multiple `Authorization` headers.** Use only the first (Go `http.Header.Get` default). Documented.
|
||||
6. **Bearer during OIDC init wait.** Bearer requests also block on init: we need `issuerURL`, `audience`, JWKs ready. If init fails, bearer requests return 503 just like cookie requests.
|
||||
7. **SSE / WebSocket bypass with bearer.** Bypass paths keep cookie-only behaviour. Operators who want bearer on streaming endpoints must remove SSE/WS bypass. Documented.
|
||||
8. **Logout endpoint with bearer.** Logout runs before bearer detection. Treated as cookie-session logout; bearer token revocation requires IdP-side action.
|
||||
9. **Excluded URLs with bearer.** Bypass excluded URLs as today; bearer not validated on excluded paths. ADDITIONALLY: `Authorization: Bearer` is stripped from the request before forwarding so the token can't leak into the excluded endpoint's downstream logs / metrics scrapers / health checks.
|
||||
10. **Concurrent identical bearer requests.** Existing `tokenCache` is concurrency-safe; no new locking.
|
||||
11. **Client rotates token between requests.** Independent verification per token; independent cache entries.
|
||||
12. **Clock skew.** Use existing `jwt.Verify` leeway. (If absent, add ±30s as a separate change; out of scope here.)
|
||||
|
||||
## 11. Testing Strategy
|
||||
|
||||
### 11.1 Integration tests (new `bearer_auth_test.go`)
|
||||
|
||||
Table-driven test against a real `httptest.Server` and the full `ServeHTTP` flow. Coverage matrix:
|
||||
|
||||
- Valid access token + allowed roles → 200, `next` ran, `X-Forwarded-User` set.
|
||||
- Valid token without configured roles → 200.
|
||||
- Wrong audience, expired, tampered signature → 401, `next` did not run.
|
||||
- ID token presented → 401 (`ID tokens not accepted`).
|
||||
- Malformed JWT (2 segments) → 401.
|
||||
- Oversized token (> MaxLength) → 401.
|
||||
- Empty bearer → 401.
|
||||
- Missing identifier claim → 401.
|
||||
- Identifier containing `\r\n` → 401.
|
||||
- `allowedRolesAndGroups` mismatch → 403.
|
||||
- `allowedRolesAndGroups` match → 200.
|
||||
- `EnableBearerAuth=false` + bearer header → cookie path runs (302 to `/authorize`).
|
||||
- Bearer + valid cookie session → bearer wins, 200.
|
||||
- `StripAuthorizationHeader=true` → downstream sees no `Authorization`.
|
||||
- `StripAuthorizationHeader=false` → downstream sees `Authorization`.
|
||||
- Case variants (`bearer`, `BEARER`) → 200.
|
||||
- SSE bypass + bearer → cookie-only check applies (bearer ignored).
|
||||
- **Replay regression**: same token 1000 times in a row → all 200.
|
||||
- **Cache-evict regression**: same token, force-evict `tokenCache` between iterations (call `tokenCache.Delete` directly), replay → still 200 (verifies `skipReplayMarking` doesn't poison the blacklist).
|
||||
- **Revocation-while-bearer regression**: bearer token verified once → admin calls `RevokeToken` adding JTI to blacklist → same token presented → 401 (verifies blacklist Get stays active on bearer path even with `skipReplayMarking` set).
|
||||
- **Alg-pin: token signed with `alg=none`** → 401, no JWKS fetch happens (verify with a counting mock).
|
||||
- **`kid` injection: 50KB random kid** → 401 immediately, no JWKS fetch.
|
||||
- **Per-IP throttle**: 21 bad bearer requests from same IP within 1 minute → 22nd returns 429 + Retry-After.
|
||||
- **`iat` upper-age**: token with `iat = now - 25h` → 401 (older than 24h default).
|
||||
- **Multi-aud without azp**: aud = `["a", "b"]`, no azp → 401.
|
||||
- **Multi-aud with matching azp**: aud = `["api-aud", "other"]`, azp = clientID → 200.
|
||||
- **Identifier with bidi-override**: sub contains U+202E → 401.
|
||||
- **Identifier with comma**: sub = `"alice,bob"` → 401.
|
||||
- **Identifier over 256 bytes** → 401.
|
||||
- **`UserIdentifierClaim=email` at startup with EnableBearerAuth=true** → startup fails.
|
||||
- **Excluded URL + bearer**: bearer header presented on excluded URL → request forwarded, downstream sees no `Authorization` header (stripped).
|
||||
|
||||
### 11.2 Unit tests (in `bearer_auth_test.go`)
|
||||
|
||||
- `classifyToken`: ID-token detection, access-token detection by `scope`/`scp`/`token_use`, ambiguous → reject.
|
||||
- `resolveIdentifier`: precedence (`userIdentifierClaim` → `sub` → `client_id`/`azp`); missing → error; empty string → error.
|
||||
- `sanitizeIdentifier`: rejects all `unicode.IsControl`; accepts email/sub-style values.
|
||||
|
||||
### 11.3 Introspection tests (`bearer_auth_introspection_test.go`)
|
||||
|
||||
- Token valid + introspection `active=true` → 200.
|
||||
- Token valid + introspection `active=false` → 401.
|
||||
- Introspection endpoint 500 → 503.
|
||||
- Second request hits introspection cache (no second HTTP call).
|
||||
|
||||
### 11.4 Startup validation tests (extend `settings_test.go` / `main_test.go`)
|
||||
|
||||
- `EnableBearerAuth=true, Audience=""` → `New()` errors.
|
||||
- `EnableBearerAuth=true, StrictAudienceValidation=false` → succeeds with warning.
|
||||
- `EnableBearerAuth=false` → no validation; existing tests untouched.
|
||||
|
||||
### 11.5 Cookie-path regression suite
|
||||
|
||||
- All existing `TestServeHTTP_*` tests in `main_servehttp_test.go` pass unmodified.
|
||||
- Add: cookie session, `EnableBearerAuth=true`, no bearer header → identical behaviour to baseline.
|
||||
- Add: dirty session still triggers `Save()` after refactor.
|
||||
|
||||
### 11.6 Principal invariants
|
||||
|
||||
- `buildPrincipalFromSession`: `Source == sourceSession`; `IDToken` / `RefreshToken` populated when present in session.
|
||||
- `buildPrincipalFromBearerToken`: `Source == sourceBearer`; `IDToken == ""`, `RefreshToken == ""`.
|
||||
- `forwardAuthorized` produces identical headers for equivalent principals regardless of source.
|
||||
|
||||
### 11.7 Coverage gate
|
||||
|
||||
- New code in `bearer_auth.go` and `principal.go`: ≥ 90% line coverage.
|
||||
- `forwardAuthorized` coverage ≥ existing `processAuthorizedRequest` coverage baseline.
|
||||
|
||||
### 11.8 Out of scope (follow-ups)
|
||||
|
||||
- Load test of bearer vs cookie hot path.
|
||||
- Fuzzing the JWT parser.
|
||||
- Additional auth methods (mTLS, API keys) — design enables them, but they are separate work.
|
||||
|
||||
## 12. Migration / Rollout
|
||||
|
||||
Default-off. Existing deployments observe no behavioural change. Operators opt in by setting:
|
||||
|
||||
```yaml
|
||||
enableBearerAuth: true
|
||||
audience: https://api.example.com # required when bearer enabled
|
||||
# optional:
|
||||
stripAuthorizationHeader: true # default
|
||||
requireTokenIntrospection: false # default; set true for real-time revocation
|
||||
userIdentifierClaim: client_id # optional override; defaults to sub fallback chain
|
||||
```
|
||||
|
||||
Documentation: update `docs/CONFIGURATION.md` with a bearer-auth section, and add a new `docs/BEARER_AUTH.md` covering the security model, threat assumptions (token issuer is trusted; audience must be set; bearer means trust the issuer's revocation policy unless introspection enabled), and recommended configurations for common IdPs.
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Token confusion (ID token used as bearer) | Reuse `detectTokenType` (`token_manager.go:187-303`) which checks `nonce`, `typ: at+jwt`, `token_use`, `scope`, aud-vs-clientID. Belt-and-braces: explicit `nonce` + `token_use == "id"` rejection on top. |
|
||||
| Audience confusion (token for service B accepted by A) | `Audience` mandatory at startup; verified via existing `VerifyJWTSignatureAndClaims`; multi-aud tokens require matching `azp == clientID`. |
|
||||
| Replay-via-blacklist false positive | `verifyOpts{skipReplayMarking: true}` on bearer path. Gates ONLY the Set; the Get stays so revoked tokens still fail. |
|
||||
| Revocation lag | Optional RFC 7662 introspection. Bearer-path introspection cache TTL capped at 60s. Set `RequireTokenIntrospection=true` for real-time revocation. |
|
||||
| `alg`-confusion / `alg=none` attacks | Hard-pin asymmetric allowlist at bearer entry, **before** JWKS fetch. Prevents wasted upstream calls and locks out HS/none probes. |
|
||||
| `kid` injection / JWKS amplification | `kid` length cap (256 bytes) + charset allowlist enforced at bearer entry. |
|
||||
| Bearer 401 brute-force / oracle | Per-IP `failedBearerAttempts` cache; configurable threshold + penalty box returning 429 + `Retry-After`. |
|
||||
| `iat` clock-manipulation / forever-tokens | `MaxTokenAgeSeconds` upper bound (default 24h); cookie path unchanged. |
|
||||
| Identifier-driven header injection | `sanitizeIdentifier`: length cap, control-char + bidi-override + `,;=` rejection. `net/http` rejects CRLF on the wire too (defence in depth). |
|
||||
| Token leakage downstream | `StripAuthorizationHeader=true` by default. Also: `Authorization` stripped on excluded-URL requests so bearer can't leak into health/metrics downstream logs. |
|
||||
| Token-in-logs | All log paths log reason categories, not raw tokens. Identifier hashed via SHA-256 truncated to 8 hex chars before any info/warn-level emission (full identifier only at debug). New `safeLogAuthEvent(category, hashedIdentifier, reasonCode)` helper makes this hard to misuse. |
|
||||
| `email` claim spoofing | Startup fails if `EnableBearerAuth && UserIdentifierClaim == "email"`. Future human-user bearer iteration must add `email_verified` enforcement. |
|
||||
| Bypass on SSE / WS endpoints | SSE/WS bypass keeps cookie-only behaviour; bearer ignored. Operators choose to widen if needed. |
|
||||
| Mixed bearer + cookie precedence | Cookie wins by default (safer for browser scenarios); `BearerOverridesCookie=true` flips. WARN log on both-present requests. |
|
||||
| Configuration drift (operator forgets audience) | Startup fails when `EnableBearerAuth=true && Audience==""`. |
|
||||
| Downstream blast radius when `StripAuthorizationHeader=false` | Documented: forwarded bearer extends token's blast radius to all downstream services. Logs at those services become token stores. Operators must treat downstream log policy accordingly. |
|
||||
| Introspection auth method (pre-existing gap, called out) | `token_introspection.go:80` uses `client_secret_basic` only; does not honour `private_key_jwt`. Out of scope for this PR but documented as a follow-up; operators using `ClientAuthMethod=private_key_jwt` + `RequireTokenIntrospection=true` should be aware introspection will use basic auth. |
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
None — all design decisions resolved during brainstorming + security review. Implementation may surface incidental questions (e.g. exact clock-skew leeway in `jwt.Verify`); those are out of scope for this spec and handled in the implementation plan.
|
||||
|
||||
## 14a. Security Review Reference
|
||||
|
||||
This design was reviewed by the `security-reviewer` subagent on 2026-05-18. Findings incorporated:
|
||||
|
||||
- **Critical**: C1 (classifier reuses `detectTokenType`), C2 (sub fallback dropped — unreachable due to `jwt.go:416`), C3 (replay-marking gates only Set, not Get; revocation regression test added).
|
||||
- **High**: H1 (alg pinned at bearer entry), H2 (kid length + charset), H3 (cookie wins by default, configurable), H4 (per-IP 401 throttle), H5 (multi-aud requires azp).
|
||||
- **Medium**: M1 (identifier max-length + bidi reject + delimiter chars), M2 (introspection cache TTL capped at 60s on bearer path), M4 (log-hashing via SHA-256[:8]), M5 (StripAuth blast-radius documented), M6 (iat upper-age bound), M7 (Authorization stripped on excluded URLs).
|
||||
- **Low/Nit**: L2 (renamed to `BearerEmitWWWAuthenticate`), N3 (startup rejects `UserIdentifierClaim=email`).
|
||||
- **Documented as pre-existing gaps (follow-up PRs)**: M3 (introspection auth method doesn't honour `private_key_jwt`).
|
||||
|
||||
## 15. Implementation Plan Reference
|
||||
|
||||
To be produced by the `writing-plans` skill in a follow-up document at `docs/superpowers/plans/2026-05-18-bearer-token-auth-plan.md`. The plan decomposes this design into ordered, independently-testable PRs.
|
||||
+101
-199
@@ -7,7 +7,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -52,6 +54,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -103,6 +106,19 @@ 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.
|
||||
@@ -141,8 +157,10 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
|
||||
// Validate the endpoint URL
|
||||
if !strings.HasPrefix(endpoint, "https://") {
|
||||
// Allow http only for localhost/development
|
||||
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
|
||||
// Allow http only for loopback/development endpoints. Use a
|
||||
// host-boundary check (not a prefix) so e.g.
|
||||
// http://localhost.evil.com isn't treated as localhost.
|
||||
if !isLoopbackRegistrationEndpoint(endpoint) {
|
||||
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
|
||||
}
|
||||
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
|
||||
@@ -203,6 +221,19 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
return nil, fmt.Errorf("registration response missing client_id")
|
||||
}
|
||||
|
||||
// The plugin's runtime client-auth default is client_secret_post
|
||||
// (settings.go Config.Validate); RFC 7591 registers a confidential
|
||||
// client. For a secret-based auth method the response must carry a
|
||||
// client_secret, otherwise every token exchange fails. Adopting a
|
||||
// secret-less client would silently break all auth.
|
||||
authMethod := "client_secret_post"
|
||||
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
|
||||
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
|
||||
}
|
||||
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && regResp.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("registration response missing client_secret for auth method %q", authMethod)
|
||||
}
|
||||
|
||||
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
|
||||
|
||||
// Cache the response
|
||||
@@ -233,11 +264,41 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
|
||||
// Required: redirect_uris
|
||||
if len(metadata.RedirectURIs) > 0 {
|
||||
// Reject malformed or wrong-scheme entries before they reach the IdP:
|
||||
// RFC 7591 expects absolute http/https redirect URIs, and an invalid
|
||||
// one sent to the IdP would corrupt the registered callback.
|
||||
for _, uri := range metadata.RedirectURIs {
|
||||
parsed, perr := url.ParseRequestURI(uri)
|
||||
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("redirect_uri %q must be an absolute http(s) URL", uri)
|
||||
}
|
||||
}
|
||||
reqData["redirect_uris"] = metadata.RedirectURIs
|
||||
} else {
|
||||
return nil, fmt.Errorf("redirect_uris is required for client registration")
|
||||
}
|
||||
|
||||
// Validate the optional URI metadata fields with the same absolute
|
||||
// http(s) rule as redirect_uris. A relative or non-http(s) value
|
||||
// (e.g. a malformed jwks_uri on a private_key_jwt client) would be
|
||||
// forwarded to the IdP and make the registered client unverifiable at
|
||||
// runtime (R141).
|
||||
for _, field := range []struct{ val, name string }{
|
||||
{metadata.LogoURI, "logo_uri"},
|
||||
{metadata.ClientURI, "client_uri"},
|
||||
{metadata.PolicyURI, "policy_uri"},
|
||||
{metadata.TOSURI, "tos_uri"},
|
||||
{metadata.JWKSURI, "jwks_uri"},
|
||||
} {
|
||||
if field.val == "" {
|
||||
continue
|
||||
}
|
||||
parsed, perr := url.ParseRequestURI(field.val)
|
||||
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("%s %q must be an absolute http(s) URL", field.name, field.val)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields - only include if set
|
||||
if len(metadata.ResponseTypes) > 0 {
|
||||
reqData["response_types"] = metadata.ResponseTypes
|
||||
@@ -292,8 +353,10 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
if metadata.TokenEndpointAuthMethod != "" {
|
||||
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
|
||||
} else {
|
||||
// Default to client_secret_basic for confidential clients
|
||||
reqData["token_endpoint_auth_method"] = "client_secret_basic"
|
||||
// Default must match the plugin's runtime client-auth default
|
||||
// (client_secret_post, settings.go Config.Validate) so an IdP that
|
||||
// enforces the registered method does not reject the token exchange.
|
||||
reqData["token_endpoint_auth_method"] = "client_secret_post"
|
||||
}
|
||||
|
||||
if metadata.DefaultMaxAge > 0 {
|
||||
@@ -310,6 +373,13 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
|
||||
if metadata.Scope != "" {
|
||||
reqData["scope"] = metadata.Scope
|
||||
} else if len(r.scopes) > 0 {
|
||||
// Register with the union of runtime auth scopes when the operator
|
||||
// gave no explicit registration scope. IdPs that only grant
|
||||
// registered/advertised scopes (Keycloak offline_access, Auth0)
|
||||
// otherwise register a client whose later token/refresh grant is
|
||||
// rejected with invalid_scope, breaking refresh (R124).
|
||||
reqData["scope"] = strings.Join(deduplicateScopes(r.scopes), " ")
|
||||
}
|
||||
|
||||
return json.Marshal(reqData)
|
||||
@@ -328,6 +398,19 @@ func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationRes
|
||||
return false
|
||||
}
|
||||
|
||||
// For a secret-based auth method the response must carry a client_secret,
|
||||
// mirroring the fresh-registration check (RegisterClient). A stored
|
||||
// record with a client_id but empty secret would otherwise be accepted
|
||||
// and installed as t.clientSecret="", breaking every token exchange
|
||||
// (R124).
|
||||
authMethod := "client_secret_post"
|
||||
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
|
||||
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
|
||||
}
|
||||
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && resp.ClientSecret == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if secret has expired
|
||||
if resp.ClientSecretExpiresAt > 0 {
|
||||
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
|
||||
@@ -370,26 +453,11 @@ func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, res
|
||||
return r.saveCredentials(resp)
|
||||
}
|
||||
|
||||
// deleteCredentialsFromStore removes credentials from the configured storage backend
|
||||
// Falls back to legacy file-based deletion if no store is configured
|
||||
func (r *DynamicClientRegistrar) deleteCredentialsFromStore(ctx context.Context) error {
|
||||
// Use store if available
|
||||
if r.store != nil {
|
||||
return r.store.Delete(ctx, r.providerURL)
|
||||
}
|
||||
// Fallback to legacy file-based deletion
|
||||
filePath := r.credentialsFilePath()
|
||||
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveCredentials persists client credentials to a file (legacy method)
|
||||
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
|
||||
filePath := r.credentialsFilePath()
|
||||
|
||||
data, err := json.MarshalIndent(resp, "", " ")
|
||||
data, err := json.MarshalIndent(resp, "", " ") // #nosec G117 -- the registration response, including client_secret, is the credential this function persists
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
@@ -424,186 +492,20 @@ func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse,
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateClientRegistration updates an existing client registration using RFC 7592
|
||||
// This requires the registration_client_uri and registration_access_token from the original registration
|
||||
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||
r.mu.RLock()
|
||||
cachedResp := r.registrationResponse
|
||||
r.mu.RUnlock()
|
||||
|
||||
if cachedResp == nil {
|
||||
return nil, fmt.Errorf("no existing registration to update")
|
||||
// isLoopbackRegistrationEndpoint reports whether the (plaintext) registration
|
||||
// endpoint targets a loopback host, matching the hostname boundary (not a
|
||||
// string prefix) so http://localhost.evil.com is not treated as localhost.
|
||||
func isLoopbackRegistrationEndpoint(endpoint string) bool {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return false
|
||||
}
|
||||
|
||||
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||
host := u.Hostname()
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Build update request
|
||||
reqBody, err := r.buildRegistrationRequest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build update request: %w", err)
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create update request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read update response: %w", err)
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var regError ClientRegistrationError
|
||||
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||
return nil, fmt.Errorf("update failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||
}
|
||||
return nil, fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
var regResp ClientRegistrationResponse
|
||||
if err := json.Unmarshal(body, ®Resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse update response: %w", err)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
r.mu.Lock()
|
||||
r.registrationResponse = ®Resp
|
||||
r.mu.Unlock()
|
||||
|
||||
// Persist updated credentials if enabled
|
||||
if r.config.PersistCredentials {
|
||||
if err := r.saveCredentialsToStore(ctx, ®Resp); err != nil {
|
||||
r.logger.Errorf("Failed to persist updated credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Infof("Successfully updated client registration for client ID: %s", regResp.ClientID)
|
||||
return ®Resp, nil
|
||||
}
|
||||
|
||||
// ReadClientRegistration reads the current client registration using RFC 7592
|
||||
func (r *DynamicClientRegistrar) ReadClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||
r.mu.RLock()
|
||||
cachedResp := r.registrationResponse
|
||||
r.mu.RUnlock()
|
||||
|
||||
if cachedResp == nil {
|
||||
return nil, fmt.Errorf("no existing registration to read")
|
||||
}
|
||||
|
||||
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cachedResp.RegistrationClientURI, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create read request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var regError ClientRegistrationError
|
||||
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||
return nil, fmt.Errorf("read failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||
}
|
||||
return nil, fmt.Errorf("read failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
var regResp ClientRegistrationResponse
|
||||
if err := json.Unmarshal(body, ®Resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse read response: %w", err)
|
||||
}
|
||||
|
||||
return ®Resp, nil
|
||||
}
|
||||
|
||||
// DeleteClientRegistration deletes the client registration using RFC 7592
|
||||
func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) error {
|
||||
r.mu.RLock()
|
||||
cachedResp := r.registrationResponse
|
||||
r.mu.RUnlock()
|
||||
|
||||
if cachedResp == nil {
|
||||
return fmt.Errorf("no existing registration to delete")
|
||||
}
|
||||
|
||||
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||
return fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cachedResp.RegistrationClientURI, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle error responses (204 No Content is success)
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var regError ClientRegistrationError
|
||||
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||
return fmt.Errorf("delete failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||
}
|
||||
return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
r.mu.Lock()
|
||||
r.registrationResponse = nil
|
||||
r.mu.Unlock()
|
||||
|
||||
// Remove credentials from storage if persistence is enabled
|
||||
if r.config.PersistCredentials {
|
||||
if err := r.deleteCredentialsFromStore(ctx); err != nil {
|
||||
r.logger.Errorf("Failed to remove credentials from storage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("Successfully deleted client registration")
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -541,6 +541,7 @@ func TestCredentialsValidation(t *testing.T) {
|
||||
name: "valid non-expiring credentials",
|
||||
response: &ClientRegistrationResponse{
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
ClientSecretExpiresAt: 0, // Never expires
|
||||
},
|
||||
expected: true,
|
||||
@@ -549,6 +550,7 @@ func TestCredentialsValidation(t *testing.T) {
|
||||
name: "valid future-expiring credentials",
|
||||
response: &ClientRegistrationResponse{
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
},
|
||||
expected: true,
|
||||
@@ -598,7 +600,7 @@ func TestBuildRegistrationRequest(t *testing.T) {
|
||||
"redirect_uris": []interface{}{"https://example.com/callback"},
|
||||
"response_types": []interface{}{"code"},
|
||||
"grant_types": []interface{}{"authorization_code", "refresh_token"},
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
@@ -735,258 +737,6 @@ func TestDCRConfigDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateClientRegistration tests the RFC 7592 client update functionality
|
||||
func TestUpdateClientRegistration(t *testing.T) {
|
||||
updateCalled := false
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
updateCalled = true
|
||||
|
||||
// Verify authorization header
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
t.Error("Missing Authorization header for update")
|
||||
}
|
||||
|
||||
resp := ClientRegistrationResponse{
|
||||
ClientID: "updated-client-id",
|
||||
ClientSecret: "updated-client-secret",
|
||||
RegistrationAccessToken: "new-access-token",
|
||||
RegistrationClientURI: r.URL.String(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dcrConfig := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
ClientMetadata: &ClientRegistrationMetadata{
|
||||
RedirectURIs: []string{"https://example.com/callback"},
|
||||
},
|
||||
}
|
||||
|
||||
registrar := NewDynamicClientRegistrar(
|
||||
server.Client(),
|
||||
NewLogger("DEBUG"),
|
||||
dcrConfig,
|
||||
server.URL,
|
||||
)
|
||||
|
||||
// Set up cached response with management credentials
|
||||
registrar.mu.Lock()
|
||||
registrar.registrationResponse = &ClientRegistrationResponse{
|
||||
ClientID: "original-client-id",
|
||||
ClientSecret: "original-client-secret",
|
||||
RegistrationAccessToken: "access-token",
|
||||
RegistrationClientURI: server.URL + "/register/client123",
|
||||
}
|
||||
registrar.mu.Unlock()
|
||||
|
||||
// Perform update
|
||||
ctx := context.Background()
|
||||
resp, err := registrar.UpdateClientRegistration(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
if !updateCalled {
|
||||
t.Error("Update endpoint was not called")
|
||||
}
|
||||
|
||||
if resp.ClientID != "updated-client-id" {
|
||||
t.Errorf("Updated ClientID mismatch: got %s", resp.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteClientRegistration tests the RFC 7592 client deletion functionality
|
||||
func TestDeleteClientRegistration(t *testing.T) {
|
||||
deleteCalled := false
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
credentialsFile := filepath.Join(tempDir, "credentials.json")
|
||||
|
||||
// Create a credentials file to test deletion
|
||||
os.WriteFile(credentialsFile, []byte(`{"client_id":"test"}`), 0600)
|
||||
|
||||
dcrConfig := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
CredentialsFile: credentialsFile,
|
||||
}
|
||||
|
||||
registrar := NewDynamicClientRegistrar(
|
||||
server.Client(),
|
||||
NewLogger("DEBUG"),
|
||||
dcrConfig,
|
||||
server.URL,
|
||||
)
|
||||
|
||||
// Set up cached response with management credentials
|
||||
registrar.mu.Lock()
|
||||
registrar.registrationResponse = &ClientRegistrationResponse{
|
||||
ClientID: "test-client-id",
|
||||
RegistrationAccessToken: "access-token",
|
||||
RegistrationClientURI: server.URL + "/register/client123",
|
||||
}
|
||||
registrar.mu.Unlock()
|
||||
|
||||
// Perform delete
|
||||
ctx := context.Background()
|
||||
err := registrar.DeleteClientRegistration(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
if !deleteCalled {
|
||||
t.Error("Delete endpoint was not called")
|
||||
}
|
||||
|
||||
// Verify cache is cleared
|
||||
if registrar.GetCachedResponse() != nil {
|
||||
t.Error("Cached response should be cleared after deletion")
|
||||
}
|
||||
|
||||
// Verify credentials file is deleted
|
||||
if _, err := os.Stat(credentialsFile); !os.IsNotExist(err) {
|
||||
t.Error("Credentials file should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadClientRegistration tests the RFC 7592 client read functionality
|
||||
func TestReadClientRegistration(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
resp := ClientRegistrationResponse{
|
||||
ClientID: "read-client-id",
|
||||
ClientSecret: "read-client-secret",
|
||||
RedirectURIs: []string{"https://example.com/callback"},
|
||||
ResponseTypes: []string{"code"},
|
||||
GrantTypes: []string{"authorization_code"},
|
||||
ApplicationType: "web",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
|
||||
|
||||
registrar := NewDynamicClientRegistrar(
|
||||
server.Client(),
|
||||
NewLogger("DEBUG"),
|
||||
dcrConfig,
|
||||
server.URL,
|
||||
)
|
||||
|
||||
// Set up cached response with management credentials
|
||||
registrar.mu.Lock()
|
||||
registrar.registrationResponse = &ClientRegistrationResponse{
|
||||
ClientID: "original-client-id",
|
||||
RegistrationAccessToken: "access-token",
|
||||
RegistrationClientURI: server.URL + "/register/client123",
|
||||
}
|
||||
registrar.mu.Unlock()
|
||||
|
||||
// Read registration
|
||||
ctx := context.Background()
|
||||
resp, err := registrar.ReadClientRegistration(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.ClientID != "read-client-id" {
|
||||
t.Errorf("Read ClientID mismatch: got %s", resp.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationsWithoutCachedResponse tests error handling when no cached response exists
|
||||
func TestOperationsWithoutCachedResponse(t *testing.T) {
|
||||
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
|
||||
|
||||
registrar := NewDynamicClientRegistrar(
|
||||
&http.Client{},
|
||||
NewLogger("DEBUG"),
|
||||
dcrConfig,
|
||||
"https://example.com",
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Update without cached response
|
||||
_, err := registrar.UpdateClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "no existing registration") {
|
||||
t.Errorf("Update should fail without cached response: %v", err)
|
||||
}
|
||||
|
||||
// Test Read without cached response
|
||||
_, err = registrar.ReadClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "no existing registration") {
|
||||
t.Errorf("Read should fail without cached response: %v", err)
|
||||
}
|
||||
|
||||
// Test Delete without cached response
|
||||
err = registrar.DeleteClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "no existing registration") {
|
||||
t.Errorf("Delete should fail without cached response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationsWithoutManagementCredentials tests error handling without management URIs
|
||||
func TestOperationsWithoutManagementCredentials(t *testing.T) {
|
||||
dcrConfig := &DynamicClientRegistrationConfig{Enabled: true}
|
||||
|
||||
registrar := NewDynamicClientRegistrar(
|
||||
&http.Client{},
|
||||
NewLogger("DEBUG"),
|
||||
dcrConfig,
|
||||
"https://example.com",
|
||||
)
|
||||
|
||||
// Set up cached response WITHOUT management credentials
|
||||
registrar.mu.Lock()
|
||||
registrar.registrationResponse = &ClientRegistrationResponse{
|
||||
ClientID: "test-client-id",
|
||||
// Missing RegistrationAccessToken and RegistrationClientURI
|
||||
}
|
||||
registrar.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Update without management credentials
|
||||
_, err := registrar.UpdateClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "registration management not supported") {
|
||||
t.Errorf("Update should fail without management credentials: %v", err)
|
||||
}
|
||||
|
||||
// Test Read without management credentials
|
||||
_, err = registrar.ReadClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "registration management not supported") {
|
||||
t.Errorf("Read should fail without management credentials: %v", err)
|
||||
}
|
||||
|
||||
// Test Delete without management credentials
|
||||
err = registrar.DeleteClientRegistration(ctx)
|
||||
if err == nil || !stringContains(err.Error(), "registration management not supported") {
|
||||
t.Errorf("Delete should fail without management credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stringContains is a helper function to check if a string contains a substring
|
||||
func stringContains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr))
|
||||
|
||||
@@ -2,6 +2,8 @@ package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -40,6 +42,35 @@ func (m *EnhancedMockJWKCache) GetJWKS(ctx context.Context, jwksURL string, http
|
||||
return m.JWKS, m.Err
|
||||
}
|
||||
|
||||
func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||
jwks, err := m.GetJWKS(ctx, jwksURL, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if jwks == nil {
|
||||
return nil, fmt.Errorf("JWKS is nil")
|
||||
}
|
||||
for i := range jwks.Keys {
|
||||
k := &jwks.Keys[i]
|
||||
if k.Kid != kid {
|
||||
continue
|
||||
}
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
return k.ToRSAPublicKey()
|
||||
case "EC":
|
||||
return k.ToECDSAPublicKey()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||
}
|
||||
|
||||
func (m *EnhancedMockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
|
||||
}
|
||||
|
||||
func (m *EnhancedMockJWKCache) Cleanup() {
|
||||
atomic.AddInt32(&m.CleanupCalls, 1)
|
||||
m.mu.Lock()
|
||||
|
||||
+438
-28
@@ -191,12 +191,23 @@ type CircuitBreaker struct {
|
||||
maxFailures int
|
||||
// timeout is how long to wait before allowing requests in half-open state
|
||||
timeout time.Duration
|
||||
// resetTimeout is how long to wait before transitioning from open to half-open
|
||||
// resetTimeout is how long to stay in half-open (after the open timeout)
|
||||
// before a successful response fully closes the circuit
|
||||
resetTimeout time.Duration
|
||||
// state tracks the current circuit breaker state
|
||||
state CircuitBreakerState
|
||||
// failures counts consecutive failures
|
||||
failures int64
|
||||
// halfOpenSince is when the circuit entered half-open; used to enforce
|
||||
// resetTimeout before a success can fully close it
|
||||
halfOpenSince time.Time
|
||||
// openedAt is when the circuit last transitioned into the Open state
|
||||
// (Closed->Open or HalfOpen->Open, set in recordFailure). allowRequest
|
||||
// and IsAvailable gate the Open->HalfOpen transition on openedAt instead
|
||||
// of the promoted lastFailureTime, so a request rejected while already
|
||||
// open cannot re-arm the open timer and keep the circuit open forever
|
||||
// under steady traffic (FIX-01).
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
|
||||
@@ -239,12 +250,32 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
||||
cb.RecordRequest()
|
||||
|
||||
if !cb.allowRequest() {
|
||||
// A request rejected while the circuit is open is an admission
|
||||
// outcome, not a never-started request: count it in total_failures
|
||||
// so GetBaseMetrics' success_rate (successes/total_requests)
|
||||
// reflects actual admission (R180). This intentionally bypasses the
|
||||
// promoted BaseRecoveryMechanism.RecordFailure(), which also sets
|
||||
// lastFailureTime -- allowRequest's Open->HalfOpen timer used to
|
||||
// read that same field, so every rejection re-armed it and the
|
||||
// circuit never left Open under steady traffic (FIX-01). The open
|
||||
// timer now runs off cb.openedAt instead, set only on a genuine
|
||||
// Closed->Open or HalfOpen->Open transition in recordFailure.
|
||||
atomic.AddInt64(&cb.totalFailures, 1)
|
||||
return fmt.Errorf("circuit breaker is open")
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err != nil {
|
||||
cb.recordFailure()
|
||||
// A terminal client error (HTTPError 4xx other than 429) reflects a
|
||||
// per-user problem such as invalid_grant, not a downstream-service
|
||||
// failure. It must not trip the state machine: in particular it
|
||||
// must not reopen a half-open circuit, or the 15s/30s half-open
|
||||
// hold (resetTimeout) extends an outage on any single per-user
|
||||
// error (FIX-09). It is still recorded in the base metrics below,
|
||||
// since it is a real error returned to the caller.
|
||||
if !isTerminalClientError(err) {
|
||||
cb.recordFailure()
|
||||
}
|
||||
cb.RecordFailure()
|
||||
return err
|
||||
}
|
||||
@@ -254,6 +285,37 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminalClientError reports whether err is an HTTPError whose status
|
||||
// code is a client error (4xx) other than 429 Too Many Requests or 408
|
||||
// Request Timeout. Such errors indicate the request itself was rejected by
|
||||
// the far end (for example a replayed authorization code), not that the far
|
||||
// end is unhealthy, so the circuit breaker's failure-counting state machine
|
||||
// ignores them (FIX-09). 429 and 408 are excluded because they are the
|
||||
// service itself signaling it is overloaded or slow, a genuine health
|
||||
// signal, not a per-request rejection — the same classification FIX-13
|
||||
// applies in token_validation_rs.go and bearer_auth.go, and that
|
||||
// internal/recovery/base.go and metrics.go apply via their retryable-status
|
||||
// checks.
|
||||
//
|
||||
// This uses a plain type assertion, not errors.As. Under yaegi v0.16.1 (the
|
||||
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
|
||||
// errors.As(err, &target) panics with "errors: *target must be interface
|
||||
// or implement error" whenever target's pointed-to type (*HTTPError here)
|
||||
// is itself interpreted, regardless of err's own concrete type. This
|
||||
// function runs on every fn() error in ExecuteWithContext, on the
|
||||
// default-on token-exchange/refresh path, so that panic reaches production
|
||||
// (FIX-09). The production error also reaches the breaker unwrapped
|
||||
// (helpers.go returns *HTTPError directly), so a type assertion is
|
||||
// sufficient and needs no Unwrap chain walk.
|
||||
func isTerminalClientError(err error) bool {
|
||||
httpErr, ok := err.(*HTTPError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 &&
|
||||
httpErr.StatusCode != 429 && httpErr.StatusCode != 408
|
||||
}
|
||||
|
||||
// Execute executes a function through the circuit breaker without context.
|
||||
// This is provided for backward compatibility with existing code.
|
||||
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
||||
@@ -273,8 +335,9 @@ func (cb *CircuitBreaker) allowRequest() bool {
|
||||
return true
|
||||
|
||||
case CircuitBreakerOpen:
|
||||
if now.Sub(cb.lastFailureTime) > cb.timeout {
|
||||
if now.Sub(cb.openedAt) > cb.timeout {
|
||||
cb.state = CircuitBreakerHalfOpen
|
||||
cb.halfOpenSince = now
|
||||
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
||||
return true
|
||||
}
|
||||
@@ -300,11 +363,13 @@ func (cb *CircuitBreaker) recordFailure() {
|
||||
case CircuitBreakerClosed:
|
||||
if cb.failures >= int64(cb.maxFailures) {
|
||||
cb.state = CircuitBreakerOpen
|
||||
cb.openedAt = time.Now()
|
||||
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
|
||||
}
|
||||
|
||||
case CircuitBreakerHalfOpen:
|
||||
cb.state = CircuitBreakerOpen
|
||||
cb.openedAt = time.Now()
|
||||
cb.LogError("Circuit breaker returned to open state after failure in half-open")
|
||||
}
|
||||
}
|
||||
@@ -317,6 +382,15 @@ func (cb *CircuitBreaker) recordSuccess() {
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerHalfOpen:
|
||||
// Honor resetTimeout: do not fully close until the circuit has been
|
||||
// in half-open for at least resetTimeout. This provides the
|
||||
// documented open -> half-open -> (reset cooldown) -> closed
|
||||
// hysteresis instead of closing on the very first half-open
|
||||
// success, which would let a stray success immediately reopen
|
||||
// (bounce). While in half-open requests are still allowed as probes.
|
||||
if time.Since(cb.halfOpenSince) < cb.resetTimeout {
|
||||
return
|
||||
}
|
||||
cb.failures = 0
|
||||
cb.state = CircuitBreakerClosed
|
||||
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
|
||||
@@ -348,7 +422,24 @@ func (cb *CircuitBreaker) Reset() {
|
||||
// IsAvailable returns whether the circuit breaker is currently allowing requests.
|
||||
// This provides a quick way to check if the service is available.
|
||||
func (cb *CircuitBreaker) IsAvailable() bool {
|
||||
return cb.allowRequest()
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerClosed:
|
||||
return true
|
||||
case CircuitBreakerHalfOpen:
|
||||
return true
|
||||
case CircuitBreakerOpen:
|
||||
// Read-only availability probe: report that a request would be
|
||||
// admitted only after the open timeout has elapsed, but do NOT
|
||||
// transition to half-open — IsAvailable must not take a permit or
|
||||
// mutate circuit state. Real traffic admission is gated by
|
||||
// allowRequest (triggerRequest) which performs the transition.
|
||||
return time.Since(cb.openedAt) > cb.timeout
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics returns comprehensive metrics about the circuit breaker.
|
||||
@@ -507,12 +598,157 @@ func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error
|
||||
}
|
||||
|
||||
// Execute runs the given function with retry logic (for backward compatibility)
|
||||
// Execute executes a function with retry logic (backward compatibility).
|
||||
// Execute runs a function with retry logic (backward compatibility).
|
||||
// This method provides the same functionality as ExecuteWithContext.
|
||||
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
|
||||
return re.ExecuteWithContext(ctx, fn)
|
||||
}
|
||||
|
||||
// singleUseRetryableErrors are error fragments that prove the request was NEVER
|
||||
// sent to the server (dial/connect failures). Retrying those is safe.
|
||||
var singleUseRetryableErrors = []string{
|
||||
"connection refused",
|
||||
"network unreachable",
|
||||
"no route to host",
|
||||
"temporary failure",
|
||||
}
|
||||
|
||||
// isNeverSentError reports whether err proves a single-use request was never
|
||||
// sent to the server, so retrying it cannot re-present already-consumed
|
||||
// input (an authorization code or a rotated refresh token).
|
||||
//
|
||||
// A *HTTPError means a response WAS received: the request definitely
|
||||
// reached the endpoint, regardless of what its body says. Before this
|
||||
// check, ExecuteSingleUseWithContext substring-matched singleUseRetryableErrors
|
||||
// against the raw error text, and helpers.go's token-endpoint HTTPError
|
||||
// carries up to 10 KiB of the response body in its Message -- an IdP 500
|
||||
// whose body happened to mention "connection refused" was retried, re-
|
||||
// sending a consumed code or a rotated refresh token (FIX-14).
|
||||
//
|
||||
// A *net.OpError is NOT proof by itself: Go's net package uses that same
|
||||
// type for a "dial" failure before any request reaches the wire, but also
|
||||
// for a "read" or "write" failure AFTER the connection is already
|
||||
// established, when the request may already have reached the IdP. Only Op
|
||||
// "dial" (a failed connect) or "proxyconnect" (net/http's failed CONNECT to
|
||||
// an HTTP proxy) prove the request never went out, so the fragment match is
|
||||
// scoped to those two.
|
||||
func isNeverSentError(err error) bool {
|
||||
// Plain type assertion, not errors.As: under yaegi v0.16.1 (the
|
||||
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
|
||||
// errors.As(err, &target) panics with "errors: *target must be
|
||||
// interface or implement error" whenever target's pointed-to type
|
||||
// (*HTTPError here) is itself interpreted, regardless of err's own
|
||||
// concrete type. ExecuteSingleUseWithContext calls this on every fn()
|
||||
// error, on the default-on authorization-code-exchange/refresh path,
|
||||
// so that panic reaches production (FIX-14). The production error
|
||||
// reaches here unwrapped (helpers.go returns *HTTPError directly), so
|
||||
// a type assertion is sufficient and needs no Unwrap chain walk.
|
||||
if _, ok := err.(*HTTPError); ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// errors.As against the compiled *net.OpError type is yaegi-safe: the
|
||||
// panic above is specific to an interpreted target type, and
|
||||
// *net.OpError is a native stdlib type.
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect") {
|
||||
return isSubstringMatch(opErr.Error(), singleUseRetryableErrors)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExecuteSingleUseWithContext retries only on errors that prove the request was
|
||||
// never sent (connect/dial failures). It intentionally does NOT retry on
|
||||
// "timeout": for a single-use operation such as the authorization-code
|
||||
// exchange, a timeout may mean the provider already consumed the one-time code,
|
||||
// so re-sending it would surface invalid_grant permanently even though the
|
||||
// first attempt succeeded.
|
||||
func (re *RetryExecutor) ExecuteSingleUseWithContext(ctx context.Context, fn func() error) error {
|
||||
re.RecordRequest()
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
re.LogInfo("Operation succeeded after %d attempts", attempt)
|
||||
}
|
||||
re.RecordSuccess()
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if !isNeverSentError(err) {
|
||||
re.RecordFailure()
|
||||
return err
|
||||
}
|
||||
|
||||
if attempt == re.config.MaxAttempts {
|
||||
re.RecordFailure()
|
||||
break
|
||||
}
|
||||
|
||||
delay := re.calculateDelay(attempt)
|
||||
if attempt == 1 || attempt%3 == 0 {
|
||||
re.LogDebug("Retrying single-use operation after %v (attempt %d/%d): %v",
|
||||
delay, attempt, re.config.MaxAttempts, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
re.RecordFailure()
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
finalErr := fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
|
||||
return finalErr
|
||||
}
|
||||
|
||||
// isSubstringMatch reports whether s contains any of the given fragments.
|
||||
func isSubstringMatch(s string, fragments []string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, frag := range fragments {
|
||||
if strings.Contains(lower, frag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// asHTTPError reports whether err is, or (via a chain of Unwrap() error
|
||||
// methods) wraps, an *HTTPError. It exists because errors.As cannot be used
|
||||
// here: under yaegi v0.16.1 (the interpreter Traefik uses to load this
|
||||
// plugin, pinned in Makefile:9), errors.As(err, &target) panics with
|
||||
// "errors: *target must be interface or implement error" whenever target's
|
||||
// pointed-to type is itself interpreted, and *HTTPError is declared in this
|
||||
// plugin so it is always interpreted at runtime — regardless of err's own
|
||||
// concrete type (see isTerminalClientError above for the same hazard on a
|
||||
// path that does not need the Unwrap walk).
|
||||
//
|
||||
// This manually reimplements errors.As's single-chain Unwrap() walk using
|
||||
// only type assertions and errors.Unwrap, both yaegi-safe, so native
|
||||
// classification is byte-for-byte identical to errors.As (see
|
||||
// TestIsRetryableError_WrappedTransient). The one place native and yaegi
|
||||
// genuinely diverge: an interpreted *HTTPError wrapped with
|
||||
// fmt.Errorf("...%w", e) is found by this walk in native Go but NOT under
|
||||
// yaegi (an interpreter quirk with wrapped interpreted-type targets,
|
||||
// verified this session) — so every producer feeding isRetryableError must
|
||||
// keep returning an *HTTPError unwrapped (exchangeTokens already does; see
|
||||
// helpers.go).
|
||||
func asHTTPError(err error) (*HTTPError, bool) {
|
||||
for err != nil {
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
return httpErr, true
|
||||
}
|
||||
err = errors.Unwrap(err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isRetryableError checks if an error should trigger a retry
|
||||
// isRetryableError determines if an error should trigger a retry attempt.
|
||||
// Checks error message against configured retryable error patterns.
|
||||
@@ -523,6 +759,17 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// A terminal HTTP error (a real status that is neither 5xx nor 429)
|
||||
// must be classified by its status BEFORE any message-substring scan.
|
||||
// Otherwise a 4xx whose provider message merely contains a
|
||||
// retryable-looking word — e.g. "certificate"/"tls" (isCertificateError),
|
||||
// "EOF" (isEOFError), or a generic "timeout" — would be reclassified
|
||||
// retryable and retried to MaxAttempts on a permanent error, repeating
|
||||
// the failing request (R123, R157).
|
||||
if statusHTTP, ok := asHTTPError(err); ok && statusHTTP.StatusCode != 0 && statusHTTP.StatusCode < 500 && statusHTTP.StatusCode != 429 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for Traefik default certificate error (startup race condition)
|
||||
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
|
||||
if isTraefikDefaultCertError(err) {
|
||||
@@ -539,19 +786,20 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
for _, retryableErr := range re.config.RetryableErrors {
|
||||
if contains(errStr, retryableErr) {
|
||||
if contains(errStr, strings.ToLower(retryableErr)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
if netErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
errStr := netErr.Error()
|
||||
errStr := strings.ToLower(netErr.Error())
|
||||
temporaryPatterns := []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
@@ -568,8 +816,19 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
|
||||
// asHTTPError (not errors.As) so retry classification survives error
|
||||
// wrapping: a transient or 5xx/429 error returned as fmt.Errorf("...:
|
||||
// %w", err) from an upstream layer must still be retried. Direct type
|
||||
// assertions on the top-level error silently misclassified wrapped
|
||||
// transient errors as permanent, giving up before any retry (R111).
|
||||
// errors.As itself cannot be used under yaegi -- see asHTTPError's doc
|
||||
// comment.
|
||||
if httpErr, ok := asHTTPError(err); ok {
|
||||
status := httpErr.StatusCode
|
||||
// A 0 StatusCode means the producer used HTTPError purely as a
|
||||
// message carrier (no real HTTP status). Treat it as unknown
|
||||
// (non-retryable) rather than spuriously matching 5xx (R118).
|
||||
return status != 0 && (status >= 500 || status == 429)
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -762,6 +1021,10 @@ type GracefulDegradation struct {
|
||||
config GracefulDegradationConfig
|
||||
mutex sync.RWMutex
|
||||
shutdownOnce sync.Once
|
||||
// beforeSharedTaskStop is a test seam, nil in production. Close calls it
|
||||
// after deciding this is the last live instance and before stopping the
|
||||
// shared health-check task.
|
||||
beforeSharedTaskStop func()
|
||||
}
|
||||
|
||||
// GracefulDegradationConfig holds configuration for graceful degradation behavior.
|
||||
@@ -788,6 +1051,53 @@ func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
|
||||
// NewGracefulDegradation creates a new graceful degradation manager
|
||||
// NewGracefulDegradation creates a new graceful degradation mechanism.
|
||||
// Initializes fallback and health check maps and starts background health monitoring.
|
||||
// gdInstances tracks live GracefulDegradation instances so the single shared
|
||||
// health-check task can run every instance's health checks (not just the first)
|
||||
// and so the task is only stopped when the last instance closes.
|
||||
var gdInstances = struct {
|
||||
sync.RWMutex
|
||||
set map[*GracefulDegradation]struct{}
|
||||
}{set: make(map[*GracefulDegradation]struct{})}
|
||||
|
||||
// globalHealthCheckMu serializes the global pass. globalPerformHealthChecks
|
||||
// runs from more than one goroutine — the shared singleton task's run loop
|
||||
// AND the once-guarded StartBackgroundTask path both drive it — so without
|
||||
// this, any health check registered via the public RegisterHealthCheck
|
||||
// API would be invoked concurrently (a latent production race, and the
|
||||
// source of an intermittent full-suite race in tests). Serializing the
|
||||
// whole pass keeps registered health checks single-threaded (R189).
|
||||
var globalHealthCheckMu sync.Mutex
|
||||
|
||||
// gdLifecycleMu serializes GracefulDegradation registration with the
|
||||
// last-instance check and shared-task stop in Close. Without it, an instance
|
||||
// created after Close decided it was the last one, but before the stop, adopted
|
||||
// the still-running task, and the stop then killed the task that the new live
|
||||
// instance relies on. With it, a new instance either counts toward the
|
||||
// last-instance check or registers after the stop, and its routine replaces
|
||||
// the stopped task. The health-check pass never takes this lock, so Close can
|
||||
// hold it while BackgroundTask.Stop waits for an in-progress pass.
|
||||
var gdLifecycleMu sync.Mutex
|
||||
|
||||
// globalPerformHealthChecks runs the health check pass for every live
|
||||
// GracefulDegradation instance. The shared singleton task calls this, so a
|
||||
// health check registered on ANY instance is exercised, and closing one
|
||||
// instance does not stall recovery for the others.
|
||||
func globalPerformHealthChecks() {
|
||||
globalHealthCheckMu.Lock()
|
||||
defer globalHealthCheckMu.Unlock()
|
||||
|
||||
gdInstances.RLock()
|
||||
instances := make([]*GracefulDegradation, 0, len(gdInstances.set))
|
||||
for gd := range gdInstances.set {
|
||||
instances = append(instances, gd)
|
||||
}
|
||||
gdInstances.RUnlock()
|
||||
|
||||
for _, gd := range instances {
|
||||
gd.performHealthChecks()
|
||||
}
|
||||
}
|
||||
|
||||
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
|
||||
gd := &GracefulDegradation{
|
||||
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
|
||||
@@ -798,6 +1108,22 @@ func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *G
|
||||
}
|
||||
|
||||
gd.stopChan = make(chan struct{})
|
||||
|
||||
// Register synchronously, before the health-check goroutine starts
|
||||
// (FIX-18). Registration used to happen only inside startHealthCheckRoutine,
|
||||
// which runs in its own goroutine, so a Close() called immediately after
|
||||
// NewGracefulDegradation returned could race ahead of it: Close's
|
||||
// gdInstances.delete would find nothing to remove, and the goroutine's
|
||||
// later add would then register an already-closed gd permanently.
|
||||
// startHealthCheckRoutine no longer touches gdInstances at all — this is
|
||||
// now the ONLY place a GracefulDegradation is added to the set, so there
|
||||
// is no second, racing registration left to worry about.
|
||||
gdLifecycleMu.Lock()
|
||||
gdInstances.Lock()
|
||||
gdInstances.set[gd] = struct{}{}
|
||||
gdInstances.Unlock()
|
||||
gdLifecycleMu.Unlock()
|
||||
|
||||
go gd.startHealthCheckRoutine()
|
||||
|
||||
return gd
|
||||
@@ -859,8 +1185,9 @@ func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary f
|
||||
|
||||
// isServiceDegraded checks if a service is currently degraded
|
||||
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
|
||||
gd.mutex.RLock()
|
||||
defer gd.mutex.RUnlock()
|
||||
// Uses a write lock because the recovery-timeout branch deletes from the map.
|
||||
gd.mutex.Lock()
|
||||
defer gd.mutex.Unlock()
|
||||
|
||||
degradedTime, exists := gd.degradedServices[serviceName]
|
||||
if !exists {
|
||||
@@ -901,25 +1228,54 @@ func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{},
|
||||
return fallback()
|
||||
}
|
||||
|
||||
// startHealthCheckRoutine starts the background health check routine
|
||||
// startHealthCheckRoutine starts the background health check routine.
|
||||
//
|
||||
// FIX-18: this runs in its own goroutine, started by NewGracefulDegradation
|
||||
// right after it registers gd in gdInstances. A Close() called immediately
|
||||
// after NewGracefulDegradation returns can race ahead of this goroutine
|
||||
// getting scheduled at all. Take gd.mutex up front and re-check gd.stopChan
|
||||
// before doing anything else: if Close() already closed it, this goroutine
|
||||
// must not create (or adopt) the shared health-check task for an
|
||||
// already-closed instance — that would leak the process-global task
|
||||
// forever, since nothing would ever be left registered to stop it.
|
||||
func (gd *GracefulDegradation) startHealthCheckRoutine() {
|
||||
// Use singleton task registry to prevent multiple instances
|
||||
gd.mutex.Lock()
|
||||
|
||||
select {
|
||||
case <-gd.stopChan:
|
||||
// Close() already ran before this goroutine got scheduled. Do not
|
||||
// register or start the shared task on behalf of a closed instance.
|
||||
gd.mutex.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Use the singleton task registry so multiple GracefulDegradation
|
||||
// instances share one health-check goroutine. The task function is the
|
||||
// global pass over every live instance, not this instance's own health
|
||||
// checks — otherwise only the first instance's checks would run.
|
||||
//
|
||||
// gd.mutex stays held through CreateSingletonTask and the healthCheckTask
|
||||
// assignment below: CreateSingletonTask only touches the global task
|
||||
// registry (a different lock), never gd.mutex, so this cannot deadlock,
|
||||
// and it keeps the stopChan check and the assignment atomic with respect
|
||||
// to a concurrent Close().
|
||||
registry := GetGlobalTaskRegistry()
|
||||
|
||||
task, err := registry.CreateSingletonTask(
|
||||
"graceful-degradation-health-check",
|
||||
gd.config.HealthCheckInterval,
|
||||
gd.performHealthChecks,
|
||||
globalPerformHealthChecks,
|
||||
gd.BaseRecoveryMechanism.logger,
|
||||
nil, // No specific wait group
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
gd.mutex.Unlock()
|
||||
gd.BaseRecoveryMechanism.logger.Errorf("Failed to create health check task: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
gd.mutex.Lock()
|
||||
gd.healthCheckTask = task
|
||||
gd.mutex.Unlock()
|
||||
|
||||
@@ -971,25 +1327,58 @@ func (gd *GracefulDegradation) Reset() {
|
||||
gd.LogInfo("Graceful degradation state has been reset")
|
||||
}
|
||||
|
||||
// Close shuts down the graceful degradation system and cleans up resources
|
||||
// Close shuts down the graceful degradation system and cleans up resources.
|
||||
func (gd *GracefulDegradation) Close() {
|
||||
gd.shutdownOnce.Do(func() {
|
||||
// Signal shutdown
|
||||
// Signal shutdown under gd.mutex. startHealthCheckRoutine checks
|
||||
// stopChan and creates or adopts the shared task under the same
|
||||
// lock, so an in-flight routine finishes before this instance
|
||||
// unregisters below. Without the lock, a routine that passed its
|
||||
// check could reach the task registry after the last Close stopped
|
||||
// the task, and RegisterBackgroundTask would start a fresh task
|
||||
// that no live instance can stop. Release the lock before the stop:
|
||||
// BackgroundTask.Stop waits for a health-check pass that takes gd.mutex.
|
||||
gd.mutex.Lock()
|
||||
select {
|
||||
case <-gd.stopChan:
|
||||
// Already closed
|
||||
default:
|
||||
close(gd.stopChan)
|
||||
}
|
||||
|
||||
// Stop health check task
|
||||
gd.mutex.Lock()
|
||||
task := gd.healthCheckTask
|
||||
gd.mutex.Unlock()
|
||||
|
||||
if task != nil {
|
||||
task.Stop()
|
||||
// Don't set to nil to avoid race conditions
|
||||
// Stop the shared health-check task only when this was the last live
|
||||
// instance. The task is process-global and shared by every
|
||||
// GracefulDegradation instance; stopping it on any one instance's
|
||||
// close would kill health checks / recovery for all surviving
|
||||
// instances (mirror of the singleton-token-cleanup lastInstance gate).
|
||||
// Hold gdLifecycleMu across the last-instance check and the stop, so a
|
||||
// concurrent NewGracefulDegradation cannot register in between and
|
||||
// adopt a task that is about to stop.
|
||||
gdLifecycleMu.Lock()
|
||||
defer gdLifecycleMu.Unlock()
|
||||
|
||||
gdInstances.Lock()
|
||||
delete(gdInstances.set, gd)
|
||||
lastInstance := len(gdInstances.set) == 0
|
||||
gdInstances.Unlock()
|
||||
|
||||
if lastInstance {
|
||||
if gd.beforeSharedTaskStop != nil {
|
||||
gd.beforeSharedTaskStop()
|
||||
}
|
||||
// Stop by name through the resource manager rather than reading
|
||||
// this instance's own gd.healthCheckTask field: with two or more
|
||||
// GracefulDegradation instances, the shared task can already be
|
||||
// running — created by a SIBLING instance's startHealthCheckRoutine
|
||||
// goroutine — while THIS instance's own goroutine has not yet run
|
||||
// and populated its local healthCheckTask field. Reading that
|
||||
// still-nil local field here would skip the stop and leak the
|
||||
// task forever even though this genuinely is the last instance.
|
||||
// StopBackgroundTask looks up the registry's current entry
|
||||
// directly, so it is correct regardless of which instance's
|
||||
// goroutine happened to create the task (FIX-18).
|
||||
_ = GetResourceManager().StopBackgroundTask("graceful-degradation-health-check")
|
||||
}
|
||||
|
||||
gd.logger.Debug("GracefulDegradation shut down successfully")
|
||||
@@ -1049,6 +1438,21 @@ func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the resources owned by this ErrorRecoveryManager,
|
||||
// currently its GracefulDegradation instance (which unregisters from
|
||||
// gdInstances and, if this was the last live instance, stops the shared
|
||||
// health-check task). Safe to call multiple times; safe on a nil receiver.
|
||||
// FIX-18: every caller that constructs an ErrorRecoveryManager must call
|
||||
// Close, or its GracefulDegradation leaks in gdInstances forever.
|
||||
func (erm *ErrorRecoveryManager) Close() {
|
||||
if erm == nil {
|
||||
return
|
||||
}
|
||||
if erm.gracefulDegradation != nil {
|
||||
erm.gracefulDegradation.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCircuitBreaker gets or creates a circuit breaker for a service
|
||||
// GetCircuitBreaker returns the circuit breaker for a specific service.
|
||||
// Creates a new circuit breaker if one doesn't exist for the service.
|
||||
@@ -1071,8 +1475,14 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
|
||||
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
|
||||
cb := erm.GetCircuitBreaker(serviceName)
|
||||
|
||||
return erm.retryExecutor.Execute(ctx, func() error {
|
||||
return cb.Execute(fn)
|
||||
// Retry INNER, circuit OUTER (matching TokenResilienceManager and the
|
||||
// documented R106 compositing rule). Wrapping cb around the whole retry
|
||||
// run means one logical call counts as a single circuit failure even
|
||||
// when it was retried internally; the previous nesting (cb inside the
|
||||
// retry loop) recorded each retry attempt as a separate failure and
|
||||
// could open the circuit after only one flaky-but-retryable call (R144).
|
||||
return cb.ExecuteWithContext(ctx, func() error {
|
||||
return erm.retryExecutor.Execute(ctx, fn)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsTerminalClientError408IsTransient pins that HTTP 408 Request Timeout
|
||||
// is treated as a transient, service-health signal by isTerminalClientError,
|
||||
// not as a terminal per-request client error. A token endpoint (or a proxy
|
||||
// in front of it) returns 408 when the upstream is slow, which is the same
|
||||
// reasoning FIX-13 already applies in token_validation_rs.go's classifier
|
||||
// and bearer_auth.go, and that internal/recovery/base.go and metrics.go
|
||||
// apply via their RetryableStatusCodes/isRetryable checks. isTerminalClientError
|
||||
// must classify 408 the same way, or a burst of token-endpoint 408s never
|
||||
// opens the refresh circuit breaker.
|
||||
//
|
||||
// Fail-on-old: isTerminalClientError excluded only 429, so it reported 408
|
||||
// as terminal.
|
||||
func TestIsTerminalClientError408IsTransient(t *testing.T) {
|
||||
err := &HTTPError{StatusCode: 408, Message: "request timeout"}
|
||||
if isTerminalClientError(err) {
|
||||
t.Fatal("408 Request Timeout must be treated as transient (like 429), not a terminal client error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerHalfOpenRequestTimeoutReopens pins the same contract at
|
||||
// the circuit-breaker level: a 408 probe result must still reopen a
|
||||
// half-open breaker, exactly like the existing 429 case
|
||||
// (TestCircuitBreakerHalfOpenRateLimitReopens in error_recovery_fix09_test.go).
|
||||
//
|
||||
// Fail-on-old: with 408 misclassified as terminal, recordFailure's HalfOpen
|
||||
// exemption for terminal client errors (FIX-09) swallowed it and the breaker
|
||||
// stayed HalfOpen instead of reopening.
|
||||
func TestCircuitBreakerHalfOpenRequestTimeoutReopens(t *testing.T) {
|
||||
cb := newFix09HalfOpenBreaker(t)
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 408, Message: "request timeout"}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("a 408 must still reopen a half-open circuit like 429, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow is a regression test
|
||||
// for FIX-01.
|
||||
//
|
||||
// Before the fix, ExecuteWithContext called the promoted
|
||||
// BaseRecoveryMechanism.RecordFailure() on every request rejected while the
|
||||
// circuit is open. RecordFailure sets lastFailureTime, the same field
|
||||
// allowRequest reads as the Open -> HalfOpen timer. Every rejection then
|
||||
// re-armed the timer, so under steady traffic shorter than Timeout the
|
||||
// breaker never left Open (see error_recovery.go:253,288 pre-fix).
|
||||
//
|
||||
// Fail-on-old: with a request every 10ms and Timeout 50ms, no request is
|
||||
// ever admitted within the 500ms budget, because each rejection pushes the
|
||||
// open window forward by another Timeout.
|
||||
func TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow(t *testing.T) {
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
ResetTimeout: 30 * time.Second,
|
||||
}, NewLogger("error"))
|
||||
|
||||
// Trip the breaker open with a single real failure.
|
||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return errors.New("boom")
|
||||
}); err == nil {
|
||||
t.Fatal("expected the tripping call to return its own error")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("expected circuit to be open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
admitted := false
|
||||
for time.Now().Before(deadline) {
|
||||
callErr := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return nil
|
||||
})
|
||||
if callErr == nil {
|
||||
admitted = true
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !admitted {
|
||||
t.Fatal("expected a probe to be admitted once Timeout elapsed with no real failure in between; open-circuit rejections must not re-arm the open timer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerOpenRejectionStillCountedInMetrics pins the R180 intent
|
||||
// alongside FIX-01: an open-circuit rejection is still an admission outcome
|
||||
// and must still increment total_failures, even though it may no longer
|
||||
// touch the open timer.
|
||||
func TestCircuitBreakerOpenRejectionStillCountedInMetrics(t *testing.T) {
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: time.Hour,
|
||||
ResetTimeout: time.Hour,
|
||||
}, NewLogger("error"))
|
||||
|
||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return errors.New("boom")
|
||||
}); err == nil {
|
||||
t.Fatal("expected the tripping call to return its own error")
|
||||
}
|
||||
|
||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
t.Fatal("fn must not be called while the circuit is open")
|
||||
return nil
|
||||
}); err == nil {
|
||||
t.Fatal("expected the rejected call to return an error")
|
||||
}
|
||||
|
||||
metrics := cb.GetBaseMetrics()
|
||||
totalFailures := metrics["total_failures"].(int64)
|
||||
if totalFailures != 2 {
|
||||
t.Errorf("total_failures = %d, want 2 (1 real failure + 1 open rejection)", totalFailures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newFix09HalfOpenBreaker builds a breaker, trips it with one real (non
|
||||
// client) failure, and waits past Timeout so the next ExecuteWithContext
|
||||
// call performs the Open -> HalfOpen probe transition itself.
|
||||
func newFix09HalfOpenBreaker(t *testing.T) *CircuitBreaker {
|
||||
t.Helper()
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ResetTimeout: 50 * time.Millisecond,
|
||||
}, NewLogger("error"))
|
||||
|
||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
|
||||
}); err == nil {
|
||||
t.Fatal("expected the tripping call to return its own error")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("setup: expected circuit open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond) // past Timeout: next call probes half-open
|
||||
return cb
|
||||
}
|
||||
|
||||
// TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen is a regression
|
||||
// test for FIX-09. A terminal client error (HTTPError 4xx other than 429,
|
||||
// e.g. invalid_grant from a replayed code) reflects a per-user problem, not
|
||||
// a downstream-service failure, so it must not reopen a half-open breaker.
|
||||
//
|
||||
// Fail-on-old: recordFailure's HalfOpen case reopened unconditionally on
|
||||
// any fn() error, so the circuit is Open (not HalfOpen) right after this
|
||||
// probe.
|
||||
func TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen(t *testing.T) {
|
||||
cb := newFix09HalfOpenBreaker(t)
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
||||
t.Fatalf("a terminal client 4xx must not reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
|
||||
// Past resetTimeout relative to the half-open entry: a subsequent
|
||||
// success must still be able to fully close the circuit.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
if err := cb.ExecuteWithContext(context.Background(), func() error { return nil }); err != nil {
|
||||
t.Fatalf("expected the closing probe to succeed, got %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Fatalf("expected circuit to close after a half-open success following the ignored client error, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerHalfOpenRateLimitReopens pins that HTTP 429 (the
|
||||
// service itself signaling it is overloaded) still reopens a half-open
|
||||
// circuit, unlike an ordinary terminal client 4xx.
|
||||
func TestCircuitBreakerHalfOpenRateLimitReopens(t *testing.T) {
|
||||
cb := newFix09HalfOpenBreaker(t)
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 429, Message: "rate limited"}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("a 429 must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerHalfOpenNetworkErrorReopens pins that a genuine network
|
||||
// error still reopens a half-open circuit.
|
||||
func TestCircuitBreakerHalfOpenNetworkErrorReopens(t *testing.T) {
|
||||
cb := newFix09HalfOpenBreaker(t)
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &net.OpError{Op: "dial", Err: errors.New("connection refused")}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("a network error must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerClosedTerminalClientErrorStaysClosed pins FIX-09's
|
||||
// scope choice: the terminal-client-error exemption applies to the Closed
|
||||
// state too, not only half-open, so a per-user invalid_grant never trips
|
||||
// the breaker at all. A following genuine 5xx still opens it normally.
|
||||
func TestCircuitBreakerClosedTerminalClientErrorStaysClosed(t *testing.T) {
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ResetTimeout: 50 * time.Millisecond,
|
||||
}, NewLogger("error"))
|
||||
|
||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Fatalf("a terminal client 4xx must not trip a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
|
||||
err = cb.ExecuteWithContext(context.Background(), func() error {
|
||||
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected the tripping call to return its own error")
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatalf("a genuine 5xx must still open a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse is a
|
||||
// regression test for FIX-14.
|
||||
//
|
||||
// helpers.go's token-endpoint exchange returns a *HTTPError whose Message
|
||||
// carries up to 10 KiB of the raw response body once a non-200 status is
|
||||
// received (helpers.go:192-195). Before the fix, ExecuteSingleUseWithContext
|
||||
// matched singleUseRetryableErrors as a plain substring of err.Error(), so
|
||||
// an IdP 500 whose body happens to mention "connection refused" (e.g. the
|
||||
// IdP's own downstream error) was retried up to MaxAttempts times even
|
||||
// though the request had definitely already reached the server. For a
|
||||
// single-use operation (authorization-code exchange, refresh) re-sending
|
||||
// consumed input causes invalid_grant or refresh-token-family revocation.
|
||||
//
|
||||
// Fail-on-old: fn is called 3 times (MaxAttempts) instead of once.
|
||||
func TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse(t *testing.T) {
|
||||
re := NewRetryExecutor(RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1,
|
||||
MaxDelay: 1,
|
||||
BackoffFactor: 1,
|
||||
}, NewLogger("error"))
|
||||
|
||||
calls := 0
|
||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
||||
calls++
|
||||
return &HTTPError{
|
||||
StatusCode: 500,
|
||||
Message: "token endpoint returned status 500: {\"error\":\"server_error\",\"detail\":\"upstream: connection refused\"}",
|
||||
}
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected the HTTPError to be returned")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fn called %d times, want 1: an HTTP response proves the request was already sent and must never be retried for a single-use operation", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSingleUseWithContextStillRetriesRealDialError guards against
|
||||
// over-broadening the FIX-14 fix: a genuine dial/connect failure -- proof
|
||||
// the request never reached the server -- must still be retried.
|
||||
func TestExecuteSingleUseWithContextStillRetriesRealDialError(t *testing.T) {
|
||||
re := NewRetryExecutor(RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1,
|
||||
MaxDelay: 1,
|
||||
BackoffFactor: 1,
|
||||
}, NewLogger("error"))
|
||||
|
||||
calls := 0
|
||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected the retried call to succeed, got: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("fn called %d times, want 2: a real dial error must still be retried", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSingleUseWithContextNeverRetriesPostSendOpError pins FIX-14's
|
||||
// minor finding: isNeverSentError must match a *net.OpError only for Op
|
||||
// "dial" or "proxyconnect" (proof the request never reached the far end).
|
||||
// A "read"/"write" OpError happens after the connection is already
|
||||
// established, when the single-use request may already have reached the
|
||||
// IdP, and must not be retried.
|
||||
func TestExecuteSingleUseWithContextNeverRetriesPostSendOpError(t *testing.T) {
|
||||
re := NewRetryExecutor(RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1,
|
||||
MaxDelay: 1,
|
||||
BackoffFactor: 1,
|
||||
}, NewLogger("error"))
|
||||
|
||||
calls := 0
|
||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
||||
calls++
|
||||
return &net.OpError{Op: "read", Net: "tcp", Err: syscall.EHOSTUNREACH}
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected the OpError to be returned")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fn called %d times, want 1: a post-send (read/write) OpError must not be retried for a single-use operation", calls)
|
||||
}
|
||||
}
|
||||
+75
-16
@@ -89,18 +89,50 @@ func TestCircuitBreakerHalfOpenTransition(t *testing.T) {
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
allowed := false
|
||||
_ = cb.Execute(func() error {
|
||||
allowed = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if !allowed {
|
||||
t.Error("Request should be allowed in half-open state")
|
||||
// First request after the open timeout probes half-open. A single success
|
||||
// does NOT immediately close: the circuit stays half-open until
|
||||
// resetTimeout has elapsed (reset-cooldown hysteresis).
|
||||
if err := cb.Execute(func() error { return nil }); err != nil {
|
||||
t.Errorf("Request should be allowed in half-open state, got: %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
||||
t.Errorf("Circuit should stay half-open during reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
|
||||
// After resetTimeout elapses, a successful request fully closes it.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if err := cb.Execute(func() error { return nil }); err != nil {
|
||||
t.Errorf("Request should be allowed, got: %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Circuit should be closed after successful half-open request, got %v", cb.GetState())
|
||||
t.Errorf("Circuit should be closed after successful request following reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreakerResetTimeoutEnforced is a regression test for the dead
|
||||
// resetTimeout config field: it must be consulted before a half-open success
|
||||
// can fully close the circuit. This failed on the old code where resetTimeout
|
||||
// was set but never read, so a single half-open success closed the circuit
|
||||
// immediately regardless of the configured reset cooldown.
|
||||
func TestCircuitBreakerResetTimeoutEnforced(t *testing.T) {
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
ResetTimeout: time.Minute,
|
||||
}, nil)
|
||||
|
||||
_ = cb.Execute(func() error { return errors.New("fail") })
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Fatal("circuit should be open after failure")
|
||||
}
|
||||
|
||||
time.Sleep(70 * time.Millisecond) // past the open timeout -> half-open
|
||||
|
||||
if err := cb.Execute(func() error { return nil }); err != nil {
|
||||
t.Fatalf("request should be allowed in half-open state, got: %v", err)
|
||||
}
|
||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
||||
t.Fatalf("circuit must remain half-open until resetTimeout elapses, got %v", circuitBreakerStateToString(cb.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +275,13 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
if !cb.IsAvailable() {
|
||||
t.Error("Circuit should be available in half-open state")
|
||||
t.Error("Circuit should report availability once the open timeout has elapsed")
|
||||
}
|
||||
// R94: IsAvailable is a read-only probe and must not mutate circuit
|
||||
// state. Merely checking availability must NOT transition the circuit
|
||||
// to half-open (that is the job of real request admission).
|
||||
if got := cb.GetState(); got != CircuitBreakerOpen {
|
||||
t.Errorf("IsAvailable must not change circuit state; expected Open, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,6 +1135,7 @@ func TestBaseRecoveryMechanism_LogMethods(t *testing.T) {
|
||||
|
||||
func TestErrorRecoveryManagerCreation(t *testing.T) {
|
||||
erm := NewErrorRecoveryManager(nil)
|
||||
t.Cleanup(erm.Close)
|
||||
|
||||
if erm == nil {
|
||||
t.Fatal("Expected non-nil error recovery manager")
|
||||
@@ -1113,6 +1152,7 @@ func TestErrorRecoveryManagerCreation(t *testing.T) {
|
||||
|
||||
func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
|
||||
erm := NewErrorRecoveryManager(nil)
|
||||
t.Cleanup(erm.Close)
|
||||
|
||||
cb1 := erm.GetCircuitBreaker("service1")
|
||||
cb2 := erm.GetCircuitBreaker("service1")
|
||||
@@ -1133,6 +1173,7 @@ func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
|
||||
|
||||
func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
|
||||
erm := NewErrorRecoveryManager(nil)
|
||||
t.Cleanup(erm.Close)
|
||||
|
||||
success := false
|
||||
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
|
||||
@@ -1151,6 +1192,7 @@ func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
|
||||
|
||||
func TestErrorRecoveryManagerMetrics(t *testing.T) {
|
||||
erm := NewErrorRecoveryManager(nil)
|
||||
t.Cleanup(erm.Close)
|
||||
|
||||
_ = erm.GetCircuitBreaker("service1")
|
||||
_ = erm.GetCircuitBreaker("service2")
|
||||
@@ -1170,6 +1212,7 @@ func TestErrorRecoveryManagerMetrics(t *testing.T) {
|
||||
func TestErrorRecoveryManagerIntegration(t *testing.T) {
|
||||
logger := GetSingletonNoOpLogger()
|
||||
erm := NewErrorRecoveryManager(logger)
|
||||
t.Cleanup(erm.Close)
|
||||
|
||||
t.Run("circuit breaker and retry integration", func(t *testing.T) {
|
||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||
@@ -1271,9 +1314,12 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
|
||||
defer gd.Close()
|
||||
|
||||
t.Run("register health check", func(t *testing.T) {
|
||||
healthy := true
|
||||
var healthy atomic.Bool
|
||||
healthy.Store(true)
|
||||
// The health check runs on a background goroutine (performHealthChecks at
|
||||
// the configured interval); a plain captured bool would be a data race.
|
||||
healthCheck := func() bool {
|
||||
return healthy
|
||||
return healthy.Load()
|
||||
}
|
||||
|
||||
gd.RegisterHealthCheck("service1", healthCheck)
|
||||
@@ -1281,7 +1327,7 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
|
||||
gd.markServiceDegraded("service1")
|
||||
assert.True(t, gd.isServiceDegraded("service1"))
|
||||
|
||||
healthy = true
|
||||
healthy.Store(true)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
@@ -1507,6 +1553,13 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
|
||||
config := DefaultGracefulDegradationConfig()
|
||||
gd := NewGracefulDegradation(config, logger)
|
||||
defer gd.Close()
|
||||
// The shared health-check task runs this gd's checks in the background
|
||||
// (globalPerformHealthChecks). Hold its pass lock so that pass cannot
|
||||
// run the check concurrently with the direct call below or change the
|
||||
// degraded state between the assertions. The deferred Unlock runs
|
||||
// before the deferred Close, so Close never waits on a blocked pass.
|
||||
globalHealthCheckMu.Lock()
|
||||
defer globalHealthCheckMu.Unlock()
|
||||
|
||||
healthCheckCalled := false
|
||||
gd.RegisterHealthCheck("test-service", func() bool {
|
||||
@@ -1529,6 +1582,9 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
|
||||
config := DefaultGracefulDegradationConfig()
|
||||
gd := NewGracefulDegradation(config, logger)
|
||||
defer gd.Close()
|
||||
// Keep the shared background pass out of this subtest (see above).
|
||||
globalHealthCheckMu.Lock()
|
||||
defer globalHealthCheckMu.Unlock()
|
||||
|
||||
gd.RegisterHealthCheck("failing-service", func() bool {
|
||||
return false
|
||||
@@ -1609,9 +1665,12 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
|
||||
return "fallback data", nil
|
||||
})
|
||||
|
||||
serviceHealthy := false
|
||||
var serviceHealthy atomic.Bool
|
||||
serviceHealthy.Store(false)
|
||||
// Health checks run on a background goroutine (performHealthChecks at the
|
||||
// configured interval); a plain captured bool is a data race.
|
||||
gd.RegisterHealthCheck("critical-service", func() bool {
|
||||
return serviceHealthy
|
||||
return serviceHealthy.Load()
|
||||
})
|
||||
|
||||
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
|
||||
@@ -1634,7 +1693,7 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
|
||||
assert.NoError(t, err3)
|
||||
assert.Equal(t, "fallback data", result3)
|
||||
|
||||
serviceHealthy = true
|
||||
serviceHealthy.Store(true)
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
metrics := gd.GetMetrics()
|
||||
|
||||
@@ -51,7 +51,7 @@ experimental:
|
||||
plugins:
|
||||
traefikoidc:
|
||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
||||
version: v0.8.0
|
||||
version: v1.0.29
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
@@ -101,6 +101,16 @@ http:
|
||||
providerURL: "https://auth.example.com"
|
||||
callbackURL: "/oauth2/callback"
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Optional: switch to RFC 7523 private_key_jwt client auth
|
||||
# (Entra ID, Okta, Auth0, Keycloak). Replaces clientSecret with a
|
||||
# signed JWT assertion. See README for details and PEM formats.
|
||||
# ----------------------------------------------------------------
|
||||
# clientAuthMethod: "private_key_jwt"
|
||||
# clientAssertionKeyPath: "/etc/traefik/oidc/client-key.pem"
|
||||
# clientAssertionKeyID: "prod-key-2026"
|
||||
# clientAssertionAlg: "RS256" # or PS256/384/512, ES256/384/512
|
||||
|
||||
# Session Configuration
|
||||
sessionEncryptionKey: "prod-encryption-key-64-chars-long-keep-it-secret-and-safe"
|
||||
sessionMaxAge: 28800 # 8 hours
|
||||
@@ -253,7 +263,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
|
||||
- "--experimental.plugins.traefikoidc.version=v0.8.0"
|
||||
- "--experimental.plugins.traefikoidc.version=v1.0.29"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -435,22 +445,22 @@ spec:
|
||||
# you can set these. NOTE: Plugin configuration takes precedence!
|
||||
|
||||
# Docker Compose env file (.env)
|
||||
---
|
||||
# ----- (below is an example .env file, NOT YAML — shown for reference) -----
|
||||
# OIDC Configuration
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_PROVIDER_URL=https://auth.example.com
|
||||
# OIDC_CLIENT_ID=your-client-id
|
||||
# OIDC_CLIENT_SECRET=your-client-secret
|
||||
# OIDC_PROVIDER_URL=https://auth.example.com
|
||||
|
||||
# Redis Configuration (fallback)
|
||||
REDIS_ENABLED=true
|
||||
REDIS_ADDRESS=redis:6379
|
||||
REDIS_PASSWORD=yourredispassword
|
||||
REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=traefikoidc:
|
||||
REDIS_CACHE_MODE=hybrid
|
||||
REDIS_POOL_SIZE=20
|
||||
REDIS_ENABLE_CIRCUIT_BREAKER=true
|
||||
REDIS_ENABLE_HEALTH_CHECK=true
|
||||
# REDIS_ENABLED=true
|
||||
# REDIS_ADDRESS=redis:6379
|
||||
# REDIS_PASSWORD=yourredispassword
|
||||
# REDIS_DB=0
|
||||
# REDIS_KEY_PREFIX=traefikoidc:
|
||||
# REDIS_CACHE_MODE=hybrid
|
||||
# REDIS_POOL_SIZE=20
|
||||
# REDIS_ENABLE_CIRCUIT_BREAKER=true
|
||||
# REDIS_ENABLE_HEALTH_CHECK=true
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -6,7 +6,7 @@ experimental:
|
||||
plugins:
|
||||
traefikoidc:
|
||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
||||
version: v0.8.0
|
||||
version: v1.0.29
|
||||
|
||||
# Dynamic configuration (dynamic.yml or labels)
|
||||
http:
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression tests for FIX-02 (review-2026-09-10 finding at
|
||||
// middleware.go:105): shouldBypassAuth bypassed OIDC auth for ANY request
|
||||
// whose method is OPTIONS, not only genuine CORS preflights. A real
|
||||
// preflight always carries both an Origin header and an
|
||||
// Access-Control-Request-Method header; the R124 fix checked only the
|
||||
// method, so an unauthenticated `OPTIONS <protected-path>` with neither
|
||||
// header reached the backend unauthenticated (reproduced against
|
||||
// http.FileServer: 200 with the protected file's body).
|
||||
//
|
||||
// TestServeHTTP_ForgedPreflightDoesNotLeakBody hardens the residual gap a
|
||||
// follow-up review found in that fix: the Origin +
|
||||
// Access-Control-Request-Method check only rules out an accidental bare
|
||||
// OPTIONS request, not a deliberate attacker, since any non-browser client
|
||||
// can set both headers itself. bypassReasonOptions still forwarded to next
|
||||
// with no session check, so a forged preflight against http.FileServer
|
||||
// still returned the protected file's body.
|
||||
//
|
||||
// A later maintainer decision (the AllowUnauthenticatedPreflight config
|
||||
// option, settings.go) restored the released default: every OPTIONS request
|
||||
// -- genuine preflight or not -- needs auth unless the operator opts in.
|
||||
// Every test below that exercises the bypass path (positive or negative)
|
||||
// sets allowUnauthenticatedPreflight: true so it keeps testing the
|
||||
// Origin+Access-Control-Request-Method gate itself rather than the flag
|
||||
// gate placed in front of it. The default-off case is pinned separately by
|
||||
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth in
|
||||
// fix02b_allow_unauthenticated_preflight_test.go.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestShouldBypassAuth_BareOptionsDoesNotBypass pins the negative case: a
|
||||
// bare OPTIONS request carrying neither Origin nor
|
||||
// Access-Control-Request-Method is not a CORS preflight and must go
|
||||
// through the normal auth pipeline like any other method.
|
||||
func TestShouldBypassAuth_BareOptionsDoesNotBypass(t *testing.T) {
|
||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
||||
|
||||
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
|
||||
t.Fatalf("bare OPTIONS (no Origin, no Access-Control-Request-Method) must not bypass auth, got bypass reason %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass pins a partial
|
||||
// case: Origin alone (e.g. a plain cross-origin GET, not a preflight) must
|
||||
// not bypass either -- both headers are required.
|
||||
func TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass(t *testing.T) {
|
||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
|
||||
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
|
||||
t.Fatalf("OPTIONS with only Origin set must not bypass auth, got bypass reason %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldBypassAuth_GenuinePreflightBypasses pins the positive case: a
|
||||
// real CORS preflight (Origin + Access-Control-Request-Method) must still
|
||||
// bypass auth, so a browser's actual preflight keeps working.
|
||||
func TestShouldBypassAuth_GenuinePreflightBypasses(t *testing.T) {
|
||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
|
||||
bypass, reason := tObj.shouldBypassAuth(req)
|
||||
if !bypass {
|
||||
t.Fatal("a genuine CORS preflight (Origin + Access-Control-Request-Method) must bypass auth")
|
||||
}
|
||||
if reason != bypassReasonOptions {
|
||||
t.Fatalf("preflight bypass reason = %q, want %q", reason, bypassReasonOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend is the
|
||||
// end-to-end reproduction from the finding: an unauthenticated OPTIONS
|
||||
// request against a protected path, with no session cookie and no CORS
|
||||
// headers, must not reach the next handler. Mirrors the reviewer's
|
||||
// http.FileServer repro that returned the protected file's body.
|
||||
func TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
|
||||
})
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
allowUnauthenticatedPreflight: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatalf("unauthenticated bare OPTIONS must not reach the backend, but next handler ran (status=%d body=%q)", rw.Code, rw.Body.String())
|
||||
}
|
||||
if rw.Code == http.StatusOK && strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
||||
t.Fatalf("protected content leaked through an unauthenticated OPTIONS request: status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_GenuinePreflightReachesBackend confirms the fix does not
|
||||
// regress the R124 behavior it builds on: a real CORS preflight for the
|
||||
// same protected path must still reach the backend unconditionally.
|
||||
func TestServeHTTP_GenuinePreflightReachesBackend(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
allowUnauthenticatedPreflight: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if !nextCalled {
|
||||
t.Fatalf("a genuine CORS preflight must still reach the backend, got status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_ForgedPreflightDoesNotLeakBody reproduces the finding's own
|
||||
// exploit: a non-browser client sends an OPTIONS request carrying BOTH
|
||||
// Origin and Access-Control-Request-Method (indistinguishable from a real
|
||||
// preflight to shouldBypassAuth) against a protected path, with no session
|
||||
// cookie. It must not receive the backend's response body, even though the
|
||||
// request still forwards unauthenticated so a real browser preflight keeps
|
||||
// getting its CORS headers answered.
|
||||
func TestServeHTTP_ForgedPreflightDoesNotLeakBody(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
next := http.FileServer(http.Dir(dir))
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
allowUnauthenticatedPreflight: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
req.Header.Set("Origin", "https://attacker.example")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
||||
t.Fatalf("forged preflight leaked protected content: status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression tests for the FIX-02 maintainer decision: the released
|
||||
// behavior required authentication for every OPTIONS request by default.
|
||||
// FIX-02 (fix02_options_bypass_test.go) narrowed an accidental
|
||||
// unconditional OPTIONS bypass down to genuine CORS preflights (Origin +
|
||||
// Access-Control-Request-Method), but that still left every preflight
|
||||
// unauthenticated by default -- a behavior change from the release. This
|
||||
// file pins the maintainer decision to restore released behavior: the
|
||||
// preflight bypass now only applies when the operator opts in via
|
||||
// AllowUnauthenticatedPreflight (default false). With the flag off, OPTIONS
|
||||
// goes through the normal auth pipeline like any other method.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth pins the default
|
||||
// (AllowUnauthenticatedPreflight=false) behavior: even a genuine CORS
|
||||
// preflight (Origin + Access-Control-Request-Method) must NOT reach the
|
||||
// backend without a session -- it goes through the normal auth pipeline
|
||||
// (redirect or 401) exactly like any other unauthenticated request. This is
|
||||
// the released behavior FIX-02 must restore by default.
|
||||
func TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
// AllowUnauthenticatedPreflight left at its zero value (false):
|
||||
// default config must not bypass auth for OPTIONS.
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatalf("default config (allowUnauthenticatedPreflight=false) must send a genuine OPTIONS preflight through the normal auth pipeline, but the backend was called unconditionally: status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
if rw.Code == http.StatusNoContent {
|
||||
t.Fatalf("default config must not answer the preflight bypass response (204 from next); got status=%d", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded
|
||||
// pins the opt-in behavior: with AllowUnauthenticatedPreflight=true, a
|
||||
// genuine CORS preflight still bypasses auth (so real browser preflights
|
||||
// keep working) and the optionsPreflightWriter still discards the body
|
||||
// (FIX-02's forged-preflight hardening keeps applying when the flag is on).
|
||||
func TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
next := http.FileServer(http.Dir(dir))
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
allowUnauthenticatedPreflight: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
||||
t.Fatalf("allowUnauthenticatedPreflight=true must still discard the preflight response body: status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth
|
||||
// pins that the flag only widens the genuine-preflight gate -- a bare
|
||||
// OPTIONS request (no Origin, no Access-Control-Request-Method) still needs
|
||||
// auth even with AllowUnauthenticatedPreflight=true.
|
||||
func TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
|
||||
})
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: next,
|
||||
sessionManager: sm,
|
||||
redirURLPath: "/callback",
|
||||
authURL: "https://idp.example.com/authorize",
|
||||
issuerURL: "https://idp.example.com",
|
||||
clientID: "test-client-id",
|
||||
scopes: []string{"openid"},
|
||||
initComplete: initComplete,
|
||||
allowUnauthenticatedPreflight: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatalf("allowUnauthenticatedPreflight=true must not bypass auth for a bare OPTIONS request (no Origin/Access-Control-Request-Method), but the backend was called: status=%d body=%q", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression tests for FIX-05 (review-2026-09-10 finding at session.go:2409,
|
||||
// session.go:3099): SetRefreshToken/SetIDToken re-Get the gorilla-registry-
|
||||
// cached chunk *sessions.Session objects for the NEW token, fill them, and
|
||||
// only THEN call the expire helper for the OLD chunks. Because gorilla's
|
||||
// Registry.Get returns the SAME cached object per cookie name for a given
|
||||
// request, the "expire old" pass re-Gets those identical objects and wipes
|
||||
// session.Values on every non-new one -- erasing the NEW chunk data this
|
||||
// call just wrote. A session loaded from existing (legacy) chunk cookies
|
||||
// whose replacement token also needs chunking loses the new token entirely.
|
||||
//
|
||||
// Adapted from the throwaway repro at
|
||||
// .claude/review-2026-09-10/repro/zz_g1_chunkwipe_test.go and
|
||||
// zz_g1_chunkwipe_id_test.go (TestG1ChunkWipeRefresh / TestG1ChunkWipeAccessControl
|
||||
// / TestG1ChunkWipeIDToken), renamed to describe the behavior they pin.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// randChunkTestToken returns a random URL-safe token of n raw bytes, large
|
||||
// enough (with maxCookieSize=1400) to force the chunked storage path.
|
||||
func randChunkTestToken(t *testing.T, n int) string {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randChunkTestJWT returns a structurally valid (3-dot-separated) JWT whose
|
||||
// payload is padded past maxCookieSize, so SetIDToken takes the chunked path.
|
||||
func randChunkTestJWT(t *testing.T) string {
|
||||
t.Helper()
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
header := enc([]byte(`{"alg":"RS256","typ":"JWT","kid":"k"}`))
|
||||
now := time.Now().Unix()
|
||||
payload := enc([]byte(fmt.Sprintf(
|
||||
`{"iss":"https://idp","sub":"u","aud":"c","iat":%d,"exp":%d,"pad":"%s"}`,
|
||||
now, now+3600, randChunkTestToken(t, 3000),
|
||||
)))
|
||||
return header + "." + payload + "." + randChunkTestToken(t, 256)
|
||||
}
|
||||
|
||||
// mergeSetCookiesIntoJar copies a response's Set-Cookie headers into jar,
|
||||
// dropping any cookie the response expired (MaxAge < 0), so the next request
|
||||
// carries forward only what a real browser would still hold.
|
||||
func mergeSetCookiesIntoJar(jar map[string]*http.Cookie, rr *httptest.ResponseRecorder) {
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.MaxAge < 0 {
|
||||
delete(jar, c.Name)
|
||||
continue
|
||||
}
|
||||
jar[c.Name] = c
|
||||
}
|
||||
}
|
||||
|
||||
// newRequestWithJarCookies builds a request carrying every cookie currently
|
||||
// held in jar, simulating a browser replaying its cookie jar on the next
|
||||
// request to the same session.
|
||||
func newRequestWithJarCookies(jar map[string]*http.Cookie) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/", nil)
|
||||
for _, c := range jar {
|
||||
r.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// runChunkedTokenReplaceRegression drives three requests against one
|
||||
// SessionManager: request 1 stores a large (chunked) token A and saves it,
|
||||
// request 2 loads the cookies A produced and replaces the token with a
|
||||
// different large (chunked) token B, request 3 loads the cookies B produced
|
||||
// and must read back B, not A and not empty.
|
||||
func runChunkedTokenReplaceRegression(
|
||||
t *testing.T,
|
||||
set func(*SessionData, string),
|
||||
get func(*SessionData) string,
|
||||
tokA, tokB string,
|
||||
) {
|
||||
t.Helper()
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
jar := map[string]*http.Cookie{}
|
||||
|
||||
r1 := newRequestWithJarCookies(jar)
|
||||
s1, err := sm.GetSession(r1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession (request 1): %v", err)
|
||||
}
|
||||
set(s1, tokA)
|
||||
rr1 := httptest.NewRecorder()
|
||||
if err := s1.Save(r1, rr1); err != nil {
|
||||
t.Fatalf("Save (request 1): %v", err)
|
||||
}
|
||||
s1.returnToPoolSafely()
|
||||
mergeSetCookiesIntoJar(jar, rr1)
|
||||
|
||||
r2 := newRequestWithJarCookies(jar)
|
||||
s2, err := sm.GetSession(r2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession (request 2): %v", err)
|
||||
}
|
||||
if got := get(s2); got != tokA {
|
||||
t.Fatalf("request 2 must load token A written by request 1 before replacing it: got len=%d, want tokA", len(got))
|
||||
}
|
||||
set(s2, tokB)
|
||||
if got := get(s2); got != tokB {
|
||||
t.Fatalf("in-memory read immediately after replacing the chunked token must return the NEW token: got len=%d (isA=%v), want tokB", len(got), got == tokA)
|
||||
}
|
||||
rr2 := httptest.NewRecorder()
|
||||
if err := s2.Save(r2, rr2); err != nil {
|
||||
t.Fatalf("Save (request 2): %v", err)
|
||||
}
|
||||
s2.returnToPoolSafely()
|
||||
mergeSetCookiesIntoJar(jar, rr2)
|
||||
|
||||
r3 := newRequestWithJarCookies(jar)
|
||||
s3, err := sm.GetSession(r3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession (request 3): %v", err)
|
||||
}
|
||||
defer s3.returnToPoolSafely()
|
||||
got := get(s3)
|
||||
if got != tokB {
|
||||
t.Fatalf("after replacing a chunked token loaded from cookies: got len=%d (isTokA=%v), want tokB (the user would be silently signed out)", len(got), got == tokA)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies pins
|
||||
// FIX-05 (session.go:2409): replacing a chunked refresh token that was
|
||||
// loaded from existing chunk cookies must not wipe the newly written chunks.
|
||||
func TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
||||
tokA := randChunkTestToken(t, 4500)
|
||||
tokB := randChunkTestToken(t, 4500)
|
||||
runChunkedTokenReplaceRegression(
|
||||
t,
|
||||
func(s *SessionData, v string) { s.SetRefreshToken(v) },
|
||||
func(s *SessionData) string { return s.GetRefreshToken() },
|
||||
tokA, tokB,
|
||||
)
|
||||
}
|
||||
|
||||
// TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies pins
|
||||
// FIX-05 (session.go:3099): the same bug in SetIDToken, using structurally
|
||||
// valid JWTs since SetIDToken rejects non-JWT-shaped input.
|
||||
func TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
||||
tokA := randChunkTestJWT(t)
|
||||
tokB := randChunkTestJWT(t)
|
||||
runChunkedTokenReplaceRegression(
|
||||
t,
|
||||
func(s *SessionData, v string) { s.SetIDToken(v) },
|
||||
func(s *SessionData) string { return s.GetIDToken() },
|
||||
tokA, tokB,
|
||||
)
|
||||
}
|
||||
|
||||
// TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies is the
|
||||
// control: SetAccessToken already uses the expire-then-fill order (it was
|
||||
// unaffected by the R154 regression) and must stay green both before and
|
||||
// after FIX-05, proving the fix does not depend on a shared helper bug.
|
||||
func TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
||||
tokA := randChunkTestToken(t, 4500)
|
||||
tokB := randChunkTestToken(t, 4500)
|
||||
runChunkedTokenReplaceRegression(
|
||||
t,
|
||||
func(s *SessionData, v string) { s.SetAccessToken(v) },
|
||||
func(s *SessionData) string { return s.GetAccessToken() },
|
||||
tokA, tokB,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression tests for FIX-06 (review-2026-09-10 finding at
|
||||
// middleware.go:309, high severity): ServeHTTP wraps the response writer
|
||||
// in *trackingWriter for panic-commit tracking (rw = tw) and then hands
|
||||
// that SAME wrapped writer to every t.next.ServeHTTP call, including the
|
||||
// SSE/WebSocket bypass and the authenticated forwarding path. Traefik runs
|
||||
// this plugin interpreted under yaegi v0.16.1, and yaegi's stdlib
|
||||
// interop layer composes a boundary wrapper only for ResponseWriter+
|
||||
// Hijacker crossing into compiled code -- not for Flusher or Unwrap. So
|
||||
// Traefik's compiled ReverseProxy calling
|
||||
// http.NewResponseController(rw).Flush() on the interpreted trackingWriter
|
||||
// gets "feature not supported" in production, even though trackingWriter
|
||||
// declares Flush/Unwrap and native tests (which never cross an
|
||||
// interpreter boundary) pass. See checkSSEFlushReachesNext in cmd/yaegicheck/main.go (run by
|
||||
// `make yaegi-validate`) for the interop reproduction.
|
||||
//
|
||||
// The fix hands t.next.ServeHTTP the ORIGINAL writer ServeHTTP received
|
||||
// (trackingWriter.Unwrap()), never the wrapper, on every forwarding path.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nextWriterSpy is a minimal next handler that records whether the
|
||||
// http.ResponseWriter it was handed is (or, transitively, still wraps) a
|
||||
// *trackingWriter, so a test can assert the downstream writer is the real
|
||||
// one Traefik would give a compiled ReverseProxy -- not the interpreted
|
||||
// wrapper.
|
||||
func nextWriterSpy(called *bool, sawTrackingWriter *bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*called = true
|
||||
if _, ok := w.(*trackingWriter); ok {
|
||||
*sawTrackingWriter = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticatedRequestWithCookie builds a request carrying a saved,
|
||||
// authenticated session cookie for user, so the SSE/WebSocket bypass's
|
||||
// cookie-only auth check (applyBypassUserHeaders) admits it.
|
||||
func authenticatedRequestWithCookie(t *testing.T, sm *SessionManager, user string) *http.Request {
|
||||
t.Helper()
|
||||
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
|
||||
session, err := sm.GetSession(setupReq)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
session.SetAuthenticated(true)
|
||||
session.SetUserIdentifier(user)
|
||||
rr := httptest.NewRecorder()
|
||||
if err := session.Save(setupReq, rr); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
session.returnToPoolSafely()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter pins FIX-06 on the
|
||||
// SSE bypass path.
|
||||
func TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
var called, sawTrackingWriter bool
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
||||
sessionManager: sm,
|
||||
initComplete: initComplete,
|
||||
}
|
||||
|
||||
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if !called {
|
||||
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated SSE bypass must forward", rw.Code, rw.Body.String())
|
||||
}
|
||||
if sawTrackingWriter {
|
||||
t.Fatal("next handler received the interpreted trackingWriter on the SSE bypass path, not the original ResponseWriter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter pins FIX-06 on
|
||||
// the WebSocket upgrade bypass path.
|
||||
func TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
var called, sawTrackingWriter bool
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
||||
sessionManager: sm,
|
||||
initComplete: initComplete,
|
||||
}
|
||||
|
||||
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
tObj.ServeHTTP(rw, req)
|
||||
|
||||
if !called {
|
||||
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated WebSocket bypass must forward", rw.Code, rw.Body.String())
|
||||
}
|
||||
if sawTrackingWriter {
|
||||
t.Fatal("next handler received the interpreted trackingWriter on the WebSocket bypass path, not the original ResponseWriter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardAuthorized_NextReceivesUnwrappedWriter pins FIX-06 on the
|
||||
// authenticated forwarding path (forwardAuthorized is ServeHTTP's and
|
||||
// bearer_auth.go's shared tail call to t.next.ServeHTTP). ServeHTTP always
|
||||
// calls forwardAuthorized with rw already wrapped in *trackingWriter (see
|
||||
// middleware.go:309); reproduce that shape directly here.
|
||||
func TestForwardAuthorized_NextReceivesUnwrappedWriter(t *testing.T) {
|
||||
var called, sawTrackingWriter bool
|
||||
oidc := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
tw := &trackingWriter{ResponseWriter: rec}
|
||||
p := &principal{Identifier: "[email protected]"}
|
||||
|
||||
oidc.forwardAuthorized(tw, req, p)
|
||||
|
||||
if !called {
|
||||
t.Fatal("next handler was not invoked")
|
||||
}
|
||||
if sawTrackingWriter {
|
||||
t.Fatal("next handler received the interpreted trackingWriter on the authenticated path, not the original ResponseWriter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression test for FIX-10 (review-2026-09-10 finding at session.go:1784,
|
||||
// middleware.go:566/789/883): a request-scoped deferred pool-return has no
|
||||
// way to tell a stale holder from the current owner (ABA on the pool-return
|
||||
// CAS). ServeHTTP acquires a session and defers its return; deep in the call
|
||||
// chain, session.Clear() returns the SAME object to the pool and the
|
||||
// handler re-acquires it under a fresh GetSession/newSession call. If a
|
||||
// concurrent request pops that pooled object before the ORIGINAL deferred
|
||||
// return fires, the stale return's CompareAndSwap(true, false) still
|
||||
// succeeds against the new owner, incorrectly releasing (and Reset()ting) a
|
||||
// session another request is actively using.
|
||||
//
|
||||
// Adapted from the throwaway repro at
|
||||
// .claude/review-2026-09-10/repro/zz_g1_aba_test.go (TestG1StaleDeferABA),
|
||||
// renamed to describe the behavior it pins and rewritten against the
|
||||
// generation-gated return the fix introduces (ownerGeneration /
|
||||
// returnToPoolIfOwner) instead of the raw returnToPoolSafely the bug lived
|
||||
// in -- a caller must adopt the new API for the fix to apply, so the test
|
||||
// exercises the same call the fixed middleware.go ServeHTTP now makes.
|
||||
//
|
||||
// The ABA reuse step (does the pool hand the SAME object back to a second
|
||||
// GetSession after Clear) is itself an unspecified sync.Pool implementation
|
||||
// detail, not part of the contract this fix establishes. Rather than depend
|
||||
// on that timing (flaky: it did not reproduce on every run), this test
|
||||
// drives the two field mutations GetSession/newSession make on every real
|
||||
// handout -- inUse.Store(true) then generation.Add(1) -- directly on the
|
||||
// SAME object Clear() just returned, deterministically recreating "a new
|
||||
// owner acquired this exact object" regardless of pool internals.
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner reproduces the
|
||||
// ABA sequence: request A acquires session O and captures its ownership
|
||||
// generation (mirrors ServeHTTP's defer registration at middleware.go:566).
|
||||
// O is cleared and returned to the pool (mirrors session.Clear at
|
||||
// middleware.go:774/868); a new owner then claims the SAME object the way
|
||||
// GetSession/newSession do (mirrors the re-acquire at
|
||||
// middleware.go:781/875) and starts using it. A's stale deferred return
|
||||
// then fires (mirrors ServeHTTP's defer running at function exit) and must
|
||||
// be a no-op against the new owner's live ownership.
|
||||
func TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
r1 := httptest.NewRequest("GET", "https://app.example.com/", nil)
|
||||
o, err := sm.GetSession(r1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession (request A): %v", err)
|
||||
}
|
||||
// ServeHTTP captures the generation right after acquiring, then defers
|
||||
// a generation-gated return instead of an unconditional one.
|
||||
staleGen := o.ownerGeneration()
|
||||
|
||||
if err := o.Clear(r1, httptest.NewRecorder()); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
if o.inUse.Load() {
|
||||
t.Fatalf("Clear must return the object to the pool (inUse still true)")
|
||||
}
|
||||
|
||||
// A new owner claims the exact same object: this is the sequence
|
||||
// GetSession/newSession run on every real handout -- generation,
|
||||
// sessionOwner, then inUse (session.go:1076-1078, 1108-1110) --
|
||||
// reproduced directly so the test does not depend on sync.Pool's
|
||||
// unspecified reuse timing. sessionOwner must be set to the new
|
||||
// generation, not left at 0: a stale returnToPoolIfOwner call gates on
|
||||
// sessionOwner, so leaving it 0 would make the object look already-free
|
||||
// rather than owned by someone else, which is what this test needs to
|
||||
// tell apart.
|
||||
o.sessionOwner.Store(o.generation.Add(1))
|
||||
o.inUse.Store(true)
|
||||
o.SetUserIdentifier("bob")
|
||||
|
||||
if staleGen == o.ownerGeneration() {
|
||||
t.Fatalf("test setup bug: new owner's generation (%d) must differ from the captured stale generation (%d)", o.ownerGeneration(), staleGen)
|
||||
}
|
||||
|
||||
// A's stale deferred return fires at ServeHTTP exit, after the new
|
||||
// owner has already claimed and started using the same object.
|
||||
o.returnToPoolIfOwner(staleGen)
|
||||
|
||||
if !o.inUse.Load() {
|
||||
t.Fatalf("stale deferred return (captured generation %d) released a session owned by another holder (current generation %d)", staleGen, o.ownerGeneration())
|
||||
}
|
||||
if o.GetUserIdentifier() != "bob" {
|
||||
t.Fatalf("stale deferred return corrupted the new owner's session data: got user %q, want \"bob\"", o.GetUserIdentifier())
|
||||
}
|
||||
|
||||
o.returnToPoolSafely()
|
||||
}
|
||||
|
||||
// TestSessionPoolReturn_CurrentGenerationStillReturns is the control: a
|
||||
// non-stale generation-gated return (the common case -- no Clear+reacquire
|
||||
// happened) must still return the object to the pool, so the fix does not
|
||||
// leak sessions on the ordinary path.
|
||||
func TestSessionPoolReturn_CurrentGenerationStillReturns(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
r := httptest.NewRequest("GET", "https://app.example.com/", nil)
|
||||
s, err := sm.GetSession(r)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
gen := s.ownerGeneration()
|
||||
|
||||
s.returnToPoolIfOwner(gen)
|
||||
|
||||
if s.inUse.Load() {
|
||||
t.Fatalf("returnToPoolIfOwner with the current generation must return the object to the pool (inUse still true)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-18: main.go wires up TWO GracefulDegradation instances per plugin
|
||||
// instance — one directly via NewErrorRecoveryManager (t.errorRecoveryManager)
|
||||
// and one nested inside NewTokenResilienceManager's own ErrorRecoveryManager
|
||||
// (t.tokenResilienceManager). Production Close() only ever closed the first
|
||||
// one (t.errorRecoveryManager.gracefulDegradation.Close()), so the second gd
|
||||
// leaked forever in the package-level gdInstances registry and the shared
|
||||
// "graceful-degradation-health-check" task's last-instance stop gate
|
||||
// (len(gdInstances.set)==0) never fired.
|
||||
//
|
||||
// gdInstancesLen (below) reads the size of the package-level gdInstances set.
|
||||
// The test asserts on the DELTA around creating and closing both managers, so
|
||||
// it stays correct regardless of what other tests in this package leave
|
||||
// registered.
|
||||
func TestFix18_ClosingBothManagersEmptiesGDInstances(t *testing.T) {
|
||||
logger := GetSingletonNoOpLogger()
|
||||
|
||||
before := gdInstancesLen()
|
||||
|
||||
erm := NewErrorRecoveryManager(logger)
|
||||
trm := NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
|
||||
|
||||
afterNew := gdInstancesLen()
|
||||
if afterNew != before+2 {
|
||||
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances (one direct, one nested in TokenResilienceManager); got delta %d", afterNew-before)
|
||||
}
|
||||
|
||||
// Let each instance's async health-check goroutine (started by
|
||||
// NewGracefulDegradation) settle — either it assigns the shared task, or
|
||||
// it observes an already-closed instance and bails — before closing.
|
||||
// This makes the Close() calls below exercise the real "task already
|
||||
// assigned, stop it by name" path deterministically on every run instead
|
||||
// of sometimes racing the goroutine (FIX-18 review, item 4).
|
||||
waitForHealthRoutineSettled(t, erm.gracefulDegradation)
|
||||
waitForHealthRoutineSettled(t, trm.errorRecoveryManager.gracefulDegradation)
|
||||
|
||||
// Close every gd this wiring created. Before FIX-18, only erm's gd had a
|
||||
// Close path reachable from TraefikOidc.Close (via
|
||||
// t.errorRecoveryManager.gracefulDegradation.Close()); trm's own gd had
|
||||
// no Close method to call at all.
|
||||
erm.Close()
|
||||
trm.Close()
|
||||
|
||||
afterClose := gdInstancesLen()
|
||||
if afterClose != before {
|
||||
t.Fatalf("gdInstances leaked %d entries after closing every gd this wiring created (afterClose delta=%d, want 0) — TokenResilienceManager's own GracefulDegradation was never closed", afterClose-before, afterClose-before)
|
||||
}
|
||||
|
||||
// Only assert the shared health-check task itself stopped when this test
|
||||
// is known to be the sole registrant (before==0): the task is a
|
||||
// process-global singleton shared by every GracefulDegradation created
|
||||
// anywhere in the suite, so this assertion would be flaky under a
|
||||
// nonzero baseline left by another test.
|
||||
if before == 0 && GetResourceManager().IsTaskRunning("graceful-degradation-health-check") {
|
||||
t.Fatal("graceful-degradation-health-check task must stop once gdInstances is empty (last-instance stop gate)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix18_TraefikOidcCloseEmptiesGDInstances pins the FIX-18 major-finding
|
||||
// gap left by the test above: calling erm.Close()/trm.Close() directly stays
|
||||
// green even if the real (*TraefikOidc).Close() is reverted to skip one of
|
||||
// them (utilities.go's t.errorRecoveryManager.Close() /
|
||||
// t.tokenResilienceManager.Close() calls). This test wires both managers on
|
||||
// a TraefikOidc exactly as NewWithContext does (main.go's
|
||||
// t.errorRecoveryManager = NewErrorRecoveryManager(...) followed by
|
||||
// t.tokenResilienceManager = NewTokenResilienceManager(...)) and drives the
|
||||
// real Close(), so it only passes when Close() actually reaches both.
|
||||
func TestFix18_TraefikOidcCloseEmptiesGDInstances(t *testing.T) {
|
||||
logger := GetSingletonNoOpLogger()
|
||||
before := gdInstancesLen()
|
||||
|
||||
oidc := &TraefikOidc{logger: logger}
|
||||
oidc.errorRecoveryManager = NewErrorRecoveryManager(logger)
|
||||
oidc.tokenResilienceManager = NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
|
||||
|
||||
afterNew := gdInstancesLen()
|
||||
if afterNew != before+2 {
|
||||
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances; got delta %d", afterNew-before)
|
||||
}
|
||||
|
||||
waitForHealthRoutineSettled(t, oidc.errorRecoveryManager.gracefulDegradation)
|
||||
waitForHealthRoutineSettled(t, oidc.tokenResilienceManager.errorRecoveryManager.gracefulDegradation)
|
||||
|
||||
// Keep a sentinel instance alive across Close() so this test's Close()
|
||||
// call is never the last live instance: this test only asserts on
|
||||
// gdInstances, but Close()'s "am I last" decision (stopIfLastInstance)
|
||||
// also stops process-global singleton tasks — singleton-token-cleanup
|
||||
// and, via GetGlobalTaskRegistry().StopAllTasks(), every other
|
||||
// registered singleton (e.g. memory-monitor) — that this test does not
|
||||
// own and must not stop out from under the rest of the suite.
|
||||
registerLiveInstance()
|
||||
t.Cleanup(func() { unregisterLiveInstance() })
|
||||
|
||||
// Balance Close()'s own unregisterLiveInstance() call: New() always
|
||||
// registers before it returns an instance for Close() to eventually
|
||||
// unregister, and this test's synthetic instance must too, or it skews
|
||||
// the process-global liveInstanceCount permanently negative for every
|
||||
// other test that runs afterward in the same binary (FIX-35 territory,
|
||||
// but liveInstanceCount is shared package state regardless of which
|
||||
// finding a given test targets).
|
||||
registerLiveInstance()
|
||||
|
||||
if err := oidc.Close(); err != nil {
|
||||
t.Fatalf("Close returned an error: %v", err)
|
||||
}
|
||||
|
||||
after := gdInstancesLen()
|
||||
if after != before {
|
||||
t.Fatalf("gdInstances leaked %d entries after TraefikOidc.Close (delta=%d, want 0) — Close must close both t.errorRecoveryManager and t.tokenResilienceManager", after-before, after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix18_CloseImmediatelyAfterNewDoesNotReRegister pins the exact race the
|
||||
// FIX-18 review's scratch probe found: NewGracefulDegradation followed
|
||||
// immediately by Close(), before the async health-check goroutine has had a
|
||||
// chance to run. The old startHealthCheckRoutine re-added gd to gdInstances
|
||||
// from inside that goroutine (after Close had already deleted it), permanently
|
||||
// re-registering an already-closed instance and restarting the shared
|
||||
// health-check task for it with nothing left able to ever stop it again.
|
||||
func TestFix18_CloseImmediatelyAfterNewDoesNotReRegister(t *testing.T) {
|
||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
||||
|
||||
cfg := DefaultGracefulDegradationConfig()
|
||||
gd := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
|
||||
gd.Close()
|
||||
|
||||
// Give the async health-check goroutine every opportunity to run and,
|
||||
// under the bug, re-register the closed instance.
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
gdInstances.RLock()
|
||||
n := len(gdInstances.set)
|
||||
gdInstances.RUnlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("closed instance was re-registered: gdInstances has %d entries, want 0", n)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix18_NewWithContextClosesManagersOnEarlyError pins the FIX-18 minor
|
||||
// finding: NewWithContext creates t.errorRecoveryManager and
|
||||
// t.tokenResilienceManager, then returns an error from a later validation
|
||||
// step (here, an invalid private_key_jwt key) without ever closing either
|
||||
// one — leaking their GracefulDegradation instances in gdInstances forever.
|
||||
func TestFix18_NewWithContextClosesManagersOnEarlyError(t *testing.T) {
|
||||
before := gdInstancesLen()
|
||||
|
||||
cfg := &Config{
|
||||
ProviderURL: "https://accounts.google.com",
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
CallbackURL: "/callback",
|
||||
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
|
||||
RateLimit: 100,
|
||||
ClientAuthMethod: "private_key_jwt",
|
||||
ClientAssertionKeyID: "test-kid",
|
||||
ClientAssertionPrivateKey: "-----BEGIN PRIVATE KEY-----\nnot-valid-base64-content!!!\n-----END PRIVATE KEY-----\n",
|
||||
}
|
||||
|
||||
plugin, err := NewWithContext(context.Background(), cfg, nil, "fix18-bad-key")
|
||||
if err == nil {
|
||||
if plugin != nil {
|
||||
plugin.Close()
|
||||
}
|
||||
t.Fatal("expected NewWithContext to fail on an invalid private_key_jwt key")
|
||||
}
|
||||
if plugin != nil {
|
||||
t.Fatal("NewWithContext must return a nil instance on error")
|
||||
}
|
||||
|
||||
after := gdInstancesLen()
|
||||
if after != before {
|
||||
t.Fatalf("NewWithContext's error path leaked %d GracefulDegradation instance(s) in gdInstances (before=%d after=%d) — the errorRecoveryManager and tokenResilienceManager created just before the failure were never closed", after-before, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// healthRoutineSettled reports whether startHealthCheckRoutine has finished
|
||||
// making its one-time decision for this instance: either it assigned a
|
||||
// health-check task, or it observed stopChan already closed and returned
|
||||
// without assigning one (in which case it never will, since it runs at most
|
||||
// once). Tests use this to wait for the async goroutine NewGracefulDegradation
|
||||
// starts to settle before asserting on shared state, instead of depending on
|
||||
// goroutine-scheduling timing.
|
||||
//
|
||||
// Test-only: moved out of error_recovery.go (production code) because its
|
||||
// only caller is waitForHealthRoutineSettled below, following the pattern
|
||||
// FIX-39 established for resetGlobalTransportPoolForTest
|
||||
// (internal/pool/export_test.go) — a test-only helper has no business being
|
||||
// compiled into the production binary.
|
||||
func (gd *GracefulDegradation) healthRoutineSettled() bool {
|
||||
gd.mutex.RLock()
|
||||
defer gd.mutex.RUnlock()
|
||||
|
||||
if gd.healthCheckTask != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
select {
|
||||
case <-gd.stopChan:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// waitForHealthRoutineSettled polls until gd's async startHealthCheckRoutine
|
||||
// goroutine has settled (see GracefulDegradation.healthRoutineSettled).
|
||||
func waitForHealthRoutineSettled(t *testing.T, gd *GracefulDegradation) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if gd.healthRoutineSettled() {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("health-check goroutine did not settle (task assigned or instance already closed) in time")
|
||||
}
|
||||
|
||||
// gdInstancesLen reads the current size of the package-level gdInstances set
|
||||
// under its own lock.
|
||||
func gdInstancesLen() int {
|
||||
gdInstances.RLock()
|
||||
defer gdInstances.RUnlock()
|
||||
return len(gdInstances.set)
|
||||
}
|
||||
|
||||
// snapshotAndClearGdInstancesForTest captures the current contents of the
|
||||
// package-level gdInstances registry and replaces it with a fresh empty set,
|
||||
// mirroring resetGdInstancesForTest (review_r34_regression_test.go). Unlike
|
||||
// that helper, it returns a restore func the caller registers with
|
||||
// t.Cleanup: a bare reset with no restore permanently discards whatever
|
||||
// GracefulDegradation instances other tests in the same binary had
|
||||
// registered before this test ran, instead of only clearing for the
|
||||
// duration of this test.
|
||||
func snapshotAndClearGdInstancesForTest() func() {
|
||||
gdInstances.Lock()
|
||||
saved := gdInstances.set
|
||||
gdInstances.set = make(map[*GracefulDegradation]struct{})
|
||||
gdInstances.Unlock()
|
||||
|
||||
return func() {
|
||||
gdInstances.Lock()
|
||||
gdInstances.set = saved
|
||||
gdInstances.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-19: RegisterBackgroundTask replaced a registered-but-not-yet-started
|
||||
// task (started==0, stopped==0) unconditionally. CreateSingletonTask runs
|
||||
// Register -> IsTaskRunning -> StartBackgroundTask without holding tasksMu
|
||||
// across the three calls (autocleanup.go:612-632), so a second concurrent
|
||||
// caller can land in RegisterBackgroundTask in the window before the first
|
||||
// caller's own Start() call runs.
|
||||
//
|
||||
// This test reproduces that exact interleaving deterministically, without
|
||||
// real goroutines, by issuing the calls in the documented race order:
|
||||
//
|
||||
// 1. Caller A registers the task (T1).
|
||||
// 2. Caller A reads its handle from the registry (mirrors
|
||||
// CreateSingletonTask's internal StartBackgroundTask lookup) but has not
|
||||
// called Start() on it yet.
|
||||
// 3. Caller B registers the SAME name before A calls Start() — at head this
|
||||
// replaces the map entry with a fresh task (T2).
|
||||
// 4. Caller A calls Start() on the handle it read in step 2.
|
||||
// 5. Caller B starts whatever is now registered under the name.
|
||||
//
|
||||
// At head this starts two independent tickers (T1 wrapping count1, T2
|
||||
// wrapping count2). Only the object actually stored in the registry (T2) is
|
||||
// reachable from StopAllTasks, so T1 becomes an orphan that keeps running
|
||||
// forever past Close/StopAllTasks. After the fix, step 3's register call
|
||||
// keeps the existing (still-pending) T1 instead of replacing it, so there is
|
||||
// only ever one task, and count2 (which would wrap a second, orphaned task)
|
||||
// never starts at all.
|
||||
func TestFix19_RegisterBackgroundTaskKeepsPendingTaskNoOrphan(t *testing.T) {
|
||||
rm := GetResourceManager()
|
||||
name := "fix19-" + t.Name()
|
||||
|
||||
var count1, count2 int32
|
||||
|
||||
// Step 1: caller A registers T1.
|
||||
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
|
||||
atomic.AddInt32(&count1, 1)
|
||||
}); err != nil {
|
||||
t.Fatalf("first RegisterBackgroundTask: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: caller A reads its handle, but has not started it yet.
|
||||
rm.tasksMu.RLock()
|
||||
aTask := rm.tasks[name]
|
||||
rm.tasksMu.RUnlock()
|
||||
if aTask == nil {
|
||||
t.Fatal("task not present in registry after first RegisterBackgroundTask")
|
||||
}
|
||||
|
||||
// Step 3: caller B registers the same name before A calls Start().
|
||||
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
|
||||
atomic.AddInt32(&count2, 1)
|
||||
}); err != nil {
|
||||
t.Fatalf("second RegisterBackgroundTask: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: caller A starts the handle it read in step 2.
|
||||
aTask.Start()
|
||||
|
||||
// Step 5: caller B starts whatever is now registered under the name.
|
||||
if err := rm.StartBackgroundTask(name); err != nil {
|
||||
t.Fatalf("StartBackgroundTask: %v", err)
|
||||
}
|
||||
|
||||
// Let both tickers (or, after the fix, the single ticker) tick a few
|
||||
// times before stopping.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Stop only the named task this test created, not rm.StopAllTasks(): the
|
||||
// ResourceManager is a process-global singleton, and StopAllTasks stops
|
||||
// every task registered on it, including unrelated singletons (e.g.
|
||||
// memory-monitor, singleton-token-cleanup) that other tests in the same
|
||||
// binary may have started and still expect running.
|
||||
if err := rm.StopBackgroundTask(name); err != nil {
|
||||
t.Fatalf("StopBackgroundTask(%q): %v", name, err)
|
||||
}
|
||||
|
||||
// Snapshot count1 right after the stop, then again after another window
|
||||
// long enough for several more ticks. A well-stopped task's counter must
|
||||
// not move between the two snapshots.
|
||||
afterStop1 := atomic.LoadInt32(&count1)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
final1 := atomic.LoadInt32(&count1)
|
||||
|
||||
if final1 != afterStop1 {
|
||||
t.Fatalf("task wrapping count1 kept running after StopBackgroundTask (orphan escaped registry): %d -> %d", afterStop1, final1)
|
||||
}
|
||||
if afterStop1 == 0 {
|
||||
t.Fatal("precondition: task wrapping count1 never ran at all")
|
||||
}
|
||||
|
||||
// count2 must never have run: after the fix, the second
|
||||
// RegisterBackgroundTask call keeps the existing pending task instead of
|
||||
// constructing and starting a second one.
|
||||
if got := atomic.LoadInt32(&count2); got != 0 {
|
||||
t.Fatalf("second RegisterBackgroundTask call must not create and start a second task while the first is still pending; count2=%d", got)
|
||||
}
|
||||
|
||||
// Cleanup: make sure the task this test created cannot keep running past
|
||||
// the test even if an assertion above already failed it.
|
||||
aTask.Stop()
|
||||
rm.tasksMu.RLock()
|
||||
bTask := rm.tasks[name]
|
||||
rm.tasksMu.RUnlock()
|
||||
if bTask != nil {
|
||||
bTask.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fix26CapturingLogger builds a *Logger whose Errorf output lands in buf
|
||||
// while Info/Debug are discarded, so a test can assert on exactly what
|
||||
// reaches the error stream without touching the process-wide os.Stderr.
|
||||
func fix26CapturingLogger(buf *bytes.Buffer) *Logger {
|
||||
return &Logger{
|
||||
logError: log.New(buf, "", 0),
|
||||
logInfo: log.New(io.Discard, "", 0),
|
||||
logDebug: log.New(io.Discard, "", 0),
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly
|
||||
// pins FIX-26. validateDiscoveredEndpoint already drops (R146) a discovered
|
||||
// http endpoint when providerURL is https, and the pre-existing "Ignoring
|
||||
// discovered ..." ERROR line already names the endpoint, the URL, and the
|
||||
// scheme-downgrade reason (it wraps ErrDiscoveredEndpointSchemeDowngrade).
|
||||
// What was missing was a way to tell that rejection apart, at a glance, from
|
||||
// every other validation rejection (SSRF block, malformed URL, path
|
||||
// traversal, ...) for the three endpoints login cannot function without. The
|
||||
// fix adds one additional, distinctly-tagged SECURITY line — naming the
|
||||
// endpoint and stating there is no config override — for exactly those
|
||||
// three (jwks_uri, authorization, token); the other four discovered
|
||||
// endpoints (revocation, end_session, introspection, registration) keep
|
||||
// only the generic line, since three of them (revocationURL,
|
||||
// oidcEndSessionURL, introspectionURL) have an operator override that
|
||||
// replaces them right after sanitize runs.
|
||||
func TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tObj := &TraefikOidc{
|
||||
logger: fix26CapturingLogger(&buf),
|
||||
providerURL: "https://provider.example.com",
|
||||
}
|
||||
|
||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
||||
TokenURL: "http://provider.example.com/token",
|
||||
})
|
||||
|
||||
if tObj.tokenURL != "" {
|
||||
t.Fatalf("an http token endpoint under an https provider must still be dropped, got %q", tObj.tokenURL)
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "SECURITY:") {
|
||||
t.Fatalf("dropping an endpoint for the https-pin reason must log a SECURITY-tagged line, got:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "token") {
|
||||
t.Fatalf("the SECURITY line must name the dropped endpoint (token), got:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "http://provider.example.com/token") {
|
||||
t.Fatalf("the SECURITY line must name the dropped URL, got:\n%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, "no override") {
|
||||
t.Fatalf("the SECURITY line must state that this check has no override, got:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog is the
|
||||
// negative case: well-formed https endpoints (jwks_uri, authorization,
|
||||
// token) must not trip the new SECURITY line.
|
||||
func TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tObj := &TraefikOidc{
|
||||
logger: fix26CapturingLogger(&buf),
|
||||
providerURL: "https://provider.example.com",
|
||||
}
|
||||
|
||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
||||
JWKSURL: "https://provider.example.com/jwks",
|
||||
AuthURL: "https://provider.example.com/auth",
|
||||
TokenURL: "https://provider.example.com/token",
|
||||
})
|
||||
|
||||
if tObj.tokenURL != "https://provider.example.com/token" {
|
||||
t.Fatalf("an https token endpoint must be accepted, got %q", tObj.tokenURL)
|
||||
}
|
||||
if strings.Contains(buf.String(), "SECURITY:") {
|
||||
t.Fatalf("accepted https endpoints must not log a SECURITY line, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly extends the
|
||||
// pin to the other two endpoints login cannot function without: jwks_uri
|
||||
// (signature verification) and authorization (the redirect target). Each
|
||||
// must independently trigger its own named SECURITY line.
|
||||
func TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
endpoint string
|
||||
}{
|
||||
{field: "jwks_uri", endpoint: "http://provider.example.com/jwks"},
|
||||
{field: "authorization", endpoint: "http://provider.example.com/auth"},
|
||||
} {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tObj := &TraefikOidc{
|
||||
logger: fix26CapturingLogger(&buf),
|
||||
providerURL: "https://provider.example.com",
|
||||
}
|
||||
md := &ProviderMetadata{}
|
||||
switch tc.field {
|
||||
case "jwks_uri":
|
||||
md.JWKSURL = tc.endpoint
|
||||
case "authorization":
|
||||
md.AuthURL = tc.endpoint
|
||||
}
|
||||
tObj.updateMetadataEndpoints(md)
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "SECURITY:") {
|
||||
t.Fatalf("%s: dropping the endpoint must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
|
||||
}
|
||||
if !strings.Contains(logged, tc.field) {
|
||||
t.Fatalf("%s: the SECURITY line must name the endpoint, got:\n%s", tc.field, logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine pins that
|
||||
// the registration endpoint never trips the SECURITY-tagged line. It is the
|
||||
// one discovered endpoint of the four (end_session, revocation,
|
||||
// introspection, registration) with no config*URL field checked inside
|
||||
// updateMetadataEndpoints at all: RegisterClient reads dcrConfig.
|
||||
// RegistrationEndpoint directly (main.go, outside this function) whenever
|
||||
// it is set, so a dropped discovered value here is orthogonal to whether
|
||||
// that separate override exists.
|
||||
func TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tObj := &TraefikOidc{
|
||||
logger: fix26CapturingLogger(&buf),
|
||||
providerURL: "https://provider.example.com",
|
||||
}
|
||||
|
||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
||||
RegistrationURL: "http://provider.example.com/register",
|
||||
})
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "Ignoring discovered registration") {
|
||||
t.Fatalf("registration must still be dropped with the generic line, got:\n%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "SECURITY:") {
|
||||
t.Fatalf("registration must never log a SECURITY line, got:\n%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine
|
||||
// pins the re-review fix at main.go:658 (low severity): a scheme-downgrade
|
||||
// drop of end_session, revocation or introspection is exactly as terminal
|
||||
// as a drop of jwks_uri/authorization/token WHEN the operator has not set
|
||||
// the matching override (oidcEndSessionURL / revocationURL /
|
||||
// introspectionURL) -- logout falls back to a local-only redirect, provider
|
||||
// revocation is skipped, or requireTokenIntrospection breaks, all silently
|
||||
// except for the generic "Ignoring discovered" line. The old
|
||||
// discoveredEndpointHasNoOverride was static and always answered false for
|
||||
// these three regardless of whether an override was actually configured, so
|
||||
// the SECURITY line never fired for them even with no override at all. It
|
||||
// must now fire exactly when the corresponding override is empty.
|
||||
func TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
endpoint string
|
||||
}{
|
||||
{field: "end_session", endpoint: "http://provider.example.com/logout"},
|
||||
{field: "revocation", endpoint: "http://provider.example.com/revoke"},
|
||||
{field: "introspection", endpoint: "http://provider.example.com/introspect"},
|
||||
} {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Deliberately no configEndSessionURL/configRevocationURL/
|
||||
// configIntrospectionURL set: the operator configured no
|
||||
// override for any of these three.
|
||||
tObj := &TraefikOidc{
|
||||
logger: fix26CapturingLogger(&buf),
|
||||
providerURL: "https://provider.example.com",
|
||||
}
|
||||
md := &ProviderMetadata{}
|
||||
switch tc.field {
|
||||
case "end_session":
|
||||
md.EndSessionURL = tc.endpoint
|
||||
case "revocation":
|
||||
md.RevokeURL = tc.endpoint
|
||||
case "introspection":
|
||||
md.IntrospectionURL = tc.endpoint
|
||||
}
|
||||
tObj.updateMetadataEndpoints(md)
|
||||
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "Ignoring discovered "+tc.field) {
|
||||
t.Fatalf("%s: the endpoint must still be dropped with the generic line, got:\n%s", tc.field, logged)
|
||||
}
|
||||
if !strings.Contains(logged, "SECURITY:") {
|
||||
t.Fatalf("%s: dropping it with no configured override must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
|
||||
}
|
||||
if !strings.Contains(logged, "no override") {
|
||||
t.Fatalf("%s: the SECURITY line must state that this check has no override, got:\n%s", tc.field, logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint pins the
|
||||
// second half of the same finding, table-driven over all three endpoints
|
||||
// that carry an override: setting the operator override for an endpoint
|
||||
// discovery drops for the scheme-downgrade reason means the endpoint does
|
||||
// not stay empty — the override runs right after sanitize and replaces it,
|
||||
// so a "requests needing this endpoint will fail" style claim would be
|
||||
// false for these three. Only end_session was pinned before; a regression
|
||||
// that stopped applying configRevocationURL or configIntrospectionURL
|
||||
// specifically would have left every test green (re-review finding at
|
||||
// fix26_scheme_downgrade_test.go:218).
|
||||
func TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
droppedURL string
|
||||
overrideURL string
|
||||
buildMD func(dropped string) *ProviderMetadata
|
||||
buildTObj func(logger *Logger, override string) *TraefikOidc
|
||||
resultingURL func(tObj *TraefikOidc) string
|
||||
}{
|
||||
{
|
||||
field: "end_session",
|
||||
droppedURL: "http://provider.example.com/logout",
|
||||
overrideURL: "https://provider.example.com/logout-override",
|
||||
buildMD: func(dropped string) *ProviderMetadata {
|
||||
return &ProviderMetadata{EndSessionURL: dropped}
|
||||
},
|
||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
||||
return &TraefikOidc{
|
||||
logger: logger,
|
||||
providerURL: "https://provider.example.com",
|
||||
configEndSessionURL: override,
|
||||
}
|
||||
},
|
||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.endSessionURL },
|
||||
},
|
||||
{
|
||||
field: "revocation",
|
||||
droppedURL: "http://provider.example.com/revoke",
|
||||
overrideURL: "https://provider.example.com/revoke-override",
|
||||
buildMD: func(dropped string) *ProviderMetadata {
|
||||
return &ProviderMetadata{RevokeURL: dropped}
|
||||
},
|
||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
||||
return &TraefikOidc{
|
||||
logger: logger,
|
||||
providerURL: "https://provider.example.com",
|
||||
configRevocationURL: override,
|
||||
}
|
||||
},
|
||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.revocationURL },
|
||||
},
|
||||
{
|
||||
field: "introspection",
|
||||
droppedURL: "http://provider.example.com/introspect",
|
||||
overrideURL: "https://provider.example.com/introspect-override",
|
||||
buildMD: func(dropped string) *ProviderMetadata {
|
||||
return &ProviderMetadata{IntrospectionURL: dropped}
|
||||
},
|
||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
||||
return &TraefikOidc{
|
||||
logger: logger,
|
||||
providerURL: "https://provider.example.com",
|
||||
configIntrospectionURL: override,
|
||||
}
|
||||
},
|
||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.introspectionURL },
|
||||
},
|
||||
} {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tObj := tc.buildTObj(fix26CapturingLogger(&buf), tc.overrideURL)
|
||||
|
||||
tObj.updateMetadataEndpoints(tc.buildMD(tc.droppedURL))
|
||||
|
||||
if got := tc.resultingURL(tObj); got != tc.overrideURL {
|
||||
t.Fatalf("%s: the configured override must replace the dropped discovered endpoint, got %q, want %q", tc.field, got, tc.overrideURL)
|
||||
}
|
||||
if strings.Contains(buf.String(), "SECURITY:") {
|
||||
t.Fatalf("%s: has a config override, so no SECURITY line applies, got:\n%s", tc.field, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package traefikoidc
|
||||
|
||||
// Regression test for FIX-34 (review-2026-09-10 finding at
|
||||
// middleware.go:1169, low severity): forwardAuthorized truncated an
|
||||
// oversized ID token to headerTemplateMaxLen (8192) bytes and still
|
||||
// forwarded it in X-Auth-Request-Token. A truncated JWT is structurally
|
||||
// invalid; a downstream that verifies the signature rejects it (fail
|
||||
// closed, fine), but one that merely base64-decodes claims without
|
||||
// verifying reads a corrupted, silently-wrong value. The header must be
|
||||
// dropped instead, matching the fail-closed pattern already used elsewhere
|
||||
// in forwardAuthorized (X-Forwarded-User / X-Auth-Request-User on a failed
|
||||
// sanitization).
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating pins
|
||||
// FIX-34: an ID token longer than headerTemplateMaxLen must not appear in
|
||||
// X-Auth-Request-Token at all, truncated or otherwise.
|
||||
func TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: NewLogger("error"),
|
||||
// minimalHeaders defaults false: the X-Auth-Request-* block (where
|
||||
// X-Auth-Request-Token is set) only runs when it is false.
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
oversizedIDToken := strings.Repeat("a", headerTemplateMaxLen+1)
|
||||
p := &principal{
|
||||
Identifier: "user",
|
||||
IDToken: oversizedIDToken,
|
||||
}
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if got := req.Header.Get("X-Auth-Request-Token"); got != "" {
|
||||
t.Fatalf("X-Auth-Request-Token must be dropped (not truncated) for an oversized ID token; got %d bytes, want header absent", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardAuthorized_IDTokenWithinLimitStillForwarded is the control: an
|
||||
// ID token at or under the header budget must still be forwarded
|
||||
// unmodified, so the fix does not regress the common case.
|
||||
func TestForwardAuthorized_IDTokenWithinLimitStillForwarded(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: NewLogger("error"),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
idToken := "header.payload.signature"
|
||||
p := &principal{
|
||||
Identifier: "user",
|
||||
IDToken: idToken,
|
||||
}
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if got := req.Header.Get("X-Auth-Request-Token"); got != idToken {
|
||||
t.Fatalf("X-Auth-Request-Token = %q, want the unmodified ID token %q", got, idToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-35: Close() decided "am I the last live instance" once, early in its
|
||||
// shutdown sequence (unregisterLiveInstance()'s return value), then reused
|
||||
// that stale boolean much later to decide whether to stop process-global
|
||||
// singleton tasks (memory-monitor, singleton-token-cleanup,
|
||||
// singleton-metadata-refresh-*). Session/cache teardown happens in between,
|
||||
// taking real time. A concurrently-created new instance (an overlapping
|
||||
// Traefik reload) can register and adopt those same singletons in that
|
||||
// window; the old instance's stale decision then stops them anyway, and the
|
||||
// new instance runs with no memory monitor, token cleanup, or health checks
|
||||
// until the next reload.
|
||||
//
|
||||
// TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose drives the REAL
|
||||
// (*TraefikOidc).Close() through this exact window via closeTestHook, rather
|
||||
// than re-implementing Close's stop decision inline: a test that never calls
|
||||
// Close itself stays green even if the production fix (utilities.go's fresh,
|
||||
// lock-guarded re-check; main.go's registerLiveInstance-before-adoption
|
||||
// ordering) is reverted, which is a much weaker regression pin.
|
||||
//
|
||||
// It establishes its own liveInstanceCount baseline (save, zero, restore via
|
||||
// t.Cleanup) instead of assuming the package-global counter is already 0.
|
||||
// Without that, a live instance left registered by an earlier test in the
|
||||
// same run (any order under -shuffle) makes the pre-FIX-35 stale "was last"
|
||||
// decision already false on its own, so this test cannot tell a reverted fix
|
||||
// from a working one — it passes either way.
|
||||
func TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose(t *testing.T) {
|
||||
// Save whether memory-monitor was already running before ResetGlobalMemoryMonitor
|
||||
// below stops it, and restore that state afterward (registered first, so
|
||||
// it runs LAST among this function's cleanups, after ResetGlobalMemoryMonitor
|
||||
// has already torn the singleton down) — otherwise this test permanently
|
||||
// destroys memory-monitor for every other test that runs afterward in the
|
||||
// same binary, regardless of what state it found the process in.
|
||||
wasMemoryMonitorRunning := GetResourceManager().IsTaskRunning("memory-monitor")
|
||||
t.Cleanup(func() {
|
||||
if wasMemoryMonitorRunning {
|
||||
GetGlobalMemoryMonitor().StartMonitoring(context.Background(), time.Second)
|
||||
}
|
||||
})
|
||||
|
||||
ResetGlobalMemoryMonitor()
|
||||
t.Cleanup(ResetGlobalMemoryMonitor)
|
||||
t.Cleanup(func() { closeTestHook = nil })
|
||||
|
||||
baseline := resetLiveInstanceCountForTest()
|
||||
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
|
||||
|
||||
// --- Old instance: already live, has adopted the memory-monitor singleton
|
||||
// (mirrors main.go's registerLiveInstance() followed by
|
||||
// memoryMonitor.StartMonitoring()).
|
||||
registerLiveInstance()
|
||||
mm := GetGlobalMemoryMonitor()
|
||||
oldCtx, oldCancel := context.WithCancel(context.Background())
|
||||
defer oldCancel()
|
||||
mm.StartMonitoring(oldCtx, time.Second)
|
||||
if !GetResourceManager().IsTaskRunning("memory-monitor") {
|
||||
t.Fatal("precondition: memory-monitor must be running after StartMonitoring")
|
||||
}
|
||||
|
||||
// --- Simulate the concurrent New(): a new instance registers and adopts
|
||||
// the memory-monitor singleton in the window between the old instance's
|
||||
// unregisterLiveInstance() and its gated singleton stops. closeTestHook
|
||||
// fires from inside the real Close(), at exactly that point.
|
||||
newCtx, newCancel := context.WithCancel(context.Background())
|
||||
defer newCancel()
|
||||
closeTestHook = func() {
|
||||
registerLiveInstance()
|
||||
mm.StartMonitoring(newCtx, time.Second) // adopts: already running, so this is a no-op start.
|
||||
}
|
||||
// Undo the hook's registerLiveInstance for the new instance and actually
|
||||
// stop the task so it does not outlive this test. Runs via t.Cleanup
|
||||
// (LIFO, before the baseline restore above) so it still executes even if
|
||||
// an assertion below fails the test early.
|
||||
t.Cleanup(func() {
|
||||
unregisterLiveInstance()
|
||||
_ = GetResourceManager().StopBackgroundTask("memory-monitor")
|
||||
})
|
||||
|
||||
// --- Drive the real Close() on an instance built the same way New()
|
||||
// builds one for this purpose: registered live, nothing else set (every
|
||||
// other field Close touches is nil-guarded).
|
||||
oldInstance := &TraefikOidc{logger: GetSingletonNoOpLogger()}
|
||||
if err := oldInstance.Close(); err != nil {
|
||||
t.Fatalf("Close returned an error: %v", err)
|
||||
}
|
||||
|
||||
if !GetResourceManager().IsTaskRunning("memory-monitor") {
|
||||
t.Fatal("memory-monitor must keep running: a new instance registered and adopted it before Close's last-instance decision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop pins the FIX-35
|
||||
// review's major finding: a separate fresh-read check only wraps the counter
|
||||
// read. Used as `if check() { stop() }`, liveInstanceMu is
|
||||
// released as soon as the check returns, before stop() (which can block for
|
||||
// real time — BackgroundTask.Stop waits up to 5s per task) ever runs. A
|
||||
// concurrent registerLiveInstance() can land in that gap and have its
|
||||
// just-adopted singleton killed by a stop decision made before it
|
||||
// registered.
|
||||
//
|
||||
// stopIfLastInstance closes that window by holding liveInstanceMu across
|
||||
// both the check and the stop call. This test proves the lock is genuinely
|
||||
// held for the stop's full duration: while a slow stop callback is running,
|
||||
// a concurrent registerLiveInstance() must not be able to complete.
|
||||
func TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop(t *testing.T) {
|
||||
baseline := resetLiveInstanceCountForTest()
|
||||
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
|
||||
|
||||
stopStarted := make(chan struct{})
|
||||
releaseStop := make(chan struct{})
|
||||
stopDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
stopIfLastInstance(func() {
|
||||
close(stopStarted)
|
||||
<-releaseStop // hold the "stop" open, simulating BackgroundTask.Stop's wait.
|
||||
})
|
||||
close(stopDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("stopIfLastInstance never invoked its stop callback at baseline (count 0)")
|
||||
}
|
||||
|
||||
registerDone := make(chan struct{})
|
||||
go func() {
|
||||
registerLiveInstance()
|
||||
close(registerDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-registerDone:
|
||||
t.Fatal("registerLiveInstance completed while the stop callback was still running — liveInstanceMu was not held across the stop, reopening the check-then-stop window")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Expected: still blocked on liveInstanceMu.
|
||||
}
|
||||
|
||||
close(releaseStop)
|
||||
|
||||
select {
|
||||
case <-registerDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("registerLiveInstance did not complete after the stop callback released liveInstanceMu")
|
||||
}
|
||||
<-stopDone
|
||||
|
||||
unregisterLiveInstance() // balance the registerLiveInstance above
|
||||
}
|
||||
|
||||
// resetLiveInstanceCountForTest establishes a clean baseline (0) for the
|
||||
// package-level liveInstanceCount so a test's last-instance decisions are not
|
||||
// skewed by instances registered-and-never-unregistered by earlier tests in
|
||||
// the same process. Test-only; mirrors resetGdInstancesForTest. Returns the
|
||||
// value liveInstanceCount held before the reset so the caller can restore it
|
||||
// via t.Cleanup (restoreLiveInstanceCountForTest) instead of permanently
|
||||
// clobbering a nonzero baseline left by another test in the same run.
|
||||
func resetLiveInstanceCountForTest() int32 {
|
||||
liveInstanceMu.Lock()
|
||||
defer liveInstanceMu.Unlock()
|
||||
previous := liveInstanceCount
|
||||
liveInstanceCount = 0
|
||||
return previous
|
||||
}
|
||||
|
||||
// restoreLiveInstanceCountForTest sets the package-level liveInstanceCount
|
||||
// back to a value captured by resetLiveInstanceCountForTest. Test-only.
|
||||
func restoreLiveInstanceCountForTest(previous int32) {
|
||||
liveInstanceMu.Lock()
|
||||
defer liveInstanceMu.Unlock()
|
||||
liveInstanceCount = previous
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits pins FIX-42: the
|
||||
// RFC 7518 §3.3 minimum key-size rejection that 8640451 added to
|
||||
// validateAlgKeyMatch (client_assertion.go:107) for private_key_jwt RS*/PS*
|
||||
// signing keys, undocumented in that commit. FIX-25's sibling decision keeps
|
||||
// the rejection as-is and records it in CHANGELOG.md; this test pins the
|
||||
// contract with a named, matchable sentinel error so the rejection survives
|
||||
// refactors and callers can detect it with errors.Is instead of matching a
|
||||
// log string.
|
||||
func TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate 1024-bit test RSA key: %v", err)
|
||||
}
|
||||
|
||||
err = validateAlgKeyMatch("RS256", key)
|
||||
if err == nil {
|
||||
t.Fatal("a 1024-bit RSA key must be rejected for RS256, got nil error")
|
||||
}
|
||||
if !errors.Is(err, ErrRSAKeyTooSmall) {
|
||||
t.Fatalf("error must wrap ErrRSAKeyTooSmall so callers can match it with errors.Is, got: %v", err)
|
||||
}
|
||||
const wantSubstring = "requires an RSA key of at least 2048 bits"
|
||||
if !strings.Contains(err.Error(), wantSubstring) {
|
||||
t.Fatalf("error message must state the requirement (%q), got: %v", wantSubstring, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAlgKeyMatch_Accepts2048BitRSAKey is the boundary/positive
|
||||
// case: a key at exactly the 2048-bit floor must be accepted.
|
||||
func TestValidateAlgKeyMatch_Accepts2048BitRSAKey(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate 2048-bit test RSA key: %v", err)
|
||||
}
|
||||
if err := validateAlgKeyMatch("RS256", key); err != nil {
|
||||
t.Fatalf("a 2048-bit RSA key must be accepted for RS256, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSharedHealthTaskSurvivesCreateDuringLastClose pins the create-versus-
|
||||
// last-close race on the shared graceful-degradation health-check task.
|
||||
//
|
||||
// Close() decides it is the last live instance and then stops the shared task.
|
||||
// A GracefulDegradation created between that decision and the stop used to
|
||||
// register at once, and its health routine adopted the still-running task.
|
||||
// The stop then killed the task that the new, live instance relies on, and
|
||||
// nothing restarted it.
|
||||
//
|
||||
// The test pauses the last instance's Close() between the decision and the
|
||||
// stop (beforeSharedTaskStop), creates a new instance, and then lets the stop
|
||||
// run. The new instance is live, so the shared task must be running at the end.
|
||||
func TestSharedHealthTaskSurvivesCreateDuringLastClose(t *testing.T) {
|
||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
||||
|
||||
cfg := DefaultGracefulDegradationConfig()
|
||||
cfg.HealthCheckInterval = time.Hour
|
||||
logger := GetSingletonNoOpLogger()
|
||||
|
||||
gdA := NewGracefulDegradation(cfg, logger)
|
||||
waitForHealthRoutineSettled(t, gdA)
|
||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
||||
t.Fatal("precondition: shared health-check task must run while gdA is live")
|
||||
}
|
||||
|
||||
parked := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
gdA.beforeSharedTaskStop = func() {
|
||||
close(parked)
|
||||
<-release
|
||||
}
|
||||
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
gdA.Close()
|
||||
close(closed)
|
||||
}()
|
||||
select {
|
||||
case <-parked:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("gdA.Close did not reach the shared-task stop")
|
||||
}
|
||||
|
||||
created := make(chan *GracefulDegradation, 1)
|
||||
go func() { created <- NewGracefulDegradation(cfg, logger) }()
|
||||
|
||||
// Without serialization, gdB registers at once and its routine adopts the
|
||||
// still-running task before gdA stops it. With serialization, gdB waits
|
||||
// behind gdA's stop; the bound only limits how long the test waits then.
|
||||
var gdB *GracefulDegradation
|
||||
select {
|
||||
case gdB = <-created:
|
||||
waitForHealthRoutineSettled(t, gdB)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("gdA.Close did not return")
|
||||
}
|
||||
if gdB == nil {
|
||||
select {
|
||||
case gdB = <-created:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("NewGracefulDegradation did not return after gdA.Close")
|
||||
}
|
||||
}
|
||||
defer gdB.Close()
|
||||
waitForHealthRoutineSettled(t, gdB)
|
||||
|
||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
||||
t.Fatal("shared health-check task is stopped although gdB is still live")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gdHealthTaskName = "graceful-degradation-health-check"
|
||||
|
||||
// TestSharedHealthTaskNotRestartedAfterLastClose pins the stop-versus-start
|
||||
// race behind the flaky TestSharedHealthTaskSurvivesOtherInstanceClose.
|
||||
//
|
||||
// startHealthCheckRoutine checks gd.stopChan under gd.mutex and then creates
|
||||
// or adopts the shared health-check task. Close() used to close stopChan
|
||||
// without gd.mutex, so a routine that had already passed its check could
|
||||
// reach the task registry AFTER the last Close() stopped the task.
|
||||
// RegisterBackgroundTask replaces a stopped task with a fresh one, so the
|
||||
// routine restarted the shared task with no live instance left to stop it.
|
||||
//
|
||||
// The test parks gd2's routine right after its stopChan check by holding the
|
||||
// task-registry mutex (Close never takes it), closes both instances, and then
|
||||
// releases the routine. The shared task must stay stopped.
|
||||
func TestSharedHealthTaskNotRestartedAfterLastClose(t *testing.T) {
|
||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
||||
|
||||
cfg := DefaultGracefulDegradationConfig()
|
||||
cfg.HealthCheckInterval = time.Hour
|
||||
logger := GetSingletonNoOpLogger()
|
||||
|
||||
gd1 := NewGracefulDegradation(cfg, logger)
|
||||
defer gd1.Close()
|
||||
waitForHealthRoutineSettled(t, gd1)
|
||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
||||
t.Fatal("precondition: shared health-check task must run while gd1 is live")
|
||||
}
|
||||
|
||||
globalTaskRegistryMutex.Lock()
|
||||
registryLocked := true
|
||||
releaseRegistry := func() {
|
||||
if registryLocked {
|
||||
registryLocked = false
|
||||
globalTaskRegistryMutex.Unlock()
|
||||
}
|
||||
}
|
||||
defer releaseRegistry()
|
||||
|
||||
gd2 := NewGracefulDegradation(cfg, logger)
|
||||
defer gd2.Close()
|
||||
waitForGoroutineParkedIn(t, "(*GracefulDegradation).startHealthCheckRoutine", "traefikoidc.GetGlobalTaskRegistry")
|
||||
|
||||
gd1.Close()
|
||||
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
gd2.Close()
|
||||
close(closed)
|
||||
}()
|
||||
|
||||
// Release the parked routine once the last Close() has stopped the task.
|
||||
// A Close() that waits for the in-flight routine keeps the task running,
|
||||
// so the bound only limits how long this test waits in that case.
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) && GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
releaseRegistry()
|
||||
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("gd2.Close did not return")
|
||||
}
|
||||
|
||||
// gd2's routine holds gd2.mutex until it has created or adopted a task,
|
||||
// so taking the lock waits for the routine to finish.
|
||||
gd2.mutex.RLock()
|
||||
routineTask := gd2.healthCheckTask
|
||||
gd2.mutex.RUnlock()
|
||||
|
||||
if routineTask != nil && atomic.LoadInt32(&routineTask.stopped) == 0 {
|
||||
routineTask.Stop()
|
||||
t.Error("gd2's health routine holds a running task after the last GracefulDegradation closed")
|
||||
}
|
||||
if GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
||||
_ = GetResourceManager().StopBackgroundTask(gdHealthTaskName)
|
||||
t.Error("shared health-check task was restarted after the last GracefulDegradation closed")
|
||||
}
|
||||
}
|
||||
|
||||
// waitForGoroutineParkedIn polls all goroutine stacks until one stack contains
|
||||
// every given frame substring.
|
||||
func waitForGoroutineParkedIn(t *testing.T, frames ...string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if goroutineStackContains(frames...) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no goroutine parked in %v", frames)
|
||||
}
|
||||
|
||||
func goroutineStackContains(frames ...string) bool {
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n := runtime.Stack(buf, true)
|
||||
if n < len(buf) {
|
||||
buf = buf[:n]
|
||||
break
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
for _, stack := range strings.Split(string(buf), "\n\n") {
|
||||
if stackHasAllFrames(stack, frames) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stackHasAllFrames(stack string, frames []string) bool {
|
||||
for _, f := range frames {
|
||||
if !strings.Contains(stack, f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package traefikoidc
|
||||
|
||||
// Shared GracefulDegradation-instance-registry test helper for
|
||||
// round-regression and FIX-round tests.
|
||||
//
|
||||
// Moved here for the R4 re-review (closing the rest of FIX-41): resetGdInstancesForTest
|
||||
// was declared inside review_r34_regression_test.go but consumed by
|
||||
// fix18_gd_lifecycle_test.go, a FIX-round test file — the dependency was not
|
||||
// even confined to other round files. Every review_rNN*_test.go and
|
||||
// fixNN*_test.go file must depend only on shared helpers like this one,
|
||||
// never on another round file's private declarations.
|
||||
|
||||
// resetGdInstancesForTest establishes a clean baseline for the process-global
|
||||
// gdInstances registry so a test's "last instance" and iteration assertions
|
||||
// are not skewed by instances created by earlier tests in the same process.
|
||||
func resetGdInstancesForTest() {
|
||||
gdInstances.Lock()
|
||||
gdInstances.set = make(map[*GracefulDegradation]struct{})
|
||||
gdInstances.Unlock()
|
||||
}
|
||||
@@ -2,10 +2,15 @@ module github.com/lukaszraczylo/traefikoidc
|
||||
|
||||
go 1.24.0
|
||||
|
||||
// Pin the toolchain to a stdlib version without the go1.26.5 CVEs
|
||||
// (GO-2026-6089 net/http ReadHeaderTimeout, GO-2026-5972 asn1 recursion,
|
||||
// GO-2026-5026 idna) reported by govulncheck (R219). Auto-downloaded.
|
||||
toolchain go1.26.6
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.35.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.3.0
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/time v0.14.0
|
||||
|
||||
@@ -12,12 +12,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
|
||||
@@ -63,6 +63,14 @@ func (m *GoroutineManager) StartGoroutine(name string, fn func(context.Context))
|
||||
m.wg.Done()
|
||||
m.mu.Lock()
|
||||
managedGoroutine.running = false
|
||||
// Prune the map entry once this goroutine has finished so that
|
||||
// finished goroutines don't accumulate unboundedly (R157). Only
|
||||
// delete when the current entry still IS the finishing
|
||||
// goroutine: a concurrent Start with the same name may have
|
||||
// already replaced it with a live one.
|
||||
if cur, ok := m.goroutines[goroutineName]; ok && cur == managedGoroutine {
|
||||
delete(m.goroutines, goroutineName)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Recover from panics
|
||||
|
||||
@@ -45,6 +45,7 @@ func TestStartGoroutine(t *testing.T) {
|
||||
|
||||
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
|
||||
executed.Store(true)
|
||||
<-ctx.Done() // keep alive so it is tracked while running
|
||||
})
|
||||
|
||||
// Give goroutine time to execute
|
||||
@@ -54,6 +55,7 @@ func TestStartGoroutine(t *testing.T) {
|
||||
t.Error("Expected goroutine to execute")
|
||||
}
|
||||
|
||||
// A live goroutine is tracked (R157: finished goroutines are pruned).
|
||||
status := gm.GetStatus()
|
||||
if len(status) != 1 {
|
||||
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
|
||||
@@ -370,8 +372,9 @@ func TestGetStatus(t *testing.T) {
|
||||
|
||||
status := gm.GetStatus()
|
||||
|
||||
if len(status) != 2 {
|
||||
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
|
||||
// R157: finished goroutines are pruned, so only the live one remains.
|
||||
if len(status) != 1 {
|
||||
t.Errorf("Expected 1 live goroutine in status, got %d", len(status))
|
||||
}
|
||||
|
||||
if runningStatus, exists := status["running"]; exists {
|
||||
@@ -394,12 +397,8 @@ func TestGetStatus(t *testing.T) {
|
||||
t.Error("Expected 'running' goroutine in status")
|
||||
}
|
||||
|
||||
if quickStatus, exists := status["quick"]; exists {
|
||||
if quickStatus.Running {
|
||||
t.Error("Expected 'quick' goroutine to be marked as not running")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected 'quick' goroutine in status")
|
||||
if _, exists := status["quick"]; exists {
|
||||
t.Error("Expected finished 'quick' goroutine to be pruned from status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +434,7 @@ func TestConcurrentStartGoroutine(t *testing.T) {
|
||||
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
|
||||
gm.StartGoroutine(name, func(ctx context.Context) {
|
||||
counter.Add(1)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
<-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
|
||||
counter.Add(-1)
|
||||
})
|
||||
}(i)
|
||||
|
||||
+197
-19
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,10 +14,23 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
||||
)
|
||||
|
||||
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
|
||||
// "f47ac10b-58cc-4372-a567-0e02b2c3d479") backed by crypto/rand. Used for CSRF
|
||||
// tokens and other opaque random identifiers — replaces github.com/google/uuid
|
||||
// to keep the plugin stdlib-only on the production path.
|
||||
func newUUIDv4() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("could not generate UUID: %w", err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
// generateNonce creates a cryptographically secure random nonce for OIDC flows.
|
||||
// The nonce is used to prevent replay attacks and associate client sessions with ID tokens.
|
||||
// Returns:
|
||||
@@ -92,9 +106,15 @@ type TokenResponse struct {
|
||||
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant)
|
||||
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {grantType},
|
||||
"client_id": {t.clientID},
|
||||
"client_secret": {t.clientSecret},
|
||||
"grant_type": {grantType},
|
||||
}
|
||||
// Snapshot client credentials under metadataMu: DCR rewrites clientID/
|
||||
// clientSecret/audience at runtime (R137).
|
||||
clientID, clientSecret, clientAuthMethod, _, clientAssertion := t.clientCredentials()
|
||||
// client_id is sent in the body for every method except client_secret_basic,
|
||||
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
|
||||
if clientAuthMethod != "client_secret_basic" || clientAssertion != nil {
|
||||
data.Set("client_id", clientID)
|
||||
}
|
||||
|
||||
if grantType == "authorization_code" {
|
||||
@@ -108,6 +128,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
||||
data.Set("refresh_token", codeOrToken)
|
||||
}
|
||||
|
||||
// RFC 8707 §2.2: repeat the same resource indicator sent on the
|
||||
// authorization request so refreshed tokens keep the same audience
|
||||
// binding as the original grant.
|
||||
if t.resource != "" {
|
||||
data.Set("resource", t.resource)
|
||||
}
|
||||
|
||||
client := t.tokenHTTPClient
|
||||
if client == nil {
|
||||
// Use shared transport pool to prevent memory leaks
|
||||
@@ -126,18 +153,35 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
||||
}
|
||||
}
|
||||
|
||||
// Read tokenURL with RLock
|
||||
// Read tokenURL with RLock — needed as audience for private_key_jwt (RFC 7523 §3).
|
||||
t.metadataMu.RLock()
|
||||
tokenURL := t.tokenURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
|
||||
useBasicAuth := false
|
||||
if clientAssertion != nil {
|
||||
assertion, err := clientAssertion.Sign(tokenURL, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
|
||||
}
|
||||
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
||||
data.Set("client_assertion", assertion)
|
||||
} else if clientAuthMethod == "client_secret_basic" {
|
||||
useBasicAuth = true
|
||||
} else {
|
||||
data.Set("client_secret", clientSecret)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if useBasicAuth {
|
||||
setOAuthBasicAuth(req, clientID, clientSecret)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
||||
}
|
||||
@@ -149,14 +193,33 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
limitReader := io.LimitReader(resp.Body, 1024*10)
|
||||
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
|
||||
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
// Return a *HTTPError so the retry executor's isRetryableError can
|
||||
// classify transient 5xx/429 responses and actually retry (R118).
|
||||
// A plain error here made the 5xx/429 retry branch dead in
|
||||
// production — every token-endpoint status was treated as permanent.
|
||||
return nil, &HTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes)),
|
||||
}
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
||||
// Cap the decoded body so a corrupted or malicious oversized 200-body cannot
|
||||
// drive an unbounded read/allocation. Token responses are small (a few KB).
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tokenResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
// RFC 6749 §5.1: a token response MUST include an access_token. A 200
|
||||
// that omitted it is a protocol violation, not a success — treat it as an
|
||||
// error here so no caller authenticates with a blank token (R158).
|
||||
// Previously this seam returned success with an empty access_token and
|
||||
// each caller had to re-check "AccessToken == \"\"", which several
|
||||
// paths missed.
|
||||
if tokenResponse.AccessToken == "" {
|
||||
return nil, fmt.Errorf("token endpoint returned 200 without an access_token (RFC 6749 §5.1)")
|
||||
}
|
||||
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
@@ -169,6 +232,17 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
||||
// Returns:
|
||||
// - *TokenResponse: New token set from the authorization server
|
||||
// - An error if the refresh operation fails
|
||||
//
|
||||
// redactToken returns a short, stable hash of a token credential suitable for
|
||||
// debug logging without leaking the credential itself (R138).
|
||||
func redactToken(token string) string {
|
||||
if token == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", sum[:4])
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -183,7 +257,8 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
|
||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
t.logger.Debugf("Token response: %+v", tokenResponse)
|
||||
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
|
||||
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
@@ -345,6 +420,14 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
idToken := session.GetIDToken()
|
||||
accessToken := session.GetAccessToken()
|
||||
refreshToken := session.GetRefreshToken()
|
||||
// Capture the trusted origin BEFORE Clear wipes the session. redirect_url
|
||||
// was persisted at authentication-initiate time (R136) and is therefore
|
||||
// the exact scheme+host the IdP already accepted as this deployment's
|
||||
// redirect_uri — unlike the current request's Host/X-Forwarded-Host,
|
||||
// the browser cannot spoof it on this logout request (FIX-07).
|
||||
trustedOrigin := trustedOriginFromRedirectURL(session.GetRedirectURL())
|
||||
|
||||
if err := session.Clear(req, rw); err != nil {
|
||||
t.logger.Errorf("Error clearing session: %v", err)
|
||||
@@ -352,15 +435,74 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
host := utils.DetermineHost(req)
|
||||
scheme := utils.DetermineScheme(req, t.forceHTTPS)
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
||||
// Blacklist the session's tokens so a token captured before logout (e.g. a
|
||||
// bearer mode access token) cannot be reused until its natural expiry.
|
||||
// RevokeToken is a no-op-safe local blacklist (guards nil) and skips
|
||||
// empty values.
|
||||
for _, tok := range []string{accessToken, idToken, refreshToken} {
|
||||
if tok != "" {
|
||||
t.RevokeToken(tok)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort provider-side revocation (single logout): the local
|
||||
// blacklist only stops reuse in-process, but a token captured before
|
||||
// logout (e.g. a bearer-mode access token or the refresh token) stays
|
||||
// valid at the IdP and a captured refresh token could keep minting new
|
||||
// tokens. Revoke both access and refresh tokens at the provider when a
|
||||
// revocation endpoint is available. Each call is bounded by
|
||||
// revocationRequestTimeout so a slow or unreachable provider cannot
|
||||
// hold the logout redirect open indefinitely (FIX-33). A genuine
|
||||
// failure is logged at error level; the expected "no revocation
|
||||
// endpoint configured" case (most deployments) stays silent so it
|
||||
// doesn't log on every logout.
|
||||
if accessToken != "" {
|
||||
if err := t.RevokeTokenWithProvider(accessToken, "access_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
|
||||
t.logger.Errorf("Provider-side revocation failed for access_token: %v", err)
|
||||
}
|
||||
}
|
||||
if refreshToken != "" {
|
||||
if err := t.RevokeTokenWithProvider(refreshToken, "refresh_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
|
||||
t.logger.Errorf("Provider-side revocation failed for refresh_token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
postLogoutRedirectURI := t.postLogoutRedirectURI
|
||||
if postLogoutRedirectURI == "" {
|
||||
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
|
||||
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
|
||||
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
|
||||
// localRedirect is used when there is no provider end-session endpoint and
|
||||
// the plugin redirects the browser itself. It must never be an absolute URL
|
||||
// derived from the request host (X-Forwarded-Host is client-controllable and
|
||||
// would be an open redirect); use a host-relative path, or the operator's
|
||||
// own configured absolute URL, instead.
|
||||
//
|
||||
// The post_logout_redirect_uri we hand to the IdP has the same constraint:
|
||||
// deriving it from baseURL (scheme://client-controlled host) lets an
|
||||
// attacker's X-Forwarded-Host steer the browser to an arbitrary origin
|
||||
// after logout (open redirect through the IdP). So we build it from
|
||||
// trustedOrigin (the redirect_uri origin the IdP already accepted at
|
||||
// login, R136) instead; when no trusted origin was recorded (pre-R136
|
||||
// session, or logout without a completed login), BuildLogoutURL omits
|
||||
// the parameter entirely rather than guessing (FIX-07 corrects R68,
|
||||
// which dropped the parameter unconditionally for every non-absolute
|
||||
// config value, including the documented default of '/').
|
||||
localRedirect := "/"
|
||||
switch {
|
||||
case strings.HasPrefix(postLogoutRedirectURI, "http"):
|
||||
// Operator configured an explicit absolute URL: already a trusted
|
||||
// origin on its own, independent of trustedOrigin.
|
||||
localRedirect = postLogoutRedirectURI
|
||||
case postLogoutRedirectURI == "":
|
||||
postLogoutRedirectURI = "" // no trusted origin below leaves it empty
|
||||
if trustedOrigin != "" {
|
||||
postLogoutRedirectURI = trustedOrigin + "/"
|
||||
}
|
||||
default:
|
||||
// Relative operator-configured path: trusted for the plugin's own
|
||||
// redirect, rebuilt against the trusted origin for the IdP.
|
||||
localRedirect = normalizeLogoutPath(postLogoutRedirectURI)
|
||||
postLogoutRedirectURI = ""
|
||||
if trustedOrigin != "" {
|
||||
postLogoutRedirectURI = trustedOrigin + localRedirect
|
||||
}
|
||||
}
|
||||
|
||||
// Read endSessionURL with RLock
|
||||
@@ -375,11 +517,34 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
http.Error(rw, "Logout error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Logout redirects must never be cached: a heuristic-caching proxy
|
||||
// replaying the 302 would re-trigger logout for an already
|
||||
// logged-out session (R147 cache-directive contract).
|
||||
rw.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(rw, req, logoutURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
|
||||
rw.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(rw, req, localRedirect, http.StatusFound)
|
||||
}
|
||||
|
||||
// trustedOriginFromRedirectURL extracts "scheme://host" from the redirect_uri
|
||||
// persisted at authentication-initiate time (R136), for reuse as the trusted
|
||||
// origin when building post_logout_redirect_uri (FIX-07). That redirect_uri
|
||||
// was already accepted by the IdP as this deployment's own callback, so its
|
||||
// origin is safe to reuse — unlike the current request's Host/
|
||||
// X-Forwarded-Host, which the browser controls. Returns "" when redirectURL
|
||||
// is empty or is not an absolute http(s) URL.
|
||||
func trustedOriginFromRedirectURL(redirectURL string) string {
|
||||
if redirectURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(redirectURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// BuildLogoutURL constructs a logout URL for the OIDC provider's end session endpoint.
|
||||
@@ -408,6 +573,19 @@ func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (strin
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// setOAuthBasicAuth sets the Authorization header per RFC 6749 §2.3.1: the
|
||||
// client_id and client_secret are form-urlencoded individually, joined with a
|
||||
// colon, then base64-encoded. This differs from http.Request.SetBasicAuth,
|
||||
// which skips the form-urlencode step — that matters for credentials with
|
||||
// reserved characters (`:`, `@`, `+`, `%`, etc.) where the wire format would
|
||||
// otherwise diverge from what the spec mandates.
|
||||
func setOAuthBasicAuth(req *http.Request, clientID, clientSecret string) {
|
||||
user := url.QueryEscape(clientID)
|
||||
pass := url.QueryEscape(clientSecret)
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
|
||||
// This ensures that OAuth scope parameters don't contain duplicates which could
|
||||
// cause issues with some authorization servers.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewUUIDv4 verifies the in-house UUID v4 generator produces RFC 4122
|
||||
// compliant identifiers. Locks in the replacement for github.com/google/uuid
|
||||
// — a regression here would weaken the CSRF token used in the OIDC flow.
|
||||
func TestNewUUIDv4(t *testing.T) {
|
||||
rfc4122v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
const samples = 1000
|
||||
seen := make(map[string]struct{}, samples)
|
||||
for i := 0; i < samples; i++ {
|
||||
got, err := newUUIDv4()
|
||||
if err != nil {
|
||||
t.Fatalf("newUUIDv4 failed: %v", err)
|
||||
}
|
||||
if !rfc4122v4.MatchString(got) {
|
||||
t.Fatalf("UUID %q does not match RFC 4122 v4 format", got)
|
||||
}
|
||||
if _, dup := seen[got]; dup {
|
||||
t.Fatalf("duplicate UUID emitted within %d samples: %q", samples, got)
|
||||
}
|
||||
seen[got] = struct{}{}
|
||||
}
|
||||
}
|
||||
+41
-14
@@ -3,6 +3,7 @@ package traefikoidc
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -25,10 +26,16 @@ type HTTPClientConfig struct {
|
||||
Timeout time.Duration
|
||||
MaxConnsPerHost int
|
||||
WriteBufferSize int
|
||||
UseCookieJar bool
|
||||
ForceHTTP2 bool
|
||||
DisableKeepAlives bool
|
||||
DisableCompression bool
|
||||
// RootCAs is an optional certificate pool used for TLS verification.
|
||||
// A nil pool means "use the system trust store" (default behavior).
|
||||
RootCAs *x509.CertPool
|
||||
// InsecureSkipVerify disables TLS certificate verification.
|
||||
// ONLY set this for local development against self-signed certificates.
|
||||
InsecureSkipVerify bool
|
||||
UseCookieJar bool
|
||||
ForceHTTP2 bool
|
||||
DisableKeepAlives bool
|
||||
DisableCompression bool
|
||||
}
|
||||
|
||||
// DefaultHTTPClientConfig returns the default configuration for general use
|
||||
@@ -54,16 +61,23 @@ func DefaultHTTPClientConfig() HTTPClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// TokenHTTPClientConfig returns configuration optimized for token operations
|
||||
// TokenHTTPClientConfig returns configuration optimized for token operations.
|
||||
func TokenHTTPClientConfig() HTTPClientConfig {
|
||||
config := DefaultHTTPClientConfig()
|
||||
config.Timeout = 10 * time.Second // Shorter timeout for token operations
|
||||
config.MaxRedirects = 50 // Token endpoints may redirect more
|
||||
config.UseCookieJar = true // Enable cookie jar for token operations
|
||||
// UseCookieJar stays false (DefaultHTTPClientConfig's default). This
|
||||
// config builds the single per-plugin-instance pooled token client
|
||||
// (main.go, CreateTokenHTTPClient), shared by every user's token and
|
||||
// refresh exchange. A jar on a shared client stores whatever
|
||||
// Set-Cookie the IdP returns for one user's exchange and replays it on
|
||||
// the next user's request to the same host - cross-user state
|
||||
// confusion at the IdP (FIX-12; R70 had turned this on). Token/refresh
|
||||
// calls are stateless back-channel requests and need no jar.
|
||||
return config
|
||||
}
|
||||
|
||||
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls
|
||||
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls.
|
||||
func OIDCProviderHTTPClientConfig() HTTPClientConfig {
|
||||
config := DefaultHTTPClientConfig()
|
||||
config.Timeout = 15 * time.Second // Slightly longer for OIDC operations
|
||||
@@ -71,7 +85,10 @@ func OIDCProviderHTTPClientConfig() HTTPClientConfig {
|
||||
config.MaxIdleConnsPerHost = 25 // More connections per OIDC provider
|
||||
config.MaxConnsPerHost = 50 // Allow more concurrent requests to OIDC provider
|
||||
config.IdleConnTimeout = 90 * time.Second // Keep connections alive longer for reuse
|
||||
config.UseCookieJar = true // Enable cookie jar for session management
|
||||
// UseCookieJar stays false, for the same cross-user reason as
|
||||
// TokenHTTPClientConfig above (FIX-12): any client built from this
|
||||
// config through the shared transport pool is one instance shared by
|
||||
// every user, so a jar would leak cookies between them.
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -131,10 +148,13 @@ func (f *HTTPClientFactory) ValidateHTTPClientConfig(config *HTTPClientConfig) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateHTTPClient creates an HTTP client with the given configuration
|
||||
// Validates configuration parameters before creating the client
|
||||
func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Client {
|
||||
// Set defaults for zero values before validation
|
||||
// applyHTTPClientDefaults fills zero-valued fields in cfg with the standard
|
||||
// conservative defaults so that a partially-populated config never yields an
|
||||
// unbounded HTTP client (zero overall Timeout or ResponseHeaderTimeout would make
|
||||
// a stalled upstream hang forever). All construction paths MUST converge on this
|
||||
// helper so behavior is identical: CreateHTTPClient and the shared transport
|
||||
// pool (CreatePooledHTTPClient / GetOrCreateTransport).
|
||||
func applyHTTPClientDefaults(config *HTTPClientConfig) {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 30 * time.Second
|
||||
}
|
||||
@@ -171,6 +191,13 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
|
||||
if config.ReadBufferSize == 0 {
|
||||
config.ReadBufferSize = 4096
|
||||
}
|
||||
}
|
||||
|
||||
// CreateHTTPClient creates an HTTP client with the given configuration
|
||||
// Validates configuration parameters before creating the client
|
||||
func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Client {
|
||||
// Set defaults for zero values before validation
|
||||
applyHTTPClientDefaults(&config)
|
||||
|
||||
// Validate configuration - only fail on critical errors
|
||||
if err := f.ValidateHTTPClientConfig(&config); err != nil {
|
||||
@@ -202,8 +229,8 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
PreferServerCipherSuites: true,
|
||||
InsecureSkipVerify: false, // Always verify certificates
|
||||
RootCAs: config.RootCAs,
|
||||
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
|
||||
},
|
||||
ForceAttemptHTTP2: config.ForceHTTP2,
|
||||
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
|
||||
|
||||
@@ -18,7 +18,10 @@ func TestOIDCProviderHTTPClientConfigUnit(t *testing.T) {
|
||||
assert.Equal(t, 25, config.MaxIdleConnsPerHost, "OIDC provider should have 25 max idle conns per host")
|
||||
assert.Equal(t, 50, config.MaxConnsPerHost, "OIDC provider should have 50 max conns per host")
|
||||
assert.Equal(t, 90*time.Second, config.IdleConnTimeout, "OIDC provider should have 90s idle conn timeout")
|
||||
assert.True(t, config.UseCookieJar, "OIDC provider should have cookie jar enabled")
|
||||
// UseCookieJar must stay false (FIX-12): this config feeds the shared
|
||||
// pooled client, and a jar there would leak one user's IdP cookies to
|
||||
// every other user of the same pooled instance.
|
||||
assert.False(t, config.UseCookieJar, "OIDC provider pooled client must not have a shared cookie jar")
|
||||
}
|
||||
|
||||
// TestCreateDefaultClientUnit tests CreateDefaultClient function
|
||||
@@ -38,7 +41,9 @@ func TestCreateTokenClientUnit(t *testing.T) {
|
||||
|
||||
require.NotNil(t, client)
|
||||
assert.NotNil(t, client.Transport, "client should have transport")
|
||||
assert.NotNil(t, client.Jar, "token client should have cookie jar")
|
||||
// No jar (FIX-12): TokenHTTPClientConfig's UseCookieJar stays false so
|
||||
// a client built from it never carries cross-request cookie state.
|
||||
assert.Nil(t, client.Jar, "token client must not have a cookie jar")
|
||||
assert.Equal(t, 10*time.Second, client.Timeout, "token client should have 10s timeout")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPooledTokenClient_DoesNotShareCookieJarAcrossRequests regresses
|
||||
// FIX-12: R70 gave CreatePooledHTTPClient's client a cookie jar whenever
|
||||
// UseCookieJar is set, and TokenHTTPClientConfig (used to build the single
|
||||
// per-plugin-instance tokenHTTPClient, main.go) set UseCookieJar: true. That
|
||||
// one client, and its one jar, is shared by every user's token/refresh
|
||||
// exchange. An IdP Set-Cookie on user A's exchange was stored in the shared
|
||||
// jar and replayed on user B's next token request to the same host -
|
||||
// cross-user state confusion at the IdP. Token/refresh calls are stateless
|
||||
// back-channel requests and must never carry a cross-request jar.
|
||||
func TestPooledTokenClient_DoesNotShareCookieJarAcrossRequests(t *testing.T) {
|
||||
var requests int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if requests == 1 {
|
||||
// Simulate the IdP setting a session/affinity cookie on user A's
|
||||
// token exchange response.
|
||||
http.SetCookie(w, &http.Cookie{Name: "idp_sess", Value: "alice"})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// User B's request: must not carry the cookie set on user A's call.
|
||||
if c, err := r.Cookie("idp_sess"); err == nil {
|
||||
t.Errorf("request %d carried leaked cookie idp_sess=%s from an earlier request", requests, c.Value)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := CreatePooledHTTPClient(TokenHTTPClientConfig())
|
||||
|
||||
// Request 1 (user A): receives and would store the IdP's Set-Cookie if
|
||||
// the client has a jar.
|
||||
resp1, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("request 1: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
// Request 2 (user B, same pooled client, same host): must be a clean
|
||||
// request with no cookie carried over from request 1.
|
||||
resp2, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("request 2: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if requests != 2 {
|
||||
t.Fatalf("want 2 requests reaching the server, got %d", requests)
|
||||
}
|
||||
}
|
||||
+140
-34
@@ -3,8 +3,10 @@ package traefikoidc
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -25,6 +27,10 @@ type sharedTransport struct {
|
||||
lastUsed time.Time
|
||||
transport *http.Transport
|
||||
refCount int
|
||||
// tlsKey identifies the TLS trust settings (CA pool + InsecureSkipVerify)
|
||||
// this transport was built with, so the at-limit fallback only reuses a
|
||||
// transport whose TLS configuration matches the caller's.
|
||||
tlsKey string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -52,20 +58,44 @@ func GetGlobalTransportPool() *SharedTransportPool {
|
||||
|
||||
// GetOrCreateTransport gets or creates a shared transport with the given config
|
||||
func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *http.Transport {
|
||||
// SECURITY FIX: Check client limit before creating new transport
|
||||
// Apply the same zero-value defaults as CreateHTTPClient so a partially
|
||||
// populated config never yields an unbounded client/transport, and so the
|
||||
// pool cache key reflects normalized values (configKey). Bounded here,
|
||||
// before the client-count gate, so both the gate and the key see
|
||||
// normalized timeouts.
|
||||
applyHTTPClientDefaults(&config)
|
||||
// SECURITY FIX: Check client limit before creating new transport.
|
||||
if atomic.LoadInt32(&p.clientCount) >= p.maxClients {
|
||||
// Return existing transport if limit reached
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
// At the client limit: only reuse a transport that was built for the
|
||||
// SAME config (same TLS trust store). refCount is mutated under the
|
||||
// write lock to avoid a data race, and a transport created for a
|
||||
// different configuration is never handed back — doing so could apply
|
||||
// the wrong (possibly verification-disabled) TLS settings to a request.
|
||||
want := tlsConfigKey(config)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, shared := range p.transports {
|
||||
if shared != nil && shared.transport != nil {
|
||||
if shared != nil && shared.transport != nil && shared.tlsKey == want {
|
||||
shared.refCount++
|
||||
shared.lastUsed = time.Now()
|
||||
return shared.transport
|
||||
}
|
||||
}
|
||||
// If no transport available, return nil (caller should handle)
|
||||
return nil
|
||||
// No TLS-compatible transport available. Returning nil would make the
|
||||
// caller fall back to http.DefaultTransport, silently dropping the
|
||||
// configured RootCAs / InsecureSkipVerify — custom-CA TLS would
|
||||
// break and self-signed skip-verify would be lost (R95). Build and
|
||||
// return the correctly-configured transport instead; honoring TLS
|
||||
// settings takes precedence over the soft client cap.
|
||||
t := newSharedTransport(config)
|
||||
p.transports[p.configKey(config)] = &sharedTransport{
|
||||
transport: t,
|
||||
refCount: 1,
|
||||
lastUsed: time.Now(),
|
||||
tlsKey: want,
|
||||
}
|
||||
atomic.AddInt32(&p.clientCount, 1)
|
||||
return t
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
@@ -82,8 +112,26 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
|
||||
// Increment client count
|
||||
atomic.AddInt32(&p.clientCount, 1)
|
||||
|
||||
// Create new transport with conservative limits
|
||||
transport := &http.Transport{
|
||||
transport := newSharedTransport(config)
|
||||
|
||||
p.transports[key] = &sharedTransport{
|
||||
transport: transport,
|
||||
refCount: 1,
|
||||
lastUsed: time.Now(),
|
||||
tlsKey: tlsConfigKey(config),
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// newSharedTransport builds a properly-configured *http.Transport from the
|
||||
// given config, enforcing TLS 1.2+ and secure cipher suites. Shared by the
|
||||
// normal acquisition path and the at-cap fallback so the TLS settings are
|
||||
// NEVER silently dropped (the at-cap fallback previously returned nil,
|
||||
// making the caller fall back to http.DefaultTransport and lose the
|
||||
// configured RootCAs / InsecureSkipVerify — R95).
|
||||
func newSharedTransport(config HTTPClientConfig) *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{
|
||||
@@ -102,30 +150,22 @@ func (p *SharedTransportPool) GetOrCreateTransport(config HTTPClientConfig) *htt
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
PreferServerCipherSuites: true,
|
||||
InsecureSkipVerify: false,
|
||||
RootCAs: config.RootCAs,
|
||||
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
|
||||
},
|
||||
ForceAttemptHTTP2: config.ForceHTTP2,
|
||||
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
|
||||
ExpectContinueTimeout: config.ExpectContinueTimeout,
|
||||
MaxIdleConns: 10, // SECURITY FIX: Further reduced
|
||||
MaxIdleConnsPerHost: 2, // SECURITY FIX: Limited connections
|
||||
IdleConnTimeout: 30 * time.Second, // Reduced from 5 minutes
|
||||
MaxIdleConns: config.MaxIdleConns,
|
||||
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
|
||||
IdleConnTimeout: config.IdleConnTimeout,
|
||||
DisableKeepAlives: config.DisableKeepAlives,
|
||||
MaxConnsPerHost: 5, // SECURITY FIX: Strict limit
|
||||
MaxConnsPerHost: config.MaxConnsPerHost,
|
||||
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
|
||||
DisableCompression: config.DisableCompression,
|
||||
WriteBufferSize: config.WriteBufferSize,
|
||||
ReadBufferSize: config.ReadBufferSize,
|
||||
}
|
||||
|
||||
p.transports[key] = &sharedTransport{
|
||||
transport: transport,
|
||||
refCount: 1,
|
||||
lastUsed: time.Now(),
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// ReleaseTransport decrements the reference count for a transport
|
||||
@@ -205,28 +245,82 @@ func (p *SharedTransportPool) performCleanup() {
|
||||
|
||||
// configKey generates a unique key for a config
|
||||
func (p *SharedTransportPool) configKey(config HTTPClientConfig) string {
|
||||
// Simple key based on main parameters
|
||||
return string(rune(config.MaxConnsPerHost)) + string(rune(config.MaxIdleConnsPerHost))
|
||||
// Pool transports by every parameter the built *http.Transport actually
|
||||
// consumes. RootCAs and InsecureSkipVerify MUST be part of the key:
|
||||
// otherwise a middleware configured with a custom CA would share a
|
||||
// transport with one using the system store, silently bypassing its
|
||||
// CA configuration. The remaining fields affect connection behavior
|
||||
// (HTTP version, compression, keep-alives, timeouts, buffer sizes,
|
||||
// dial settings); omitting them would hand a caller a transport built
|
||||
// with different settings than it requested (e.g. wrong compression).
|
||||
skip := "0"
|
||||
if config.InsecureSkipVerify {
|
||||
skip = "1"
|
||||
}
|
||||
return fmt.Sprintf("%d|%d|%p|%s|%d|%d|%d|%v|%v|%v|%d|%d|%d|%d|%d|%d",
|
||||
config.MaxConnsPerHost,
|
||||
config.MaxIdleConnsPerHost,
|
||||
config.RootCAs,
|
||||
skip,
|
||||
config.DialTimeout,
|
||||
config.KeepAlive,
|
||||
config.TLSHandshakeTimeout,
|
||||
config.ForceHTTP2,
|
||||
config.DisableKeepAlives,
|
||||
config.DisableCompression,
|
||||
config.ResponseHeaderTimeout,
|
||||
config.ExpectContinueTimeout,
|
||||
config.WriteBufferSize,
|
||||
config.ReadBufferSize,
|
||||
config.IdleConnTimeout,
|
||||
config.MaxIdleConns,
|
||||
)
|
||||
}
|
||||
|
||||
// Cleanup closes all transports and stops the cleanup goroutine
|
||||
// tlsConfigKey identifies only the TLS trust settings of a config — the CA pool
|
||||
// and the InsecureSkipVerify flag. Two configs with the same tlsConfigKey are
|
||||
// safe to serve from the same transport even if other (non-TLS) parameters such
|
||||
// as connection limits differ; configs with different TLS settings are not.
|
||||
func tlsConfigKey(config HTTPClientConfig) string {
|
||||
skip := "0"
|
||||
if config.InsecureSkipVerify {
|
||||
skip = "1"
|
||||
}
|
||||
return fmt.Sprintf("%p|%s", config.RootCAs, skip)
|
||||
}
|
||||
|
||||
// Cleanup closes all transports, resets the pool, and restarts the cleanup
|
||||
// goroutine so the (singleton) pool remains fully usable afterwards. Prior
|
||||
// to R125, Cleanup canceled the cleanup goroutine but never restarted it
|
||||
// and left clientCount unreset: a reused singleton then had a permanently
|
||||
// dead cleaner (idle conns never pruned) and a stale count (the soft
|
||||
// maxClients cap keyed off it was wrong after reuse).
|
||||
func (p *SharedTransportPool) Cleanup() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Stop the cleanup goroutine
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
for _, shared := range p.transports {
|
||||
shared.transport.CloseIdleConnections()
|
||||
if shared != nil && shared.transport != nil {
|
||||
shared.transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
p.transports = make(map[string]*sharedTransport)
|
||||
atomic.StoreInt32(&p.clientCount, 0)
|
||||
oldCancel := p.cancel
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.ctx = ctx
|
||||
p.cancel = cancel
|
||||
p.mu.Unlock()
|
||||
|
||||
if oldCancel != nil {
|
||||
oldCancel()
|
||||
}
|
||||
go p.cleanupIdleTransports(ctx)
|
||||
}
|
||||
|
||||
// CreatePooledHTTPClient creates an HTTP client using the shared transport pool
|
||||
func CreatePooledHTTPClient(config HTTPClientConfig) *http.Client {
|
||||
// Apply the same zero-value defaults as CreateHTTPClient so a partially
|
||||
// populated config still gets a bounded overall request Timeout.
|
||||
applyHTTPClientDefaults(&config)
|
||||
pool := GetGlobalTransportPool()
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
|
||||
@@ -235,6 +329,18 @@ func CreatePooledHTTPClient(config HTTPClientConfig) *http.Client {
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
// Attach a jar only for callers that opt in via UseCookieJar, matching
|
||||
// CreateHTTPClient (http_client_factory.go). TokenHTTPClientConfig and
|
||||
// OIDCProviderHTTPClientConfig deliberately leave UseCookieJar false:
|
||||
// a client built through this pool from either config is one instance
|
||||
// shared across every user, so a jar would leak one user's cookies
|
||||
// (e.g. Set-Cookie on a token/refresh response) into another user's
|
||||
// requests to the same host (FIX-12).
|
||||
if config.UseCookieJar {
|
||||
jar, _ := cookiejar.New(nil) // Safe to ignore: nil options rarely fail
|
||||
client.Jar = jar
|
||||
}
|
||||
|
||||
// Configure redirect policy
|
||||
maxRedirects := config.MaxRedirects
|
||||
if maxRedirects == 0 {
|
||||
|
||||
@@ -63,9 +63,17 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
|
||||
}
|
||||
|
||||
config := DefaultHTTPClientConfig()
|
||||
config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
|
||||
assert.Nil(t, transport, "should return nil when at client limit")
|
||||
// R95: at the client limit with no TLS-compatible transport, we must
|
||||
// still return a correctly-configured transport (never nil, which
|
||||
// previously fell back to http.DefaultTransport and dropped TLS
|
||||
// settings).
|
||||
require.NotNil(t, transport, "at-cap transport must not be nil")
|
||||
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("at-cap transport must preserve the requested TLS settings")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client limit with existing transport", func(t *testing.T) {
|
||||
@@ -256,7 +264,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
|
||||
assert.Len(t, pool.transports, 0, "all transports should be removed")
|
||||
})
|
||||
|
||||
t.Run("cleanup cancels context", func(t *testing.T) {
|
||||
t.Run("cleanup cancels the old context and stays reusable", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
pool := &SharedTransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
@@ -270,10 +278,16 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
|
||||
pool.Cleanup()
|
||||
|
||||
select {
|
||||
case <-pool.ctx.Done():
|
||||
// Context was canceled
|
||||
case <-ctx.Done():
|
||||
// The previous cleanup context was canceled (goroutine stopped).
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("context should be canceled")
|
||||
t.Error("the previous cleanup context should be canceled")
|
||||
}
|
||||
|
||||
// R125: Cleanup must leave the pool fully reusable — a fresh
|
||||
// context is installed and new transports can still be acquired.
|
||||
if tr := pool.GetOrCreateTransport(DefaultHTTPClientConfig()); tr == nil {
|
||||
t.Error("pool must remain usable after Cleanup")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -689,3 +703,58 @@ func TestSharedTransportPoolEdgeCases(t *testing.T) {
|
||||
assert.Equal(t, int32(0), finalCount, "client count should decrement on cleanup")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSharedTransportPoolAppliesConfigLimits is a regression test: the pooled
|
||||
// transport must apply the configured connection limits (MaxConnsPerHost,
|
||||
// MaxIdleConnsPerHost, MaxIdleConns, IdleConnTimeout) rather than hardcoded
|
||||
// constants. Previously GetOrCreateTransport built the transport with fixed
|
||||
// values (5/2/10/30s) while configKey still incorporated these config fields,
|
||||
// so the config was valid, keyed, and validated but silently ignored
|
||||
// (dead config) — e.g. OIDCProviderHTTPClientConfig's tuning had no effect.
|
||||
func TestSharedTransportPoolAppliesConfigLimits(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pool := &SharedTransportPool{
|
||||
transports: make(map[string]*sharedTransport),
|
||||
maxConns: 20,
|
||||
clientCount: 0,
|
||||
maxClients: 5,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
config := DefaultHTTPClientConfig()
|
||||
config.MaxConnsPerHost = 37
|
||||
config.MaxIdleConnsPerHost = 13
|
||||
config.MaxIdleConns = 41
|
||||
config.IdleConnTimeout = 7 * time.Second
|
||||
|
||||
transport := pool.GetOrCreateTransport(config)
|
||||
require.NotNil(t, transport)
|
||||
|
||||
assert.Equal(t, 37, transport.MaxConnsPerHost, "MaxConnsPerHost must come from config")
|
||||
assert.Equal(t, 13, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost must come from config")
|
||||
assert.Equal(t, 41, transport.MaxIdleConns, "MaxIdleConns must come from config")
|
||||
assert.Equal(t, 7*time.Second, transport.IdleConnTimeout, "IdleConnTimeout must come from config")
|
||||
}
|
||||
|
||||
// TestCreatePooledHTTPClient_CookieJarOption regresses a config no-effect:
|
||||
// CreatePooledHTTPClient ignored config.UseCookieJar, so pooled token/OIDC
|
||||
// clients dropped the cookie jar that the non-pooled CreateHTTPClient
|
||||
// honors (queue cookies from auth-server token/refresh responses were
|
||||
// never stored or re-sent).
|
||||
func TestCreatePooledHTTPClient_CookieJarOption(t *testing.T) {
|
||||
t.Run("enabled", func(t *testing.T) {
|
||||
client := CreatePooledHTTPClient(HTTPClientConfig{UseCookieJar: true})
|
||||
if client.Jar == nil {
|
||||
t.Fatal("expected a cookie jar when UseCookieJar is true")
|
||||
}
|
||||
})
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
client := CreatePooledHTTPClient(HTTPClientConfig{UseCookieJar: false})
|
||||
if client.Jar != nil {
|
||||
t.Fatal("expected no cookie jar when UseCookieJar is false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnhanceSessionSecurity_CaseVariantForwardedHostStaysHostScoped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
xfh string
|
||||
}{
|
||||
{name: "exact-case parent", xfh: "corp.example.com"},
|
||||
{name: "upper-case parent", xfh: "CORP.example.com"},
|
||||
{name: "mixed-case parent", xfh: "Corp.Example.COM"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.corp.example.com/", nil)
|
||||
req.Host = "app.corp.example.com"
|
||||
req.Header.Set("X-Forwarded-Host", tc.xfh)
|
||||
opts := sm.EnhanceSessionSecurity(nil, req)
|
||||
if opts.Domain != "app.corp.example.com" {
|
||||
t.Fatalf("Domain = %q, want host-scoped app.corp.example.com", opts.Domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case-variant forwarded host equal to serving host is kept", func(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||
req := httptest.NewRequest(http.MethodGet, "https://internal.svc/", nil)
|
||||
req.Host = "internal.svc"
|
||||
req.Header.Set("X-Forwarded-Host", "App.Example.com")
|
||||
opts := sm.EnhanceSessionSecurity(nil, req)
|
||||
if opts.Domain != "app.example.com" {
|
||||
t.Fatalf("Domain = %q, want app.example.com", opts.Domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforceIatAge_RejectsFutureIat(t *testing.T) {
|
||||
const maxAge = 24 * time.Hour
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
iat time.Time
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "now", iat: now},
|
||||
{name: "within future skew", iat: now.Add(ClockSkewToleranceFuture / 2)},
|
||||
{name: "one hour ahead", iat: now.Add(time.Hour), wantErr: true},
|
||||
{name: "just beyond skew", iat: now.Add(ClockSkewToleranceFuture + 10*time.Second), wantErr: true},
|
||||
{name: "older than max age", iat: now.Add(-48 * time.Hour), wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := enforceIatAge(map[string]interface{}{"iat": float64(tc.iat.Unix())}, maxAge)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("enforceIatAge err = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKCache_CooldownServesParsedEntryWithoutReparse(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
c := NewJWKCache()
|
||||
url := fmt.Sprintf("https://jwks.invalid/%d", time.Now().UnixNano())
|
||||
// The raw set is empty, so only the pre-parsed entry can satisfy the lookup.
|
||||
_ = c.cache.SetLocal(url, &JWKSet{}, time.Hour)
|
||||
_ = c.cache.SetLocal(url+parsedKeysSuffix, &parsedJWKS{keys: map[string]crypto.PublicKey{"k1": &priv.PublicKey}}, time.Hour)
|
||||
c.lastSignatureRefresh[url] = time.Now()
|
||||
|
||||
got, err := c.getPublicKeyFresh(context.Background(), url, "k1", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("getPublicKeyFresh: %v", err)
|
||||
}
|
||||
if got != crypto.PublicKey(&priv.PublicKey) {
|
||||
t.Fatal("cooldown branch did not serve the cached parsed key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontchannelLogout_FloodCannotEvictSignedMarkers(t *testing.T) {
|
||||
cache := &CacheInterfaceWrapper{cache: NewUniversalCache(UniversalCacheConfig{
|
||||
Type: CacheTypeSession,
|
||||
MaxSize: 5000,
|
||||
DefaultTTL: sessionInvalidationTTL,
|
||||
Logger: NewLogger("error"),
|
||||
SkipAutoCleanup: true,
|
||||
MonotonicMarkers: true,
|
||||
})}
|
||||
oidc := &TraefikOidc{
|
||||
logger: NewLogger("error"),
|
||||
enableFrontchannelLogout: true,
|
||||
sessionInvalidationCache: cache,
|
||||
issuerURL: "https://idp.example.com",
|
||||
}
|
||||
t.Cleanup(func() { _ = cache.cache.Close() })
|
||||
|
||||
if err := oidc.invalidateSession("", "real-subject"); err != nil {
|
||||
t.Fatalf("invalidateSession: %v", err)
|
||||
}
|
||||
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err != nil {
|
||||
t.Fatalf("mark jti: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 6000; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/fc?iss=https://idp.example.com&sid=flood-%d", i), nil)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.handleFrontchannelLogout(rw, req)
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("flood request %d: status %d", i, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sub", "real-subject")); !ok {
|
||||
t.Error("backchannel sub marker evicted by front-channel flood")
|
||||
}
|
||||
if err := oidc.checkAndMarkLogoutJTIProcessed("real-jti", time.Now().Unix()); err == nil {
|
||||
t.Error("jti replay marker evicted by front-channel flood: replay accepted")
|
||||
}
|
||||
held := 0
|
||||
for i := 0; i < 6000; i++ {
|
||||
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", fmt.Sprintf("flood-%d", i))); ok {
|
||||
held++
|
||||
}
|
||||
}
|
||||
if held > maxFrontchannelMarkers {
|
||||
t.Errorf("front-channel markers held = %d, want <= %d", held, maxFrontchannelMarkers)
|
||||
}
|
||||
if _, ok := cache.Get(oidc.buildSessionInvalidationKey("sid", "flood-5999")); !ok {
|
||||
t.Error("most recent front-channel marker must remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerQuota_Admit(t *testing.T) {
|
||||
var q markerQuota
|
||||
if got := q.admit("a", 2); got != "" {
|
||||
t.Errorf("admit a = %q, want none", got)
|
||||
}
|
||||
if got := q.admit("a", 2); got != "" {
|
||||
t.Errorf("re-admit a = %q, want none", got)
|
||||
}
|
||||
if got := q.admit("b", 2); got != "" {
|
||||
t.Errorf("admit b = %q, want none", got)
|
||||
}
|
||||
if got := q.admit("c", 2); got != "a" {
|
||||
t.Errorf("admit c evicted %q, want a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_InitWaitQueueFullFailsFast(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
logger: NewLogger("error"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
}
|
||||
oidc.initWaiters = maxInitWaiters
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
start := time.Now()
|
||||
oidc.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/protected", nil))
|
||||
|
||||
if rw.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rw.Code)
|
||||
}
|
||||
if rw.Header().Get("Retry-After") == "" {
|
||||
t.Error("missing Retry-After header")
|
||||
}
|
||||
if time.Since(start) > 5*time.Second {
|
||||
t.Errorf("rejected after %v, want immediate", time.Since(start))
|
||||
}
|
||||
if oidc.initWaiters != maxInitWaiters {
|
||||
t.Errorf("initWaiters = %d, want unchanged %d", oidc.initWaiters, maxInitWaiters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTP_InitWaiterReleasedOnCancel(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
logger: NewLogger("error"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil).WithContext(ctx)
|
||||
oidc.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if oidc.initWaiters != 0 {
|
||||
t.Fatalf("initWaiters = %d after canceled wait, want 0", oidc.initWaiters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldCookies_CapsDeletionHeaders(t *testing.T) {
|
||||
sm, err := NewSessionManager("test-encryption-key-32-bytes-long!!", false, "cookie.example.com", "testpref", 0, newNoOpLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("session manager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sm.Shutdown() })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "a.b.example.com"
|
||||
for i := 0; i < 180; i++ {
|
||||
req.AddCookie(&http.Cookie{Name: fmt.Sprintf("testpref_%d", i), Value: "x"})
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
sm.CleanupOldCookies(rw, req)
|
||||
|
||||
got := len(rw.Result().Cookies())
|
||||
if got == 0 {
|
||||
t.Fatal("expected deletion cookies for the first wave")
|
||||
}
|
||||
if limit := maxCleanupCookies * 4; got > limit {
|
||||
t.Fatalf("deletion Set-Cookie headers = %d, want <= %d", got, limit)
|
||||
}
|
||||
for _, c := range rw.Result().Cookies() {
|
||||
if !strings.HasPrefix(c.Name, "testpref_") {
|
||||
t.Errorf("unexpected deletion cookie %q", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIDTokenMemoFollowsStoredToken(t *testing.T) {
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef", false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sm.Shutdown(); err != nil {
|
||||
t.Errorf("Shutdown: %v", err)
|
||||
}
|
||||
})
|
||||
future := float64(time.Now().Add(time.Hour).Unix())
|
||||
first := tokenWithGroups(t, map[string]interface{}{"sub": "first", "exp": future})
|
||||
second := tokenWithGroups(t, map[string]interface{}{"sub": "second", "exp": future})
|
||||
|
||||
session, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
steps := []struct {
|
||||
name string
|
||||
write func()
|
||||
want string
|
||||
}{
|
||||
{"first read", func() { session.SetIDToken(first) }, first},
|
||||
{"repeat read", func() {}, first},
|
||||
{"after overwrite", func() { session.SetIDToken(second) }, second},
|
||||
{"after clearing", func() { session.SetIDToken("") }, ""},
|
||||
{"after Reset", func() { session.SetIDToken(first); _ = session.GetIDToken(); session.Reset() }, ""},
|
||||
}
|
||||
for _, st := range steps {
|
||||
st.write()
|
||||
if got := session.GetIDToken(); got != st.want {
|
||||
t.Fatalf("%s: GetIDToken = %q, want %q", st.name, got, st.want)
|
||||
}
|
||||
if st.name == "repeat read" && session.idTokenMemoRaw != first {
|
||||
t.Fatal("repeat read did not go through the memo; the test no longer covers it")
|
||||
}
|
||||
}
|
||||
if session.idTokenMemoRaw != "" || session.idTokenMemo != "" {
|
||||
t.Fatal("Reset left an ID-token memo on the pooled session")
|
||||
}
|
||||
}
|
||||
@@ -1,727 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,895 +0,0 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInputValidator(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
logger := NewLogger("debug")
|
||||
validator, err := NewInputValidator(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Valid token validation", func(t *testing.T) {
|
||||
validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpMC6M6FNqI2J-I2NxrragtnDxGxdJUvDERDQVHzeNlVQiuqWDEeO_O-0KptafbfyuGqfQxH_6dp2_MeFpAc" // trufflehog:ignore
|
||||
|
||||
result := validator.ValidateToken(validToken)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid token to pass validation, got errors: %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid token validation", func(t *testing.T) {
|
||||
invalidTokens := []string{
|
||||
"", // Empty token
|
||||
"invalid.token", // Invalid format
|
||||
"a.b", // Too few parts
|
||||
"a.b.c.d", // Too many parts
|
||||
}
|
||||
|
||||
for _, token := range invalidTokens {
|
||||
result := validator.ValidateToken(token)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid token '%s' to fail validation", token)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid email validation", func(t *testing.T) {
|
||||
validEmails := []string{
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
}
|
||||
|
||||
for _, email := range validEmails {
|
||||
result := validator.ValidateEmail(email)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid email '%s' to pass validation, got errors: %v", email, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid email validation", func(t *testing.T) {
|
||||
invalidEmails := []string{
|
||||
"", // Empty
|
||||
"invalid", // No @ symbol
|
||||
"@domain.com", // No local part
|
||||
"user@", // No domain
|
||||
"user@domain", // No TLD
|
||||
"[email protected]", // Double dots
|
||||
}
|
||||
|
||||
for _, email := range invalidEmails {
|
||||
result := validator.ValidateEmail(email)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid email '%s' to fail validation", email)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid URL validation", func(t *testing.T) {
|
||||
validURLs := []string{
|
||||
"https://example.com",
|
||||
"https://sub.domain.com/path",
|
||||
"https://localhost:8080/callback",
|
||||
}
|
||||
|
||||
for _, url := range validURLs {
|
||||
result := validator.ValidateURL(url)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid URL '%s' to pass validation, got errors: %v", url, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid URL validation", func(t *testing.T) {
|
||||
invalidURLs := []string{
|
||||
"", // Empty
|
||||
"not-a-url", // Invalid format
|
||||
"ftp://example.com", // Wrong scheme
|
||||
"https://", // No host
|
||||
}
|
||||
|
||||
for _, url := range invalidURLs {
|
||||
result := validator.ValidateURL(url)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid URL '%s' to fail validation", url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid username validation", func(t *testing.T) {
|
||||
validUsernames := []string{
|
||||
"user123",
|
||||
"test_user",
|
||||
"user-name",
|
||||
}
|
||||
|
||||
for _, username := range validUsernames {
|
||||
result := validator.ValidateUsername(username)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid username '%s' to pass validation, got errors: %v", username, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid username validation", func(t *testing.T) {
|
||||
invalidUsernames := []string{
|
||||
"", // Empty
|
||||
"a", // Too short
|
||||
strings.Repeat("a", 100), // Too long
|
||||
"user name", // Spaces
|
||||
}
|
||||
|
||||
for _, username := range invalidUsernames {
|
||||
result := validator.ValidateUsername(username)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid username '%s' to fail validation", username)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid claim validation", func(t *testing.T) {
|
||||
validClaims := map[string]string{
|
||||
"sub": "user123",
|
||||
"email": "[email protected]",
|
||||
"name": "John Doe",
|
||||
}
|
||||
|
||||
for key, value := range validClaims {
|
||||
result := validator.ValidateClaim(key, value)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid claim '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid claim validation", func(t *testing.T) {
|
||||
invalidClaims := map[string]string{
|
||||
"": "value", // Empty key
|
||||
"long_key": strings.Repeat("a", 10000), // Too long value
|
||||
}
|
||||
|
||||
for key, value := range invalidClaims {
|
||||
result := validator.ValidateClaim(key, value)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid claim '%s'='%s' to fail validation", key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid header validation", func(t *testing.T) {
|
||||
validHeaders := map[string]string{
|
||||
"Authorization": "Bearer token123",
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom": "custom-value",
|
||||
}
|
||||
|
||||
for key, value := range validHeaders {
|
||||
result := validator.ValidateHeader(key, value)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid header '%s'='%s' to pass validation, got errors: %v", key, value, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid header validation", func(t *testing.T) {
|
||||
invalidHeaders := map[string]string{
|
||||
"": "value", // Empty key
|
||||
"Invalid\nKey": "value", // Control characters in key
|
||||
"key": "value\r\n", // Control characters in value
|
||||
}
|
||||
|
||||
for key, value := range invalidHeaders {
|
||||
result := validator.ValidateHeader(key, value)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid header '%s'='%s' to fail validation", key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizeInput(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
logger := NewLogger("debug")
|
||||
validator, err := NewInputValidator(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
maxLen int
|
||||
}{
|
||||
{
|
||||
name: "Normal text",
|
||||
input: "Hello World",
|
||||
maxLen: 100,
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "Control characters",
|
||||
input: "text\x00with\x01control\x02chars",
|
||||
maxLen: 100,
|
||||
expected: "textwithcontrolchars",
|
||||
},
|
||||
{
|
||||
name: "Truncation",
|
||||
input: "very long text",
|
||||
maxLen: 5,
|
||||
expected: "very ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.SanitizeInput(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected sanitized input '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBoundaryValues(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
logger := NewLogger("debug")
|
||||
validator, err := NewInputValidator(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Valid boundary values", func(t *testing.T) {
|
||||
validValues := []interface{}{
|
||||
int(50),
|
||||
int64(100),
|
||||
float64(75.5),
|
||||
}
|
||||
|
||||
for _, value := range validValues {
|
||||
result := validator.ValidateBoundaryValues(value, 1, 1000)
|
||||
if !result.IsValid {
|
||||
t.Errorf("Expected valid boundary value %v to pass validation, got errors: %v", value, result.Errors)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid boundary values", func(t *testing.T) {
|
||||
invalidValues := []interface{}{
|
||||
int(-1),
|
||||
int64(2000),
|
||||
"not a number",
|
||||
}
|
||||
|
||||
for _, value := range invalidValues {
|
||||
result := validator.ValidateBoundaryValues(value, 1, 1000)
|
||||
if result.IsValid {
|
||||
t.Errorf("Expected invalid boundary value %v to fail validation", value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultInputValidationConfig(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
|
||||
if config.MaxTokenLength <= 0 {
|
||||
t.Error("Expected positive MaxTokenLength")
|
||||
}
|
||||
if config.MaxEmailLength <= 0 {
|
||||
t.Error("Expected positive MaxEmailLength")
|
||||
}
|
||||
if config.MaxUsernameLength <= 0 {
|
||||
t.Error("Expected positive MaxUsernameLength")
|
||||
}
|
||||
if config.MaxClaimLength <= 0 {
|
||||
t.Error("Expected positive MaxClaimLength")
|
||||
}
|
||||
if config.MaxHeaderLength <= 0 {
|
||||
t.Error("Expected positive MaxHeaderLength")
|
||||
}
|
||||
if !config.StrictMode {
|
||||
t.Error("Expected StrictMode to be true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputValidationHelpers(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
logger := NewLogger("debug")
|
||||
validator, err := NewInputValidator(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
t.Run("isValidBase64URL", func(t *testing.T) {
|
||||
validBase64URL := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
if !validator.isValidBase64URL(validBase64URL) {
|
||||
t.Error("Expected valid base64url to be recognized")
|
||||
}
|
||||
|
||||
invalidBase64URL := "invalid+base64/with+padding="
|
||||
if validator.isValidBase64URL(invalidBase64URL) {
|
||||
t.Error("Expected invalid base64url to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("containsNullBytes", func(t *testing.T) {
|
||||
withNull := "text\x00with\x00null"
|
||||
if !validator.containsNullBytes(withNull) {
|
||||
t.Error("Expected string with null bytes to be detected")
|
||||
}
|
||||
|
||||
withoutNull := "normal text"
|
||||
if validator.containsNullBytes(withoutNull) {
|
||||
t.Error("Expected string without null bytes to pass")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("containsControlCharacters", func(t *testing.T) {
|
||||
withControl := "text\x01with\x02control"
|
||||
if !validator.containsControlCharacters(withControl) {
|
||||
t.Error("Expected string with control characters to be detected")
|
||||
}
|
||||
|
||||
withoutControl := "normal text"
|
||||
if validator.containsControlCharacters(withoutControl) {
|
||||
t.Error("Expected string without control characters to pass")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("containsPathTraversal", func(t *testing.T) {
|
||||
withTraversal := "../../../etc/passwd"
|
||||
if !validator.containsPathTraversal(withTraversal) {
|
||||
t.Error("Expected path traversal to be detected")
|
||||
}
|
||||
|
||||
normalPath := "/normal/path"
|
||||
if validator.containsPathTraversal(normalPath) {
|
||||
t.Error("Expected normal path to pass")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detectSecurityRisk", func(t *testing.T) {
|
||||
riskyInputs := []string{
|
||||
"<script>alert('xss')</script>",
|
||||
"'; DROP TABLE users; --",
|
||||
"javascript:alert('xss')",
|
||||
}
|
||||
|
||||
for _, input := range riskyInputs {
|
||||
if validator.detectSecurityRisk(input) == "" {
|
||||
t.Errorf("Expected security risk to be detected in: %s", input)
|
||||
}
|
||||
}
|
||||
|
||||
safeInput := "normal safe text"
|
||||
if validator.detectSecurityRisk(safeInput) != "" {
|
||||
t.Error("Expected safe input to pass security check")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputValidationEdgeCases(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
logger := NewLogger("debug")
|
||||
validator, err := NewInputValidator(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create validator: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Empty inputs", func(t *testing.T) {
|
||||
// Most validations should reject empty inputs
|
||||
if result := validator.ValidateToken(""); result.IsValid {
|
||||
t.Error("Expected empty token to be rejected")
|
||||
}
|
||||
if result := validator.ValidateEmail(""); result.IsValid {
|
||||
t.Error("Expected empty email to be rejected")
|
||||
}
|
||||
if result := validator.ValidateURL(""); result.IsValid {
|
||||
t.Error("Expected empty URL to be rejected")
|
||||
}
|
||||
if result := validator.ValidateUsername(""); result.IsValid {
|
||||
t.Error("Expected empty username to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Very long inputs", func(t *testing.T) {
|
||||
longString := strings.Repeat("a", 10000)
|
||||
|
||||
if result := validator.ValidateEmail(longString + "@domain.com"); result.IsValid {
|
||||
t.Error("Expected very long email to be rejected")
|
||||
}
|
||||
if result := validator.ValidateUsername(longString); result.IsValid {
|
||||
t.Error("Expected very long username to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unicode handling", func(t *testing.T) {
|
||||
unicodeEmail := "用户@example.com"
|
||||
// Should handle unicode gracefully
|
||||
validator.ValidateEmail(unicodeEmail) // Don't fail on unicode
|
||||
|
||||
unicodeUsername := "用户名"
|
||||
validator.ValidateUsername(unicodeUsername) // Don't fail on unicode
|
||||
})
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateToken tests comprehensive token validation
|
||||
func TestInputValidatorValidateToken(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidJWTToken",
|
||||
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjJ9.signature", // trufflehog:ignore
|
||||
expectValid: true,
|
||||
description: "Valid JWT token should pass validation",
|
||||
},
|
||||
{
|
||||
name: "InvalidOpaqueToken",
|
||||
token: "opaque_access_token_that_is_long_enough_to_pass",
|
||||
expectValid: false,
|
||||
description: "Opaque token (non-JWT) should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyToken",
|
||||
token: "",
|
||||
expectValid: false,
|
||||
description: "Empty token should fail validation",
|
||||
},
|
||||
{
|
||||
name: "TokenWithNullBytes",
|
||||
token: "token_with_null\x00byte",
|
||||
expectValid: false,
|
||||
description: "Token with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "TokenTooLong",
|
||||
token: strings.Repeat("a", config.MaxTokenLength+1),
|
||||
expectValid: false,
|
||||
description: "Token exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "TokenWithControlCharacters",
|
||||
token: "token_with_control\x01character",
|
||||
expectValid: false,
|
||||
description: "Token with control characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "TokenWithHighUnicode",
|
||||
token: "token_with_unicode_\uffff",
|
||||
expectValid: false,
|
||||
description: "Token with high unicode characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "MaliciousJWTWithExtraData",
|
||||
token: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig.malicious_extra", // trufflehog:ignore
|
||||
expectValid: false,
|
||||
description: "JWT with extra malicious data should fail validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateToken(tt.token)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateEmail tests email validation edge cases
|
||||
func TestInputValidatorValidateEmail(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidEmail",
|
||||
email: "[email protected]",
|
||||
expectValid: true,
|
||||
description: "Valid email should pass validation",
|
||||
},
|
||||
{
|
||||
name: "ValidEmailWithSubdomain",
|
||||
email: "[email protected]",
|
||||
expectValid: true,
|
||||
description: "Valid email with subdomain should pass validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyEmail",
|
||||
email: "",
|
||||
expectValid: false,
|
||||
description: "Empty email should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmailWithoutAtSign",
|
||||
email: "userexample.com",
|
||||
expectValid: false,
|
||||
description: "Email without @ sign should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmailWithNullBytes",
|
||||
email: "user@example\x00.com",
|
||||
expectValid: false,
|
||||
description: "Email with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmailTooLong",
|
||||
email: strings.Repeat("a", config.MaxEmailLength-10) + "@example.com",
|
||||
expectValid: false,
|
||||
description: "Email exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmailWithControlCharacters",
|
||||
email: "user\[email protected]",
|
||||
expectValid: false,
|
||||
description: "Email with control characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "MaliciousEmailWithScriptTag",
|
||||
email: "user<script>@example.com",
|
||||
expectValid: false,
|
||||
description: "Email with script tag should fail validation",
|
||||
},
|
||||
{
|
||||
name: "EmailWithUnicodeCharacters",
|
||||
email: "üser@éxample.com",
|
||||
expectValid: false,
|
||||
description: "Email with unicode should fail basic validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateEmail(tt.email)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateURL tests URL validation with security focus
|
||||
func TestInputValidatorValidateURL(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidHTTPSURL",
|
||||
url: "https://example.com/path",
|
||||
expectValid: true,
|
||||
description: "Valid HTTPS URL should pass validation",
|
||||
},
|
||||
{
|
||||
name: "ValidHTTPURL",
|
||||
url: "http://example.com/path",
|
||||
expectValid: true,
|
||||
description: "Valid HTTP URL should pass validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyURL",
|
||||
url: "",
|
||||
expectValid: false,
|
||||
description: "Empty URL should fail validation",
|
||||
},
|
||||
{
|
||||
name: "InvalidScheme",
|
||||
url: "ftp://example.com",
|
||||
expectValid: false,
|
||||
description: "URL with invalid scheme should fail validation",
|
||||
},
|
||||
{
|
||||
name: "URLWithNullBytes",
|
||||
url: "https://example\x00.com",
|
||||
expectValid: false,
|
||||
description: "URL with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "URLTooLong",
|
||||
url: "https://" + strings.Repeat("a", config.MaxURLLength) + ".com",
|
||||
expectValid: false,
|
||||
description: "URL exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "MalformedURL",
|
||||
url: "https://",
|
||||
expectValid: false,
|
||||
description: "Malformed URL should fail validation",
|
||||
},
|
||||
{
|
||||
name: "HTTPSLocalhostURL",
|
||||
url: "https://localhost:8080/path",
|
||||
expectValid: true,
|
||||
description: "HTTPS localhost URL should be allowed for development",
|
||||
},
|
||||
{
|
||||
name: "HTTPLocalhostURL",
|
||||
url: "http://localhost:8080/path",
|
||||
expectValid: false,
|
||||
description: "HTTP localhost URL should fail validation for security",
|
||||
},
|
||||
{
|
||||
name: "PrivateIPURL",
|
||||
url: "https://192.168.1.1/path",
|
||||
expectValid: false,
|
||||
description: "Private IP URL should fail validation for security",
|
||||
},
|
||||
{
|
||||
name: "JavaScriptURL",
|
||||
url: "javascript:alert(1)",
|
||||
expectValid: false,
|
||||
description: "JavaScript URL should fail validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateURL(tt.url)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateClaim tests claim validation with security focus
|
||||
func TestInputValidatorValidateClaim(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
claimName string
|
||||
claimValue string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidStringClaim",
|
||||
claimName: "email",
|
||||
claimValue: "[email protected]",
|
||||
expectValid: true,
|
||||
description: "Valid string claim should pass validation",
|
||||
},
|
||||
{
|
||||
name: "ValidNumberClaim",
|
||||
claimName: "exp",
|
||||
claimValue: "1516239022",
|
||||
expectValid: true,
|
||||
description: "Valid number claim should pass validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyClaimName",
|
||||
claimName: "",
|
||||
claimValue: "value",
|
||||
expectValid: false,
|
||||
description: "Empty claim name should fail validation",
|
||||
},
|
||||
{
|
||||
name: "ClaimWithNullBytes",
|
||||
claimName: "test",
|
||||
claimValue: "value\x00with_null",
|
||||
expectValid: false,
|
||||
description: "Claim with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "ClaimValueTooLong",
|
||||
claimName: "test",
|
||||
claimValue: strings.Repeat("a", config.MaxClaimLength+1),
|
||||
expectValid: false,
|
||||
description: "Claim value exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "ClaimWithControlCharacters",
|
||||
claimName: "test",
|
||||
claimValue: "value\x01with_control",
|
||||
expectValid: false,
|
||||
description: "Claim with control characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "MaliciousClaimWithHTML",
|
||||
claimName: "test",
|
||||
claimValue: "<script>alert('xss')</script>",
|
||||
expectValid: false,
|
||||
description: "Claim with HTML/script should fail validation",
|
||||
},
|
||||
{
|
||||
name: "ClaimWithExcessiveUnicode",
|
||||
claimName: "test",
|
||||
claimValue: strings.Repeat("🚀", 100), // Many unicode chars
|
||||
expectValid: false,
|
||||
description: "Claim with excessive unicode should fail validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateClaim(tt.claimName, tt.claimValue)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateHeader tests HTTP header validation
|
||||
func TestInputValidatorValidateHeader(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headerName string
|
||||
headerValue string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidHeader",
|
||||
headerName: "Authorization",
|
||||
headerValue: "Bearer token123",
|
||||
expectValid: true,
|
||||
description: "Valid header should pass validation",
|
||||
},
|
||||
{
|
||||
name: "ValidContentType",
|
||||
headerName: "Content-Type",
|
||||
headerValue: "application/json",
|
||||
expectValid: true,
|
||||
description: "Valid content type header should pass validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyHeaderName",
|
||||
headerName: "",
|
||||
headerValue: "value",
|
||||
expectValid: false,
|
||||
description: "Empty header name should fail validation",
|
||||
},
|
||||
{
|
||||
name: "HeaderWithNullBytes",
|
||||
headerName: "test",
|
||||
headerValue: "value\x00with_null",
|
||||
expectValid: false,
|
||||
description: "Header with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "HeaderValueTooLong",
|
||||
headerName: "test",
|
||||
headerValue: strings.Repeat("a", config.MaxHeaderLength+1),
|
||||
expectValid: false,
|
||||
description: "Header value exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "HeaderWithCRLF",
|
||||
headerName: "test",
|
||||
headerValue: "value\r\nMalicious: header",
|
||||
expectValid: false,
|
||||
description: "Header with CRLF should fail validation to prevent injection",
|
||||
},
|
||||
{
|
||||
name: "HeaderWithControlCharacters",
|
||||
headerName: "test",
|
||||
headerValue: "value\x01with_control",
|
||||
expectValid: false,
|
||||
description: "Header with control characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "MaliciousHeaderWithHTML",
|
||||
headerName: "test",
|
||||
headerValue: "<script>alert('xss')</script>",
|
||||
expectValid: false,
|
||||
description: "Header with HTML/script should fail validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateHeader(tt.headerName, tt.headerValue)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputValidatorValidateUsername tests username validation
|
||||
func TestInputValidatorValidateUsername(t *testing.T) {
|
||||
config := DefaultInputValidationConfig()
|
||||
validator, _ := NewInputValidator(config, newNoOpLogger())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
description string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidUsername",
|
||||
username: "john_doe",
|
||||
expectValid: true,
|
||||
description: "Valid username should pass validation",
|
||||
},
|
||||
{
|
||||
name: "ValidUsernameWithNumbers",
|
||||
username: "user123",
|
||||
expectValid: true,
|
||||
description: "Valid username with numbers should pass validation",
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
username: "",
|
||||
expectValid: false,
|
||||
description: "Empty username should fail validation",
|
||||
},
|
||||
{
|
||||
name: "UsernameWithNullBytes",
|
||||
username: "user\x00name",
|
||||
expectValid: false,
|
||||
description: "Username with null bytes should fail validation",
|
||||
},
|
||||
{
|
||||
name: "UsernameTooLong",
|
||||
username: strings.Repeat("a", config.MaxUsernameLength+1),
|
||||
expectValid: false,
|
||||
description: "Username exceeding max length should fail validation",
|
||||
},
|
||||
{
|
||||
name: "UsernameWithSpecialChars",
|
||||
username: "user@name",
|
||||
expectValid: false,
|
||||
description: "Username with special characters should fail validation",
|
||||
},
|
||||
{
|
||||
name: "UsernameWithSpaces",
|
||||
username: "user name",
|
||||
expectValid: false,
|
||||
description: "Username with spaces should fail validation",
|
||||
},
|
||||
{
|
||||
name: "UsernameWithControlCharacters",
|
||||
username: "user\x01name",
|
||||
expectValid: false,
|
||||
description: "Username with control characters should fail validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := validator.ValidateUsername(tt.username)
|
||||
|
||||
if result.IsValid != tt.expectValid {
|
||||
t.Errorf("Expected valid=%v, got %v. %s", tt.expectValid, result.IsValid, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+6
-34
@@ -6,14 +6,10 @@ import "time"
|
||||
type BackendType string
|
||||
|
||||
const (
|
||||
BackendTypeMemory BackendType = "memory"
|
||||
BackendTypeRedis BackendType = "redis"
|
||||
BackendTypeHybrid BackendType = "hybrid"
|
||||
BackendTypeRedis BackendType = "redis"
|
||||
|
||||
// Aliases for backward compatibility
|
||||
TypeMemory BackendType = "memory"
|
||||
TypeRedis BackendType = "redis"
|
||||
TypeHybrid BackendType = "hybrid"
|
||||
// TypeRedis is an alias for backward compatibility
|
||||
TypeRedis BackendType = "redis"
|
||||
)
|
||||
|
||||
// Config provides common configuration for cache backends
|
||||
@@ -24,6 +20,7 @@ type Config struct {
|
||||
Type BackendType
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
TLSServerName string
|
||||
PoolSize int
|
||||
RedisDB int
|
||||
CleanupInterval time.Duration
|
||||
@@ -34,17 +31,8 @@ type Config struct {
|
||||
EnableCircuitBreaker bool
|
||||
EnableHealthCheck bool
|
||||
EnableMetrics 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,
|
||||
}
|
||||
EnableTLS bool
|
||||
TLSSkipVerify bool
|
||||
}
|
||||
|
||||
// DefaultRedisConfig returns a default configuration for Redis caching
|
||||
@@ -61,19 +49,3 @@ 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
@@ -1,59 +0,0 @@
|
||||
//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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -35,4 +35,21 @@ 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")
|
||||
)
|
||||
|
||||
Vendored
-685
@@ -1,685 +0,0 @@
|
||||
// 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
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
l1Hits atomic.Int64
|
||||
errors atomic.Int64
|
||||
l2Writes atomic.Int64
|
||||
l1Writes atomic.Int64
|
||||
misses atomic.Int64
|
||||
l2Hits atomic.Int64
|
||||
fallbackMode atomic.Bool
|
||||
}
|
||||
|
||||
// asyncWriteItem represents an async write operation
|
||||
type asyncWriteItem struct {
|
||||
ctx context.Context
|
||||
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),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: config.Logger,
|
||||
}
|
||||
|
||||
// Start async write worker
|
||||
h.wg.Add(1)
|
||||
go h.asyncWriteWorker()
|
||||
|
||||
// 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", 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", 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", 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", key)
|
||||
default:
|
||||
// Buffer is full, log and continue
|
||||
h.logger.Warnf("Async write buffer full, dropping L2 write for key: %s", 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", 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", 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)
|
||||
// Use goroutine to avoid blocking the read path
|
||||
go func() {
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := h.primary.Set(writeCtx, key, value, ttl); err != nil {
|
||||
h.logger.Debugf("Failed to populate L1 cache from L2 for key %s: %v", key, err)
|
||||
} else {
|
||||
h.logger.Debugf("Populated L1 cache from L2 for key: %s", key)
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
// Asynchronously populate L1
|
||||
go func(k string, v []byte) {
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = h.primary.Set(writeCtx, k, v, 0) // Use default TTL
|
||||
}(key, value)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
|
||||
// Asynchronously populate L1
|
||||
go func(k string, v []byte, t time.Duration) {
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = h.primary.Set(writeCtx, k, v, t)
|
||||
}(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
|
||||
}
|
||||
|
||||
// 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", item.key, err)
|
||||
h.recordL2Error()
|
||||
} else {
|
||||
h.l2Writes.Add(1)
|
||||
h.logger.Debugf("Async write to L2 completed for key: %s", 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
|
||||
}
|
||||
-1490
File diff suppressed because it is too large
Load Diff
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
|
||||
// Package backends 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: MemoryBackend, RedisBackend, and HybridBackend
|
||||
// Implementations include: RedisBackend
|
||||
type CacheBackend interface {
|
||||
// Set stores a value in the cache with the specified TTL
|
||||
// Returns an error if the operation fails
|
||||
|
||||
+29
-60
@@ -11,28 +11,24 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCacheBackendContract defines a set of tests that all CacheBackend implementations must pass
|
||||
// This ensures that Memory, Redis, and Hybrid backends all behave consistently
|
||||
// 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).
|
||||
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 := setupRedisBackend(t)
|
||||
runContractTests(t, backend)
|
||||
})
|
||||
|
||||
t.Run("HybridBackend", func(t *testing.T) {
|
||||
backend := setupHybridBackend(t)
|
||||
runContractTests(t, backend)
|
||||
backend, advanceClock := setupRedisBackend(t)
|
||||
runContractTests(t, backend, advanceClock)
|
||||
})
|
||||
}
|
||||
|
||||
// runContractTests executes all contract tests against a backend
|
||||
func runContractTests(t *testing.T, backend CacheBackend) {
|
||||
// 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)) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -62,7 +58,7 @@ func runContractTests(t *testing.T, backend CacheBackend) {
|
||||
})
|
||||
|
||||
t.Run("TTLExpiration", func(t *testing.T) {
|
||||
testTTLExpiration(t, ctx, backend)
|
||||
testTTLExpiration(t, ctx, backend, advanceClock)
|
||||
})
|
||||
|
||||
t.Run("Clear", func(t *testing.T) {
|
||||
@@ -213,7 +209,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) {
|
||||
func testTTLExpiration(t *testing.T, ctx context.Context, backend CacheBackend, advanceClock func(d time.Duration)) {
|
||||
t.Helper()
|
||||
|
||||
key := "ttl-key"
|
||||
@@ -229,8 +225,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")
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// Advance past expiration.
|
||||
advanceClock(200 * time.Millisecond)
|
||||
|
||||
// Verify expired
|
||||
exists, err = backend.Exists(ctx, key)
|
||||
@@ -378,44 +374,17 @@ func testSpecialCharactersInKeys(t *testing.T, ctx context.Context, backend Cach
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to setup different backend types
|
||||
// These will be implemented in respective test files
|
||||
|
||||
func setupMemoryBackend(t *testing.T) CacheBackend {
|
||||
// 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)) {
|
||||
t.Helper()
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
Vendored
-535
@@ -1,535 +0,0 @@
|
||||
// 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
@@ -1,182 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user