mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573c6b7496 | ||
|
|
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 | ||
|
|
1362cc0dac | ||
|
|
249dcad1b3 | ||
|
|
de4b4d7258 | ||
|
|
9d52f1b018 | ||
|
|
57724918fe | ||
|
|
775de2ada1 | ||
|
|
7816e05c98 | ||
|
|
8bf7998150 | ||
|
|
22c4323fcb | ||
|
|
06b219d1f8 | ||
|
|
413e4a1b7d | ||
|
|
69e0d98c67 | ||
|
|
6d893df12b |
@@ -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
|
||||
|
||||
@@ -11,11 +11,13 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
with:
|
||||
go-version: "1.24.11"
|
||||
go-version: "1.26.x"
|
||||
secrets: inherit
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
docker/
|
||||
.claude/*.out
|
||||
.claude/
|
||||
*.test
|
||||
.leann/
|
||||
|
||||
+49
-32
@@ -14,21 +14,22 @@ linters:
|
||||
- gosec
|
||||
- misspell
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
disable:
|
||||
- exhaustive
|
||||
- funlen
|
||||
- gocognit
|
||||
- gocyclo # Disabled: OAuth/OIDC flows are inherently complex
|
||||
- goprintffuncname # Disabled: naming convention is project-specific
|
||||
- lll
|
||||
- mnd
|
||||
- testpackage
|
||||
- whitespace # Disabled: style preference about newlines
|
||||
- wsl
|
||||
settings:
|
||||
dupl:
|
||||
@@ -47,29 +48,13 @@ linters:
|
||||
- fmt.Fprintln
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 10 # Increased to reduce noise for standard OAuth2/OIDC strings
|
||||
min-occurrences: 15 # Increased to reduce noise for standard OAuth2/OIDC strings and common patterns like "true"
|
||||
ignore-tests: true
|
||||
gocritic:
|
||||
# Using default enabled checks in v2
|
||||
enabled-checks:
|
||||
- appendCombine
|
||||
- boolExprSimplify
|
||||
- builtinShadow
|
||||
- commentedOutCode
|
||||
- emptyFallthrough
|
||||
- equalFold
|
||||
- hexLiteral
|
||||
- indexAlloc
|
||||
- initClause
|
||||
- methodExprCall
|
||||
- nestingReduce
|
||||
- rangeExprCopy
|
||||
- rangeValCopy
|
||||
- stringXbytes
|
||||
- typeAssertChain
|
||||
- typeUnparen
|
||||
- unlabelStmt
|
||||
- yodaStyleExpr
|
||||
# Disable style-only checks that add noise
|
||||
disabled-checks:
|
||||
- ifElseChain # Style preference, switch not always clearer
|
||||
- elseif # Style preference
|
||||
gocyclo:
|
||||
min-complexity: 30 # OAuth/OIDC flows are inherently complex; set higher for Yaegi compatibility
|
||||
gosec:
|
||||
@@ -106,23 +91,23 @@ linters:
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
- name: if-return
|
||||
# - name: exported # Disabled: too noisy, not all exported functions need comments
|
||||
# - name: if-return # Disabled: style preference
|
||||
- name: increment-decrement
|
||||
- name: var-naming
|
||||
- name: var-declaration
|
||||
- name: package-comments
|
||||
# - name: var-naming # Disabled: too strict for legacy code (IP vs Ip)
|
||||
# - name: var-declaration # Disabled: explicit zero values can be clearer
|
||||
# - name: package-comments # Disabled: handled by other tools
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
# - name: indent-error-flow # Disabled: style preference
|
||||
- name: errorf
|
||||
- name: empty-block
|
||||
# - name: empty-block # Disabled: sometimes empty blocks are intentional
|
||||
- name: superfluous-else
|
||||
- name: unused-parameter
|
||||
# - name: unused-parameter # Disabled: test callbacks and interface implementations often have required unused params
|
||||
- name: unreachable-code
|
||||
- name: redefines-builtin-id
|
||||
# - name: redefines-builtin-id # Disabled: min/max helpers are common before Go 1.21
|
||||
unparam:
|
||||
check-exported: false
|
||||
staticcheck:
|
||||
@@ -132,8 +117,15 @@ linters:
|
||||
- -QF1003 # Tagged switch - style preference, may affect Yaegi
|
||||
- -QF1007 # Merge conditional assignment - style preference
|
||||
- -QF1008 # Remove embedded field - may break Yaegi compatibility
|
||||
- -QF1011 # Omit type from declaration - style preference
|
||||
- -QF1012 # Use fmt.Fprintf - style preference
|
||||
- -SA9003 # Empty branch - sometimes intentional for future work
|
||||
- -ST1000 # Package comment format - not required for all packages
|
||||
- -ST1003 # Package name format - allowed for test packages
|
||||
- -ST1016 # Receiver name consistency - legacy code
|
||||
- -ST1020 # Comment format for methods - style preference
|
||||
- -ST1021 # Comment format for types - style preference
|
||||
- -ST1023 # Omit type from declaration - style preference
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
@@ -144,18 +136,43 @@ linters:
|
||||
- goconst
|
||||
- gocyclo
|
||||
- gosec
|
||||
- govet
|
||||
- ineffassign
|
||||
- noctx
|
||||
- prealloc
|
||||
- unparam
|
||||
- revive
|
||||
- gocritic
|
||||
path: _test\.go
|
||||
- linters:
|
||||
- dupl
|
||||
- gocyclo
|
||||
- govet
|
||||
- noctx
|
||||
- prealloc
|
||||
- unparam
|
||||
- revive
|
||||
- gocritic
|
||||
path: test.*\.go
|
||||
- linters:
|
||||
- gocritic
|
||||
- unused
|
||||
- errcheck
|
||||
- revive
|
||||
path: mocks.*\.go
|
||||
- linters:
|
||||
- errcheck
|
||||
- revive
|
||||
- gocritic
|
||||
- govet
|
||||
- unparam
|
||||
path: internal/testutil/
|
||||
- linters:
|
||||
- govet
|
||||
- unparam
|
||||
- noctx
|
||||
- prealloc
|
||||
path: integration/
|
||||
- linters:
|
||||
- gosec
|
||||
text: 'G404:'
|
||||
|
||||
@@ -47,3 +47,14 @@ release:
|
||||
name_template: "v{{ .Version }}"
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
args:
|
||||
- sign-blob
|
||||
- "--bundle=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes"
|
||||
artifacts: checksum
|
||||
output: true
|
||||
|
||||
+98
-1609
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 @@
|
||||
# Security Fix: Integer Overflow Protection in Cache Serialization
|
||||
|
||||
## Summary
|
||||
|
||||
Fixed **High severity** integer overflow vulnerability identified by GitHub Advanced Security in PR #117.
|
||||
|
||||
## Vulnerability
|
||||
|
||||
**Locations**: `universal_cache.go` lines 789 and 811
|
||||
- `result := make([]byte, len(bytes)+1)` - Raw bytes path
|
||||
- `result := make([]byte, len(jsonData)+1)` - JSON encoding path
|
||||
|
||||
**Risk**: Potential integer overflow when allocating memory for very large cache entries.
|
||||
|
||||
## Fix Applied
|
||||
|
||||
1. **Added size limit constant**:
|
||||
```go
|
||||
maxCacheEntrySize = 64 * 1024 * 1024 // 64 MiB
|
||||
```
|
||||
|
||||
2. **Size validation before allocation**:
|
||||
- Validates entry size doesn't exceed limit
|
||||
- Validates adding marker byte won't overflow
|
||||
- Returns descriptive error messages
|
||||
|
||||
3. **Comprehensive test coverage**:
|
||||
- Oversized byte slices (>64 MiB)
|
||||
- Exact max size edge case
|
||||
- Safe sizes (normal operation)
|
||||
- Large JSON data structures
|
||||
|
||||
## Verification
|
||||
|
||||
✅ All tests pass with race detection
|
||||
✅ No security issues (golangci-lint, gosec)
|
||||
✅ 76.3% test coverage maintained
|
||||
|
||||
## Impact
|
||||
|
||||
- No breaking changes
|
||||
- Negligible performance overhead
|
||||
- Prevents potential buffer overflows
|
||||
- Predictable memory usage
|
||||
|
||||
---
|
||||
|
||||
**Date**: January 8, 2026
|
||||
**Severity**: High → Resolved
|
||||
@@ -0,0 +1,49 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIX-27 regression test.
|
||||
//
|
||||
// accessTokenUnexpired (the lenient-audience-path time-claims re-check)
|
||||
// returned false whenever iat was absent, even though 8640451 made iat
|
||||
// OPTIONAL in jwt.Verify itself (R126: "Hard-requiring it here also
|
||||
// rejected otherwise-valid access ... tokens whose provider omits iat").
|
||||
// The two paths had drifted onto different contracts for the same claim.
|
||||
// A lenient (wrong-audience) access token without iat, in a session with
|
||||
// no ID token to corroborate it, was always forced to refresh or
|
||||
// re-authenticate, while the identical token with a matching audience
|
||||
// authenticated fine via the main jwt.Verify path.
|
||||
//
|
||||
// Fail-on-old: a syntactically valid, unexpired token with no iat is
|
||||
// rejected by accessTokenUnexpired (returns false).
|
||||
func TestAccessTokenUnexpired_IatOptional(t *testing.T) {
|
||||
tm := &TraefikOidc{}
|
||||
now := time.Now()
|
||||
|
||||
noIat := makeTestJWT(t, map[string]interface{}{
|
||||
"exp": float64(now.Add(time.Hour).Unix()),
|
||||
// intentionally no "iat", matching jwt.Verify's R126 contract
|
||||
})
|
||||
if !tm.accessTokenUnexpired(noIat) {
|
||||
t.Error("a token with a valid exp and no iat must be accepted, matching jwt.Verify's iat-optional contract (R126/FIX-27)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessTokenUnexpired_IatStillBoundedWhenPresent is the positive
|
||||
// control: an iat far in the future (used-before-issued) must still be
|
||||
// rejected when iat IS present — the fix makes iat optional, not ignored.
|
||||
func TestAccessTokenUnexpired_IatStillBoundedWhenPresent(t *testing.T) {
|
||||
tm := &TraefikOidc{}
|
||||
now := time.Now()
|
||||
|
||||
futureIat := makeTestJWT(t, map[string]interface{}{
|
||||
"exp": float64(now.Add(time.Hour).Unix()),
|
||||
"iat": float64(now.Add(time.Hour).Unix()), // far in the future
|
||||
})
|
||||
if tm.accessTokenUnexpired(futureIat) {
|
||||
t.Error("a present iat far in the future (used-before-issued) must still be rejected")
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+122
-27
@@ -28,6 +28,12 @@ type BackgroundTask struct {
|
||||
stopped int32 // 1 = stopped, 0 = not stopped
|
||||
started int32 // 1 = started, 0 = not started
|
||||
doneClosed int32 // 1 = doneChan closed, 0 = not closed
|
||||
// startRefused is set when Start's circuit-breaker or concurrency-limit
|
||||
// early-return fires (FIX-19). started stays 0 and stopped stays 0 in
|
||||
// that case, which is indistinguishable from "registered but Start has
|
||||
// not been called yet" — RegisterBackgroundTask needs the distinction to
|
||||
// know this task's slot is actually free to reuse.
|
||||
startRefused int32 // 1 = a Start call was refused, 0 = never refused
|
||||
}
|
||||
|
||||
// NewBackgroundTask creates a new background task with the specified configuration.
|
||||
@@ -46,6 +52,12 @@ func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), log
|
||||
if len(wg) > 0 {
|
||||
externalWG = wg[0]
|
||||
}
|
||||
// run() uses time.NewTicker(interval), which panics for d <= 0 inside
|
||||
// the started goroutine (autocleanup.go:186), crashing the whole
|
||||
// process on a misconfigured interval. Normalize here (R149).
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
return &BackgroundTask{
|
||||
name: name,
|
||||
interval: interval,
|
||||
@@ -81,6 +93,7 @@ func (bt *BackgroundTask) Start() {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Debugf("Cannot start task %s: %v (circuit breaker protection working as expected)", bt.name, err)
|
||||
}
|
||||
atomic.StoreInt32(&bt.startRefused, 1)
|
||||
// Close doneChan since the task won't run
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
@@ -88,8 +101,21 @@ func (bt *BackgroundTask) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
// Reserve the task slot immediately when starting
|
||||
registry.cb.OnTaskStart(bt.name)
|
||||
// Reserve the task slot immediately when starting. OnTaskStart
|
||||
// is the authoritative concurrency gate (R165): it returns false
|
||||
// if maxConcurrent is already reached, in which case this task
|
||||
// must not launch run() (its slot was not taken).
|
||||
if !registry.cb.OnTaskStart(bt.name) {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Debugf("Cannot start task %s: concurrency limit reached, slot not acquired", bt.name)
|
||||
}
|
||||
atomic.StoreInt32(&bt.startRefused, 1)
|
||||
// Close doneChan since the task won't run
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
atomic.StoreInt32(&bt.started, 1)
|
||||
bt.internalWG.Add(1)
|
||||
@@ -114,6 +140,14 @@ func (bt *BackgroundTask) Stop() {
|
||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||
close(bt.doneChan)
|
||||
}
|
||||
// Also close stopChan (R117): a concurrent Start may be in-flight
|
||||
// and about to launch run(). Signaling stopChan lets it exit at
|
||||
// the next stop check instead of leaking the goroutine and its
|
||||
// circuit-breaker slot (taken by OnTaskStart but never released).
|
||||
func() {
|
||||
defer func() { _ = recover() }() // safe if already closed
|
||||
close(bt.stopChan)
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,18 +168,37 @@ func (bt *BackgroundTask) Stop() {
|
||||
// This avoids the race condition with WaitGroup
|
||||
select {
|
||||
case <-bt.doneChan:
|
||||
// Normal completion
|
||||
// Normal completion: the goroutine has returned, so it is safe
|
||||
// to wait on the WaitGroup.
|
||||
bt.internalWG.Wait()
|
||||
case <-time.After(5 * time.Second):
|
||||
// The task goroutine is still running (its taskFunc is blocked or
|
||||
// long-lived). Do NOT wait on the WaitGroup here — that would
|
||||
// block Stop() (and shutdown) for as long as the task runs.
|
||||
// Return now; the goroutine is reaped when it completes.
|
||||
if bt.logger != nil {
|
||||
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the internal WaitGroup synchronously after doneChan signals
|
||||
bt.internalWG.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// safeRunTask executes the task function, recovering from any panic so that a
|
||||
// single panicking run does not permanently kill the background goroutine and its
|
||||
// periodic loop. Under yaegi (where this plugin runs) unexpected panics are more
|
||||
// likely, and a silently-dead background task (metadata refresh, autocleanup,
|
||||
// memory monitor) would otherwise stop forever with no log or restart.
|
||||
func (bt *BackgroundTask) safeRunTask() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if bt.logger != nil {
|
||||
bt.logger.Errorf("Background task %s panicked (recovered): %v", bt.name, r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
bt.taskFunc()
|
||||
}
|
||||
|
||||
// run is the main loop for the background task.
|
||||
// It executes the task function immediately, then periodically
|
||||
// until the stop signal is received.
|
||||
@@ -187,7 +240,7 @@ func (bt *BackgroundTask) run() {
|
||||
}
|
||||
return
|
||||
default:
|
||||
bt.taskFunc()
|
||||
bt.safeRunTask()
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -206,7 +259,7 @@ func (bt *BackgroundTask) run() {
|
||||
}
|
||||
return
|
||||
default:
|
||||
bt.taskFunc()
|
||||
bt.safeRunTask()
|
||||
}
|
||||
case <-bt.stopChan:
|
||||
if bt.logger != nil {
|
||||
@@ -329,9 +382,30 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// OnTaskStart records a task starting execution
|
||||
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
|
||||
atomic.AddInt32(&cb.concurrentTasks, 1)
|
||||
// OnTaskStart records a task starting execution. It atomically reserves a
|
||||
// concurrency slot and returns whether one was available. The reservation
|
||||
// is fundamental (R165): CanCreateTask's limit check is only advisory —
|
||||
// it is also called by RegisterTask without starting the task — so a
|
||||
// check-then-act TOCTOU meant two concurrently starting tasks could both
|
||||
// read current < limit and then both start, letting live tasks exceed
|
||||
// maxConcurrent and defeating the DoS backstop. OnTaskStart is the
|
||||
// single commit point, so it enforces maxConcurrent here via a CAS loop:
|
||||
// only maxConcurrent tasks can hold slots at once. BackgroundTask.Start
|
||||
// aborts (without launching run) when this returns false.
|
||||
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
|
||||
max := atomic.LoadInt32(&cb.maxConcurrent)
|
||||
for {
|
||||
current := atomic.LoadInt32(&cb.concurrentTasks)
|
||||
if current >= max {
|
||||
if cb.logger != nil {
|
||||
cb.logger.Debug("Concurrent task limit reached, refusing slot for: %s", taskName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if atomic.CompareAndSwapInt32(&cb.concurrentTasks, current, current+1) {
|
||||
break
|
||||
}
|
||||
}
|
||||
cb.tasksMu.Lock()
|
||||
cb.activeTasks[taskName] = struct{}{}
|
||||
cb.tasksMu.Unlock()
|
||||
@@ -342,6 +416,7 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
|
||||
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
|
||||
atomic.LoadInt32(&cb.concurrentTasks), taskName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OnTaskComplete records a task completing execution
|
||||
@@ -463,10 +538,13 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
|
||||
// Task execution tracking is now handled in the run() method
|
||||
|
||||
tr.tasks[name] = task
|
||||
tr.cb.OnTaskSuccess(name)
|
||||
// Execution tracking (the circuit-breaker concurrent count) is handled by
|
||||
// Start()/run(): Start() reserves a slot via OnTaskStart and run() releases
|
||||
// it via OnTaskComplete. Do NOT also OnTaskSuccess here — that would
|
||||
// double-count every registered task (+1 on register, +1 on Start, -1 on
|
||||
// stop), leaking one slot per task and eventually blocking all background
|
||||
// task creation at the concurrency limit.
|
||||
|
||||
if tr.logger != nil {
|
||||
tr.logger.Debug("Registered background task: %s", name)
|
||||
@@ -499,9 +577,18 @@ func (tr *TaskRegistry) GetTask(name string) (*BackgroundTask, bool) {
|
||||
return task, exists
|
||||
}
|
||||
|
||||
// StopAllTasks stops all registered background tasks
|
||||
// StopAllTasks stops all registered background tasks.
|
||||
//
|
||||
// Tasks can live in two places: those registered directly via
|
||||
// TaskRegistry.RegisterTask (stored in tr.tasks) and process-global
|
||||
// singletons created via TaskRegistry.CreateSingletonTask, which delegate to
|
||||
// GetResourceManager().RegisterBackgroundTask and store the task in the
|
||||
// ResourceManager's registry (see CreateSingletonTask below) — NOT in
|
||||
// tr.tasks. Stop both so neither a direct task nor a singleton task leaks its
|
||||
// ticker goroutine past the last Close.
|
||||
func (tr *TaskRegistry) StopAllTasks() {
|
||||
// First, copy the tasks map to avoid deadlock with GetTaskCount()
|
||||
// First, stop tasks registered directly in this registry.
|
||||
// Copy the tasks map to avoid deadlock with GetTaskCount().
|
||||
tr.mu.Lock()
|
||||
tasksCopy := make(map[string]*BackgroundTask, len(tr.tasks))
|
||||
for name, task := range tr.tasks {
|
||||
@@ -511,13 +598,15 @@ func (tr *TaskRegistry) StopAllTasks() {
|
||||
tr.tasks = make(map[string]*BackgroundTask)
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Now stop all tasks without holding the lock
|
||||
for name, task := range tasksCopy {
|
||||
for _, task := range tasksCopy {
|
||||
task.Stop()
|
||||
if tr.logger != nil {
|
||||
tr.logger.Debug("Stopped background task during shutdown: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Then stop process-global singleton tasks, which live in the
|
||||
// ResourceManager's registry, not in tr.tasks (otherwise the singleton
|
||||
// task teardown would be a silent no-op and leak every singleton task's
|
||||
// ticker goroutine past the last Close).
|
||||
GetResourceManager().StopAllTasks()
|
||||
}
|
||||
|
||||
// GetTaskCount returns the number of active tasks
|
||||
@@ -599,8 +688,9 @@ func GetGlobalTaskMemoryMonitor(logger *Logger) *TaskMemoryMonitor {
|
||||
return globalTaskMemoryMonitor
|
||||
}
|
||||
|
||||
// NewTaskMemoryMonitor creates a new memory monitor for task registry
|
||||
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior
|
||||
// NewTaskMemoryMonitor creates a new memory monitor for task registry.
|
||||
//
|
||||
// Deprecated: Use GetGlobalTaskMemoryMonitor instead for singleton behavior.
|
||||
func NewTaskMemoryMonitor(logger *Logger, registry *TaskRegistry) *TaskMemoryMonitor {
|
||||
return GetGlobalTaskMemoryMonitor(logger)
|
||||
}
|
||||
@@ -712,7 +802,7 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
|
||||
|
||||
// Check for goroutine leaks (arbitrary threshold)
|
||||
if stats.Goroutines > 100 {
|
||||
mm.logger.Infof("High goroutine count detected: %d", stats.Goroutines)
|
||||
mm.logger.Debugf("High goroutine count detected: %d", stats.Goroutines)
|
||||
}
|
||||
|
||||
// Check for heap growth without corresponding GC activity
|
||||
@@ -720,9 +810,14 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
|
||||
historyLen := len(mm.statsHistory)
|
||||
if historyLen >= 2 {
|
||||
prev := mm.statsHistory[historyLen-2]
|
||||
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
|
||||
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
|
||||
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
|
||||
// Guard against div-by-zero (prev.HeapAlloc == 0) which would make
|
||||
// heapGrowth == +Inf and fire a spurious "memory leak" alert for a
|
||||
// first-ever measurement (R103).
|
||||
if prev.HeapAlloc > 0 {
|
||||
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
|
||||
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
|
||||
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
|
||||
}
|
||||
}
|
||||
}
|
||||
mm.mu.RUnlock()
|
||||
|
||||
+17
-13
@@ -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
|
||||
|
||||
+1094
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++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+63
-15
@@ -5,10 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBlacklistDuration = 24 * time.Hour
|
||||
)
|
||||
|
||||
// CacheManager manages all caching components using the universal cache
|
||||
type CacheManager struct {
|
||||
manager *UniversalCacheManager
|
||||
@@ -16,19 +12,23 @@ type CacheManager struct {
|
||||
}
|
||||
|
||||
var (
|
||||
globalCacheManagerInstance *CacheManager
|
||||
cacheManagerInitOnce sync.Once
|
||||
globalCacheManagerInstance *CacheManager
|
||||
cacheManagerInitOnce sync.Once
|
||||
cacheManagerActiveFingerprint string
|
||||
)
|
||||
|
||||
// GetGlobalCacheManager returns a singleton CacheManager instance
|
||||
// Deprecated: Use GetGlobalCacheManagerWithConfig instead
|
||||
// GetGlobalCacheManager returns a singleton CacheManager instance.
|
||||
//
|
||||
// Deprecated: Use GetGlobalCacheManagerWithConfig instead.
|
||||
func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
|
||||
return GetGlobalCacheManagerWithConfig(wg, nil)
|
||||
}
|
||||
|
||||
// GetGlobalCacheManagerWithConfig returns a singleton CacheManager instance with optional Redis configuration
|
||||
func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheManager {
|
||||
fp := redisFingerprint(config)
|
||||
cacheManagerInitOnce.Do(func() {
|
||||
cacheManagerActiveFingerprint = fp
|
||||
var redisConfig *RedisConfig
|
||||
var logger *Logger
|
||||
|
||||
@@ -54,14 +54,32 @@ func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheM
|
||||
manager: GetUniversalCacheManagerWithConfig(logger, redisConfig),
|
||||
}
|
||||
})
|
||||
// Warn loudly if a later instance asks for a DIFFERENT explicit Redis
|
||||
// backend than the one that won initialization: the cache manager is a
|
||||
// process-global singleton shared across plugin instances (yaegi), so this
|
||||
// instance's divergent configuration is silently ignored, which would
|
||||
// otherwise collapse cache/state isolation between routes (rank 9).
|
||||
if fp != "" && cacheManagerActiveFingerprint != "" && fp != cacheManagerActiveFingerprint {
|
||||
NewLogger(config.LogLevel).Errorf("cache manager already initialized with Redis backend %q; this instance's Redis backend %q is IGNORED (process-global singleton). Use a single consistent cache configuration across all routes.", cacheManagerActiveFingerprint, fp)
|
||||
}
|
||||
return globalCacheManagerInstance
|
||||
}
|
||||
|
||||
// redisFingerprint returns a stable identifier for an explicitly-enabled Redis
|
||||
// backend (address + key prefix), or "" when Redis is not explicitly enabled.
|
||||
// Used to detect divergent cache configurations across plugin instances.
|
||||
func redisFingerprint(config *Config) string {
|
||||
if config == nil || config.Redis == nil || !config.Redis.Enabled {
|
||||
return ""
|
||||
}
|
||||
return config.Redis.Address + "|" + config.Redis.KeyPrefix
|
||||
}
|
||||
|
||||
// GetSharedTokenBlacklist returns the shared token blacklist cache
|
||||
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache()}
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetBlacklistCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedTokenCache returns the shared token cache
|
||||
@@ -85,7 +103,7 @@ func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
|
||||
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &JWKCache{cache: cm.manager.GetJWKCache()}
|
||||
return &JWKCache{cache: cm.manager.GetJWKCache(), lastForceRefresh: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
// GetSharedIntrospectionCache returns the shared token introspection cache
|
||||
@@ -93,7 +111,7 @@ func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||
func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache()}
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetIntrospectionCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedTokenTypeCache returns the shared token type cache
|
||||
@@ -101,7 +119,23 @@ func (cm *CacheManager) GetSharedIntrospectionCache() CacheInterface {
|
||||
func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache()}
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedSessionInvalidationCache returns the shared session invalidation cache
|
||||
// for backchannel and front-channel logout (IdP-initiated logout)
|
||||
func (cm *CacheManager) GetSharedSessionInvalidationCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetSessionInvalidationCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedRefreshResultCache returns the short-lived refresh-result cache used
|
||||
// by the refresh path to coalesce grants across Traefik replicas via Redis.
|
||||
func (cm *CacheManager) GetSharedRefreshResultCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetRefreshResultCache(), managed: true}
|
||||
}
|
||||
|
||||
// Close gracefully shuts down all cache components
|
||||
@@ -121,7 +155,8 @@ func CleanupGlobalCacheManager() error {
|
||||
|
||||
// CacheInterfaceWrapper wraps UniversalCache to implement CacheInterface
|
||||
type CacheInterfaceWrapper struct {
|
||||
cache *UniversalCache
|
||||
cache *UniversalCache
|
||||
managed bool // If true, cache is managed globally and Close() is a no-op
|
||||
}
|
||||
|
||||
// Set stores a value
|
||||
@@ -134,6 +169,13 @@ func (c *CacheInterfaceWrapper) Get(key string) (interface{}, bool) {
|
||||
return c.cache.Get(key)
|
||||
}
|
||||
|
||||
// SetIfAbsent atomically stores a value only if the key is not already
|
||||
// present. See UniversalCache.SetIfAbsent and AtomicSetIfAbsentCache
|
||||
// (FIX-17).
|
||||
func (c *CacheInterfaceWrapper) SetIfAbsent(key string, value interface{}, ttl time.Duration) (bool, error) {
|
||||
return c.cache.SetIfAbsent(key, value, ttl)
|
||||
}
|
||||
|
||||
// Delete removes a key
|
||||
func (c *CacheInterfaceWrapper) Delete(key string) {
|
||||
c.cache.Delete(key)
|
||||
@@ -149,9 +191,15 @@ func (c *CacheInterfaceWrapper) Cleanup() {
|
||||
c.cache.Cleanup()
|
||||
}
|
||||
|
||||
// Close shuts down the cache
|
||||
// Close shuts down the cache if it's not managed globally.
|
||||
// For managed caches (from UniversalCacheManager), this is a no-op to prevent log flooding
|
||||
// when multiple plugin instances are closed during Traefik configuration reloads.
|
||||
func (c *CacheInterfaceWrapper) Close() {
|
||||
// Close the underlying cache to stop goroutines
|
||||
if c.managed {
|
||||
// Cache is managed globally by UniversalCacheManager, so we don't close it here.
|
||||
return
|
||||
}
|
||||
// Standalone cache - close it properly to stop cleanup goroutines
|
||||
if c.cache != nil {
|
||||
_ = c.cache.Close() // Safe to ignore: closing cache is best-effort during shutdown
|
||||
}
|
||||
|
||||
+158
-308
@@ -178,6 +178,11 @@ func TestCacheInterfaceWrapper_Clear(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
cache := cm.GetSharedTokenBlacklist()
|
||||
|
||||
// The shared token blacklist is process-global across tests; under shuffled
|
||||
// execution a prior test may have left items behind. Clear first so the
|
||||
// size assertions are stable regardless of test order.
|
||||
cache.Clear()
|
||||
|
||||
cache.Set("key1", "value1", time.Hour)
|
||||
cache.Set("key2", "value2", time.Hour)
|
||||
|
||||
@@ -219,6 +224,159 @@ func TestCacheInterfaceWrapper_Close(t *testing.T) {
|
||||
nilWrapper.Close()
|
||||
}
|
||||
|
||||
// TestCacheInterfaceWrapper_ManagedClose_Regression tests that managed cache wrappers
|
||||
// don't close the underlying cache when Close() is called. This is a regression test
|
||||
// for issue #105 where multiple plugin instances closing shared caches caused log flooding.
|
||||
func TestCacheInterfaceWrapper_ManagedClose_Regression(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
|
||||
// Get a managed cache wrapper
|
||||
cache := cm.GetSharedTokenBlacklist()
|
||||
wrapper, ok := cache.(*CacheInterfaceWrapper)
|
||||
if !ok {
|
||||
t.Fatal("Expected CacheInterfaceWrapper")
|
||||
}
|
||||
|
||||
// Verify it's marked as managed
|
||||
if !wrapper.managed {
|
||||
t.Error("Expected shared cache wrapper to be marked as managed")
|
||||
}
|
||||
|
||||
// Set some data before Close
|
||||
cache.Set("test-key", "test-value", time.Hour)
|
||||
|
||||
// Close the wrapper (should be a no-op for managed caches)
|
||||
wrapper.Close()
|
||||
|
||||
// Verify the cache is still operational after Close
|
||||
value, found := cache.Get("test-key")
|
||||
if !found {
|
||||
t.Error("Expected cache to still work after Close() on managed wrapper")
|
||||
}
|
||||
if value != "test-value" {
|
||||
t.Errorf("Expected 'test-value', got %v", value)
|
||||
}
|
||||
|
||||
// Can still set new values
|
||||
cache.Set("new-key", "new-value", time.Hour)
|
||||
newValue, found := cache.Get("new-key")
|
||||
if !found || newValue != "new-value" {
|
||||
t.Error("Expected to be able to set new values after Close() on managed wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheInterfaceWrapper_StandaloneClose tests that standalone cache wrappers
|
||||
// properly close the underlying cache when Close() is called.
|
||||
func TestCacheInterfaceWrapper_StandaloneClose(t *testing.T) {
|
||||
// Create a standalone cache (not from the global cache manager)
|
||||
standaloneCache := NewCache()
|
||||
|
||||
wrapper, ok := standaloneCache.(*CacheInterfaceWrapper)
|
||||
if !ok {
|
||||
t.Fatal("Expected CacheInterfaceWrapper")
|
||||
}
|
||||
|
||||
// Verify it's NOT marked as managed
|
||||
if wrapper.managed {
|
||||
t.Error("Expected standalone cache wrapper to NOT be marked as managed")
|
||||
}
|
||||
|
||||
// Set some data
|
||||
standaloneCache.Set("test-key", "test-value", time.Hour)
|
||||
|
||||
// Get baseline goroutine count
|
||||
baselineGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Close the wrapper (should actually close the underlying cache)
|
||||
wrapper.Close()
|
||||
|
||||
// Give cleanup goroutine time to stop
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Goroutine count should decrease (cleanup routine stopped)
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
if finalGoroutines > baselineGoroutines {
|
||||
// This is acceptable - other tests might have started goroutines
|
||||
t.Logf("Goroutine count: baseline=%d, final=%d", baselineGoroutines, finalGoroutines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheInterfaceWrapper_MultipleInstancesClose_Regression tests that multiple
|
||||
// plugin instances can close their cache wrappers without affecting shared caches.
|
||||
// This is a regression test for issue #105.
|
||||
func TestCacheInterfaceWrapper_MultipleInstancesClose_Regression(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
|
||||
// Simulate multiple plugin instances getting cache references
|
||||
instances := make([]*CacheInterfaceWrapper, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
cache := cm.GetSharedTokenBlacklist()
|
||||
wrapper, ok := cache.(*CacheInterfaceWrapper)
|
||||
if !ok {
|
||||
t.Fatal("Expected CacheInterfaceWrapper")
|
||||
}
|
||||
instances[i] = wrapper
|
||||
|
||||
// Each instance might set some data
|
||||
cache.Set(fmt.Sprintf("instance-%d-key", i), fmt.Sprintf("value-%d", i), time.Hour)
|
||||
}
|
||||
|
||||
// Close all instances (simulating plugin shutdown/reload)
|
||||
for _, wrapper := range instances {
|
||||
wrapper.Close()
|
||||
}
|
||||
|
||||
// The shared cache should still work after all instances closed their wrappers
|
||||
newCache := cm.GetSharedTokenBlacklist()
|
||||
|
||||
// Data set by earlier instances should still be accessible
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("instance-%d-key", i)
|
||||
value, found := newCache.Get(key)
|
||||
if !found {
|
||||
t.Errorf("Expected data from instance %d to still be accessible", i)
|
||||
}
|
||||
expectedValue := fmt.Sprintf("value-%d", i)
|
||||
if value != expectedValue {
|
||||
t.Errorf("Expected '%s', got '%v'", expectedValue, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Should be able to add new data
|
||||
newCache.Set("after-close-key", "after-close-value", time.Hour)
|
||||
value, found := newCache.Get("after-close-key")
|
||||
if !found || value != "after-close-value" {
|
||||
t.Error("Expected to be able to use cache after all wrapper Close() calls")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllSharedCachesMarkedAsManaged verifies all shared cache getters
|
||||
// return managed wrappers to prevent the log flooding issue.
|
||||
func TestAllSharedCachesMarkedAsManaged(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cache CacheInterface
|
||||
}{
|
||||
{"TokenBlacklist", cm.GetSharedTokenBlacklist()},
|
||||
{"IntrospectionCache", cm.GetSharedIntrospectionCache()},
|
||||
{"TokenTypeCache", cm.GetSharedTokenTypeCache()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wrapper, ok := tt.cache.(*CacheInterfaceWrapper)
|
||||
if !ok {
|
||||
t.Fatalf("Expected CacheInterfaceWrapper for %s", tt.name)
|
||||
}
|
||||
if !wrapper.managed {
|
||||
t.Errorf("%s cache wrapper should be marked as managed", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInterfaceWrapper_GetStats(t *testing.T) {
|
||||
cm := getTestCacheManager(t)
|
||||
cache := cm.GetSharedTokenBlacklist()
|
||||
@@ -312,314 +470,6 @@ func TestConcurrentCacheAccess(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHARDED CACHE TESTS
|
||||
// =============================================================================
|
||||
|
||||
func TestShardedCacheBasicOperations(t *testing.T) {
|
||||
t.Run("SetAndGet", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
cache.Set("key2", 42, 5*time.Minute)
|
||||
cache.Set("key3", true, 5*time.Minute)
|
||||
|
||||
val1, ok := cache.Get("key1")
|
||||
if !ok || val1 != "value1" {
|
||||
t.Errorf("Expected 'value1', got %v, ok=%v", val1, ok)
|
||||
}
|
||||
|
||||
val2, ok := cache.Get("key2")
|
||||
if !ok || val2 != 42 {
|
||||
t.Errorf("Expected 42, got %v, ok=%v", val2, ok)
|
||||
}
|
||||
|
||||
val3, ok := cache.Get("key3")
|
||||
if !ok || val3 != true {
|
||||
t.Errorf("Expected true, got %v, ok=%v", val3, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetNonExistent", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
val, ok := cache.Get("nonexistent")
|
||||
if ok || val != nil {
|
||||
t.Errorf("Expected nil/false for nonexistent key, got %v/%v", val, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
cache.Delete("key1")
|
||||
|
||||
val, ok := cache.Get("key1")
|
||||
if ok || val != nil {
|
||||
t.Errorf("Expected nil/false after delete, got %v/%v", val, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Exists", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 5*time.Minute)
|
||||
|
||||
if !cache.Exists("key1") {
|
||||
t.Error("Expected Exists to return true for existing key")
|
||||
}
|
||||
|
||||
if cache.Exists("nonexistent") {
|
||||
t.Error("Expected Exists to return false for nonexistent key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Size", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
if cache.Size() != 0 {
|
||||
t.Errorf("Expected size 0, got %d", cache.Size())
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
if cache.Size() != 100 {
|
||||
t.Errorf("Expected size 100, got %d", cache.Size())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Clear", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
cache.Clear()
|
||||
|
||||
if cache.Size() != 0 {
|
||||
t.Errorf("Expected size 0 after clear, got %d", cache.Size())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheExpiration(t *testing.T) {
|
||||
t.Run("ItemExpires", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("key1", "value1", 50*time.Millisecond)
|
||||
|
||||
if !cache.Exists("key1") {
|
||||
t.Error("Item should exist immediately after set")
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if cache.Exists("key1") {
|
||||
t.Error("Item should have expired")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CleanupRemovesExpired", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("expired%d", i), i, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
cache.Cleanup()
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
if cache.Exists(fmt.Sprintf("expired%d", i)) {
|
||||
t.Errorf("Expired item %d should not exist after cleanup", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
if !cache.Exists(fmt.Sprintf("valid%d", i)) {
|
||||
t.Errorf("Valid item %d should still exist after cleanup", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZeroTTLNeverExpires", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 1000)
|
||||
|
||||
cache.Set("permanent", "value", 0)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if !cache.Exists("permanent") {
|
||||
t.Error("Item with 0 TTL should never expire")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheConcurrency(t *testing.T) {
|
||||
t.Run("ConcurrentSetGet", func(t *testing.T) {
|
||||
cache := NewShardedCache(64, 10000)
|
||||
const numGoroutines = 100
|
||||
const numOperations = 1000
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errors int32
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", id, j)
|
||||
cache.Set(key, j, 5*time.Minute)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", id, j)
|
||||
cache.Get(key)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if atomic.LoadInt32(&errors) > 0 {
|
||||
t.Errorf("Encountered %d errors during concurrent access", errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentMixedOperations", func(t *testing.T) {
|
||||
cache := NewShardedCache(64, 10000)
|
||||
const numGoroutines = 50
|
||||
const numOperations = 500
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d", j%100)
|
||||
switch j % 4 {
|
||||
case 0:
|
||||
cache.Set(key, j, 5*time.Minute)
|
||||
case 1:
|
||||
cache.Get(key)
|
||||
case 2:
|
||||
cache.Exists(key)
|
||||
case 3:
|
||||
cache.Delete(key)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("NoConcurrentPanics", func(t *testing.T) {
|
||||
cache := NewShardedCache(32, 5000)
|
||||
const numGoroutines = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Panic in goroutine %d: %v", id, r)
|
||||
}
|
||||
}()
|
||||
|
||||
for j := 0; j < 100; j++ {
|
||||
cache.Set(fmt.Sprintf("k%d", j), j, time.Millisecond)
|
||||
cache.Get(fmt.Sprintf("k%d", j))
|
||||
cache.Cleanup()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheEviction(t *testing.T) {
|
||||
t.Run("EvictsWhenFull", func(t *testing.T) {
|
||||
cache := NewShardedCache(4, 100)
|
||||
|
||||
for i := 0; i < 600; i++ {
|
||||
cache.Set(fmt.Sprintf("key%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
size := cache.Size()
|
||||
if size >= 600 {
|
||||
t.Errorf("Expected eviction to reduce size below 600, got %d", size)
|
||||
}
|
||||
t.Logf("Cache size after adding 600 items: %d", size)
|
||||
})
|
||||
|
||||
t.Run("EvictsExpiredFirst", func(t *testing.T) {
|
||||
cache := NewShardedCache(4, 100)
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
cache.Set(fmt.Sprintf("expired%d", i), i, 1*time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.Set(fmt.Sprintf("valid%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
validCount := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
if cache.Exists(fmt.Sprintf("valid%d", i)) {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if validCount < 80 {
|
||||
t.Errorf("Expected at least 80 valid items, got %d", validCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardedCacheShardDistribution(t *testing.T) {
|
||||
t.Run("EvenDistribution", func(t *testing.T) {
|
||||
cache := NewShardedCache(16, 16000)
|
||||
|
||||
for i := 0; i < 10000; i++ {
|
||||
cache.Set(fmt.Sprintf("key-%d", i), i, 5*time.Minute)
|
||||
}
|
||||
|
||||
stats := cache.ShardStats()
|
||||
|
||||
average := 10000 / 16
|
||||
for i, count := range stats {
|
||||
if count > average*3 || count < average/3 {
|
||||
t.Errorf("Shard %d has uneven distribution: %d items (expected ~%d)", i, count, average)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CACHE COMPATIBILITY TESTS
|
||||
// =============================================================================
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+103
-44
@@ -7,50 +7,32 @@ import (
|
||||
// REDACTED is the placeholder value for sensitive information
|
||||
const REDACTED = "[REDACTED]"
|
||||
|
||||
// MarshalJSON implements custom JSON marshalling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalJSON() ([]byte, error) {
|
||||
// Build a map manually to avoid type alias issues with yaegi
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Copy public fields
|
||||
result["providerURL"] = c.ProviderURL
|
||||
result["clientID"] = c.ClientID
|
||||
result["callbackURL"] = c.CallbackURL
|
||||
result["logoutURL"] = c.LogoutURL
|
||||
result["postLogoutRedirectURI"] = c.PostLogoutRedirectURI
|
||||
result["scopes"] = c.Scopes
|
||||
result["forceHTTPS"] = c.ForceHTTPS
|
||||
result["logLevel"] = c.LogLevel
|
||||
result["rateLimit"] = c.RateLimit
|
||||
result["excludedURLs"] = c.ExcludedURLs
|
||||
result["allowedUserDomains"] = c.AllowedUserDomains
|
||||
result["allowedUsers"] = c.AllowedUsers
|
||||
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
||||
|
||||
// Redact sensitive fields
|
||||
result["clientSecret"] = REDACTED
|
||||
result["sessionEncryptionKey"] = REDACTED
|
||||
|
||||
// Handle Redis config
|
||||
if c.Redis != nil {
|
||||
redisMap := make(map[string]interface{})
|
||||
redisMap["enabled"] = c.Redis.Enabled
|
||||
redisMap["address"] = c.Redis.Address
|
||||
redisMap["password"] = REDACTED
|
||||
redisMap["db"] = c.Redis.DB
|
||||
redisMap["poolSize"] = c.Redis.PoolSize
|
||||
redisMap["cacheMode"] = c.Redis.CacheMode
|
||||
result["redis"] = redisMap
|
||||
// marshalDCR builds the JSON/YAML-safe representation of the dynamic
|
||||
// client registration config, redacting the initial access token.
|
||||
func marshalDCR(d *DynamicClientRegistrationConfig) map[string]interface{} {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Marshal(result)
|
||||
m := make(map[string]interface{})
|
||||
m["enabled"] = d.Enabled
|
||||
m["persistCredentials"] = d.PersistCredentials
|
||||
m["storageBackend"] = d.StorageBackend
|
||||
m["initialAccessToken"] = REDACTED
|
||||
m["registrationEndpoint"] = d.RegistrationEndpoint
|
||||
m["credentialsFile"] = d.CredentialsFile
|
||||
m["redisKeyPrefix"] = d.RedisKeyPrefix
|
||||
return m
|
||||
}
|
||||
|
||||
// MarshalYAML implements custom YAML marshalling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalYAML() (interface{}, error) {
|
||||
// Build a map manually to avoid type alias issues with yaegi
|
||||
// configToMap builds the JSON/YAML-safe map representation of Config. Both
|
||||
// MarshalJSON and MarshalYAML share it so the (hand-maintained for yaegi
|
||||
// compatibility) field/redaction list cannot drift between the two
|
||||
// encoders — previously the list silently dropped entire config areas
|
||||
// (dynamicClientRegistration, securityHeaders, audience, overrideScopes,
|
||||
// enablePKCE, allowOpaqueTokens, requireTokenIntrospection,
|
||||
// strictAudienceValidation, extraAuthParams, client assertion settings)
|
||||
// and never redacted their embedded secrets (R150).
|
||||
func (c Config) configToMap() map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Copy public fields
|
||||
@@ -68,11 +50,66 @@ func (c Config) MarshalYAML() (interface{}, error) {
|
||||
result["allowedUsers"] = c.AllowedUsers
|
||||
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
||||
|
||||
// Redact sensitive fields
|
||||
// Fields that the legacy hand-written list dropped (R150): important
|
||||
// non-secret config that must survive any JSON/YAML round-trip.
|
||||
result["audience"] = c.Audience
|
||||
result["resource"] = c.Resource
|
||||
result["cookiePrefix"] = c.CookiePrefix
|
||||
result["cookieDomain"] = c.CookieDomain
|
||||
result["userIdentifierClaim"] = c.UserIdentifierClaim
|
||||
result["groupClaimName"] = c.GroupClaimName
|
||||
result["roleClaimName"] = c.RoleClaimName
|
||||
result["oidcEndSessionURL"] = c.OIDCEndSessionURL
|
||||
result["revocationURL"] = c.RevocationURL
|
||||
result["introspectionURL"] = c.IntrospectionURL
|
||||
result["headers"] = c.Headers
|
||||
result["allowedClaims"] = c.AllowedClaims
|
||||
result["extraAuthParams"] = c.ExtraAuthParams
|
||||
result["refreshGracePeriodSeconds"] = c.RefreshGracePeriodSeconds
|
||||
result["maxRefreshTokenAgeSeconds"] = c.MaxRefreshTokenAgeSeconds
|
||||
result["sessionMaxAge"] = c.SessionMaxAge
|
||||
result["overrideScopes"] = c.OverrideScopes
|
||||
result["disableReplayDetection"] = c.DisableReplayDetection
|
||||
result["requireTokenIntrospection"] = c.RequireTokenIntrospection
|
||||
result["allowOpaqueTokens"] = c.AllowOpaqueTokens
|
||||
result["strictAudienceValidation"] = c.StrictAudienceValidation
|
||||
result["enablePKCE"] = c.EnablePKCE
|
||||
result["allowPrivateIPAddresses"] = c.AllowPrivateIPAddresses
|
||||
result["allowUnauthenticatedPreflight"] = c.AllowUnauthenticatedPreflight
|
||||
result["streamingRefresh"] = c.StreamingRefresh
|
||||
result["minimalHeaders"] = c.MinimalHeaders
|
||||
result["stripAuthCookies"] = c.StripAuthCookies
|
||||
result["cookiePath"] = c.CookiePath
|
||||
result["enableBackchannelLogout"] = c.EnableBackchannelLogout
|
||||
result["enableFrontchannelLogout"] = c.EnableFrontchannelLogout
|
||||
result["backchannelLogoutURL"] = c.BackchannelLogoutURL
|
||||
result["frontchannelLogoutURL"] = c.FrontchannelLogoutURL
|
||||
result["caCertPath"] = c.CACertPath
|
||||
result["caCertPEM"] = c.CACertPEM
|
||||
result["insecureSkipVerify"] = c.InsecureSkipVerify
|
||||
result["clientAuthMethod"] = c.ClientAuthMethod
|
||||
result["clientAssertionKeyID"] = c.ClientAssertionKeyID
|
||||
result["clientAssertionAlg"] = c.ClientAssertionAlg
|
||||
result["clientAssertionKeyPath"] = c.ClientAssertionKeyPath
|
||||
result["perSourceLoginRateLimit"] = c.PerSourceLoginRateLimit
|
||||
result["enableBearerAuth"] = c.EnableBearerAuth
|
||||
result["bearerIdentifierClaim"] = c.BearerIdentifierClaim
|
||||
result["stripAuthorizationHeader"] = c.StripAuthorizationHeader
|
||||
result["bearerEmitWWWAuthenticate"] = c.BearerEmitWWWAuthenticate
|
||||
result["bearerOverridesCookie"] = c.BearerOverridesCookie
|
||||
result["maxTokenAgeSeconds"] = c.MaxTokenAgeSeconds
|
||||
result["maxIdentifierLength"] = c.MaxIdentifierLength
|
||||
result["bearerFailureThreshold"] = c.BearerFailureThreshold
|
||||
result["bearerFailureWindowSeconds"] = c.BearerFailureWindowSeconds
|
||||
result["bearerFailurePenaltySeconds"] = c.BearerFailurePenaltySeconds
|
||||
|
||||
// Redact sensitive fields (existing secrets plus the previously-leaked
|
||||
// client assertion private key, R150).
|
||||
result["clientSecret"] = REDACTED
|
||||
result["sessionEncryptionKey"] = REDACTED
|
||||
result["clientAssertionPrivateKey"] = REDACTED
|
||||
|
||||
// Handle Redis config
|
||||
// Handle Redis config (redacts password)
|
||||
if c.Redis != nil {
|
||||
redisMap := make(map[string]interface{})
|
||||
redisMap["enabled"] = c.Redis.Enabled
|
||||
@@ -84,7 +121,29 @@ func (c Config) MarshalYAML() (interface{}, error) {
|
||||
result["redis"] = redisMap
|
||||
}
|
||||
|
||||
return result, nil
|
||||
// Handle dynamic client registration (redacts the initial access token)
|
||||
if c.DynamicClientRegistration != nil {
|
||||
result["dynamicClientRegistration"] = marshalDCR(c.DynamicClientRegistration)
|
||||
}
|
||||
|
||||
// Handle security headers
|
||||
if c.SecurityHeaders != nil {
|
||||
result["securityHeaders"] = c.SecurityHeaders
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(c.configToMap())
|
||||
}
|
||||
|
||||
// MarshalYAML implements custom YAML marshaling to redact sensitive fields
|
||||
// Rewritten without type aliases for yaegi compatibility
|
||||
func (c Config) MarshalYAML() (interface{}, error) {
|
||||
return c.configToMap(), nil
|
||||
}
|
||||
|
||||
// MarshalJSON for RedisConfig to redact sensitive fields
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/dcrstorage"
|
||||
)
|
||||
|
||||
// DCRStorageBackend represents the type of storage backend for DCR credentials.
|
||||
// Alias for internal package type for backward compatibility.
|
||||
type DCRStorageBackend = dcrstorage.StorageBackend
|
||||
|
||||
const (
|
||||
// DCRStorageBackendFile uses file-based storage (default for backward compatibility)
|
||||
DCRStorageBackendFile DCRStorageBackend = dcrstorage.StorageBackendFile
|
||||
|
||||
// DCRStorageBackendRedis uses Redis for distributed storage
|
||||
DCRStorageBackendRedis DCRStorageBackend = dcrstorage.StorageBackendRedis
|
||||
|
||||
// DCRStorageBackendAuto automatically selects Redis if available, otherwise file
|
||||
DCRStorageBackendAuto DCRStorageBackend = dcrstorage.StorageBackendAuto
|
||||
)
|
||||
|
||||
// DCRCredentialsStore defines the interface for storing DCR credentials.
|
||||
// This abstraction allows different storage backends (file, Redis) to be used
|
||||
// for persisting OIDC Dynamic Client Registration credentials across nodes.
|
||||
type DCRCredentialsStore interface {
|
||||
// Save stores the client registration response for a provider
|
||||
// The providerURL is used as a key to support multi-tenant scenarios
|
||||
Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error
|
||||
|
||||
// Load retrieves stored credentials for a provider
|
||||
// Returns nil, nil if no credentials exist (not an error)
|
||||
Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error)
|
||||
|
||||
// Delete removes stored credentials for a provider
|
||||
Delete(ctx context.Context, providerURL string) error
|
||||
|
||||
// Exists checks if credentials exist for a provider
|
||||
Exists(ctx context.Context, providerURL string) (bool, error)
|
||||
}
|
||||
|
||||
// loggerAdapter adapts our Logger to the dcrstorage.Logger interface
|
||||
type loggerAdapter struct {
|
||||
logger *Logger
|
||||
}
|
||||
|
||||
func (l *loggerAdapter) Debug(msg string) { l.logger.Debug("%s", msg) }
|
||||
func (l *loggerAdapter) Debugf(format string, args ...any) { l.logger.Debugf(format, args...) }
|
||||
func (l *loggerAdapter) Info(msg string) { l.logger.Info("%s", msg) }
|
||||
func (l *loggerAdapter) Infof(format string, args ...any) { l.logger.Infof(format, args...) }
|
||||
func (l *loggerAdapter) Error(msg string) { l.logger.Error("%s", msg) }
|
||||
func (l *loggerAdapter) Errorf(format string, args ...any) { l.logger.Errorf(format, args...) }
|
||||
|
||||
// cacheAdapter adapts UniversalCache to dcrstorage.Cache interface
|
||||
type cacheAdapter struct {
|
||||
cache *UniversalCache
|
||||
}
|
||||
|
||||
func (c *cacheAdapter) Get(key string) (any, bool) {
|
||||
return c.cache.Get(key)
|
||||
}
|
||||
|
||||
func (c *cacheAdapter) Set(key string, value any, ttl time.Duration) error {
|
||||
return c.cache.Set(key, value, ttl)
|
||||
}
|
||||
|
||||
func (c *cacheAdapter) Delete(key string) {
|
||||
c.cache.Delete(key)
|
||||
}
|
||||
|
||||
// fileStoreWrapper wraps dcrstorage.FileStore to implement DCRCredentialsStore
|
||||
type fileStoreWrapper struct {
|
||||
inner *dcrstorage.FileStore
|
||||
}
|
||||
|
||||
func (w *fileStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
|
||||
innerCreds := convertCredsToInternal(creds)
|
||||
return w.inner.Save(ctx, providerURL, innerCreds)
|
||||
}
|
||||
|
||||
func (w *fileStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
|
||||
innerCreds, err := w.inner.Load(ctx, providerURL)
|
||||
if err != nil || innerCreds == nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertCredsFromInternal(innerCreds), nil
|
||||
}
|
||||
|
||||
func (w *fileStoreWrapper) Delete(ctx context.Context, providerURL string) error {
|
||||
return w.inner.Delete(ctx, providerURL)
|
||||
}
|
||||
|
||||
func (w *fileStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
|
||||
return w.inner.Exists(ctx, providerURL)
|
||||
}
|
||||
|
||||
// basePath returns the base path used for storing credentials (for backward compatibility in tests)
|
||||
func (w *fileStoreWrapper) basePath() string {
|
||||
return w.inner.BasePath()
|
||||
}
|
||||
|
||||
// getFilePath returns the file path for storing credentials for a specific provider (for backward compatibility in tests)
|
||||
func (w *fileStoreWrapper) getFilePath(providerURL string) string {
|
||||
return w.inner.GetFilePath(providerURL)
|
||||
}
|
||||
|
||||
// redisStoreWrapper wraps dcrstorage.RedisStore to implement DCRCredentialsStore
|
||||
type redisStoreWrapper struct {
|
||||
inner *dcrstorage.RedisStore
|
||||
}
|
||||
|
||||
func (w *redisStoreWrapper) Save(ctx context.Context, providerURL string, creds *ClientRegistrationResponse) error {
|
||||
innerCreds := convertCredsToInternal(creds)
|
||||
return w.inner.Save(ctx, providerURL, innerCreds)
|
||||
}
|
||||
|
||||
func (w *redisStoreWrapper) Load(ctx context.Context, providerURL string) (*ClientRegistrationResponse, error) {
|
||||
innerCreds, err := w.inner.Load(ctx, providerURL)
|
||||
if err != nil || innerCreds == nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertCredsFromInternal(innerCreds), nil
|
||||
}
|
||||
|
||||
func (w *redisStoreWrapper) Delete(ctx context.Context, providerURL string) error {
|
||||
return w.inner.Delete(ctx, providerURL)
|
||||
}
|
||||
|
||||
func (w *redisStoreWrapper) Exists(ctx context.Context, providerURL string) (bool, error) {
|
||||
return w.inner.Exists(ctx, providerURL)
|
||||
}
|
||||
|
||||
// FileCredentialsStore implements DCRCredentialsStore using file-based storage.
|
||||
// This is the default storage backend for backward compatibility with existing deployments.
|
||||
type FileCredentialsStore = fileStoreWrapper
|
||||
|
||||
// RedisCredentialsStore implements DCRCredentialsStore using Redis-backed cache.
|
||||
// This storage backend enables sharing DCR credentials across multiple Traefik instances.
|
||||
type RedisCredentialsStore = redisStoreWrapper
|
||||
|
||||
// NewFileCredentialsStore creates a new file-based credentials store.
|
||||
// If basePath is empty, defaults to /tmp/oidc-client-credentials.json
|
||||
func NewFileCredentialsStore(basePath string, logger *Logger) *FileCredentialsStore {
|
||||
var dcrLogger dcrstorage.Logger
|
||||
if logger != nil {
|
||||
dcrLogger = &loggerAdapter{logger: logger}
|
||||
}
|
||||
inner := dcrstorage.NewFileStore(basePath, dcrLogger)
|
||||
return &fileStoreWrapper{inner: inner}
|
||||
}
|
||||
|
||||
// NewRedisCredentialsStore creates a new Redis-backed credentials store.
|
||||
// The cache should be configured with a Redis backend for distributed storage.
|
||||
// If keyPrefix is empty, defaults to "dcr:creds:"
|
||||
func NewRedisCredentialsStore(cache *UniversalCache, keyPrefix string, logger *Logger) *RedisCredentialsStore {
|
||||
var dcrLogger dcrstorage.Logger
|
||||
if logger != nil {
|
||||
dcrLogger = &loggerAdapter{logger: logger}
|
||||
}
|
||||
cacheAdapt := &cacheAdapter{cache: cache}
|
||||
inner := dcrstorage.NewRedisStore(cacheAdapt, keyPrefix, dcrLogger)
|
||||
return &redisStoreWrapper{inner: inner}
|
||||
}
|
||||
|
||||
// Helper functions to convert between main package and internal package types
|
||||
func convertCredsToInternal(creds *ClientRegistrationResponse) *dcrstorage.ClientRegistrationResponse {
|
||||
if creds == nil {
|
||||
return nil
|
||||
}
|
||||
return &dcrstorage.ClientRegistrationResponse{
|
||||
SubjectType: creds.SubjectType,
|
||||
LogoURI: creds.LogoURI,
|
||||
RegistrationAccessToken: creds.RegistrationAccessToken,
|
||||
RegistrationClientURI: creds.RegistrationClientURI,
|
||||
Scope: creds.Scope,
|
||||
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
|
||||
TOSURI: creds.TOSURI,
|
||||
PolicyURI: creds.PolicyURI,
|
||||
ClientSecret: creds.ClientSecret,
|
||||
ApplicationType: creds.ApplicationType,
|
||||
ClientID: creds.ClientID,
|
||||
ClientName: creds.ClientName,
|
||||
JWKSURI: creds.JWKSURI,
|
||||
ClientURI: creds.ClientURI,
|
||||
Contacts: creds.Contacts,
|
||||
GrantTypes: creds.GrantTypes,
|
||||
ResponseTypes: creds.ResponseTypes,
|
||||
RedirectURIs: creds.RedirectURIs,
|
||||
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
|
||||
ClientIDIssuedAt: creds.ClientIDIssuedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func convertCredsFromInternal(creds *dcrstorage.ClientRegistrationResponse) *ClientRegistrationResponse {
|
||||
if creds == nil {
|
||||
return nil
|
||||
}
|
||||
return &ClientRegistrationResponse{
|
||||
SubjectType: creds.SubjectType,
|
||||
LogoURI: creds.LogoURI,
|
||||
RegistrationAccessToken: creds.RegistrationAccessToken,
|
||||
RegistrationClientURI: creds.RegistrationClientURI,
|
||||
Scope: creds.Scope,
|
||||
TokenEndpointAuthMethod: creds.TokenEndpointAuthMethod,
|
||||
TOSURI: creds.TOSURI,
|
||||
PolicyURI: creds.PolicyURI,
|
||||
ClientSecret: creds.ClientSecret,
|
||||
ApplicationType: creds.ApplicationType,
|
||||
ClientID: creds.ClientID,
|
||||
ClientName: creds.ClientName,
|
||||
JWKSURI: creds.JWKSURI,
|
||||
ClientURI: creds.ClientURI,
|
||||
Contacts: creds.Contacts,
|
||||
GrantTypes: creds.GrantTypes,
|
||||
ResponseTypes: creds.ResponseTypes,
|
||||
RedirectURIs: creds.RedirectURIs,
|
||||
ClientSecretExpiresAt: creds.ClientSecretExpiresAt,
|
||||
ClientIDIssuedAt: creds.ClientIDIssuedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDCRCredentialsStore creates a DCRCredentialsStore based on configuration.
|
||||
// This factory function handles backend selection logic:
|
||||
// - "file": Use file-based storage (default for backward compatibility)
|
||||
// - "redis": Use Redis exclusively (fails if Redis unavailable)
|
||||
// - "auto": Use Redis if available, fallback to file
|
||||
func NewDCRCredentialsStore(
|
||||
config *DynamicClientRegistrationConfig,
|
||||
cacheManager *CacheManager,
|
||||
logger *Logger,
|
||||
) (DCRCredentialsStore, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("DCR config is nil")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = GetSingletonNoOpLogger()
|
||||
}
|
||||
|
||||
backend := config.StorageBackend
|
||||
if backend == "" {
|
||||
backend = string(DCRStorageBackendAuto) // Default to auto selection
|
||||
}
|
||||
|
||||
switch DCRStorageBackend(backend) {
|
||||
case DCRStorageBackendFile:
|
||||
logger.Info("Using file-based storage for DCR credentials")
|
||||
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
|
||||
|
||||
case DCRStorageBackendRedis:
|
||||
cache := getDCRCache(cacheManager)
|
||||
if cache == nil {
|
||||
return nil, fmt.Errorf("redis storage requested but Redis/cache not configured")
|
||||
}
|
||||
logger.Info("Using Redis storage for DCR credentials")
|
||||
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
|
||||
|
||||
case DCRStorageBackendAuto:
|
||||
// Try Redis first, fallback to file
|
||||
cache := getDCRCache(cacheManager)
|
||||
if cache != nil && cache.backend != nil {
|
||||
logger.Info("Auto-selected Redis storage for DCR credentials")
|
||||
return NewRedisCredentialsStore(cache, config.RedisKeyPrefix, logger), nil
|
||||
}
|
||||
logger.Info("Redis not available, using file storage for DCR credentials")
|
||||
return NewFileCredentialsStore(config.CredentialsFile, logger), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown DCR storage backend: %s", backend)
|
||||
}
|
||||
}
|
||||
|
||||
// getDCRCache safely retrieves the DCR credentials cache from the cache manager
|
||||
func getDCRCache(cacheManager *CacheManager) *UniversalCache {
|
||||
if cacheManager == nil {
|
||||
return nil
|
||||
}
|
||||
cacheManager.mu.RLock()
|
||||
defer cacheManager.mu.RUnlock()
|
||||
|
||||
if cacheManager.manager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cacheManager.manager.GetDCRCredentialsCache()
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFileCredentialsStore_SaveLoad tests the file-based credentials store
|
||||
func TestFileCredentialsStore_SaveLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temp directory for test files
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
testCreds := &ClientRegistrationResponse{
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
RegistrationAccessToken: "test-access-token",
|
||||
RegistrationClientURI: "https://example.com/register/test-client-id",
|
||||
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
TokenEndpointAuthMethod: "client_secret_basic",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
providerURL := "https://auth.example.com"
|
||||
|
||||
t.Run("save and load credentials", func(t *testing.T) {
|
||||
// Save credentials
|
||||
err := store.Save(ctx, providerURL, testCreds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save credentials: %v", err)
|
||||
}
|
||||
|
||||
// Load credentials
|
||||
loaded, err := store.Load(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load credentials: %v", err)
|
||||
}
|
||||
|
||||
if loaded == nil {
|
||||
t.Fatal("Expected credentials but got nil")
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if loaded.ClientID != testCreds.ClientID {
|
||||
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
|
||||
}
|
||||
if loaded.ClientSecret != testCreds.ClientSecret {
|
||||
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
|
||||
}
|
||||
if loaded.RegistrationAccessToken != testCreds.RegistrationAccessToken {
|
||||
t.Errorf("RegistrationAccessToken mismatch: got %s, want %s", loaded.RegistrationAccessToken, testCreds.RegistrationAccessToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load non-existent credentials", func(t *testing.T) {
|
||||
tempDir2 := t.TempDir()
|
||||
store2 := NewFileCredentialsStore(filepath.Join(tempDir2, "nonexistent.json"), logger)
|
||||
|
||||
loaded, err := store2.Load(ctx, "https://nonexistent.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for non-existent file: %v", err)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Error("Expected nil for non-existent credentials")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exists check", func(t *testing.T) {
|
||||
exists, err := store.Exists(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Exists check failed: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Expected credentials to exist")
|
||||
}
|
||||
|
||||
exists, err = store.Exists(ctx, "https://nonexistent.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists check failed: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Expected credentials to not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete credentials", func(t *testing.T) {
|
||||
err := store.Delete(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete credentials: %v", err)
|
||||
}
|
||||
|
||||
exists, _ := store.Exists(ctx, providerURL)
|
||||
if exists {
|
||||
t.Error("Expected credentials to be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete non-existent credentials", func(t *testing.T) {
|
||||
// Should not error
|
||||
err := store.Delete(ctx, "https://nonexistent.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete should not error for non-existent: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_MultiProvider tests multi-provider support
|
||||
func TestFileCredentialsStore_MultiProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
provider1 := "https://auth1.example.com"
|
||||
provider2 := "https://auth2.example.com"
|
||||
|
||||
creds1 := &ClientRegistrationResponse{
|
||||
ClientID: "client-1",
|
||||
ClientSecret: "secret-1",
|
||||
}
|
||||
creds2 := &ClientRegistrationResponse{
|
||||
ClientID: "client-2",
|
||||
ClientSecret: "secret-2",
|
||||
}
|
||||
|
||||
// Save credentials for both providers
|
||||
if err := store.Save(ctx, provider1, creds1); err != nil {
|
||||
t.Fatalf("Failed to save creds1: %v", err)
|
||||
}
|
||||
if err := store.Save(ctx, provider2, creds2); err != nil {
|
||||
t.Fatalf("Failed to save creds2: %v", err)
|
||||
}
|
||||
|
||||
// Load and verify each provider's credentials
|
||||
loaded1, err := store.Load(ctx, provider1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load creds1: %v", err)
|
||||
}
|
||||
if loaded1.ClientID != "client-1" {
|
||||
t.Errorf("Provider 1 ClientID mismatch: got %s", loaded1.ClientID)
|
||||
}
|
||||
|
||||
loaded2, err := store.Load(ctx, provider2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load creds2: %v", err)
|
||||
}
|
||||
if loaded2.ClientID != "client-2" {
|
||||
t.Errorf("Provider 2 ClientID mismatch: got %s", loaded2.ClientID)
|
||||
}
|
||||
|
||||
// Delete one shouldn't affect the other
|
||||
if err := store.Delete(ctx, provider1); err != nil {
|
||||
t.Fatalf("Failed to delete creds1: %v", err)
|
||||
}
|
||||
|
||||
exists, _ := store.Exists(ctx, provider2)
|
||||
if !exists {
|
||||
t.Error("Provider 2 credentials should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_ConcurrentAccess tests thread safety
|
||||
func TestFileCredentialsStore_ConcurrentAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
providerURL := "https://auth.example.com"
|
||||
|
||||
creds := &ClientRegistrationResponse{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
concurrency := 10
|
||||
|
||||
// Concurrent saves
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = store.Save(ctx, providerURL, creds)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Concurrent loads
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = store.Load(ctx, providerURL)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Final verification
|
||||
loaded, err := store.Load(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load after concurrent access: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.ClientID != "test-client" {
|
||||
t.Error("Credentials corrupted after concurrent access")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_InvalidInput tests error handling
|
||||
func TestFileCredentialsStore_InvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("save nil credentials", func(t *testing.T) {
|
||||
err := store.Save(ctx, "https://example.com", nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil credentials")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty provider URL uses default path", func(t *testing.T) {
|
||||
creds := &ClientRegistrationResponse{ClientID: "test"}
|
||||
err := store.Save(ctx, "", creds)
|
||||
if err != nil {
|
||||
t.Fatalf("Save with empty provider URL failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Load with empty provider URL failed: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.ClientID != "test" {
|
||||
t.Error("Failed to load credentials with empty provider URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_DefaultPath tests default path behavior
|
||||
func TestFileCredentialsStore_DefaultPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore("", logger)
|
||||
|
||||
// Just verify we can create with empty path and it has a default
|
||||
if store.basePath() == "" {
|
||||
t.Error("Expected default base path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedisCredentialsStore_WithMemoryCache tests Redis store with in-memory cache
|
||||
func TestRedisCredentialsStore_WithMemoryCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create an in-memory cache for testing
|
||||
cache := NewUniversalCache(UniversalCacheConfig{
|
||||
Type: CacheTypeGeneral,
|
||||
MaxSize: 100,
|
||||
DefaultTTL: time.Hour,
|
||||
Logger: GetSingletonNoOpLogger(),
|
||||
})
|
||||
defer cache.Close()
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewRedisCredentialsStore(cache, "", logger)
|
||||
|
||||
ctx := context.Background()
|
||||
providerURL := "https://auth.example.com"
|
||||
|
||||
testCreds := &ClientRegistrationResponse{
|
||||
ClientID: "redis-test-client",
|
||||
ClientSecret: "redis-test-secret",
|
||||
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
RegistrationAccessToken: "redis-test-token",
|
||||
RedirectURIs: []string{"https://app.example.com/callback"},
|
||||
}
|
||||
|
||||
t.Run("save and load credentials", func(t *testing.T) {
|
||||
err := store.Save(ctx, providerURL, testCreds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load credentials: %v", err)
|
||||
}
|
||||
|
||||
if loaded == nil {
|
||||
t.Fatal("Expected credentials but got nil")
|
||||
}
|
||||
if loaded.ClientID != testCreds.ClientID {
|
||||
t.Errorf("ClientID mismatch: got %s, want %s", loaded.ClientID, testCreds.ClientID)
|
||||
}
|
||||
if loaded.ClientSecret != testCreds.ClientSecret {
|
||||
t.Errorf("ClientSecret mismatch: got %s, want %s", loaded.ClientSecret, testCreds.ClientSecret)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exists check", func(t *testing.T) {
|
||||
exists, err := store.Exists(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Exists check failed: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Expected credentials to exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete credentials", func(t *testing.T) {
|
||||
err := store.Delete(ctx, providerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete credentials: %v", err)
|
||||
}
|
||||
|
||||
exists, _ := store.Exists(ctx, providerURL)
|
||||
if exists {
|
||||
t.Error("Expected credentials to be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load non-existent credentials", func(t *testing.T) {
|
||||
loaded, err := store.Load(ctx, "https://nonexistent.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for non-existent: %v", err)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Error("Expected nil for non-existent credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRedisCredentialsStore_TTLFromExpiry tests TTL calculation
|
||||
func TestRedisCredentialsStore_TTLFromExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewUniversalCache(UniversalCacheConfig{
|
||||
Type: CacheTypeGeneral,
|
||||
MaxSize: 100,
|
||||
DefaultTTL: time.Hour,
|
||||
Logger: GetSingletonNoOpLogger(),
|
||||
})
|
||||
defer cache.Close()
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewRedisCredentialsStore(cache, "", logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("expired credentials should fail", func(t *testing.T) {
|
||||
expiredCreds := &ClientRegistrationResponse{
|
||||
ClientID: "expired-client",
|
||||
ClientSecret: "expired-secret",
|
||||
ClientSecretExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // Already expired
|
||||
}
|
||||
|
||||
err := store.Save(ctx, "https://expired.example.com", expiredCreds)
|
||||
if err == nil {
|
||||
t.Error("Expected error for expired credentials")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credentials without expiry use default TTL", func(t *testing.T) {
|
||||
creds := &ClientRegistrationResponse{
|
||||
ClientID: "no-expiry-client",
|
||||
ClientSecret: "no-expiry-secret",
|
||||
ClientSecretExpiresAt: 0, // No expiry
|
||||
}
|
||||
|
||||
err := store.Save(ctx, "https://noexpiry.example.com", creds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save credentials without expiry: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRedisCredentialsStore_InvalidInput tests error handling
|
||||
func TestRedisCredentialsStore_InvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewUniversalCache(UniversalCacheConfig{
|
||||
Type: CacheTypeGeneral,
|
||||
MaxSize: 100,
|
||||
DefaultTTL: time.Hour,
|
||||
Logger: GetSingletonNoOpLogger(),
|
||||
})
|
||||
defer cache.Close()
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewRedisCredentialsStore(cache, "", logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("save nil credentials", func(t *testing.T) {
|
||||
err := store.Save(ctx, "https://example.com", nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDCRStorageFactory tests the factory function
|
||||
func TestDCRStorageFactory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := GetSingletonNoOpLogger()
|
||||
|
||||
t.Run("nil config returns error", func(t *testing.T) {
|
||||
_, err := NewDCRCredentialsStore(nil, nil, logger)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file backend creates file store", func(t *testing.T) {
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
StorageBackend: "file",
|
||||
CredentialsFile: "/tmp/test-creds.json",
|
||||
}
|
||||
|
||||
store, err := NewDCRCredentialsStore(config, nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create file store: %v", err)
|
||||
}
|
||||
if store == nil {
|
||||
t.Error("Expected store but got nil")
|
||||
}
|
||||
|
||||
_, ok := store.(*FileCredentialsStore)
|
||||
if !ok {
|
||||
t.Error("Expected FileCredentialsStore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redis backend without cache manager returns error", func(t *testing.T) {
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
StorageBackend: "redis",
|
||||
}
|
||||
|
||||
_, err := NewDCRCredentialsStore(config, nil, logger)
|
||||
if err == nil {
|
||||
t.Error("Expected error for redis backend without cache manager")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto backend without redis falls back to file", func(t *testing.T) {
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
StorageBackend: "auto",
|
||||
}
|
||||
|
||||
store, err := NewDCRCredentialsStore(config, nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auto store: %v", err)
|
||||
}
|
||||
|
||||
_, ok := store.(*FileCredentialsStore)
|
||||
if !ok {
|
||||
t.Error("Expected FileCredentialsStore for auto without redis")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown backend returns error", func(t *testing.T) {
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
StorageBackend: "unknown",
|
||||
}
|
||||
|
||||
_, err := NewDCRCredentialsStore(config, nil, logger)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown backend")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty backend defaults to auto", func(t *testing.T) {
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
StorageBackend: "",
|
||||
}
|
||||
|
||||
store, err := NewDCRCredentialsStore(config, nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store with empty backend: %v", err)
|
||||
}
|
||||
|
||||
// Should default to file (auto without redis)
|
||||
_, ok := store.(*FileCredentialsStore)
|
||||
if !ok {
|
||||
t.Error("Expected FileCredentialsStore for empty backend")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDynamicClientRegistrar_WithStore tests registrar with store
|
||||
func TestDynamicClientRegistrar_WithStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
}
|
||||
|
||||
registrar := NewDynamicClientRegistrarWithStore(
|
||||
nil, // httpClient
|
||||
logger,
|
||||
config,
|
||||
"https://auth.example.com",
|
||||
store,
|
||||
)
|
||||
|
||||
if registrar == nil {
|
||||
t.Fatal("Expected registrar but got nil")
|
||||
}
|
||||
|
||||
if registrar.store == nil {
|
||||
t.Error("Expected store to be set")
|
||||
}
|
||||
|
||||
// Test SetStore
|
||||
newStore := NewFileCredentialsStore(filepath.Join(tempDir, "new.json"), logger)
|
||||
registrar.SetStore(newStore)
|
||||
|
||||
if registrar.store != newStore {
|
||||
t.Error("SetStore did not update the store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDynamicClientRegistrar_CredentialsFromStore tests loading from store
|
||||
func TestDynamicClientRegistrar_CredentialsFromStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
providerURL := "https://auth.example.com"
|
||||
ctx := context.Background()
|
||||
|
||||
// Pre-save credentials
|
||||
testCreds := &ClientRegistrationResponse{
|
||||
ClientID: "pre-saved-client",
|
||||
ClientSecret: "pre-saved-secret",
|
||||
ClientSecretExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
}
|
||||
if err := store.Save(ctx, providerURL, testCreds); err != nil {
|
||||
t.Fatalf("Failed to pre-save credentials: %v", err)
|
||||
}
|
||||
|
||||
config := &DynamicClientRegistrationConfig{
|
||||
Enabled: true,
|
||||
PersistCredentials: true,
|
||||
}
|
||||
|
||||
registrar := NewDynamicClientRegistrarWithStore(
|
||||
nil,
|
||||
logger,
|
||||
config,
|
||||
providerURL,
|
||||
store,
|
||||
)
|
||||
|
||||
// Test loading via the internal method
|
||||
loaded, err := registrar.loadCredentialsFromStore(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load from store: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatal("Expected credentials but got nil")
|
||||
}
|
||||
if loaded.ClientID != "pre-saved-client" {
|
||||
t.Errorf("ClientID mismatch: got %s", loaded.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_CorruptedFile tests handling of corrupted files
|
||||
func TestFileCredentialsStore_CorruptedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
basePath := filepath.Join(tempDir, "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(basePath, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
providerURL := "https://auth.example.com"
|
||||
|
||||
// Write corrupted JSON
|
||||
filePath := store.getFilePath(providerURL)
|
||||
if err := os.WriteFile(filePath, []byte("{corrupted json"), 0600); err != nil {
|
||||
t.Fatalf("Failed to write corrupted file: %v", err)
|
||||
}
|
||||
|
||||
// Should return error for corrupted file
|
||||
_, err := store.Load(ctx, providerURL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for corrupted JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCredentialsStore_DirectoryCreation tests auto directory creation
|
||||
func TestFileCredentialsStore_DirectoryCreation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
deepPath := filepath.Join(tempDir, "deep", "nested", "path", "credentials.json")
|
||||
logger := GetSingletonNoOpLogger()
|
||||
store := NewFileCredentialsStore(deepPath, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
creds := &ClientRegistrationResponse{ClientID: "test"}
|
||||
|
||||
err := store.Save(ctx, "https://example.com", creds)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save with nested directory: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(ctx, "https://example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load after nested directory creation: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.ClientID != "test" {
|
||||
t.Error("Failed to load credentials from nested directory")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
+388
-19
@@ -5,6 +5,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
||||
## Table of Contents
|
||||
|
||||
- [Required Parameters](#required-parameters)
|
||||
- [Client Authentication](#client-authentication)
|
||||
- [Optional Parameters](#optional-parameters)
|
||||
- [Security Options](#security-options)
|
||||
- [Session Management](#session-management)
|
||||
@@ -22,7 +23,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
||||
|-----------|------|-------------|---------|
|
||||
| `providerURL` | string | Base URL of the OIDC provider | `https://accounts.google.com` |
|
||||
| `clientID` | string | OAuth 2.0 client identifier | `1234567890.apps.googleusercontent.com` |
|
||||
| `clientSecret` | string | OAuth 2.0 client secret | `your-client-secret` |
|
||||
| `clientSecret` | string | OAuth 2.0 client secret. Required when `clientAuthMethod` is unset, `client_secret_post`, or `client_secret_basic`. Optional when `clientAuthMethod: private_key_jwt`. | `your-client-secret` |
|
||||
| `sessionEncryptionKey` | string | Key for encrypting session data (min 32 bytes) | `your-32-byte-encryption-key-here` |
|
||||
| `callbackURL` | string | Path where provider redirects after authentication | `/oauth2/callback` |
|
||||
|
||||
@@ -45,6 +46,134 @@ spec:
|
||||
|
||||
---
|
||||
|
||||
## Client Authentication
|
||||
|
||||
The middleware supports three client authentication methods at the token and
|
||||
revocation endpoints. The default is `client_secret_post` (current behavior);
|
||||
`private_key_jwt` is opt-in and backwards compatible.
|
||||
|
||||
| Method | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `client_secret_post` | yes | `client_id` + `client_secret` in the request body. |
|
||||
| `client_secret_basic` | no | RFC 6749 §2.3.1 — `client_id` + `client_secret` in the `Authorization: Basic` header (form-urlencoded then base64); not in the body. |
|
||||
| `private_key_jwt` | no | RFC 7523 §2.2 — plugin signs a short-lived JWT with a private key and sends it as `client_assertion`. |
|
||||
|
||||
Select via `clientAuthMethod`:
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: private_key_jwt
|
||||
```
|
||||
|
||||
### client_secret_post
|
||||
|
||||
Default. The plugin sends `client_id` and `client_secret` as form parameters
|
||||
in the token / revocation request body. No additional configuration required.
|
||||
|
||||
### private_key_jwt
|
||||
|
||||
Asymmetric client authentication per
|
||||
[RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523). Use this when your
|
||||
IdP enforces short secret TTLs, when policy mandates secretless clients, or
|
||||
when you want to avoid distributing a shared secret to the proxy.
|
||||
|
||||
For each token / revocation request the plugin builds a JWS with:
|
||||
|
||||
- `iss` = `sub` = `clientID`
|
||||
- `aud` = token endpoint URL
|
||||
- `iat` = now, `exp` = now + 60s
|
||||
- `jti` = random hex per request
|
||||
- `kid` header = `clientAssertionKeyID`
|
||||
|
||||
**Required fields:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `clientAuthMethod` | string | `client_secret_post` | Set to `private_key_jwt`. |
|
||||
| `clientAssertionPrivateKey` | string | none | Inline PEM private key. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8, PKCS#1, and SEC1 formats accepted. |
|
||||
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk. Mutually exclusive with `clientAssertionPrivateKey`. |
|
||||
| `clientAssertionKeyID` | string | none | `kid` header inserted in the JWS. Must match the public key registered with the IdP. |
|
||||
| `clientAssertionAlg` | string | `RS256` | One of `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`. |
|
||||
|
||||
When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
|
||||
|
||||
With `clientAssertionAlg` set to `RS256`, `RS384`, `RS512`, `PS256`, `PS384`,
|
||||
or `PS512`, the RSA key must be 2048 bits or larger (RFC 7518 §3.3). The
|
||||
plugin rejects a smaller key at startup instead of at every token exchange.
|
||||
There is no override for this check.
|
||||
|
||||
**Example — inline PEM:**
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-auth
|
||||
spec:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: https://idp.example.com
|
||||
clientID: my-client-id
|
||||
sessionEncryptionKey: your-32-byte-encryption-key-here
|
||||
callbackURL: /oauth2/callback
|
||||
clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyID: key-2026-01
|
||||
clientAssertionAlg: RS256
|
||||
clientAssertionPrivateKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
|
||||
MZj4ev7QnMa1mYV3Kx1jRkH5YwXQ7N2J2j8K5pP6h0oZmXq1yQv4r8wZb3sH9D2k
|
||||
... (truncated) ...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
**Example — key on disk:**
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyPath: /etc/traefik/oidc/client-key.pem
|
||||
clientAssertionKeyID: key-2026-01
|
||||
clientAssertionAlg: RS256
|
||||
```
|
||||
|
||||
**Generating an RS256 key with OpenSSL:**
|
||||
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
-out client-key.pem
|
||||
openssl rsa -in client-key.pem -pubout -out client-pub.pem
|
||||
```
|
||||
|
||||
Register `client-pub.pem` (or its JWK form) with your IdP under the same
|
||||
`kid` you set in `clientAssertionKeyID`.
|
||||
|
||||
**Notes:**
|
||||
|
||||
- The private key is parsed once at plugin startup. Key rotation requires a
|
||||
Traefik reload.
|
||||
- Assertion lifetime is fixed at 60 seconds.
|
||||
- A fresh random `jti` is generated per request.
|
||||
- The `aud` claim is the token endpoint URL (from discovery).
|
||||
- Tracking issue:
|
||||
[#135](https://github.com/lukaszraczylo/traefikoidc/issues/135).
|
||||
|
||||
### client_secret_basic
|
||||
|
||||
Per [RFC 6749 §2.3.1][rfc6749-2-3-1], the plugin sends the client credentials
|
||||
in an `Authorization: Basic` header instead of the body. Both halves
|
||||
(`client_id`, `client_secret`) are form-urlencoded individually, joined with
|
||||
a colon, then base64-encoded. Use this when your IdP requires Basic auth at
|
||||
the token endpoint and rejects credentials in the body.
|
||||
|
||||
```yaml
|
||||
clientAuthMethod: client_secret_basic
|
||||
clientID: your-client-id
|
||||
clientSecret: your-client-secret
|
||||
```
|
||||
|
||||
[rfc6749-2-3-1]: https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
||||
|
||||
---
|
||||
|
||||
## Optional Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
@@ -52,23 +181,91 @@ spec:
|
||||
| `logoutURL` | string | `callbackURL + "/logout"` | Path for logout requests |
|
||||
| `postLogoutRedirectURI` | string | `/` | Redirect URL after logout |
|
||||
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
|
||||
| `forceHTTPS` | bool | `false` | Force HTTPS for redirect URIs |
|
||||
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
|
||||
| `rateLimit` | int | `100` | Maximum requests per second |
|
||||
| `excludedURLs` | []string | none | Paths that bypass authentication |
|
||||
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
|
||||
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
|
||||
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
|
||||
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. See [Logout Behavior](#logout-behavior) for what happens at logout when this is set. |
|
||||
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
|
||||
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
|
||||
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
|
||||
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
|
||||
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
|
||||
| `clientAssertionPrivateKey` | string | none | Inline PEM private key for `private_key_jwt`. Mutually exclusive with `clientAssertionKeyPath`. PKCS#8 / PKCS#1 / SEC1. |
|
||||
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
|
||||
| `clientAssertionKeyID` | string | none | `kid` header for `private_key_jwt` assertions. Required when `clientAuthMethod: private_key_jwt`. |
|
||||
| `clientAssertionAlg` | string | `RS256` | Signing algorithm for `private_key_jwt`. One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. |
|
||||
| `extraAuthParams` | map | none | Extra query parameters appended to the authorization request (e.g. re-selective prompt). |
|
||||
| `perSourceLoginRateLimit` | int | `0` (off) | Throttle OIDC auth events (callback + login initiation) per external client source, in auth events per minute. Keys and classifies the source by RemoteAddr only, never `X-Forwarded-For`, so a client cannot spoof it. Internal/loopback sources are never throttled. RemoteAddr is the TCP peer. Behind a load balancer or CDN, the limiter keys on the proxy address: a private address is never throttled, and a public address puts every client in one shared bucket. |
|
||||
| `stripAuthCookies` | bool | `false` | Strip OIDC session cookies from the hop to the backend (mitigates HTTP 431). |
|
||||
| `enableBackchannelLogout` | bool | `false` | Enable OIDC back-channel logout (IdP-initiated, server-to-server). |
|
||||
| `backchannelLogoutURL` | string | derived | Back-channel logout endpoint path. |
|
||||
| `enableFrontchannelLogout` | bool | `false` | Enable OIDC front-channel logout (browser iframe logout). |
|
||||
| `frontchannelLogoutURL` | string | derived | Front-channel logout endpoint path. |
|
||||
| `caCertPath` / `caCertPEM` | string | none | Custom CA bundle for OIDC TLS verification (filesystem path, or inline PEM). Mutually exclusive with `insecureSkipVerify`. |
|
||||
| `insecureSkipVerify` | bool | `false` | Disable TLS verification for the OIDC client (load balancer / mTLS edge). Emits a loud warning at startup. |
|
||||
|
||||
### Logout Behavior
|
||||
|
||||
A request to `logoutURL` clears the session, redirects the browser, and
|
||||
also revokes the session's tokens.
|
||||
|
||||
1. The plugin blacklists the access, ID, and refresh tokens locally, so this
|
||||
instance rejects them immediately.
|
||||
2. If `revocationURL` is set or discovered, the plugin also revokes the
|
||||
access token and the refresh token at the provider (RFC 7009). Each
|
||||
revocation call times out after 3 seconds. The two calls run one after
|
||||
the other, so together they can delay the logout redirect by up to about
|
||||
6 seconds. A revocation failure logs at error level and does not stop
|
||||
the redirect. Deployments with no revocation endpoint log nothing for
|
||||
this step.
|
||||
3. The plugin redirects to `oidcEndSessionURL` (RP-initiated logout) only
|
||||
when `oidcEndSessionURL` is set or discovered and the session still
|
||||
holds an ID token. Otherwise it redirects to `postLogoutRedirectURI`.
|
||||
4. When the plugin uses `oidcEndSessionURL`, it also sends
|
||||
`post_logout_redirect_uri`. An absolute `postLogoutRedirectURI` value is
|
||||
sent unchanged. A relative value, or the default `/`, is joined to the
|
||||
origin of the redirect URI recorded at login. The plugin does not use
|
||||
the logout request's `Host` header for this, because a client can spoof
|
||||
it. The plugin omits the parameter only for a session created before
|
||||
that origin was recorded. The provider then decides where to send the
|
||||
user.
|
||||
|
||||
### TLS Termination at Load Balancer
|
||||
|
||||
If running Traefik behind a load balancer (AWS ALB, Google Cloud LB, Azure App Gateway) that terminates TLS:
|
||||
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
|
||||
the correct default behind any TLS-terminating load balancer (AWS ALB, Google
|
||||
Cloud LB, Azure App Gateway) — `X-Forwarded-Proto` cannot be trusted (ALB may
|
||||
overwrite it).
|
||||
|
||||
```yaml
|
||||
forceHTTPS: true # Required for correct redirect URIs
|
||||
```
|
||||
Set `forceHTTPS: false` only when you serve OIDC over plaintext HTTP (local
|
||||
dev). Otherwise leave it at default.
|
||||
|
||||
Without this setting, redirect URIs will use `http://` instead of `https://`, causing OAuth callback failures.
|
||||
### Streaming Endpoints (SSE and WebSocket)
|
||||
|
||||
The middleware automatically bypasses the OIDC redirect for two request kinds
|
||||
that browsers cannot follow a 302 on:
|
||||
|
||||
| Bypass | Triggered by |
|
||||
|--------|--------------|
|
||||
| Server-Sent Events (SSE) | `Accept: text/event-stream` |
|
||||
| WebSocket upgrade | `Upgrade: websocket` + `Connection: upgrade` (RFC 6455) |
|
||||
|
||||
These requests do **not** require any explicit configuration — they are
|
||||
handled implicitly. However, the bypass is **not** unauthenticated:
|
||||
|
||||
- A valid, encrypted session cookie is required. Requests without one are
|
||||
rejected (the connection cannot proceed to the backend).
|
||||
- The session cookie is sealed with `sessionEncryptionKey`, so the
|
||||
`authenticated` flag cannot be forged.
|
||||
- Validation is cookie-only — no JWK fetch / signature verification — so
|
||||
streaming endpoints keep working when the OIDC provider is briefly
|
||||
unavailable.
|
||||
- The user identifier from the session is forwarded as `X-Forwarded-User`
|
||||
(and `X-Auth-Request-User` unless `minimalHeaders: true`).
|
||||
|
||||
For browser clients, the user must complete the normal OIDC flow on a
|
||||
regular HTTP page first; the resulting session cookie is then reused on the
|
||||
SSE / WebSocket connection.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +275,8 @@ Without this setting, redirect URIs will use `http://` instead of `https://`, ca
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `audience` | string | `clientID` | Expected audience for access token validation |
|
||||
| `audience` | string | `clientID`, or `resource` if set | Expected audience for access token validation |
|
||||
| `resource` | string | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs — see [docs/RFC8707.md](RFC8707.md) |
|
||||
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
|
||||
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
||||
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
||||
@@ -90,6 +288,16 @@ audience: "https://my-api.example.com"
|
||||
strictAudienceValidation: true
|
||||
```
|
||||
|
||||
#### RFC 8707 Resource Indicators
|
||||
|
||||
```yaml
|
||||
resource: "https://api.example.com"
|
||||
```
|
||||
|
||||
Alternative to `audience` for IdPs that implement [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707).
|
||||
See [docs/RFC8707.md](RFC8707.md) for exactly what request is made, why only a single
|
||||
resource is supported, and current per-provider support.
|
||||
|
||||
#### Opaque Token Support
|
||||
|
||||
```yaml
|
||||
@@ -104,6 +312,77 @@ strictAudienceValidation: true
|
||||
|-----------|------|---------|-------------|
|
||||
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
||||
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
||||
| `allowUnauthenticatedPreflight` | bool | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` with `Origin` + `Access-Control-Request-Method`); response body is still discarded. Default requires auth for every `OPTIONS` request. |
|
||||
| `streamingRefresh` | bool | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request with `401` when the IdP revokes the grant (`invalid_grant`). Forward it as before when the IdP cannot be reached. |
|
||||
|
||||
### Discovered Endpoint Validation
|
||||
|
||||
When `providerURL` is `https://`, the plugin drops any endpoint the
|
||||
`.well-known/openid-configuration` document advertises as plain `http://`
|
||||
(authorization, token, jwks_uri, revocation, end_session, introspection,
|
||||
registration). A dropped credential-bearing endpoint would otherwise send
|
||||
the client secret or a token over an unauthenticated channel. There is no
|
||||
configuration flag to disable this check.
|
||||
|
||||
A dropped endpoint logs an `ERROR`-level line naming it, and a dropped
|
||||
`token`, `jwks_uri`, or `authorization` endpoint — the three logins cannot
|
||||
function without — additionally logs a `SECURITY:`-prefixed line, because a
|
||||
blank endpoint otherwise fails every login with no other signal:
|
||||
|
||||
```
|
||||
SECURITY: dropped the discovered token endpoint "http://idp.example.com/token": it is plaintext http while providerURL "https://idp.example.com" is https, and this check has no override; requests needing the token endpoint will fail until the provider serves it over https
|
||||
```
|
||||
|
||||
The `revocationURL`, `oidcEndSessionURL` and `introspectionURL` config
|
||||
fields can supply those three endpoints directly, and when you set one of
|
||||
them, a plaintext-`http://` drop of the matching discovered endpoint does
|
||||
not break anything — the override replaces the endpoint right after
|
||||
sanitize runs and the `SECURITY:` line does not fire for it.
|
||||
|
||||
Leave the override unset, though, and a drop is exactly as terminal as it is
|
||||
for `token`, `jwks_uri` and `authorization`: RP-initiated logout falls back
|
||||
to a local-only redirect (the IdP session stays alive), provider-side
|
||||
revocation is skipped, or `requireTokenIntrospection` breaks — all with no
|
||||
signal beyond the generic `ERROR` line. So `revocation`, `end_session` and
|
||||
`introspection` log the same `SECURITY:` line too, whenever the matching
|
||||
override is not set. `registration` never logs the `SECURITY:` line, under
|
||||
any configuration — it is not covered by this check. Dynamic Client
|
||||
Registration reads its own, separate override
|
||||
(`dynamicClientRegistration.registrationEndpoint`), independently of this
|
||||
discovery step, but only when you set it: leave it unset and a
|
||||
plaintext-`http://` drop of the discovered registration endpoint makes
|
||||
Dynamic Client Registration fail with only the generic `ERROR` line above —
|
||||
no `SECURITY:`-tagged signal, unlike `revocation`, `end_session` and
|
||||
`introspection`.
|
||||
|
||||
`token`, `jwks_uri` and `authorization` log the `SECURITY:` line
|
||||
unconditionally — these three have no config field that can supply them at
|
||||
all, so a drop always breaks login regardless of any other setting.
|
||||
|
||||
If your IdP's discovery document ever advertises an `http://` endpoint under
|
||||
an `https://` `providerURL`, fix the discovery document (for example, a
|
||||
TLS-terminating proxy in front of the IdP that does not forward
|
||||
`X-Forwarded-Proto`) rather than relying on the plugin to relax the check.
|
||||
|
||||
### Bearer-token (M2M) authentication
|
||||
|
||||
Opt-in path that accepts `Authorization: Bearer <jwt>` instead of the cookie
|
||||
session flow. M2M-only, default off, audience-mandatory. See
|
||||
[docs/BEARER_AUTH.md](BEARER_AUTH.md) for the threat model and operational
|
||||
guidance.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `enableBearerAuth` | bool | `false` | Master switch. Startup fails if true with empty `audience` or with `bearerIdentifierClaim=email`. |
|
||||
| `bearerIdentifierClaim` | string | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
||||
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
|
||||
| `bearerEmitWWWAuthenticate` | bool | `true` | Emit RFC 6750 `WWW-Authenticate: Bearer error="..."` hints on 401. |
|
||||
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
|
||||
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). `0` and unset both map to the 24h default; no value disables the check, and a bearer token or opaque-introspection response without `iat` is rejected. |
|
||||
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
||||
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
||||
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
||||
| `bearerFailurePenaltySeconds` | int | `60` | 429 + `Retry-After` duration after the threshold trips. |
|
||||
|
||||
---
|
||||
|
||||
@@ -113,8 +392,10 @@ strictAudienceValidation: true
|
||||
|-----------|------|---------|-------------|
|
||||
| `sessionMaxAge` | int | `86400` (24h) | Maximum session age in seconds |
|
||||
| `refreshGracePeriodSeconds` | int | `60` | Seconds before expiry to attempt refresh |
|
||||
| `maxRefreshTokenAgeSeconds` | int | `21600` | Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with `invalid_grant` retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set `0` to disable. Default `21600` (6h). |
|
||||
| `cookieDomain` | string | auto-detected | Domain for session cookies |
|
||||
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
|
||||
| `cookiePath` | string | `/` | Path prefix for session cookies. Set it to the middleware's path (for example `/app`) so the browser does not send OIDC cookies to unprotected paths (avoids HTTP 431). |
|
||||
|
||||
### Multi-Subdomain Setup
|
||||
|
||||
@@ -254,22 +535,77 @@ minimalHeaders: true # Only forwards X-Forwarded-User
|
||||
```yaml
|
||||
headers:
|
||||
- name: "X-User-Email"
|
||||
value: "{{{{.Claims.email}}}}"
|
||||
value: "{{.Claims.email}}"
|
||||
- name: "X-User-ID"
|
||||
value: "{{{{.Claims.sub}}}}"
|
||||
value: "{{.Claims.sub}}"
|
||||
- name: "Authorization"
|
||||
value: "Bearer {{{{.AccessToken}}}}"
|
||||
value: "Bearer {{.AccessToken}}"
|
||||
- name: "X-User-Roles"
|
||||
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
|
||||
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
- `{{.Claims.field}}` - ID token claims
|
||||
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
|
||||
- `{{.AccessToken}}` - Raw access token
|
||||
- `{{.IdToken}}` - Raw ID token
|
||||
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
|
||||
- `{{.RefreshToken}}` - Raw refresh token
|
||||
|
||||
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
|
||||
**Important — file provider only:** Traefik's file provider runs every dynamic
|
||||
configuration file through Go templating before the plugin sees it, so plain
|
||||
`{{ }}` fails there with `can't evaluate field AccessToken in type bool`.
|
||||
Escape with a raw string: ``value: "{{`{{.Claims.email}}`}}"``. All other
|
||||
providers (Kubernetes CRD, Docker labels, Consul, ...) pass the value through
|
||||
untouched — use the plain form shown above. Do not use quadruple braces
|
||||
(`{{{{ }}}}`); no configuration path collapses them, and template validation
|
||||
rejects them (issues #149, #151).
|
||||
|
||||
**Validation and the claims whitelist.** Header templates are parsed and
|
||||
validated when the middleware loads; an invalid or disallowed template stops the
|
||||
middleware from starting (the route then returns Traefik's
|
||||
`invalid handler type: <nil>`). To prevent a template from accidentally
|
||||
forwarding raw tokens or sensitive claims, only a fixed set of claim fields may
|
||||
be emitted — the standard OIDC claims plus common provider claims (`email`,
|
||||
`name`, `given_name`, `family_name`, `preferred_username`, `sub`, `groups`,
|
||||
`roles`, `internal_role`, `role`, `department`, `organization`, `realm_access`,
|
||||
`resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`, `locale`, `zoneinfo`,
|
||||
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
|
||||
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
|
||||
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
|
||||
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
|
||||
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
|
||||
.Claims.groups}}` is allowed).
|
||||
|
||||
**`allowedClaims`** extends the whitelist for claims your provider issues that
|
||||
are not built in:
|
||||
|
||||
```yaml
|
||||
allowedClaims:
|
||||
- employee_id
|
||||
- cost_center
|
||||
headers:
|
||||
- name: "X-Employee-Id"
|
||||
value: "{{.Claims.employee_id}}"
|
||||
```
|
||||
|
||||
Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
|
||||
`get` (`{{get .Claims "employee_id"}}`). Subfields of a whitelisted map-valued
|
||||
claim (e.g. `realm_access.roles`) are already accessible without listing them
|
||||
individually.
|
||||
|
||||
**`toJson`** forwards an object or array claim as one JSON header:
|
||||
|
||||
```yaml
|
||||
allowedClaims:
|
||||
- tenant
|
||||
headers:
|
||||
- name: "X-Tenant"
|
||||
value: "{{get .Claims \"tenant\" | toJson}}"
|
||||
```
|
||||
|
||||
It only serializes values the template can already reach, so the whitelist
|
||||
still applies; `{{toJson .Claims}}` is rejected. Control characters are
|
||||
JSON-escaped, and values over 8192 bytes or with bidi-override characters are
|
||||
dropped; an oversize value is logged at error level (header name only).
|
||||
|
||||
---
|
||||
|
||||
@@ -384,10 +720,14 @@ scopes:
|
||||
|
||||
### Dynamic Client Registration (RFC 7591)
|
||||
|
||||
Dynamic Client Registration allows the middleware to automatically register itself with the OIDC provider, eliminating the need to manually create client credentials.
|
||||
|
||||
**Basic Configuration (Single Instance):**
|
||||
|
||||
```yaml
|
||||
dynamicClientRegistration:
|
||||
enabled: true
|
||||
initialAccessToken: "your-token" # Optional
|
||||
initialAccessToken: "your-token" # Optional, if provider requires it
|
||||
persistCredentials: true
|
||||
credentialsFile: "/tmp/oidc-credentials.json"
|
||||
clientMetadata:
|
||||
@@ -400,6 +740,35 @@ dynamicClientRegistration:
|
||||
- "refresh_token"
|
||||
```
|
||||
|
||||
**Multi-Replica Deployment (Kubernetes):**
|
||||
|
||||
For Kubernetes deployments with multiple replicas, use Redis storage to share credentials across all instances and prevent registration race conditions:
|
||||
|
||||
```yaml
|
||||
dynamicClientRegistration:
|
||||
enabled: true
|
||||
persistCredentials: true
|
||||
storageBackend: "redis" # Share credentials via Redis
|
||||
redisKeyPrefix: "myapp:dcr:" # Optional custom prefix
|
||||
clientMetadata:
|
||||
redirect_uris:
|
||||
- "https://your-app.com/oauth2/callback"
|
||||
client_name: "My Application"
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
address: "redis:6379"
|
||||
cacheMode: "redis"
|
||||
```
|
||||
|
||||
**Storage Backend Options:**
|
||||
|
||||
| Backend | Description | Use Case |
|
||||
|---------|-------------|----------|
|
||||
| `file` | Store credentials in local file | Single instance deployments |
|
||||
| `redis` | Store credentials in Redis | Multi-replica Kubernetes deployments |
|
||||
| `auto` | Use Redis if available, fallback to file | Flexible deployments (default) |
|
||||
|
||||
### Multi-Replica Deployment
|
||||
|
||||
Without Redis, disable replay detection:
|
||||
|
||||
+95
@@ -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
|
||||
|
||||
+5
-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 |
|
||||
@@ -353,6 +353,8 @@ allowPrivateIPAddresses: true # Required for private IPs
|
||||
- Roles: User Client Role mapper with "Add to ID token" enabled
|
||||
- Groups: Group Membership mapper with "Add to ID token" enabled
|
||||
|
||||
See [KEYCLOAK_SETUP_GUIDE.md](KEYCLOAK_SETUP_GUIDE.md) for detailed step-by-step setup instructions, mapper configuration, troubleshooting, and performance optimization.
|
||||
|
||||
---
|
||||
|
||||
## AWS Cognito
|
||||
|
||||
+14
-6
@@ -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`) |
|
||||
|
||||
|
||||
+162
-5
@@ -90,6 +90,7 @@
|
||||
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
|
||||
<a href="#deployment" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Deployment</a>
|
||||
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
|
||||
<a href="#logout" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Logout</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
|
||||
@@ -114,6 +115,7 @@
|
||||
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
|
||||
<a href="#deployment" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Deployment</a>
|
||||
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
|
||||
<a href="#logout" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -193,7 +195,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Dynamic Registration</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration for automatic client setup without manual configuration</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">RFC 7591 Dynamic Client Registration with Redis storage support for multi-replica deployments</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -640,7 +642,7 @@ spec:
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientSecret</code></td>
|
||||
<td class="py-2 px-3">OAuth 2.0 client secret</td>
|
||||
<td class="py-2 px-3">OAuth 2.0 client secret. Only required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is unset or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">sessionEncryptionKey</code></td>
|
||||
@@ -716,6 +718,11 @@ spec:
|
||||
<td class="py-2 px-3">86400</td>
|
||||
<td class="py-2 px-3">Maximum session age in seconds (24 hours default)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">maxRefreshTokenAgeSeconds</code></td>
|
||||
<td class="py-2 px-3">21600</td>
|
||||
<td class="py-2 px-3">Heuristic upper bound on stored refresh-token lifetime (6 hours default). Past this, the plugin treats the RT as expired without contacting the IdP. Set <code>0</code> to disable.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">cookiePrefix</code></td>
|
||||
<td class="py-2 px-3">_oidc_raczylo_</td>
|
||||
@@ -728,9 +735,14 @@ spec:
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code></td>
|
||||
<td class="py-2 px-3">clientID</td>
|
||||
<td class="py-2 px-3">clientID, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code> if set</td>
|
||||
<td class="py-2 px-3">Custom audience for access token validation</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">resource</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">RFC 8707 resource indicator, alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code> for supporting IdPs — see docs/RFC8707.md.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
@@ -746,15 +758,48 @@ spec:
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Require RFC 7662 introspection for opaque tokens</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">disableReplayDetection</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Disable JTI replay detection (for multi-replica without Redis)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code></td>
|
||||
<td class="py-2 px-3">client_secret_post</td>
|
||||
<td class="py-2 px-3">Selects how the plugin authenticates to the token endpoint. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_post</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret_basic</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">Inline PEM private key used to sign client assertions for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyPath</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">Path to a PEM private key file. Alternative to <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionPrivateKey</code>.</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionKeyID</code></td>
|
||||
<td class="py-2 px-3">none</td>
|
||||
<td class="py-2 px-3">JWS <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">kid</code> header value. Required when <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAuthMethod</code> is <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">private_key_jwt</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">clientAssertionAlg</code></td>
|
||||
<td class="py-2 px-3">RS256</td>
|
||||
<td class="py-2 px-3">Signing algorithm for the client assertion. One of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">RS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">PS512</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES256</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES384</code>/<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">ES512</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Private Key JWT (RFC 7523)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Use this when your IdP (Entra ID, Okta, Auth0, Keycloak) pressures short-lived secrets, or when policy mandates secretless service-to-service authentication. The plugin signs a 60-second assertion with the configured private key and sends it as <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_assertion</code> instead of <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">client_secret</code>. Public-key registration on the IdP replaces shared-secret rotation. See <a href="https://www.rfc-editor.org/rfc/rfc7523" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">RFC 7523</a> and <a href="https://github.com/lukaszraczylo/traefikoidc/issues/135" target="_blank" rel="noopener" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline">issue #135</a>.</p>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>clientAuthMethod: private_key_jwt
|
||||
clientAssertionKeyPath: /etc/traefik/oidc-client.pem
|
||||
clientAssertionKeyID: my-client-key-2026
|
||||
# clientSecret no longer required</code></pre>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Example: Google Workspace with Domain Restriction</h3>
|
||||
|
||||
@@ -856,7 +901,54 @@ spec:
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.enableTLS</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Enable TLS for Redis connections</td>
|
||||
<td class="py-2 px-3">Enable TLS for Redis connections (e.g. AWS ElastiCache in-transit encryption)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis.tlsSkipVerify</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Skip TLS server certificate verification (testing only; not recommended in production)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Dynamic Client Registration (RFC 7591)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-3 text-sm">Automatically register your application with the OIDC provider. Supports Redis storage for multi-replica deployments:</p>
|
||||
<div class="overflow-x-auto mb-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Parameter</th>
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Default</th>
|
||||
<th class="text-left py-2 px-3 text-gray-900 dark:text-gray-100">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-600 dark:text-gray-400">
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.enabled</code></td>
|
||||
<td class="py-2 px-3">false</td>
|
||||
<td class="py-2 px-3">Enable dynamic client registration</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.persistCredentials</code></td>
|
||||
<td class="py-2 px-3">true</td>
|
||||
<td class="py-2 px-3">Persist registered credentials across restarts</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.storageBackend</code></td>
|
||||
<td class="py-2 px-3">auto</td>
|
||||
<td class="py-2 px-3">Storage backend: <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">file</code>, <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">redis</code>, or <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">auto</code> (uses Redis if available)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.redisKeyPrefix</code></td>
|
||||
<td class="py-2 px-3">dcr:creds:</td>
|
||||
<td class="py-2 px-3">Redis key prefix for DCR credentials</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">dynamicClientRegistration.clientMetadata.redirect_uris</code></td>
|
||||
<td class="py-2 px-3">-</td>
|
||||
<td class="py-2 px-3">Redirect URIs for the registered client (required)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1177,6 +1269,71 @@ spec:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- IdP-Initiated Logout Section -->
|
||||
<section id="logout" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">IdP-Initiated Logout</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Support for OIDC Back-Channel and Front-Channel Logout specifications</p>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-server mr-2 text-blue-500"></i>
|
||||
Back-Channel Logout
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Server-to-server logout notification. The IdP sends a signed JWT (logout_token) directly to your application when a user logs out.
|
||||
</p>
|
||||
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
|
||||
<li>• Signed JWT logout tokens</li>
|
||||
<li>• Session ID (sid) based invalidation</li>
|
||||
<li>• Subject (sub) based invalidation</li>
|
||||
<li>• Works behind firewalls</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-browser mr-2 text-purple-500"></i>
|
||||
Front-Channel Logout
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Browser-based logout via iframe. The IdP embeds an iframe pointing to your logout endpoint during user logout.
|
||||
</p>
|
||||
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
|
||||
<li>• Iframe-based session termination</li>
|
||||
<li>• Immediate cookie invalidation</li>
|
||||
<li>• Simple GET request handling</li>
|
||||
<li>• Issuer validation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuration Example</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
|
||||
middlewares:
|
||||
oidc-auth:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
# ... other OIDC configuration ...
|
||||
|
||||
# Back-Channel Logout (server-to-server)
|
||||
enableBackchannelLogout: true
|
||||
backchannelLogoutURL: "/backchannel-logout"
|
||||
|
||||
# Front-Channel Logout (browser-based)
|
||||
enableFrontchannelLogout: true
|
||||
frontchannelLogoutURL: "/frontchannel-logout"</code></pre>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mt-4">
|
||||
Configure your IdP with the full URLs (e.g., <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">https://your-app.example.com/backchannel-logout</code>).
|
||||
When a user logs out from the IdP, all their sessions across your applications will be invalidated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Choose Section -->
|
||||
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# Bearer Token Authentication — Design Spec
|
||||
|
||||
- **Date**: 2026-05-18
|
||||
- **Status**: Design — pending implementation plan
|
||||
- **Supersedes**: PR #93 (broken implementation; recommended to close in favour of this design)
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Add an opt-in path that lets API clients (machine-to-machine) authenticate by presenting a signed access token in the `Authorization: Bearer <token>` header, bypassing the cookie-based OIDC redirect flow. Identity, roles, and authorization checks remain consistent with the existing cookie path; the only thing that changes is how the principal is established for that single request.
|
||||
|
||||
The feature is implemented by extracting a shared `forwardAuthorized` pipeline from the existing `processAuthorizedRequest`, introducing a `principal` value type, and adding a small bearer-specific entrypoint that builds a principal directly from a verified JWT — without synthesising a fake `SessionData`.
|
||||
|
||||
## 2. Motivation
|
||||
|
||||
PR #93 attempted this feature by building an in-memory `SessionData` from JWT claims and reusing `processAuthorizedRequest`. The approach has three latent defects:
|
||||
|
||||
1. The synthetic session omits `mainSession.Values["user_identifier"]`. `processAuthorizedRequest` reads it via `GetUserIdentifier()`; when empty it bails to `defaultInitiateAuthentication` and issues an OIDC redirect. The feature is non-functional in practice despite the unit test passing.
|
||||
2. `verifyToken` accepts both ID tokens (audience match against `clientID`) and access tokens. ID tokens are not API credentials; treating them as such is a classic token-confusion vector.
|
||||
3. `verifyToken` adds JTI to the replay blacklist on first verify. Once the verified-token cache evicts, subsequent reuse of the same bearer token triggers a false-positive replay rejection.
|
||||
|
||||
Rather than patch a synthetic-session approach that will keep generating bugs as `SessionData` evolves, this spec replaces it with a cleaner abstraction where session lifecycle and post-auth header injection live in separate units.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Accept `Authorization: Bearer <jwt>` from M2M clients, validate the token, and forward the request downstream with identity headers populated.
|
||||
- Enforce the same `allowedRolesAndGroups` policy as the cookie path.
|
||||
- Default-off; safe defaults when enabled (audience required, ID tokens rejected, identifier sanitised).
|
||||
- No behavioural change to the cookie path. Existing tests must continue to pass without modification.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Human-user / browser flows. Bearer is M2M-only in this iteration.
|
||||
- Pure opaque access tokens on the bearer path. Tokens must be JWTs; introspection (RFC 7662) is supported *on top of* JWT verification for revocation state, not as a substitute for it.
|
||||
- mTLS, API keys, or any other auth method. The `principal` abstraction enables them later, but they are not delivered here.
|
||||
- Per-route bearer configuration. Single middleware-wide setting.
|
||||
|
||||
## 5. Decided Requirements
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Consumer type | Machine-to-machine (M2M) only |
|
||||
| Token format | JWT only (signature, issuer, audience, exp) |
|
||||
| Audience | Mandatory when feature enabled; startup fails if `Audience == ""` |
|
||||
| Token type | Access tokens only; ID tokens explicitly rejected |
|
||||
| Revocation | JWT-only verification by default; introspection (RFC 7662) opt-in via existing `RequireTokenIntrospection` |
|
||||
| Identity claim | New `BearerIdentifierClaim` config (string, default `"sub"`). Bearer path reads this claim exclusively; does NOT use `UserIdentifierClaim` (which defaults to `"email"` and drives the cookie path). Resolved value must be a non-empty string. `sub` is mandatory per `jwt.go:416` regardless, so even with a different `BearerIdentifierClaim` the token must still carry a valid `sub`. Decoupling avoids the M2M-vs-human-user identity-claim conflict and the email-spoofing footgun. |
|
||||
| Identifier sanitisation | Reject value containing any `unicode.IsControl` char, any Unicode bidi-override (U+202A–U+202E, U+2066–U+2069), leading/trailing whitespace, commas, semicolons, equals signs. Max length 256 bytes. |
|
||||
| Token classifier | **Reuse existing `detectTokenType(jwt, token)` at `token_manager.go:187-303`** which already handles `nonce`, `typ: at+jwt`, `token_use`, `scope`, and aud-vs-clientID priority. Bearer path rejects any token where `detectTokenType == true` (ID token). Do not invent a parallel classifier. |
|
||||
| Algorithm pinning | Hard-pin `alg ∈ {RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512}`, enforced **before** JWKS lookup on the bearer path. Prevents wasted JWKS fetches for `alg=none`/HS attacker probes. |
|
||||
| `kid` hardening | `kid` ≤ 256 bytes, charset `[A-Za-z0-9._\-=]`. Reject before JWKS lookup. |
|
||||
| Token age | Bearer path enforces `now - iat <= MaxTokenAgeSeconds` (default 86400 / 24h, configurable). Cookie path unchanged. |
|
||||
| Multi-audience policy | If `aud` is an array (length > 1), require `azp` claim to be present and equal to `clientID`. Single-string `aud` unaffected. |
|
||||
| Mixed bearer + cookie precedence | **Cookie wins by default** when both are presented (safer for browser scenarios). Operator opt-in: `BearerOverridesCookie=true` to flip. Either way, a warning is logged on the request. |
|
||||
| Bearer + excluded URL | `Authorization` header is **stripped** before forwarding when the request hits an excluded URL. Prevents bearer leaking into public endpoints' downstream logs and prevents recon via excluded paths. |
|
||||
| Per-source bearer 401 throttle | New sharded cache `failedBearerAttempts` keyed by client IP. After N (default 20) consecutive 401s from one IP within 1 minute, reject further bearer requests from that IP with 429 for 60s. Applied BEFORE `verifyToken` to deny JWKS amplification. |
|
||||
| `Authorization` header passthrough | New `StripAuthorizationHeader` config, default `true` |
|
||||
| Roles/groups gating | Same `allowedRolesAndGroups` rules as cookie path |
|
||||
| Default state | `EnableBearerAuth` = `false` |
|
||||
| JTI replay marking | Suppressed on bearer path; cookie path unchanged |
|
||||
| Failure response shape | 401 with generic body; `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 |
|
||||
| Introspection endpoint outage | 503 (distinguishes infra outage from token rejection) |
|
||||
| Mixed bearer + cookie | Bearer wins; cookie ignored on that request |
|
||||
| SSE/WS bypass + bearer | Bypass paths keep cookie-only check; bearer header ignored on SSE/WS |
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
HTTP req ──► │ ServeHTTP │ (existing entry; adds bearer detection)
|
||||
└─────────┬────────┘
|
||||
┌───────────┴────────────┐
|
||||
▼ ▼
|
||||
cookie / session bearer (Authorization: Bearer …)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────────┐
|
||||
│ buildPrincipal │ │ buildPrincipal │
|
||||
│ FromSession() │ │ FromBearerToken() │
|
||||
└────────┬───────┘ └─────────┬──────────┘
|
||||
│ produces *principal │
|
||||
└──────────────┬───────────┘
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ forwardAuthorized(rw,req,p)│ (shared pipeline)
|
||||
│ • roles/groups gate │
|
||||
│ • header injection │
|
||||
│ • header templates │
|
||||
│ • security headers │
|
||||
│ • cookie stripping │
|
||||
│ • next.ServeHTTP │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**Invariant**: `forwardAuthorized` never touches session storage. Session-specific concerns (Save, IsDirty, backchannel-logout invalidation) stay inside `processAuthorizedRequest` around the call to `forwardAuthorized`.
|
||||
|
||||
**Feature gate**: when `EnableBearerAuth == false`, the bearer-detection check in `ServeHTTP` is a no-op. Existing deployments observe byte-identical behaviour.
|
||||
|
||||
## 7. Components
|
||||
|
||||
### 7.1 `principal` type (new file `principal.go`)
|
||||
|
||||
```go
|
||||
type principalSource int
|
||||
|
||||
const (
|
||||
sourceSession principalSource = iota
|
||||
sourceBearer
|
||||
)
|
||||
|
||||
type principal struct {
|
||||
Identifier string // drives X-Forwarded-User
|
||||
Email string // optional, "" for M2M
|
||||
Subject string // sub claim
|
||||
ClientID string // azp / client_id, M2M caller
|
||||
Claims map[string]interface{} // raw claims for templates / groups
|
||||
AccessToken string // for X-Auth-Request-Token (gated by minimalHeaders)
|
||||
IDToken string // "" on bearer path
|
||||
RefreshToken string // "" on bearer path
|
||||
Source principalSource
|
||||
}
|
||||
```
|
||||
|
||||
Pure data. No methods that mutate it. No I/O. No manager pointer.
|
||||
|
||||
### 7.2 `buildPrincipalFromSession(*SessionData) *principal` (new in `principal.go`)
|
||||
|
||||
Read-only adapter over existing `SessionData` getters: `GetUserIdentifier`, `GetEmail`, `GetAccessToken`, `GetIDToken`, `GetRefreshToken`, cached claims via `GetIDTokenClaims`. Does not write back to the session. This is the only function that still knows about `SessionData`.
|
||||
|
||||
### 7.3 `buildPrincipalFromBearerToken(token string) (*principal, error)` (new in `bearer_auth.go`)
|
||||
|
||||
1. **Length / format guards**: `len(token) <= AccessTokenConfig.MaxLength`, exactly two dots, non-empty after trim.
|
||||
2. **Parse header for early alg/kid pinning** (without trusting payload): decode JOSE header; reject if `alg` ∉ asymmetric allowlist; reject if `kid` missing, > 256 bytes, or contains chars outside `[A-Za-z0-9._\-=]`. This happens **before** JWKS lookup so attacker noise doesn't amplify into JWKS fetches.
|
||||
3. **Per-IP 401 throttle check**: if this IP is in the `failedBearerAttempts` penalty box, return 429 immediately.
|
||||
4. `t.verifyToken(token, verifyOpts{skipReplayMarking: true})` — reuses signature, issuer, audience, expiration, JTI Get (replay detection). The `skipReplayMarking` flag gates ONLY the JTI Set at `token_manager.go:108-143`; the JTI Get at `token_manager.go:44-47, 80-89` remains active so revoked tokens (via `RevokeToken` adding to blacklist) are still rejected.
|
||||
5. **Re-parse claims** (`parseJWT(token)` is cheap and already done internally; reuse via a single decode if practical).
|
||||
6. **Token-type guard**: call existing `detectTokenType(jwt, token)` (`token_manager.go:187-303`). Reject when it returns `true` (ID token). Belt-and-braces: also reject if `claims["nonce"]` is a non-empty string or `claims["token_use"] == "id"`.
|
||||
7. **Multi-audience hardening**: if `claims["aud"]` is a `[]interface{}` with length > 1, require `claims["azp"]` to be a non-empty string equal to `t.clientID`; reject otherwise.
|
||||
8. **`iat` upper-age bound**: reject when `time.Now().Unix() - int64(claims["iat"].(float64)) > MaxTokenAgeSeconds` (default 86400).
|
||||
9. **Optional introspection**: if `requireTokenIntrospection` is set, call `introspectToken`; reject if `active == false` (401); surface 503 on transport failure. Bearer-path introspection cache TTL is capped at 60s (not 5min) to keep the "real-time revocation" promise close to true.
|
||||
10. **Identifier resolution**: read `t.bearerIdentifierClaim` (defaults to `"sub"`); do NOT use `t.userIdentifierClaim` (cookie path's setting, default `email`). The bearer path does NOT fall back to other claims because `jwt.Verify` already enforces non-empty `sub` (`jwt.go:416-419`). Empty/missing identifier → 401.
|
||||
11. **Identifier sanitisation**: trim, then reject if length > 256 OR contains any of: `unicode.IsControl`, bidi-override (U+202A–U+202E, U+2066–U+2069), `,`, `;`, `=`.
|
||||
12. Return `&principal{ Source: sourceBearer, … }`.
|
||||
|
||||
On any failure path: increment the per-IP `failedBearerAttempts` counter; return the appropriate HTTP status (401 / 403 / 429 / 503) without revealing the failure reason in the response body. Reason is logged at debug only, with the identifier (if resolved) hashed via SHA-256 truncated to 8 hex chars.
|
||||
|
||||
### 7.4 `forwardAuthorized(rw, req, *principal)` (new in `middleware.go`, extracted)
|
||||
|
||||
The shared post-auth pipeline. Lifted verbatim from the existing `processAuthorizedRequest`:
|
||||
|
||||
1. Roles/groups extraction via existing `extractGroupsAndRolesFromClaims`.
|
||||
2. `allowedRolesAndGroups` gate (existing logic).
|
||||
3. Inject `X-Forwarded-User`, `X-User-Groups`, `X-User-Roles`.
|
||||
4. Inject `X-Auth-Request-*` (gated by `minimalHeaders`).
|
||||
5. Header templates.
|
||||
6. Security headers.
|
||||
7. Cookie strip when `stripAuthCookies`.
|
||||
8. **New**: `Authorization` header strip when `stripAuthorizationHeader` AND `principal.Source == sourceBearer`.
|
||||
9. `t.next.ServeHTTP(rw, req)`.
|
||||
|
||||
Does not call `Save`, does not check `IsDirty`. Session persistence stays with the cookie-path caller.
|
||||
|
||||
### 7.5 `handleBearerRequest(rw, req)` (new in `bearer_auth.go`)
|
||||
|
||||
```
|
||||
1. Detect "Authorization: Bearer <token>" (case-insensitive prefix).
|
||||
2. token = TrimSpace(authHeader[7:]); reject empty.
|
||||
3. p, err := buildPrincipalFromBearerToken(token).
|
||||
On err → 401 with WWW-Authenticate, log reason at debug.
|
||||
4. forwardAuthorized(rw, req, p).
|
||||
```
|
||||
|
||||
Target: ~40 lines.
|
||||
|
||||
### 7.6 Refactor of `processAuthorizedRequest` (modify `middleware.go`)
|
||||
|
||||
Splits along the principal boundary:
|
||||
- Session-specific part (backchannel-logout invalidation, `IsDirty` / `Save`) stays in `processAuthorizedRequest`.
|
||||
- Everything else moves to `forwardAuthorized`.
|
||||
- `processAuthorizedRequest` ends with `forwardAuthorized(rw, req, buildPrincipalFromSession(session))`.
|
||||
|
||||
### 7.7 `verifyOpts` extension to `verifyToken` (modify `token_manager.go`)
|
||||
|
||||
Add a parameter struct:
|
||||
```go
|
||||
type verifyOpts struct {
|
||||
skipReplayMarking bool // suppress JTI Set (token_manager.go:108-143); blacklist Get stays active
|
||||
}
|
||||
```
|
||||
|
||||
Both the type and field are unexported (internal-only knob). Signature change: `verifyToken(token string)` becomes `verifyToken(token string, opts verifyOpts)`. Existing callers pass `verifyOpts{}` (zero value = current behaviour). Bearer path passes `verifyOpts{skipReplayMarking: true}`.
|
||||
|
||||
**Critical semantics — must be reflected in implementation and tests:**
|
||||
- `skipReplayMarking` only gates the **Set** at `token_manager.go:108-143` (the call adding the JTI to the blacklist and replay cache).
|
||||
- The blacklist **Get** at `token_manager.go:44-47, 80-89` stays unconditionally active on the bearer path. Tokens revoked via `RevokeToken` (which adds the JTI to the blacklist) MUST still be rejected on the bearer path.
|
||||
- Must NOT be implemented by mutating `t.disableReplayDetection` (struct field) — that would create a cross-request race that disables replay protection globally.
|
||||
|
||||
A targeted regression test exercises: bearer token verified once → admin calls `RevokeToken` adding the JTI to the blacklist → same token replayed → 401.
|
||||
|
||||
### 7.8 Config additions (modify `settings.go`)
|
||||
|
||||
```go
|
||||
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
|
||||
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
|
||||
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
|
||||
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
|
||||
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
|
||||
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
|
||||
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
|
||||
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
|
||||
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
|
||||
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
|
||||
```
|
||||
|
||||
Defaults (applied in `CreateConfig` for the bearer-related fields; values >0 only honoured when `EnableBearerAuth=true`):
|
||||
- `EnableBearerAuth`: `false`.
|
||||
- `BearerIdentifierClaim`: `"sub"`.
|
||||
- `StripAuthorizationHeader`: `true`.
|
||||
- `BearerEmitWWWAuthenticate`: `true` (RFC 6750 hint enabled by default; flip to false if recon-exposure is a concern).
|
||||
- `BearerOverridesCookie`: `false` (cookie wins when both present; flip to `true` for the legacy/industry-default behaviour).
|
||||
- `MaxTokenAgeSeconds`: `86400` (24h upper bound on `iat`).
|
||||
- `MaxIdentifierLength`: `256`.
|
||||
- `BearerFailureThreshold`: `20` (consecutive 401s per IP before throttle).
|
||||
- `BearerFailureWindowSeconds`: `60`.
|
||||
- `BearerFailurePenaltySeconds`: `60` (429 reply for this long after threshold tripped).
|
||||
|
||||
### 7.9 Startup validation (modify `main.go` `New()`)
|
||||
|
||||
- `EnableBearerAuth && Audience == ""` → fatal error.
|
||||
- `EnableBearerAuth && !StrictAudienceValidation` → warning log (recommended hardening).
|
||||
- `EnableBearerAuth && BearerIdentifierClaim == "email"` → fatal error (the bearer path is M2M and an `email` identifier without `email_verified` enforcement is a spoofing vector; default `BearerIdentifierClaim=sub` avoids this; explicit override to `email` is rejected).
|
||||
- `EnableBearerAuth && MaxTokenAgeSeconds <= 0` → reset to default 86400 with info log.
|
||||
- `EnableBearerAuth && BearerFailureThreshold <= 0` → reset to default 20 with info log.
|
||||
|
||||
## 8. Data Flow
|
||||
|
||||
### 8.1 Bearer path
|
||||
|
||||
```
|
||||
ServeHTTP entry (pre-init paths unchanged: logout, backchannel, frontchannel, excluded URLs, SSE/WS bypass)
|
||||
│
|
||||
├─ enableBearerAuth == false? → fall through to cookie path
|
||||
│
|
||||
└─ enableBearerAuth == true AND Authorization starts with "Bearer "
|
||||
│
|
||||
▼
|
||||
handleBearerRequest
|
||||
│
|
||||
├─ format guards (empty, length, segment count)
|
||||
│
|
||||
▼
|
||||
verifyToken(token, verifyOpts{SkipReplayMarking: true})
|
||||
│ signature, issuer, audience (strict), exp
|
||||
│
|
||||
▼
|
||||
classifyToken(claims) → reject ID tokens
|
||||
│
|
||||
▼
|
||||
if requireTokenIntrospection: introspectToken → active check
|
||||
│
|
||||
▼
|
||||
resolveIdentifier(claims) → sanitiseIdentifier
|
||||
│
|
||||
▼
|
||||
principal{Source: sourceBearer, …}
|
||||
│
|
||||
▼
|
||||
forwardAuthorized(rw, req, principal)
|
||||
│
|
||||
├─ roles/groups gate (403 on deny)
|
||||
├─ header injection
|
||||
├─ header templates
|
||||
├─ security headers
|
||||
├─ strip OIDC cookies (existing)
|
||||
├─ strip Authorization header (new, when configured)
|
||||
└─ next.ServeHTTP(rw, req)
|
||||
```
|
||||
|
||||
### 8.2 Cookie path (refactored, semantically unchanged)
|
||||
|
||||
```
|
||||
processAuthorizedRequest
|
||||
1. Session validity / backchannel-logout invalidation (unchanged).
|
||||
2. principal := buildPrincipalFromSession(session).
|
||||
3. forwardAuthorized(rw, req, principal).
|
||||
4. if session.IsDirty(): session.Save().
|
||||
```
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
| Trigger | Status | Body | WWW-Authenticate | Debug log reason |
|
||||
|---|---|---|---|---|
|
||||
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` | empty bearer token |
|
||||
| Token over MaxLength | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token exceeds max length |
|
||||
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` | malformed JWT |
|
||||
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | unsupported alg |
|
||||
| Missing/oversized/bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid kid |
|
||||
| Signature / issuer / aud / exp fail | 401 | `Unauthorized` | `Bearer error="invalid_token"` | reason from verifyToken (category only) |
|
||||
| `iat` older than MaxTokenAgeSeconds | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token too old (iat outside age bound) |
|
||||
| Multi-aud without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | multi-aud token without azp match |
|
||||
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` | ID tokens not accepted on bearer path |
|
||||
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token JTI in blacklist |
|
||||
| Introspection `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token inactive at IdP |
|
||||
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) | introspection unavailable |
|
||||
| Identifier claim missing/empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` | no identifier claim |
|
||||
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid identifier characters |
|
||||
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <BearerFailurePenaltySeconds>` | source IP in penalty box |
|
||||
| Roles/groups not allowed | 403 | `Access denied` | (none) | user not in allowedRolesAndGroups |
|
||||
|
||||
Responses never include token contents, never include the raw failure reason, and never set `Location` headers (API clients cannot follow redirects).
|
||||
|
||||
## 10. Edge Cases
|
||||
|
||||
1. **Both bearer header and cookie session present.** Cookie wins by default (safer against browser/extension/proxy bearer injection). `BearerOverridesCookie=true` flips to bearer-wins. Either way: WARN log includes both source markers so operators can audit.
|
||||
2. **`Authorization: Basic …`.** Not bearer; cookie path runs as today.
|
||||
3. **`Authorization: Bearer ` (trailing space, no value).** Empty after trim → 401.
|
||||
4. **Mixed-case prefix (`bearer`, `BEARER`, `BeArEr`).** Case-insensitive prefix check; token value preserved verbatim.
|
||||
5. **Multiple `Authorization` headers.** Use only the first (Go `http.Header.Get` default). Documented.
|
||||
6. **Bearer during OIDC init wait.** Bearer requests also block on init: we need `issuerURL`, `audience`, JWKs ready. If init fails, bearer requests return 503 just like cookie requests.
|
||||
7. **SSE / WebSocket bypass with bearer.** Bypass paths keep cookie-only behaviour. Operators who want bearer on streaming endpoints must remove SSE/WS bypass. Documented.
|
||||
8. **Logout endpoint with bearer.** Logout runs before bearer detection. Treated as cookie-session logout; bearer token revocation requires IdP-side action.
|
||||
9. **Excluded URLs with bearer.** Bypass excluded URLs as today; bearer not validated on excluded paths. ADDITIONALLY: `Authorization: Bearer` is stripped from the request before forwarding so the token can't leak into the excluded endpoint's downstream logs / metrics scrapers / health checks.
|
||||
10. **Concurrent identical bearer requests.** Existing `tokenCache` is concurrency-safe; no new locking.
|
||||
11. **Client rotates token between requests.** Independent verification per token; independent cache entries.
|
||||
12. **Clock skew.** Use existing `jwt.Verify` leeway. (If absent, add ±30s as a separate change; out of scope here.)
|
||||
|
||||
## 11. Testing Strategy
|
||||
|
||||
### 11.1 Integration tests (new `bearer_auth_test.go`)
|
||||
|
||||
Table-driven test against a real `httptest.Server` and the full `ServeHTTP` flow. Coverage matrix:
|
||||
|
||||
- Valid access token + allowed roles → 200, `next` ran, `X-Forwarded-User` set.
|
||||
- Valid token without configured roles → 200.
|
||||
- Wrong audience, expired, tampered signature → 401, `next` did not run.
|
||||
- ID token presented → 401 (`ID tokens not accepted`).
|
||||
- Malformed JWT (2 segments) → 401.
|
||||
- Oversized token (> MaxLength) → 401.
|
||||
- Empty bearer → 401.
|
||||
- Missing identifier claim → 401.
|
||||
- Identifier containing `\r\n` → 401.
|
||||
- `allowedRolesAndGroups` mismatch → 403.
|
||||
- `allowedRolesAndGroups` match → 200.
|
||||
- `EnableBearerAuth=false` + bearer header → cookie path runs (302 to `/authorize`).
|
||||
- Bearer + valid cookie session → bearer wins, 200.
|
||||
- `StripAuthorizationHeader=true` → downstream sees no `Authorization`.
|
||||
- `StripAuthorizationHeader=false` → downstream sees `Authorization`.
|
||||
- Case variants (`bearer`, `BEARER`) → 200.
|
||||
- SSE bypass + bearer → cookie-only check applies (bearer ignored).
|
||||
- **Replay regression**: same token 1000 times in a row → all 200.
|
||||
- **Cache-evict regression**: same token, force-evict `tokenCache` between iterations (call `tokenCache.Delete` directly), replay → still 200 (verifies `skipReplayMarking` doesn't poison the blacklist).
|
||||
- **Revocation-while-bearer regression**: bearer token verified once → admin calls `RevokeToken` adding JTI to blacklist → same token presented → 401 (verifies blacklist Get stays active on bearer path even with `skipReplayMarking` set).
|
||||
- **Alg-pin: token signed with `alg=none`** → 401, no JWKS fetch happens (verify with a counting mock).
|
||||
- **`kid` injection: 50KB random kid** → 401 immediately, no JWKS fetch.
|
||||
- **Per-IP throttle**: 21 bad bearer requests from same IP within 1 minute → 22nd returns 429 + Retry-After.
|
||||
- **`iat` upper-age**: token with `iat = now - 25h` → 401 (older than 24h default).
|
||||
- **Multi-aud without azp**: aud = `["a", "b"]`, no azp → 401.
|
||||
- **Multi-aud with matching azp**: aud = `["api-aud", "other"]`, azp = clientID → 200.
|
||||
- **Identifier with bidi-override**: sub contains U+202E → 401.
|
||||
- **Identifier with comma**: sub = `"alice,bob"` → 401.
|
||||
- **Identifier over 256 bytes** → 401.
|
||||
- **`UserIdentifierClaim=email` at startup with EnableBearerAuth=true** → startup fails.
|
||||
- **Excluded URL + bearer**: bearer header presented on excluded URL → request forwarded, downstream sees no `Authorization` header (stripped).
|
||||
|
||||
### 11.2 Unit tests (in `bearer_auth_test.go`)
|
||||
|
||||
- `classifyToken`: ID-token detection, access-token detection by `scope`/`scp`/`token_use`, ambiguous → reject.
|
||||
- `resolveIdentifier`: precedence (`userIdentifierClaim` → `sub` → `client_id`/`azp`); missing → error; empty string → error.
|
||||
- `sanitizeIdentifier`: rejects all `unicode.IsControl`; accepts email/sub-style values.
|
||||
|
||||
### 11.3 Introspection tests (`bearer_auth_introspection_test.go`)
|
||||
|
||||
- Token valid + introspection `active=true` → 200.
|
||||
- Token valid + introspection `active=false` → 401.
|
||||
- Introspection endpoint 500 → 503.
|
||||
- Second request hits introspection cache (no second HTTP call).
|
||||
|
||||
### 11.4 Startup validation tests (extend `settings_test.go` / `main_test.go`)
|
||||
|
||||
- `EnableBearerAuth=true, Audience=""` → `New()` errors.
|
||||
- `EnableBearerAuth=true, StrictAudienceValidation=false` → succeeds with warning.
|
||||
- `EnableBearerAuth=false` → no validation; existing tests untouched.
|
||||
|
||||
### 11.5 Cookie-path regression suite
|
||||
|
||||
- All existing `TestServeHTTP_*` tests in `main_servehttp_test.go` pass unmodified.
|
||||
- Add: cookie session, `EnableBearerAuth=true`, no bearer header → identical behaviour to baseline.
|
||||
- Add: dirty session still triggers `Save()` after refactor.
|
||||
|
||||
### 11.6 Principal invariants
|
||||
|
||||
- `buildPrincipalFromSession`: `Source == sourceSession`; `IDToken` / `RefreshToken` populated when present in session.
|
||||
- `buildPrincipalFromBearerToken`: `Source == sourceBearer`; `IDToken == ""`, `RefreshToken == ""`.
|
||||
- `forwardAuthorized` produces identical headers for equivalent principals regardless of source.
|
||||
|
||||
### 11.7 Coverage gate
|
||||
|
||||
- New code in `bearer_auth.go` and `principal.go`: ≥ 90% line coverage.
|
||||
- `forwardAuthorized` coverage ≥ existing `processAuthorizedRequest` coverage baseline.
|
||||
|
||||
### 11.8 Out of scope (follow-ups)
|
||||
|
||||
- Load test of bearer vs cookie hot path.
|
||||
- Fuzzing the JWT parser.
|
||||
- Additional auth methods (mTLS, API keys) — design enables them, but they are separate work.
|
||||
|
||||
## 12. Migration / Rollout
|
||||
|
||||
Default-off. Existing deployments observe no behavioural change. Operators opt in by setting:
|
||||
|
||||
```yaml
|
||||
enableBearerAuth: true
|
||||
audience: https://api.example.com # required when bearer enabled
|
||||
# optional:
|
||||
stripAuthorizationHeader: true # default
|
||||
requireTokenIntrospection: false # default; set true for real-time revocation
|
||||
userIdentifierClaim: client_id # optional override; defaults to sub fallback chain
|
||||
```
|
||||
|
||||
Documentation: update `docs/CONFIGURATION.md` with a bearer-auth section, and add a new `docs/BEARER_AUTH.md` covering the security model, threat assumptions (token issuer is trusted; audience must be set; bearer means trust the issuer's revocation policy unless introspection enabled), and recommended configurations for common IdPs.
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Token confusion (ID token used as bearer) | Reuse `detectTokenType` (`token_manager.go:187-303`) which checks `nonce`, `typ: at+jwt`, `token_use`, `scope`, aud-vs-clientID. Belt-and-braces: explicit `nonce` + `token_use == "id"` rejection on top. |
|
||||
| Audience confusion (token for service B accepted by A) | `Audience` mandatory at startup; verified via existing `VerifyJWTSignatureAndClaims`; multi-aud tokens require matching `azp == clientID`. |
|
||||
| Replay-via-blacklist false positive | `verifyOpts{skipReplayMarking: true}` on bearer path. Gates ONLY the Set; the Get stays so revoked tokens still fail. |
|
||||
| Revocation lag | Optional RFC 7662 introspection. Bearer-path introspection cache TTL capped at 60s. Set `RequireTokenIntrospection=true` for real-time revocation. |
|
||||
| `alg`-confusion / `alg=none` attacks | Hard-pin asymmetric allowlist at bearer entry, **before** JWKS fetch. Prevents wasted upstream calls and locks out HS/none probes. |
|
||||
| `kid` injection / JWKS amplification | `kid` length cap (256 bytes) + charset allowlist enforced at bearer entry. |
|
||||
| Bearer 401 brute-force / oracle | Per-IP `failedBearerAttempts` cache; configurable threshold + penalty box returning 429 + `Retry-After`. |
|
||||
| `iat` clock-manipulation / forever-tokens | `MaxTokenAgeSeconds` upper bound (default 24h); cookie path unchanged. |
|
||||
| Identifier-driven header injection | `sanitizeIdentifier`: length cap, control-char + bidi-override + `,;=` rejection. `net/http` rejects CRLF on the wire too (defence in depth). |
|
||||
| Token leakage downstream | `StripAuthorizationHeader=true` by default. Also: `Authorization` stripped on excluded-URL requests so bearer can't leak into health/metrics downstream logs. |
|
||||
| Token-in-logs | All log paths log reason categories, not raw tokens. Identifier hashed via SHA-256 truncated to 8 hex chars before any info/warn-level emission (full identifier only at debug). New `safeLogAuthEvent(category, hashedIdentifier, reasonCode)` helper makes this hard to misuse. |
|
||||
| `email` claim spoofing | Startup fails if `EnableBearerAuth && UserIdentifierClaim == "email"`. Future human-user bearer iteration must add `email_verified` enforcement. |
|
||||
| Bypass on SSE / WS endpoints | SSE/WS bypass keeps cookie-only behaviour; bearer ignored. Operators choose to widen if needed. |
|
||||
| Mixed bearer + cookie precedence | Cookie wins by default (safer for browser scenarios); `BearerOverridesCookie=true` flips. WARN log on both-present requests. |
|
||||
| Configuration drift (operator forgets audience) | Startup fails when `EnableBearerAuth=true && Audience==""`. |
|
||||
| Downstream blast radius when `StripAuthorizationHeader=false` | Documented: forwarded bearer extends token's blast radius to all downstream services. Logs at those services become token stores. Operators must treat downstream log policy accordingly. |
|
||||
| Introspection auth method (pre-existing gap, called out) | `token_introspection.go:80` uses `client_secret_basic` only; does not honour `private_key_jwt`. Out of scope for this PR but documented as a follow-up; operators using `ClientAuthMethod=private_key_jwt` + `RequireTokenIntrospection=true` should be aware introspection will use basic auth. |
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
None — all design decisions resolved during brainstorming + security review. Implementation may surface incidental questions (e.g. exact clock-skew leeway in `jwt.Verify`); those are out of scope for this spec and handled in the implementation plan.
|
||||
|
||||
## 14a. Security Review Reference
|
||||
|
||||
This design was reviewed by the `security-reviewer` subagent on 2026-05-18. Findings incorporated:
|
||||
|
||||
- **Critical**: C1 (classifier reuses `detectTokenType`), C2 (sub fallback dropped — unreachable due to `jwt.go:416`), C3 (replay-marking gates only Set, not Get; revocation regression test added).
|
||||
- **High**: H1 (alg pinned at bearer entry), H2 (kid length + charset), H3 (cookie wins by default, configurable), H4 (per-IP 401 throttle), H5 (multi-aud requires azp).
|
||||
- **Medium**: M1 (identifier max-length + bidi reject + delimiter chars), M2 (introspection cache TTL capped at 60s on bearer path), M4 (log-hashing via SHA-256[:8]), M5 (StripAuth blast-radius documented), M6 (iat upper-age bound), M7 (Authorization stripped on excluded URLs).
|
||||
- **Low/Nit**: L2 (renamed to `BearerEmitWWWAuthenticate`), N3 (startup rejects `UserIdentifierClaim=email`).
|
||||
- **Documented as pre-existing gaps (follow-up PRs)**: M3 (introspection auth method doesn't honour `private_key_jwt`).
|
||||
|
||||
## 15. Implementation Plan Reference
|
||||
|
||||
To be produced by the `writing-plans` skill in a follow-up document at `docs/superpowers/plans/2026-05-18-bearer-token-auth-plan.md`. The plan decomposes this design into ordered, independently-testable PRs.
|
||||
+162
-191
@@ -7,7 +7,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -50,7 +52,9 @@ type DynamicClientRegistrar struct {
|
||||
logger *Logger
|
||||
config *DynamicClientRegistrationConfig
|
||||
registrationResponse *ClientRegistrationResponse
|
||||
store DCRCredentialsStore // Storage backend for credentials
|
||||
providerURL string
|
||||
scopes []string // Runtime auth/scopes from the operator config (merged)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -73,8 +77,50 @@ func NewDynamicClientRegistrar(
|
||||
}
|
||||
}
|
||||
|
||||
// NewDynamicClientRegistrarWithStore creates a new dynamic client registrar with a specific storage backend
|
||||
func NewDynamicClientRegistrarWithStore(
|
||||
httpClient *http.Client,
|
||||
logger *Logger,
|
||||
dcrConfig *DynamicClientRegistrationConfig,
|
||||
providerURL string,
|
||||
store DCRCredentialsStore,
|
||||
) *DynamicClientRegistrar {
|
||||
if logger == nil {
|
||||
logger = GetSingletonNoOpLogger()
|
||||
}
|
||||
|
||||
return &DynamicClientRegistrar{
|
||||
httpClient: httpClient,
|
||||
logger: logger,
|
||||
config: dcrConfig,
|
||||
providerURL: providerURL,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// SetStore sets the credentials store for the registrar
|
||||
// This allows setting the store after creation when the cache manager is available
|
||||
func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.store = store
|
||||
}
|
||||
|
||||
// EffectiveTokenEndpointAuthMethod returns the token-endpoint auth method that
|
||||
// was (or will be) advertised to the IdP during registration:
|
||||
// ClientMetadata.TokenEndpointAuthMethod if set, else client_secret_post
|
||||
// (RFC 7591 confidential-client default). The IdP provisions the client
|
||||
// according to this method, so the runtime must authenticate to the token
|
||||
// endpoint with the SAME method or every exchange fails (R157).
|
||||
func (r *DynamicClientRegistrar) EffectiveTokenEndpointAuthMethod() string {
|
||||
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
|
||||
return r.config.ClientMetadata.TokenEndpointAuthMethod
|
||||
}
|
||||
return "client_secret_post"
|
||||
}
|
||||
|
||||
// RegisterClient performs dynamic client registration with the OIDC provider
|
||||
// It first attempts to load existing credentials from a file if persistence is enabled,
|
||||
// It first attempts to load existing credentials from storage if persistence is enabled,
|
||||
// then registers a new client if no valid credentials exist.
|
||||
func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registrationEndpoint string) (*ClientRegistrationResponse, error) {
|
||||
if r.config == nil || !r.config.Enabled {
|
||||
@@ -83,10 +129,13 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
|
||||
// Try to load existing credentials if persistence is enabled
|
||||
if r.config.PersistCredentials {
|
||||
if resp, err := r.loadCredentials(); err == nil && resp != nil {
|
||||
resp, err := r.loadCredentialsFromStore(ctx)
|
||||
if err != nil {
|
||||
r.logger.Debugf("Failed to load credentials from store: %v", err)
|
||||
} else if resp != nil {
|
||||
// Check if credentials are still valid (not expired)
|
||||
if r.areCredentialsValid(resp) {
|
||||
r.logger.Info("Loaded existing client credentials from file")
|
||||
r.logger.Info("Loaded existing client credentials from storage")
|
||||
r.mu.Lock()
|
||||
r.registrationResponse = resp
|
||||
r.mu.Unlock()
|
||||
@@ -108,8 +157,10 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
|
||||
// Validate the endpoint URL
|
||||
if !strings.HasPrefix(endpoint, "https://") {
|
||||
// Allow http only for localhost/development
|
||||
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
|
||||
// Allow http only for loopback/development endpoints. Use a
|
||||
// host-boundary check (not a prefix) so e.g.
|
||||
// http://localhost.evil.com isn't treated as localhost.
|
||||
if !isLoopbackRegistrationEndpoint(endpoint) {
|
||||
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
|
||||
}
|
||||
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
|
||||
@@ -170,6 +221,19 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
return nil, fmt.Errorf("registration response missing client_id")
|
||||
}
|
||||
|
||||
// The plugin's runtime client-auth default is client_secret_post
|
||||
// (settings.go Config.Validate); RFC 7591 registers a confidential
|
||||
// client. For a secret-based auth method the response must carry a
|
||||
// client_secret, otherwise every token exchange fails. Adopting a
|
||||
// secret-less client would silently break all auth.
|
||||
authMethod := "client_secret_post"
|
||||
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
|
||||
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
|
||||
}
|
||||
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && regResp.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("registration response missing client_secret for auth method %q", authMethod)
|
||||
}
|
||||
|
||||
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
|
||||
|
||||
// Cache the response
|
||||
@@ -179,7 +243,7 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
||||
|
||||
// Persist credentials if enabled
|
||||
if r.config.PersistCredentials {
|
||||
if err := r.saveCredentials(®Resp); err != nil {
|
||||
if err := r.saveCredentialsToStore(ctx, ®Resp); err != nil {
|
||||
r.logger.Errorf("Failed to persist client credentials: %v", err)
|
||||
// Don't fail registration if persistence fails
|
||||
}
|
||||
@@ -200,11 +264,41 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
|
||||
// Required: redirect_uris
|
||||
if len(metadata.RedirectURIs) > 0 {
|
||||
// Reject malformed or wrong-scheme entries before they reach the IdP:
|
||||
// RFC 7591 expects absolute http/https redirect URIs, and an invalid
|
||||
// one sent to the IdP would corrupt the registered callback.
|
||||
for _, uri := range metadata.RedirectURIs {
|
||||
parsed, perr := url.ParseRequestURI(uri)
|
||||
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("redirect_uri %q must be an absolute http(s) URL", uri)
|
||||
}
|
||||
}
|
||||
reqData["redirect_uris"] = metadata.RedirectURIs
|
||||
} else {
|
||||
return nil, fmt.Errorf("redirect_uris is required for client registration")
|
||||
}
|
||||
|
||||
// Validate the optional URI metadata fields with the same absolute
|
||||
// http(s) rule as redirect_uris. A relative or non-http(s) value
|
||||
// (e.g. a malformed jwks_uri on a private_key_jwt client) would be
|
||||
// forwarded to the IdP and make the registered client unverifiable at
|
||||
// runtime (R141).
|
||||
for _, field := range []struct{ val, name string }{
|
||||
{metadata.LogoURI, "logo_uri"},
|
||||
{metadata.ClientURI, "client_uri"},
|
||||
{metadata.PolicyURI, "policy_uri"},
|
||||
{metadata.TOSURI, "tos_uri"},
|
||||
{metadata.JWKSURI, "jwks_uri"},
|
||||
} {
|
||||
if field.val == "" {
|
||||
continue
|
||||
}
|
||||
parsed, perr := url.ParseRequestURI(field.val)
|
||||
if perr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("%s %q must be an absolute http(s) URL", field.name, field.val)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields - only include if set
|
||||
if len(metadata.ResponseTypes) > 0 {
|
||||
reqData["response_types"] = metadata.ResponseTypes
|
||||
@@ -259,8 +353,10 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
if metadata.TokenEndpointAuthMethod != "" {
|
||||
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
|
||||
} else {
|
||||
// Default to client_secret_basic for confidential clients
|
||||
reqData["token_endpoint_auth_method"] = "client_secret_basic"
|
||||
// Default must match the plugin's runtime client-auth default
|
||||
// (client_secret_post, settings.go Config.Validate) so an IdP that
|
||||
// enforces the registered method does not reject the token exchange.
|
||||
reqData["token_endpoint_auth_method"] = "client_secret_post"
|
||||
}
|
||||
|
||||
if metadata.DefaultMaxAge > 0 {
|
||||
@@ -277,6 +373,13 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
||||
|
||||
if metadata.Scope != "" {
|
||||
reqData["scope"] = metadata.Scope
|
||||
} else if len(r.scopes) > 0 {
|
||||
// Register with the union of runtime auth scopes when the operator
|
||||
// gave no explicit registration scope. IdPs that only grant
|
||||
// registered/advertised scopes (Keycloak offline_access, Auth0)
|
||||
// otherwise register a client whose later token/refresh grant is
|
||||
// rejected with invalid_scope, breaking refresh (R124).
|
||||
reqData["scope"] = strings.Join(deduplicateScopes(r.scopes), " ")
|
||||
}
|
||||
|
||||
return json.Marshal(reqData)
|
||||
@@ -295,6 +398,19 @@ func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationRes
|
||||
return false
|
||||
}
|
||||
|
||||
// For a secret-based auth method the response must carry a client_secret,
|
||||
// mirroring the fresh-registration check (RegisterClient). A stored
|
||||
// record with a client_id but empty secret would otherwise be accepted
|
||||
// and installed as t.clientSecret="", breaking every token exchange
|
||||
// (R124).
|
||||
authMethod := "client_secret_post"
|
||||
if r.config != nil && r.config.ClientMetadata != nil && r.config.ClientMetadata.TokenEndpointAuthMethod != "" {
|
||||
authMethod = r.config.ClientMetadata.TokenEndpointAuthMethod
|
||||
}
|
||||
if (authMethod == "client_secret_post" || authMethod == "client_secret_basic") && resp.ClientSecret == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if secret has expired
|
||||
if resp.ClientSecretExpiresAt > 0 {
|
||||
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
|
||||
@@ -315,11 +431,33 @@ func (r *DynamicClientRegistrar) credentialsFilePath() string {
|
||||
return "/tmp/oidc-client-credentials.json"
|
||||
}
|
||||
|
||||
// saveCredentials persists client credentials to a file
|
||||
// loadCredentialsFromStore loads client credentials from the configured storage backend
|
||||
// Falls back to legacy file-based loading if no store is configured
|
||||
func (r *DynamicClientRegistrar) loadCredentialsFromStore(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||
// Use store if available
|
||||
if r.store != nil {
|
||||
return r.store.Load(ctx, r.providerURL)
|
||||
}
|
||||
// Fallback to legacy file-based loading
|
||||
return r.loadCredentials()
|
||||
}
|
||||
|
||||
// saveCredentialsToStore persists client credentials to the configured storage backend
|
||||
// Falls back to legacy file-based saving if no store is configured
|
||||
func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, resp *ClientRegistrationResponse) error {
|
||||
// Use store if available
|
||||
if r.store != nil {
|
||||
return r.store.Save(ctx, r.providerURL, resp)
|
||||
}
|
||||
// Fallback to legacy file-based saving
|
||||
return r.saveCredentials(resp)
|
||||
}
|
||||
|
||||
// saveCredentials persists client credentials to a file (legacy method)
|
||||
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
|
||||
filePath := r.credentialsFilePath()
|
||||
|
||||
data, err := json.MarshalIndent(resp, "", " ")
|
||||
data, err := json.MarshalIndent(resp, "", " ") // #nosec G117 -- the registration response, including client_secret, is the credential this function persists
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
@@ -333,7 +471,7 @@ func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationRespons
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCredentials loads client credentials from a file
|
||||
// loadCredentials loads client credentials from a file (legacy method)
|
||||
func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse, error) {
|
||||
filePath := r.credentialsFilePath()
|
||||
|
||||
@@ -354,187 +492,20 @@ func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse,
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateClientRegistration updates an existing client registration using RFC 7592
|
||||
// This requires the registration_client_uri and registration_access_token from the original registration
|
||||
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||
r.mu.RLock()
|
||||
cachedResp := r.registrationResponse
|
||||
r.mu.RUnlock()
|
||||
|
||||
if cachedResp == nil {
|
||||
return nil, fmt.Errorf("no existing registration to update")
|
||||
// isLoopbackRegistrationEndpoint reports whether the (plaintext) registration
|
||||
// endpoint targets a loopback host, matching the hostname boundary (not a
|
||||
// string prefix) so http://localhost.evil.com is not treated as localhost.
|
||||
func isLoopbackRegistrationEndpoint(endpoint string) bool {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return false
|
||||
}
|
||||
|
||||
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||
host := u.Hostname()
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Build update request
|
||||
reqBody, err := r.buildRegistrationRequest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build update request: %w", err)
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create update request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read update response: %w", err)
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var regError ClientRegistrationError
|
||||
if jsonErr := json.Unmarshal(body, ®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.saveCredentials(®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 file if persistence is enabled
|
||||
if r.config.PersistCredentials {
|
||||
filePath := r.credentialsFilePath()
|
||||
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||
r.logger.Errorf("Failed to remove credentials file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("Successfully deleted client registration")
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+439
-29
@@ -191,12 +191,23 @@ type CircuitBreaker struct {
|
||||
maxFailures int
|
||||
// timeout is how long to wait before allowing requests in half-open state
|
||||
timeout time.Duration
|
||||
// resetTimeout is how long to wait before transitioning from open to half-open
|
||||
// resetTimeout is how long to stay in half-open (after the open timeout)
|
||||
// before a successful response fully closes the circuit
|
||||
resetTimeout time.Duration
|
||||
// state tracks the current circuit breaker state
|
||||
state CircuitBreakerState
|
||||
// failures counts consecutive failures
|
||||
failures int64
|
||||
// halfOpenSince is when the circuit entered half-open; used to enforce
|
||||
// resetTimeout before a success can fully close it
|
||||
halfOpenSince time.Time
|
||||
// openedAt is when the circuit last transitioned into the Open state
|
||||
// (Closed->Open or HalfOpen->Open, set in recordFailure). allowRequest
|
||||
// and IsAvailable gate the Open->HalfOpen transition on openedAt instead
|
||||
// of the promoted lastFailureTime, so a request rejected while already
|
||||
// open cannot re-arm the open timer and keep the circuit open forever
|
||||
// under steady traffic (FIX-01).
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
|
||||
@@ -239,12 +250,32 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
||||
cb.RecordRequest()
|
||||
|
||||
if !cb.allowRequest() {
|
||||
// A request rejected while the circuit is open is an admission
|
||||
// outcome, not a never-started request: count it in total_failures
|
||||
// so GetBaseMetrics' success_rate (successes/total_requests)
|
||||
// reflects actual admission (R180). This intentionally bypasses the
|
||||
// promoted BaseRecoveryMechanism.RecordFailure(), which also sets
|
||||
// lastFailureTime -- allowRequest's Open->HalfOpen timer used to
|
||||
// read that same field, so every rejection re-armed it and the
|
||||
// circuit never left Open under steady traffic (FIX-01). The open
|
||||
// timer now runs off cb.openedAt instead, set only on a genuine
|
||||
// Closed->Open or HalfOpen->Open transition in recordFailure.
|
||||
atomic.AddInt64(&cb.totalFailures, 1)
|
||||
return fmt.Errorf("circuit breaker is open")
|
||||
}
|
||||
|
||||
err := fn()
|
||||
if err != nil {
|
||||
cb.recordFailure()
|
||||
// A terminal client error (HTTPError 4xx other than 429) reflects a
|
||||
// per-user problem such as invalid_grant, not a downstream-service
|
||||
// failure. It must not trip the state machine: in particular it
|
||||
// must not reopen a half-open circuit, or the 15s/30s half-open
|
||||
// hold (resetTimeout) extends an outage on any single per-user
|
||||
// error (FIX-09). It is still recorded in the base metrics below,
|
||||
// since it is a real error returned to the caller.
|
||||
if !isTerminalClientError(err) {
|
||||
cb.recordFailure()
|
||||
}
|
||||
cb.RecordFailure()
|
||||
return err
|
||||
}
|
||||
@@ -254,6 +285,37 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminalClientError reports whether err is an HTTPError whose status
|
||||
// code is a client error (4xx) other than 429 Too Many Requests or 408
|
||||
// Request Timeout. Such errors indicate the request itself was rejected by
|
||||
// the far end (for example a replayed authorization code), not that the far
|
||||
// end is unhealthy, so the circuit breaker's failure-counting state machine
|
||||
// ignores them (FIX-09). 429 and 408 are excluded because they are the
|
||||
// service itself signaling it is overloaded or slow, a genuine health
|
||||
// signal, not a per-request rejection — the same classification FIX-13
|
||||
// applies in token_validation_rs.go and bearer_auth.go, and that
|
||||
// internal/recovery/base.go and metrics.go apply via their retryable-status
|
||||
// checks.
|
||||
//
|
||||
// This uses a plain type assertion, not errors.As. Under yaegi v0.16.1 (the
|
||||
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
|
||||
// errors.As(err, &target) panics with "errors: *target must be interface
|
||||
// or implement error" whenever target's pointed-to type (*HTTPError here)
|
||||
// is itself interpreted, regardless of err's own concrete type. This
|
||||
// function runs on every fn() error in ExecuteWithContext, on the
|
||||
// default-on token-exchange/refresh path, so that panic reaches production
|
||||
// (FIX-09). The production error also reaches the breaker unwrapped
|
||||
// (helpers.go returns *HTTPError directly), so a type assertion is
|
||||
// sufficient and needs no Unwrap chain walk.
|
||||
func isTerminalClientError(err error) bool {
|
||||
httpErr, ok := err.(*HTTPError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 &&
|
||||
httpErr.StatusCode != 429 && httpErr.StatusCode != 408
|
||||
}
|
||||
|
||||
// Execute executes a function through the circuit breaker without context.
|
||||
// This is provided for backward compatibility with existing code.
|
||||
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
||||
@@ -273,8 +335,9 @@ func (cb *CircuitBreaker) allowRequest() bool {
|
||||
return true
|
||||
|
||||
case CircuitBreakerOpen:
|
||||
if now.Sub(cb.lastFailureTime) > cb.timeout {
|
||||
if now.Sub(cb.openedAt) > cb.timeout {
|
||||
cb.state = CircuitBreakerHalfOpen
|
||||
cb.halfOpenSince = now
|
||||
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
||||
return true
|
||||
}
|
||||
@@ -300,11 +363,13 @@ func (cb *CircuitBreaker) recordFailure() {
|
||||
case CircuitBreakerClosed:
|
||||
if cb.failures >= int64(cb.maxFailures) {
|
||||
cb.state = CircuitBreakerOpen
|
||||
cb.openedAt = time.Now()
|
||||
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
|
||||
}
|
||||
|
||||
case CircuitBreakerHalfOpen:
|
||||
cb.state = CircuitBreakerOpen
|
||||
cb.openedAt = time.Now()
|
||||
cb.LogError("Circuit breaker returned to open state after failure in half-open")
|
||||
}
|
||||
}
|
||||
@@ -317,6 +382,15 @@ func (cb *CircuitBreaker) recordSuccess() {
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerHalfOpen:
|
||||
// Honor resetTimeout: do not fully close until the circuit has been
|
||||
// in half-open for at least resetTimeout. This provides the
|
||||
// documented open -> half-open -> (reset cooldown) -> closed
|
||||
// hysteresis instead of closing on the very first half-open
|
||||
// success, which would let a stray success immediately reopen
|
||||
// (bounce). While in half-open requests are still allowed as probes.
|
||||
if time.Since(cb.halfOpenSince) < cb.resetTimeout {
|
||||
return
|
||||
}
|
||||
cb.failures = 0
|
||||
cb.state = CircuitBreakerClosed
|
||||
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
|
||||
@@ -348,7 +422,24 @@ func (cb *CircuitBreaker) Reset() {
|
||||
// IsAvailable returns whether the circuit breaker is currently allowing requests.
|
||||
// This provides a quick way to check if the service is available.
|
||||
func (cb *CircuitBreaker) IsAvailable() bool {
|
||||
return cb.allowRequest()
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitBreakerClosed:
|
||||
return true
|
||||
case CircuitBreakerHalfOpen:
|
||||
return true
|
||||
case CircuitBreakerOpen:
|
||||
// Read-only availability probe: report that a request would be
|
||||
// admitted only after the open timeout has elapsed, but do NOT
|
||||
// transition to half-open — IsAvailable must not take a permit or
|
||||
// mutate circuit state. Real traffic admission is gated by
|
||||
// allowRequest (triggerRequest) which performs the transition.
|
||||
return time.Since(cb.openedAt) > cb.timeout
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics returns comprehensive metrics about the circuit breaker.
|
||||
@@ -507,12 +598,157 @@ func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error
|
||||
}
|
||||
|
||||
// Execute runs the given function with retry logic (for backward compatibility)
|
||||
// Execute executes a function with retry logic (backward compatibility).
|
||||
// Execute runs a function with retry logic (backward compatibility).
|
||||
// This method provides the same functionality as ExecuteWithContext.
|
||||
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
|
||||
return re.ExecuteWithContext(ctx, fn)
|
||||
}
|
||||
|
||||
// singleUseRetryableErrors are error fragments that prove the request was NEVER
|
||||
// sent to the server (dial/connect failures). Retrying those is safe.
|
||||
var singleUseRetryableErrors = []string{
|
||||
"connection refused",
|
||||
"network unreachable",
|
||||
"no route to host",
|
||||
"temporary failure",
|
||||
}
|
||||
|
||||
// isNeverSentError reports whether err proves a single-use request was never
|
||||
// sent to the server, so retrying it cannot re-present already-consumed
|
||||
// input (an authorization code or a rotated refresh token).
|
||||
//
|
||||
// A *HTTPError means a response WAS received: the request definitely
|
||||
// reached the endpoint, regardless of what its body says. Before this
|
||||
// check, ExecuteSingleUseWithContext substring-matched singleUseRetryableErrors
|
||||
// against the raw error text, and helpers.go's token-endpoint HTTPError
|
||||
// carries up to 10 KiB of the response body in its Message -- an IdP 500
|
||||
// whose body happened to mention "connection refused" was retried, re-
|
||||
// sending a consumed code or a rotated refresh token (FIX-14).
|
||||
//
|
||||
// A *net.OpError is NOT proof by itself: Go's net package uses that same
|
||||
// type for a "dial" failure before any request reaches the wire, but also
|
||||
// for a "read" or "write" failure AFTER the connection is already
|
||||
// established, when the request may already have reached the IdP. Only Op
|
||||
// "dial" (a failed connect) or "proxyconnect" (net/http's failed CONNECT to
|
||||
// an HTTP proxy) prove the request never went out, so the fragment match is
|
||||
// scoped to those two.
|
||||
func isNeverSentError(err error) bool {
|
||||
// Plain type assertion, not errors.As: under yaegi v0.16.1 (the
|
||||
// interpreter Traefik uses to load this plugin, pinned in Makefile:9),
|
||||
// errors.As(err, &target) panics with "errors: *target must be
|
||||
// interface or implement error" whenever target's pointed-to type
|
||||
// (*HTTPError here) is itself interpreted, regardless of err's own
|
||||
// concrete type. ExecuteSingleUseWithContext calls this on every fn()
|
||||
// error, on the default-on authorization-code-exchange/refresh path,
|
||||
// so that panic reaches production (FIX-14). The production error
|
||||
// reaches here unwrapped (helpers.go returns *HTTPError directly), so
|
||||
// a type assertion is sufficient and needs no Unwrap chain walk.
|
||||
if _, ok := err.(*HTTPError); ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// errors.As against the compiled *net.OpError type is yaegi-safe: the
|
||||
// panic above is specific to an interpreted target type, and
|
||||
// *net.OpError is a native stdlib type.
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect") {
|
||||
return isSubstringMatch(opErr.Error(), singleUseRetryableErrors)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExecuteSingleUseWithContext retries only on errors that prove the request was
|
||||
// never sent (connect/dial failures). It intentionally does NOT retry on
|
||||
// "timeout": for a single-use operation such as the authorization-code
|
||||
// exchange, a timeout may mean the provider already consumed the one-time code,
|
||||
// so re-sending it would surface invalid_grant permanently even though the
|
||||
// first attempt succeeded.
|
||||
func (re *RetryExecutor) ExecuteSingleUseWithContext(ctx context.Context, fn func() error) error {
|
||||
re.RecordRequest()
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= re.config.MaxAttempts; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
re.LogInfo("Operation succeeded after %d attempts", attempt)
|
||||
}
|
||||
re.RecordSuccess()
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if !isNeverSentError(err) {
|
||||
re.RecordFailure()
|
||||
return err
|
||||
}
|
||||
|
||||
if attempt == re.config.MaxAttempts {
|
||||
re.RecordFailure()
|
||||
break
|
||||
}
|
||||
|
||||
delay := re.calculateDelay(attempt)
|
||||
if attempt == 1 || attempt%3 == 0 {
|
||||
re.LogDebug("Retrying single-use operation after %v (attempt %d/%d): %v",
|
||||
delay, attempt, re.config.MaxAttempts, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
re.RecordFailure()
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
finalErr := fmt.Errorf("operation failed after %d attempts: %w", re.config.MaxAttempts, lastErr)
|
||||
return finalErr
|
||||
}
|
||||
|
||||
// isSubstringMatch reports whether s contains any of the given fragments.
|
||||
func isSubstringMatch(s string, fragments []string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, frag := range fragments {
|
||||
if strings.Contains(lower, frag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// asHTTPError reports whether err is, or (via a chain of Unwrap() error
|
||||
// methods) wraps, an *HTTPError. It exists because errors.As cannot be used
|
||||
// here: under yaegi v0.16.1 (the interpreter Traefik uses to load this
|
||||
// plugin, pinned in Makefile:9), errors.As(err, &target) panics with
|
||||
// "errors: *target must be interface or implement error" whenever target's
|
||||
// pointed-to type is itself interpreted, and *HTTPError is declared in this
|
||||
// plugin so it is always interpreted at runtime — regardless of err's own
|
||||
// concrete type (see isTerminalClientError above for the same hazard on a
|
||||
// path that does not need the Unwrap walk).
|
||||
//
|
||||
// This manually reimplements errors.As's single-chain Unwrap() walk using
|
||||
// only type assertions and errors.Unwrap, both yaegi-safe, so native
|
||||
// classification is byte-for-byte identical to errors.As (see
|
||||
// TestIsRetryableError_WrappedTransient). The one place native and yaegi
|
||||
// genuinely diverge: an interpreted *HTTPError wrapped with
|
||||
// fmt.Errorf("...%w", e) is found by this walk in native Go but NOT under
|
||||
// yaegi (an interpreter quirk with wrapped interpreted-type targets,
|
||||
// verified this session) — so every producer feeding isRetryableError must
|
||||
// keep returning an *HTTPError unwrapped (exchangeTokens already does; see
|
||||
// helpers.go).
|
||||
func asHTTPError(err error) (*HTTPError, bool) {
|
||||
for err != nil {
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
return httpErr, true
|
||||
}
|
||||
err = errors.Unwrap(err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isRetryableError checks if an error should trigger a retry
|
||||
// isRetryableError determines if an error should trigger a retry attempt.
|
||||
// Checks error message against configured retryable error patterns.
|
||||
@@ -523,6 +759,17 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// A terminal HTTP error (a real status that is neither 5xx nor 429)
|
||||
// must be classified by its status BEFORE any message-substring scan.
|
||||
// Otherwise a 4xx whose provider message merely contains a
|
||||
// retryable-looking word — e.g. "certificate"/"tls" (isCertificateError),
|
||||
// "EOF" (isEOFError), or a generic "timeout" — would be reclassified
|
||||
// retryable and retried to MaxAttempts on a permanent error, repeating
|
||||
// the failing request (R123, R157).
|
||||
if statusHTTP, ok := asHTTPError(err); ok && statusHTTP.StatusCode != 0 && statusHTTP.StatusCode < 500 && statusHTTP.StatusCode != 429 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for Traefik default certificate error (startup race condition)
|
||||
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
|
||||
if isTraefikDefaultCertError(err) {
|
||||
@@ -539,19 +786,20 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
for _, retryableErr := range re.config.RetryableErrors {
|
||||
if contains(errStr, retryableErr) {
|
||||
if contains(errStr, strings.ToLower(retryableErr)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
if netErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
errStr := netErr.Error()
|
||||
errStr := strings.ToLower(netErr.Error())
|
||||
temporaryPatterns := []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
@@ -568,8 +816,19 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if httpErr, ok := err.(*HTTPError); ok {
|
||||
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
|
||||
// asHTTPError (not errors.As) so retry classification survives error
|
||||
// wrapping: a transient or 5xx/429 error returned as fmt.Errorf("...:
|
||||
// %w", err) from an upstream layer must still be retried. Direct type
|
||||
// assertions on the top-level error silently misclassified wrapped
|
||||
// transient errors as permanent, giving up before any retry (R111).
|
||||
// errors.As itself cannot be used under yaegi -- see asHTTPError's doc
|
||||
// comment.
|
||||
if httpErr, ok := asHTTPError(err); ok {
|
||||
status := httpErr.StatusCode
|
||||
// A 0 StatusCode means the producer used HTTPError purely as a
|
||||
// message carrier (no real HTTP status). Treat it as unknown
|
||||
// (non-retryable) rather than spuriously matching 5xx (R118).
|
||||
return status != 0 && (status >= 500 || status == 429)
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -762,6 +1021,10 @@ type GracefulDegradation struct {
|
||||
config GracefulDegradationConfig
|
||||
mutex sync.RWMutex
|
||||
shutdownOnce sync.Once
|
||||
// beforeSharedTaskStop is a test seam, nil in production. Close calls it
|
||||
// after deciding this is the last live instance and before stopping the
|
||||
// shared health-check task.
|
||||
beforeSharedTaskStop func()
|
||||
}
|
||||
|
||||
// GracefulDegradationConfig holds configuration for graceful degradation behavior.
|
||||
@@ -788,6 +1051,53 @@ func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
|
||||
// NewGracefulDegradation creates a new graceful degradation manager
|
||||
// NewGracefulDegradation creates a new graceful degradation mechanism.
|
||||
// Initializes fallback and health check maps and starts background health monitoring.
|
||||
// gdInstances tracks live GracefulDegradation instances so the single shared
|
||||
// health-check task can run every instance's health checks (not just the first)
|
||||
// and so the task is only stopped when the last instance closes.
|
||||
var gdInstances = struct {
|
||||
sync.RWMutex
|
||||
set map[*GracefulDegradation]struct{}
|
||||
}{set: make(map[*GracefulDegradation]struct{})}
|
||||
|
||||
// globalHealthCheckMu serializes the global pass. globalPerformHealthChecks
|
||||
// runs from more than one goroutine — the shared singleton task's run loop
|
||||
// AND the once-guarded StartBackgroundTask path both drive it — so without
|
||||
// this, any health check registered via the public RegisterHealthCheck
|
||||
// API would be invoked concurrently (a latent production race, and the
|
||||
// source of an intermittent full-suite race in tests). Serializing the
|
||||
// whole pass keeps registered health checks single-threaded (R189).
|
||||
var globalHealthCheckMu sync.Mutex
|
||||
|
||||
// gdLifecycleMu serializes GracefulDegradation registration with the
|
||||
// last-instance check and shared-task stop in Close. Without it, an instance
|
||||
// created after Close decided it was the last one, but before the stop, adopted
|
||||
// the still-running task, and the stop then killed the task that the new live
|
||||
// instance relies on. With it, a new instance either counts toward the
|
||||
// last-instance check or registers after the stop, and its routine replaces
|
||||
// the stopped task. The health-check pass never takes this lock, so Close can
|
||||
// hold it while BackgroundTask.Stop waits for an in-progress pass.
|
||||
var gdLifecycleMu sync.Mutex
|
||||
|
||||
// globalPerformHealthChecks runs the health check pass for every live
|
||||
// GracefulDegradation instance. The shared singleton task calls this, so a
|
||||
// health check registered on ANY instance is exercised, and closing one
|
||||
// instance does not stall recovery for the others.
|
||||
func globalPerformHealthChecks() {
|
||||
globalHealthCheckMu.Lock()
|
||||
defer globalHealthCheckMu.Unlock()
|
||||
|
||||
gdInstances.RLock()
|
||||
instances := make([]*GracefulDegradation, 0, len(gdInstances.set))
|
||||
for gd := range gdInstances.set {
|
||||
instances = append(instances, gd)
|
||||
}
|
||||
gdInstances.RUnlock()
|
||||
|
||||
for _, gd := range instances {
|
||||
gd.performHealthChecks()
|
||||
}
|
||||
}
|
||||
|
||||
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
|
||||
gd := &GracefulDegradation{
|
||||
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
|
||||
@@ -798,6 +1108,22 @@ func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *G
|
||||
}
|
||||
|
||||
gd.stopChan = make(chan struct{})
|
||||
|
||||
// Register synchronously, before the health-check goroutine starts
|
||||
// (FIX-18). Registration used to happen only inside startHealthCheckRoutine,
|
||||
// which runs in its own goroutine, so a Close() called immediately after
|
||||
// NewGracefulDegradation returned could race ahead of it: Close's
|
||||
// gdInstances.delete would find nothing to remove, and the goroutine's
|
||||
// later add would then register an already-closed gd permanently.
|
||||
// startHealthCheckRoutine no longer touches gdInstances at all — this is
|
||||
// now the ONLY place a GracefulDegradation is added to the set, so there
|
||||
// is no second, racing registration left to worry about.
|
||||
gdLifecycleMu.Lock()
|
||||
gdInstances.Lock()
|
||||
gdInstances.set[gd] = struct{}{}
|
||||
gdInstances.Unlock()
|
||||
gdLifecycleMu.Unlock()
|
||||
|
||||
go gd.startHealthCheckRoutine()
|
||||
|
||||
return gd
|
||||
@@ -859,8 +1185,9 @@ func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary f
|
||||
|
||||
// isServiceDegraded checks if a service is currently degraded
|
||||
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
|
||||
gd.mutex.RLock()
|
||||
defer gd.mutex.RUnlock()
|
||||
// Uses a write lock because the recovery-timeout branch deletes from the map.
|
||||
gd.mutex.Lock()
|
||||
defer gd.mutex.Unlock()
|
||||
|
||||
degradedTime, exists := gd.degradedServices[serviceName]
|
||||
if !exists {
|
||||
@@ -901,25 +1228,54 @@ func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{},
|
||||
return fallback()
|
||||
}
|
||||
|
||||
// startHealthCheckRoutine starts the background health check routine
|
||||
// startHealthCheckRoutine starts the background health check routine.
|
||||
//
|
||||
// FIX-18: this runs in its own goroutine, started by NewGracefulDegradation
|
||||
// right after it registers gd in gdInstances. A Close() called immediately
|
||||
// after NewGracefulDegradation returns can race ahead of this goroutine
|
||||
// getting scheduled at all. Take gd.mutex up front and re-check gd.stopChan
|
||||
// before doing anything else: if Close() already closed it, this goroutine
|
||||
// must not create (or adopt) the shared health-check task for an
|
||||
// already-closed instance — that would leak the process-global task
|
||||
// forever, since nothing would ever be left registered to stop it.
|
||||
func (gd *GracefulDegradation) startHealthCheckRoutine() {
|
||||
// Use singleton task registry to prevent multiple instances
|
||||
gd.mutex.Lock()
|
||||
|
||||
select {
|
||||
case <-gd.stopChan:
|
||||
// Close() already ran before this goroutine got scheduled. Do not
|
||||
// register or start the shared task on behalf of a closed instance.
|
||||
gd.mutex.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Use the singleton task registry so multiple GracefulDegradation
|
||||
// instances share one health-check goroutine. The task function is the
|
||||
// global pass over every live instance, not this instance's own health
|
||||
// checks — otherwise only the first instance's checks would run.
|
||||
//
|
||||
// gd.mutex stays held through CreateSingletonTask and the healthCheckTask
|
||||
// assignment below: CreateSingletonTask only touches the global task
|
||||
// registry (a different lock), never gd.mutex, so this cannot deadlock,
|
||||
// and it keeps the stopChan check and the assignment atomic with respect
|
||||
// to a concurrent Close().
|
||||
registry := GetGlobalTaskRegistry()
|
||||
|
||||
task, err := registry.CreateSingletonTask(
|
||||
"graceful-degradation-health-check",
|
||||
gd.config.HealthCheckInterval,
|
||||
gd.performHealthChecks,
|
||||
globalPerformHealthChecks,
|
||||
gd.BaseRecoveryMechanism.logger,
|
||||
nil, // No specific wait group
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
gd.mutex.Unlock()
|
||||
gd.BaseRecoveryMechanism.logger.Errorf("Failed to create health check task: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
gd.mutex.Lock()
|
||||
gd.healthCheckTask = task
|
||||
gd.mutex.Unlock()
|
||||
|
||||
@@ -954,7 +1310,7 @@ func (gd *GracefulDegradation) GetDegradedServices() []string {
|
||||
gd.mutex.RLock()
|
||||
defer gd.mutex.RUnlock()
|
||||
|
||||
var degraded []string
|
||||
degraded := make([]string, 0, len(gd.degradedServices))
|
||||
for serviceName := range gd.degradedServices {
|
||||
degraded = append(degraded, serviceName)
|
||||
}
|
||||
@@ -971,25 +1327,58 @@ func (gd *GracefulDegradation) Reset() {
|
||||
gd.LogInfo("Graceful degradation state has been reset")
|
||||
}
|
||||
|
||||
// Close shuts down the graceful degradation system and cleans up resources
|
||||
// Close shuts down the graceful degradation system and cleans up resources.
|
||||
func (gd *GracefulDegradation) Close() {
|
||||
gd.shutdownOnce.Do(func() {
|
||||
// Signal shutdown
|
||||
// Signal shutdown under gd.mutex. startHealthCheckRoutine checks
|
||||
// stopChan and creates or adopts the shared task under the same
|
||||
// lock, so an in-flight routine finishes before this instance
|
||||
// unregisters below. Without the lock, a routine that passed its
|
||||
// check could reach the task registry after the last Close stopped
|
||||
// the task, and RegisterBackgroundTask would start a fresh task
|
||||
// that no live instance can stop. Release the lock before the stop:
|
||||
// BackgroundTask.Stop waits for a health-check pass that takes gd.mutex.
|
||||
gd.mutex.Lock()
|
||||
select {
|
||||
case <-gd.stopChan:
|
||||
// Already closed
|
||||
default:
|
||||
close(gd.stopChan)
|
||||
}
|
||||
|
||||
// Stop health check task
|
||||
gd.mutex.Lock()
|
||||
task := gd.healthCheckTask
|
||||
gd.mutex.Unlock()
|
||||
|
||||
if task != nil {
|
||||
task.Stop()
|
||||
// Don't set to nil to avoid race conditions
|
||||
// Stop the shared health-check task only when this was the last live
|
||||
// instance. The task is process-global and shared by every
|
||||
// GracefulDegradation instance; stopping it on any one instance's
|
||||
// close would kill health checks / recovery for all surviving
|
||||
// instances (mirror of the singleton-token-cleanup lastInstance gate).
|
||||
// Hold gdLifecycleMu across the last-instance check and the stop, so a
|
||||
// concurrent NewGracefulDegradation cannot register in between and
|
||||
// adopt a task that is about to stop.
|
||||
gdLifecycleMu.Lock()
|
||||
defer gdLifecycleMu.Unlock()
|
||||
|
||||
gdInstances.Lock()
|
||||
delete(gdInstances.set, gd)
|
||||
lastInstance := len(gdInstances.set) == 0
|
||||
gdInstances.Unlock()
|
||||
|
||||
if lastInstance {
|
||||
if gd.beforeSharedTaskStop != nil {
|
||||
gd.beforeSharedTaskStop()
|
||||
}
|
||||
// Stop by name through the resource manager rather than reading
|
||||
// this instance's own gd.healthCheckTask field: with two or more
|
||||
// GracefulDegradation instances, the shared task can already be
|
||||
// running — created by a SIBLING instance's startHealthCheckRoutine
|
||||
// goroutine — while THIS instance's own goroutine has not yet run
|
||||
// and populated its local healthCheckTask field. Reading that
|
||||
// still-nil local field here would skip the stop and leak the
|
||||
// task forever even though this genuinely is the last instance.
|
||||
// StopBackgroundTask looks up the registry's current entry
|
||||
// directly, so it is correct regardless of which instance's
|
||||
// goroutine happened to create the task (FIX-18).
|
||||
_ = GetResourceManager().StopBackgroundTask("graceful-degradation-health-check")
|
||||
}
|
||||
|
||||
gd.logger.Debug("GracefulDegradation shut down successfully")
|
||||
@@ -1049,6 +1438,21 @@ func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the resources owned by this ErrorRecoveryManager,
|
||||
// currently its GracefulDegradation instance (which unregisters from
|
||||
// gdInstances and, if this was the last live instance, stops the shared
|
||||
// health-check task). Safe to call multiple times; safe on a nil receiver.
|
||||
// FIX-18: every caller that constructs an ErrorRecoveryManager must call
|
||||
// Close, or its GracefulDegradation leaks in gdInstances forever.
|
||||
func (erm *ErrorRecoveryManager) Close() {
|
||||
if erm == nil {
|
||||
return
|
||||
}
|
||||
if erm.gracefulDegradation != nil {
|
||||
erm.gracefulDegradation.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCircuitBreaker gets or creates a circuit breaker for a service
|
||||
// GetCircuitBreaker returns the circuit breaker for a specific service.
|
||||
// Creates a new circuit breaker if one doesn't exist for the service.
|
||||
@@ -1071,8 +1475,14 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
|
||||
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
|
||||
cb := erm.GetCircuitBreaker(serviceName)
|
||||
|
||||
return erm.retryExecutor.Execute(ctx, func() error {
|
||||
return cb.Execute(fn)
|
||||
// Retry INNER, circuit OUTER (matching TokenResilienceManager and the
|
||||
// documented R106 compositing rule). Wrapping cb around the whole retry
|
||||
// run means one logical call counts as a single circuit failure even
|
||||
// when it was retried internally; the previous nesting (cb inside the
|
||||
// retry loop) recorded each retry attempt as a separate failure and
|
||||
// could open the circuit after only one flaky-but-retryable call (R144).
|
||||
return cb.ExecuteWithContext(ctx, func() error {
|
||||
return erm.retryExecutor.Execute(ctx, fn)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+198
-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
|
||||
}
|
||||
|
||||
@@ -336,6 +411,7 @@ func createStringMap(keys []string) map[string]struct{} {
|
||||
// and redirects to the provider's logout endpoint or configured post-logout URI.
|
||||
// It handles potential errors during session retrieval or clearing.
|
||||
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
t.logger.Debug("Processing logout request")
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Error getting session: %v", err)
|
||||
@@ -344,6 +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)
|
||||
@@ -351,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
|
||||
@@ -374,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.
|
||||
@@ -407,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,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,740 +0,0 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// InputValidator provides comprehensive input validation and sanitization
|
||||
// to protect against common security vulnerabilities including SQL injection,
|
||||
// XSS, path traversal, and other injection attacks. It validates and sanitizes
|
||||
// various input types used in OIDC authentication flows.
|
||||
type InputValidator struct {
|
||||
usernameRegex *regexp.Regexp
|
||||
tokenRegex *regexp.Regexp
|
||||
logger *Logger
|
||||
urlRegex *regexp.Regexp
|
||||
emailRegex *regexp.Regexp
|
||||
sqlInjectionPatterns []string
|
||||
pathTraversalPatterns []string
|
||||
xssPatterns []string
|
||||
maxUsernameLength int
|
||||
maxURLLength int
|
||||
maxTokenLength int
|
||||
maxEmailLength int
|
||||
maxClaimLength int
|
||||
maxHeaderLength int
|
||||
allowPrivateIPAddresses bool // Allow private IP addresses in URL validation
|
||||
}
|
||||
|
||||
// ValidationResult encapsulates the outcome of input validation.
|
||||
// It includes the sanitized value, detected security risks, validation
|
||||
// errors and warnings, and an overall validity status.
|
||||
type ValidationResult struct {
|
||||
SanitizedValue string `json:"sanitized_value,omitempty"`
|
||||
SecurityRisk string `json:"security_risk,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
}
|
||||
|
||||
// InputValidationConfig defines the configuration parameters for input validation.
|
||||
// It specifies maximum lengths for various input types and controls whether
|
||||
// strict validation mode is enabled.
|
||||
type InputValidationConfig struct {
|
||||
MaxTokenLength int `json:"max_token_length"`
|
||||
MaxURLLength int `json:"max_url_length"`
|
||||
MaxHeaderLength int `json:"max_header_length"`
|
||||
MaxClaimLength int `json:"max_claim_length"`
|
||||
MaxEmailLength int `json:"max_email_length"`
|
||||
MaxUsernameLength int `json:"max_username_length"`
|
||||
StrictMode bool `json:"strict_mode"`
|
||||
AllowPrivateIPAddresses bool `json:"allow_private_ip_addresses"` // Allow private IP addresses in URL validation
|
||||
}
|
||||
|
||||
// DefaultInputValidationConfig returns a secure default configuration
|
||||
// for input validation with reasonable limits based on industry standards
|
||||
// and security best practices.
|
||||
func DefaultInputValidationConfig() InputValidationConfig {
|
||||
return InputValidationConfig{
|
||||
MaxTokenLength: 50000, // 50KB for tokens
|
||||
MaxURLLength: 2048, // Standard URL length limit
|
||||
MaxHeaderLength: 8192, // 8KB for headers
|
||||
MaxClaimLength: 1024, // 1KB for individual claims
|
||||
MaxEmailLength: 254, // RFC 5321 limit
|
||||
MaxUsernameLength: 64, // Reasonable username limit
|
||||
StrictMode: true, // Enable strict validation by default
|
||||
}
|
||||
}
|
||||
|
||||
// NewInputValidator creates a new input validator with the specified configuration.
|
||||
// It compiles all necessary regex patterns and initializes security pattern lists.
|
||||
//
|
||||
// Parameters:
|
||||
// - config: Validation configuration with size limits and mode settings.
|
||||
// - logger: Logger instance for recording validation events.
|
||||
//
|
||||
// Returns:
|
||||
// - A configured InputValidator instance.
|
||||
// - An error if regex compilation fails.
|
||||
func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputValidator, error) {
|
||||
// Compile regex patterns
|
||||
emailRegex, err := regexp.Compile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile email regex: %w", err)
|
||||
}
|
||||
|
||||
urlRegex, err := regexp.Compile(`^https?://[a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})?(?::[0-9]+)?(?:/[^\s]*)?$`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile URL regex: %w", err)
|
||||
}
|
||||
|
||||
tokenRegex, err := regexp.Compile(`^[A-Za-z0-9._-]+$`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile token regex: %w", err)
|
||||
}
|
||||
|
||||
usernameRegex, err := regexp.Compile(`^[a-zA-Z0-9._-]+$`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile username regex: %w", err)
|
||||
}
|
||||
|
||||
return &InputValidator{
|
||||
maxTokenLength: config.MaxTokenLength,
|
||||
maxURLLength: config.MaxURLLength,
|
||||
maxHeaderLength: config.MaxHeaderLength,
|
||||
maxClaimLength: config.MaxClaimLength,
|
||||
maxEmailLength: config.MaxEmailLength,
|
||||
maxUsernameLength: config.MaxUsernameLength,
|
||||
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
|
||||
emailRegex: emailRegex,
|
||||
urlRegex: urlRegex,
|
||||
tokenRegex: tokenRegex,
|
||||
usernameRegex: usernameRegex,
|
||||
sqlInjectionPatterns: []string{
|
||||
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
|
||||
"union", "select", "insert", "update", "delete", "drop",
|
||||
"create", "alter", "exec", "execute", "script",
|
||||
},
|
||||
xssPatterns: []string{
|
||||
"<script", "</script>", "javascript:", "vbscript:",
|
||||
"onload=", "onerror=", "onclick=", "onmouseover=",
|
||||
"<iframe", "<object", "<embed", "<link", "<meta",
|
||||
},
|
||||
pathTraversalPatterns: []string{
|
||||
"../", "..\\", "%2e%2e%2f", "%2e%2e%5c",
|
||||
"..%2f", "..%5c", "%252e%252e%252f",
|
||||
},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates JWT tokens and similar token strings
|
||||
func (iv *InputValidator) ValidateToken(token string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check for empty token
|
||||
if token == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if len(token) > iv.maxTokenLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("token length %d exceeds maximum %d", len(token), iv.maxTokenLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for minimum reasonable length
|
||||
if len(token) < 10 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token is too short to be valid")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for valid JWT structure (3 parts separated by dots)
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token does not have valid JWT structure (expected 3 parts)")
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate each part is base64url encoded
|
||||
for i, part := range parts {
|
||||
if !iv.isValidBase64URL(part) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("token part %d is not valid base64url", i+1))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(token); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
}
|
||||
|
||||
// Check for null bytes and control characters
|
||||
if iv.containsNullBytes(token) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token contains null bytes")
|
||||
return result
|
||||
}
|
||||
|
||||
if iv.containsControlCharacters(token) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token contains control characters")
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate UTF-8 encoding
|
||||
if !utf8.ValidString(token) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "token contains invalid UTF-8 sequences")
|
||||
return result
|
||||
}
|
||||
|
||||
result.SanitizedValue = token
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateEmail validates email addresses
|
||||
func (iv *InputValidator) ValidateEmail(email string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check for empty email
|
||||
if email == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if len(email) > iv.maxEmailLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("email length %d exceeds maximum %d", len(email), iv.maxEmailLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Sanitize email (trim whitespace, convert to lowercase)
|
||||
sanitized := strings.TrimSpace(strings.ToLower(email))
|
||||
|
||||
// Check regex pattern
|
||||
if !iv.emailRegex.MatchString(sanitized) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email format is invalid")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
}
|
||||
|
||||
// Additional email-specific validations
|
||||
parts := strings.Split(sanitized, "@")
|
||||
if len(parts) != 2 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email must contain exactly one @ symbol")
|
||||
return result
|
||||
}
|
||||
|
||||
localPart, domain := parts[0], parts[1]
|
||||
|
||||
// Validate local part
|
||||
if len(localPart) == 0 || len(localPart) > 64 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email local part length is invalid")
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if len(domain) == 0 || len(domain) > 253 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email domain length is invalid")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for consecutive dots
|
||||
if strings.Contains(sanitized, "..") {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "email contains consecutive dots")
|
||||
return result
|
||||
}
|
||||
|
||||
result.SanitizedValue = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateURL validates URLs
|
||||
func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check for empty URL
|
||||
if urlStr == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "URL cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if len(urlStr) > iv.maxURLLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("URL length %d exceeds maximum %d", len(urlStr), iv.maxURLLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Sanitize URL (trim whitespace)
|
||||
sanitized := strings.TrimSpace(urlStr)
|
||||
|
||||
// Parse URL
|
||||
parsedURL, err := url.Parse(sanitized)
|
||||
if err != nil {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("URL parsing failed: %v", err))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check scheme
|
||||
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "URL scheme must be http or https")
|
||||
return result
|
||||
}
|
||||
|
||||
// Prefer HTTPS
|
||||
if parsedURL.Scheme == "http" {
|
||||
result.Warnings = append(result.Warnings, "HTTP URLs are less secure than HTTPS")
|
||||
}
|
||||
|
||||
// Check host
|
||||
if parsedURL.Host == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "URL must have a valid host")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for localhost or private IPs for security
|
||||
// Allow localhost for HTTPS (development/testing) but warn about it
|
||||
hostname := strings.ToLower(parsedURL.Hostname())
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
|
||||
if parsedURL.Scheme == "https" {
|
||||
// Allow HTTPS localhost for development but warn
|
||||
result.Warnings = append(result.Warnings, "localhost URLs should only be used for development/testing")
|
||||
} else {
|
||||
// Reject non-HTTPS localhost for security
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "non-HTTPS localhost URLs are not allowed for security")
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled
|
||||
if !iv.allowPrivateIPAddresses {
|
||||
if strings.HasPrefix(hostname, "10.") ||
|
||||
strings.HasPrefix(hostname, "192.168.") ||
|
||||
strings.HasPrefix(hostname, "172.") {
|
||||
// For 172.x check if it's in the 172.16.0.0/12 range
|
||||
if strings.HasPrefix(hostname, "172.") {
|
||||
parts := strings.Split(hostname, ".")
|
||||
if len(parts) >= 2 {
|
||||
if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
|
||||
return result
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "private IP URLs are not allowed for security")
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
}
|
||||
|
||||
// Check for path traversal attempts
|
||||
if iv.containsPathTraversal(sanitized) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "URL contains path traversal patterns")
|
||||
return result
|
||||
}
|
||||
|
||||
result.SanitizedValue = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateUsername validates usernames
|
||||
func (iv *InputValidator) ValidateUsername(username string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check for empty username
|
||||
if username == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "username cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if len(username) > iv.maxUsernameLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("username length %d exceeds maximum %d", len(username), iv.maxUsernameLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if len(username) < 2 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "username must be at least 2 characters long")
|
||||
return result
|
||||
}
|
||||
|
||||
// Sanitize username (trim whitespace)
|
||||
sanitized := strings.TrimSpace(username)
|
||||
|
||||
// Check regex pattern
|
||||
if !iv.usernameRegex.MatchString(sanitized) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "username contains invalid characters (only letters, numbers, dots, underscores, and hyphens allowed)")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(sanitized); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
}
|
||||
|
||||
result.SanitizedValue = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateClaim validates individual JWT claims
|
||||
func (iv *InputValidator) ValidateClaim(claimName, claimValue string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check claim name
|
||||
if claimName == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "claim name cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check claim value length
|
||||
if len(claimValue) > iv.maxClaimLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("claim value length %d exceeds maximum %d", len(claimValue), iv.maxClaimLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for null bytes and control characters
|
||||
if iv.containsNullBytes(claimValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "claim value contains null bytes")
|
||||
return result
|
||||
}
|
||||
|
||||
if iv.containsControlCharacters(claimValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "claim value contains control characters")
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate UTF-8 encoding
|
||||
if !utf8.ValidString(claimValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "claim value contains invalid UTF-8 sequences")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(claimValue); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for excessive unicode (emojis and special characters)
|
||||
unicodeCount := 0
|
||||
runeCount := 0
|
||||
for _, r := range claimValue {
|
||||
runeCount++
|
||||
if r > 127 { // Non-ASCII character
|
||||
unicodeCount++
|
||||
}
|
||||
}
|
||||
// If more than 50% of the characters are unicode, consider it suspicious
|
||||
if runeCount > 0 && unicodeCount > runeCount/2 {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "claim value contains excessive unicode characters")
|
||||
return result
|
||||
}
|
||||
|
||||
// Specific validations based on claim name
|
||||
switch claimName {
|
||||
case "email":
|
||||
emailResult := iv.ValidateEmail(claimValue)
|
||||
if !emailResult.IsValid {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, emailResult.Errors...)
|
||||
}
|
||||
result.Warnings = append(result.Warnings, emailResult.Warnings...)
|
||||
result.SanitizedValue = emailResult.SanitizedValue
|
||||
|
||||
case "iss", "aud":
|
||||
urlResult := iv.ValidateURL(claimValue)
|
||||
if !urlResult.IsValid {
|
||||
// For issuer/audience, we're more lenient - just warn
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("%s claim is not a valid URL: %v", claimName, urlResult.Errors))
|
||||
}
|
||||
result.SanitizedValue = claimValue
|
||||
|
||||
case "preferred_username", "username":
|
||||
usernameResult := iv.ValidateUsername(claimValue)
|
||||
if !usernameResult.IsValid {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, usernameResult.Errors...)
|
||||
}
|
||||
result.Warnings = append(result.Warnings, usernameResult.Warnings...)
|
||||
result.SanitizedValue = usernameResult.SanitizedValue
|
||||
|
||||
default:
|
||||
// Generic string validation
|
||||
result.SanitizedValue = strings.TrimSpace(claimValue)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateHeader validates HTTP header values
|
||||
func (iv *InputValidator) ValidateHeader(headerName, headerValue string) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
// Check header name
|
||||
if headerName == "" {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header name cannot be empty")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for control characters in header name (including CRLF)
|
||||
if iv.containsControlCharacters(headerName) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header name contains control characters")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for CRLF injection in header name
|
||||
if strings.Contains(headerName, "\r") || strings.Contains(headerName, "\n") {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header name contains CRLF characters (potential header injection)")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check header value length
|
||||
if len(headerValue) > iv.maxHeaderLength {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("header value length %d exceeds maximum %d", len(headerValue), iv.maxHeaderLength))
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for null bytes and control characters (except allowed ones)
|
||||
if iv.containsNullBytes(headerValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header value contains null bytes")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for CRLF injection
|
||||
if strings.Contains(headerValue, "\r") || strings.Contains(headerValue, "\n") {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header value contains CRLF characters (potential header injection)")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for control characters in header value
|
||||
if iv.containsControlCharacters(headerValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header value contains control characters")
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate UTF-8 encoding
|
||||
if !utf8.ValidString(headerValue) {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "header value contains invalid UTF-8 sequences")
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if risk := iv.detectSecurityRisk(headerValue); risk != "" {
|
||||
result.SecurityRisk = risk
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("potential security risk detected: %s", risk))
|
||||
return result
|
||||
}
|
||||
|
||||
result.SanitizedValue = strings.TrimSpace(headerValue)
|
||||
return result
|
||||
}
|
||||
|
||||
// isValidBase64URL checks if a string is valid base64url encoding
|
||||
func (iv *InputValidator) isValidBase64URL(s string) bool {
|
||||
// Base64url uses A-Z, a-z, 0-9, -, _ and no padding
|
||||
for _, r := range s {
|
||||
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
|
||||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// containsNullBytes checks if a string contains null bytes
|
||||
func (iv *InputValidator) containsNullBytes(s string) bool {
|
||||
return strings.Contains(s, "\x00")
|
||||
}
|
||||
|
||||
// containsControlCharacters checks if a string contains control characters
|
||||
func (iv *InputValidator) containsControlCharacters(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsPathTraversal checks for path traversal patterns
|
||||
func (iv *InputValidator) containsPathTraversal(s string) bool {
|
||||
lowerS := strings.ToLower(s)
|
||||
for _, pattern := range iv.pathTraversalPatterns {
|
||||
if strings.Contains(lowerS, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectSecurityRisk detects potential security risks in input
|
||||
func (iv *InputValidator) detectSecurityRisk(input string) string {
|
||||
lowerInput := strings.ToLower(input)
|
||||
|
||||
// Check for SQL injection patterns
|
||||
for _, pattern := range iv.sqlInjectionPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return "sql_injection"
|
||||
}
|
||||
}
|
||||
|
||||
// Check for XSS patterns
|
||||
for _, pattern := range iv.xssPatterns {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return "xss"
|
||||
}
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if iv.containsPathTraversal(input) {
|
||||
return "path_traversal"
|
||||
}
|
||||
|
||||
// Check for excessive length (potential DoS)
|
||||
if len(input) > 10000 {
|
||||
return "excessive_length"
|
||||
}
|
||||
|
||||
// Check for suspicious character patterns
|
||||
if iv.containsNullBytes(input) {
|
||||
return "null_bytes"
|
||||
}
|
||||
|
||||
// Check for binary data patterns
|
||||
nonPrintableCount := 0
|
||||
for _, r := range input {
|
||||
if !unicode.IsPrint(r) && !unicode.IsSpace(r) {
|
||||
nonPrintableCount++
|
||||
}
|
||||
}
|
||||
if nonPrintableCount > len(input)/10 { // More than 10% non-printable
|
||||
return "binary_data"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SanitizeInput provides general input sanitization
|
||||
func (iv *InputValidator) SanitizeInput(input string, maxLength int) string {
|
||||
// Trim whitespace
|
||||
sanitized := strings.TrimSpace(input)
|
||||
|
||||
// Truncate if too long
|
||||
if len(sanitized) > maxLength {
|
||||
sanitized = sanitized[:maxLength]
|
||||
}
|
||||
|
||||
// Remove null bytes
|
||||
sanitized = strings.ReplaceAll(sanitized, "\x00", "")
|
||||
|
||||
// Remove other control characters except tab, newline, carriage return
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
if !unicode.IsControl(r) || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ValidateBoundaryValues validates numeric boundary values
|
||||
func (iv *InputValidator) ValidateBoundaryValues(value interface{}, min, max int64) ValidationResult {
|
||||
result := ValidationResult{IsValid: true, Errors: []string{}, Warnings: []string{}}
|
||||
|
||||
var numValue int64
|
||||
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
numValue = int64(v)
|
||||
case int32:
|
||||
numValue = int64(v)
|
||||
case int64:
|
||||
numValue = v
|
||||
case float64:
|
||||
numValue = int64(v)
|
||||
if float64(numValue) != v {
|
||||
result.Warnings = append(result.Warnings, "floating point value truncated to integer")
|
||||
}
|
||||
default:
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, "value is not a numeric type")
|
||||
return result
|
||||
}
|
||||
|
||||
if numValue < min {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("value %d is below minimum %d", numValue, min))
|
||||
}
|
||||
|
||||
if numValue > max {
|
||||
result.IsValid = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("value %d exceeds maximum %d", numValue, max))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user