mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa579ccf4 | ||
|
|
2af05701dc | ||
|
|
03a755cb53 | ||
|
|
dc0e7e0238 | ||
|
|
b2e79d8798 | ||
|
|
52ef32ece7 | ||
|
|
3bf7c60ef4 | ||
|
|
775ca7afc3 | ||
|
|
a1273e6883 | ||
|
|
0bc0079a58 | ||
|
|
20294f1339 | ||
|
|
43938ed8a8 | ||
|
|
46679c82eb | ||
|
|
a46be72be5 | ||
|
|
91966c1bec | ||
|
|
c465fc888b | ||
|
|
047fea3c75 | ||
|
|
0c092a5a22 | ||
|
|
8f458b4f6e | ||
|
|
17c28fd574 | ||
|
|
21cc2ed747 | ||
|
|
ded90e5dc1 | ||
|
|
46777d0510 | ||
|
|
f990365cb8 | ||
|
|
85eb9ecd16 | ||
|
|
3495e70cbb | ||
|
|
fcb21a36e6 | ||
|
|
a6c38c0747 |
@@ -0,0 +1,52 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "09:00"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
include: "scope"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "github-actions"
|
||||||
|
reviewers:
|
||||||
|
- "lukaszraczylo"
|
||||||
|
|
||||||
|
# Maintain Go module dependencies
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "09:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
include: "scope"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "go"
|
||||||
|
reviewers:
|
||||||
|
- "lukaszraczylo"
|
||||||
|
# Group patch updates together
|
||||||
|
groups:
|
||||||
|
patch-updates:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
# Ignore certain dependencies if needed
|
||||||
|
ignore:
|
||||||
|
# Example: ignore specific versions
|
||||||
|
# - dependency-name: "github.com/example/package"
|
||||||
|
# versions: ["1.x", "2.x"]
|
||||||
@@ -18,43 +18,6 @@ jobs:
|
|||||||
pr-checks:
|
pr-checks:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.24.11"
|
||||||
coverage-threshold: 70
|
coverage-threshold: 70
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
yaegi-validate:
|
|
||||||
name: Yaegi interpreter compatibility
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v7
|
|
||||||
with:
|
|
||||||
go-version: "1.26.x"
|
|
||||||
|
|
||||||
# Pinned to the yaegi version Traefik v3.7.1 vendors (Makefile
|
|
||||||
# YAEGI_VERSION), so this tests the real interpreter a release runs
|
|
||||||
# under, not a newer one that may tolerate more.
|
|
||||||
- name: Install yaegi
|
|
||||||
run: go install github.com/traefik/yaegi/cmd/[email protected]
|
|
||||||
|
|
||||||
# yaegi resolves the plugin's own import path
|
|
||||||
# (github.com/lukaszraczylo/traefikoidc) through GOPATH/src, same as
|
|
||||||
# `make yaegi-validate` does locally.
|
|
||||||
- name: Symlink checkout into GOPATH
|
|
||||||
run: |
|
|
||||||
mkdir -p "$(go env GOPATH)/src/github.com/lukaszraczylo"
|
|
||||||
ln -sfn "$GITHUB_WORKSPACE" "$(go env GOPATH)/src/github.com/lukaszraczylo/traefikoidc"
|
|
||||||
|
|
||||||
# Runs cmd/yaegicheck under the real interpreter: imports the plugin
|
|
||||||
# package, exercises CreateConfig + New, and drives every yaegi-only
|
|
||||||
# regression check (errors.As-on-an-interpreted-target panics,
|
|
||||||
# SSE Flush across the interpreter boundary, etc.) that native
|
|
||||||
# `go build`/`go test` cannot see.
|
|
||||||
- name: Run yaegi regression checks
|
|
||||||
run: DO_NOT_TRACK=1 GOFLAGS=-mod=vendor yaegi run ./cmd/yaegicheck/main.go
|
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.24.11"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
docker/
|
docker/
|
||||||
.claude/
|
.claude/*.out
|
||||||
*.test
|
*.test
|
||||||
.leann/
|
.leann/
|
||||||
|
|||||||
+130
-5
@@ -1,13 +1,41 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
# Traefik plugins are source-only - no binary builds
|
# Two release artefacts:
|
||||||
# Traefik loads plugins via Yaegi interpreter at runtime
|
#
|
||||||
|
# 1. The Traefik plugin: source-only — Traefik loads it via the Yaegi
|
||||||
|
# interpreter from the source tarball published on GitHub releases.
|
||||||
|
# 2. oidcgate: a standalone forward-auth daemon built from cmd/oidcgate.
|
||||||
|
# Shipped as both per-OS/arch binary archives AND a multi-arch Docker
|
||||||
|
# image at ghcr.io/lukaszraczylo/oidcgate, tagged to match the release.
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- skip: true
|
- id: oidcgate
|
||||||
|
main: ./cmd/oidcgate
|
||||||
|
binary: oidcgate
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
- -buildvcs=false
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X main.version={{.Version}}
|
||||||
|
- -X main.commit={{.ShortCommit}}
|
||||||
|
- -X main.date={{.Date}}
|
||||||
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
|
||||||
# Create source archive for GitHub releases
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [tar.gz]
|
# Source archive for the Traefik plugin path. meta:true → no binary
|
||||||
|
# builds attached; everything comes from `files:` below.
|
||||||
|
- id: source-plugin
|
||||||
|
meta: true
|
||||||
|
formats: [tar.gz]
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
|
||||||
files:
|
files:
|
||||||
- "*.go"
|
- "*.go"
|
||||||
@@ -25,6 +53,93 @@ archives:
|
|||||||
- "!regression/**"
|
- "!regression/**"
|
||||||
- "!examples/**"
|
- "!examples/**"
|
||||||
- "!docs/**"
|
- "!docs/**"
|
||||||
|
- "!cmd/**"
|
||||||
|
|
||||||
|
# Per-OS/arch binary archives for the oidcgate daemon.
|
||||||
|
- id: oidcgate
|
||||||
|
ids: [oidcgate]
|
||||||
|
formats: [tar.gz]
|
||||||
|
name_template: "oidcgate_v{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
files:
|
||||||
|
- LICENSE*
|
||||||
|
- README*
|
||||||
|
- src: docs/OIDCGATE.md
|
||||||
|
dst: docs/
|
||||||
|
- src: examples/oidcgate.yaml
|
||||||
|
dst: examples/
|
||||||
|
|
||||||
|
# Build a Docker image per (linux, arch) combo. Tag suffixes are
|
||||||
|
# combined into a single multi-arch manifest list below via
|
||||||
|
# docker_manifests, so end users pull a single tag.
|
||||||
|
dockers:
|
||||||
|
- id: oidcgate-amd64
|
||||||
|
ids: [oidcgate]
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
use: buildx
|
||||||
|
dockerfile: cmd/oidcgate/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--pull"
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.title=oidcgate"
|
||||||
|
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
|
||||||
|
- "--label=org.opencontainers.image.created={{ .Date }}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
- "--label=org.opencontainers.image.licenses=MIT"
|
||||||
|
|
||||||
|
- id: oidcgate-arm64
|
||||||
|
ids: [oidcgate]
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
use: buildx
|
||||||
|
dockerfile: cmd/oidcgate/Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--pull"
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
- "--label=org.opencontainers.image.title=oidcgate"
|
||||||
|
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
|
||||||
|
- "--label=org.opencontainers.image.created={{ .Date }}"
|
||||||
|
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
- "--label=org.opencontainers.image.licenses=MIT"
|
||||||
|
|
||||||
|
# Multi-arch manifests — these are what users actually pull.
|
||||||
|
# Tags match the release tag (vX.Y.Z) exactly, plus a few convenience tags.
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Version }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}.{{ .Minor }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
- name_template: "ghcr.io/lukaszraczylo/oidcgate:latest"
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
|
||||||
|
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
|
||||||
|
skip_push: auto
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
|
||||||
@@ -58,3 +173,13 @@ signs:
|
|||||||
- "--yes"
|
- "--yes"
|
||||||
artifacts: checksum
|
artifacts: checksum
|
||||||
output: true
|
output: true
|
||||||
|
|
||||||
|
# Sign the Docker images and manifests with cosign keyless.
|
||||||
|
docker_signs:
|
||||||
|
- cmd: cosign
|
||||||
|
artifacts: all
|
||||||
|
args:
|
||||||
|
- sign
|
||||||
|
- "${artifact}@${digest}"
|
||||||
|
- "--yes"
|
||||||
|
output: true
|
||||||
|
|||||||
+5
-22
@@ -60,25 +60,14 @@ testData:
|
|||||||
scopes:
|
scopes:
|
||||||
- roles
|
- roles
|
||||||
|
|
||||||
# Templated headers forwarded to backends. Values are Go templates and must
|
# Templated headers forwarded to backends.
|
||||||
# be written with plain double braces — the catalog analyzer feeds them to
|
# NOTE: use quadruple braces — the YAML parser collapses {{{{ → {{ so the
|
||||||
# the plugin verbatim (issue #151; quadruple braces do NOT get collapsed).
|
# Go template engine receives the correct expression.
|
||||||
# Only when your dynamic config file is rendered by Traefik's file-provider
|
|
||||||
# Go templating do the braces need escaping there, e.g.
|
|
||||||
# value: "{{`{{.Claims.email}}`}}"
|
|
||||||
# (see README "Templated headers").
|
|
||||||
headers:
|
headers:
|
||||||
- name: X-User-Email
|
- name: X-User-Email
|
||||||
value: "{{.Claims.email}}"
|
value: "{{{{.Claims.email}}}}"
|
||||||
- name: Authorization
|
- name: Authorization
|
||||||
value: "Bearer {{.AccessToken}}"
|
value: "Bearer {{{{.AccessToken}}}}"
|
||||||
# Object/array claim as one JSON header; the claim must be whitelisted.
|
|
||||||
- name: X-Tenant
|
|
||||||
value: '{{get .Claims "tenant" | toJson}}'
|
|
||||||
|
|
||||||
# Extends the built-in claim whitelist for templated headers.
|
|
||||||
allowedClaims:
|
|
||||||
- tenant
|
|
||||||
|
|
||||||
# Security headers (default profile is enabled out of the box)
|
# Security headers (default profile is enabled out of the box)
|
||||||
securityHeaders:
|
securityHeaders:
|
||||||
@@ -107,15 +96,9 @@ testData:
|
|||||||
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
|
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
|
||||||
# bearerOverridesCookie: false # default; cookie wins when both are present
|
# bearerOverridesCookie: false # default; cookie wins when both are present
|
||||||
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
|
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
|
||||||
# introspectionURL: https://idp.example.com/oauth/introspect # manual override; set when discovery omits introspection_endpoint
|
|
||||||
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
|
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
|
||||||
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
|
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
|
||||||
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
|
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
|
||||||
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
|
# bearerFailureWindowSeconds: 60 # rolling window over which 401s are counted
|
||||||
# bearerFailurePenaltySeconds: 60 # 429 + Retry-After duration after threshold trips
|
# bearerFailurePenaltySeconds: 60 # 429 + Retry-After duration after threshold trips
|
||||||
|
|
||||||
# Optional: RFC 8707 resource indicator, alternative to `audience` for
|
|
||||||
# supporting IdPs. See docs/RFC8707.md for exact request behavior and
|
|
||||||
# provider support.
|
|
||||||
# resource: https://api.example.com
|
|
||||||
|
|
||||||
|
|||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
|
||||||
|
|
||||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **`allowUnauthenticatedPreflight` (default `false`).** Every `OPTIONS`
|
|
||||||
request needs authentication by default, as in the last release. Set the
|
|
||||||
option to `true` to let a genuine CORS preflight reach the backend
|
|
||||||
without a session. Only an `OPTIONS` request that carries both `Origin`
|
|
||||||
and `Access-Control-Request-Method` bypasses authentication, and the
|
|
||||||
plugin discards the backend's response body for it, because a browser
|
|
||||||
never reads a preflight body (`settings.go`, `middleware.go`, FIX-02).
|
|
||||||
See the option table in [CONFIGURATION.md](docs/CONFIGURATION.md).
|
|
||||||
|
|
||||||
- **`streamingRefresh` (default `false`).** SSE and WebSocket requests do
|
|
||||||
not check the token expiry, so a session stays usable for streaming until
|
|
||||||
the session cookie expires. Set the option to `true` to refresh an expired
|
|
||||||
token on these requests. If the IdP rejects the refresh with
|
|
||||||
`invalid_grant`, the request gets `401`. If the IdP cannot be reached, the
|
|
||||||
request is forwarded as before. The request waits for the full refresh, so
|
|
||||||
a rotated refresh token is never lost (`middleware.go`).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Opaque bearer tokens when all three bearer flags are set.** A deployment
|
|
||||||
that sets `enableBearerAuth`, `requireTokenIntrospection` and
|
|
||||||
`allowOpaqueTokens` now accepts opaque bearer tokens after RFC 7662
|
|
||||||
introspection. The last release rejected every non-JWT bearer token. The
|
|
||||||
plugin binds an accepted opaque token to the client through `client_id` or
|
|
||||||
`aud`. It also applies the identifier, token-age, logout, revocation and
|
|
||||||
`nbf` checks (`bearer_auth.go`).
|
|
||||||
|
|
||||||
- **Sessions created before this upgrade can omit `post_logout_redirect_uri`
|
|
||||||
on their first logout.** The plugin now builds that parameter only from the
|
|
||||||
redirect-URI origin recorded at login, never from the logout request's
|
|
||||||
host. Older sessions have no recorded origin, so those users stay on the
|
|
||||||
IdP's logout page once (`helpers.go`).
|
|
||||||
|
|
||||||
- **Shutdown waits up to 5 seconds for a token refresh in progress.** On
|
|
||||||
shutdown, for example a Traefik reload, the refresh coordinator lets a
|
|
||||||
refresh that already reached the IdP deliver its tokens, then returns
|
|
||||||
(`shutdownRefreshDrainTimeout`, `refresh_coordinator.go`). A refresh that
|
|
||||||
has not started does not start after shutdown.
|
|
||||||
|
|
||||||
- **A panic after the request reaches the backend now aborts the
|
|
||||||
connection.** The plugin re-panics it, for example the reverse proxy's
|
|
||||||
`http.ErrAbortHandler` when an upstream drops the connection mid-body. Go's
|
|
||||||
HTTP server then aborts the connection. Before, the plugin recovered the
|
|
||||||
panic, so a truncated response could reach the client as a complete 200. A
|
|
||||||
panic before the request reaches the backend still returns 500
|
|
||||||
(`middleware.go`).
|
|
||||||
|
|
||||||
- **private_key_jwt now rejects RSA signing keys under 2048 bits.**
|
|
||||||
`validateAlgKeyMatch` stops `New()` with an error when a configured
|
|
||||||
`private_key_jwt` RS\*/PS\* key is smaller than 2048 bits (RFC 7518 §3.3).
|
|
||||||
A smaller key signs locally, but a conformant IdP rejects it at token
|
|
||||||
exchange with `invalid_client`, so the plugin now fails at construction
|
|
||||||
instead of at every runtime exchange. There is no override: deployments
|
|
||||||
using an RSA key under 2048 bits must generate a 2048-bit-or-larger key
|
|
||||||
before upgrading. The rejection returns a matchable `ErrRSAKeyTooSmall`
|
|
||||||
sentinel (`client_assertion.go`).
|
|
||||||
|
|
||||||
- **A discovered `http://` endpoint is now dropped when `providerURL` is
|
|
||||||
`https://`.** `validateDiscoveredEndpoint` refuses to accept an
|
|
||||||
authorization, token, jwks_uri, revocation, end_session, introspection, or
|
|
||||||
registration endpoint served over plaintext HTTP when the operator
|
|
||||||
configured an HTTPS provider (R146): using it as-is would send a client
|
|
||||||
secret or token over an unauthenticated channel. There is no configuration
|
|
||||||
flag to disable this check. A dropped endpoint logs an `ERROR` line; a
|
|
||||||
dropped `token`, `jwks_uri`, or `authorization` endpoint — the three
|
|
||||||
every login needs — additionally logs a `SECURITY:`-prefixed line naming
|
|
||||||
the endpoint, the dropped URL, and the fact that there is no override, so
|
|
||||||
the failure is diagnosable instead of surfacing only as a broken login.
|
|
||||||
See [Discovered Endpoint
|
|
||||||
Validation](docs/CONFIGURATION.md#discovered-endpoint-validation)
|
|
||||||
(`url_helpers.go`, `main.go`).
|
|
||||||
|
|
||||||
- **Blacklist entries in Redis use the `blacklist:` key prefix.** The
|
|
||||||
blacklist cache now has its own cache type (`CacheTypeBlacklist`), so its
|
|
||||||
Redis keys move from the shared `token:` prefix to `blacklist:` (R128).
|
|
||||||
A cached token entry and a revocation marker for the same raw token can
|
|
||||||
no longer overwrite each other. For one release, a blacklist miss also
|
|
||||||
reads the legacy `token:<key>` entry and treats it as revoked only when
|
|
||||||
the stored value is boolean `true` (`checkLegacyBlacklistMarker` in
|
|
||||||
`universal_cache.go`, FIX-16). The fallback works in one direction only:
|
|
||||||
during a rolling deploy, a replica that runs the previous version does
|
|
||||||
not see revocations that an upgraded replica writes under `blacklist:`.
|
|
||||||
A blacklist miss checks the legacy `token:` entry at most once per key in
|
|
||||||
each 30 seconds, so the fallback adds little Redis traffic.
|
|
||||||
|
|
||||||
- **Claim validation accepts four previously-rejected or silently-dropped
|
|
||||||
claim shapes.** Each change below is intentional, matches a production
|
|
||||||
change, and is pinned by an existing regression test.
|
|
||||||
- `iat` (issued-at) is optional in `jwt.Verify`, per RFC 7519 §4.1.6. A
|
|
||||||
token that omits it is validated on `iss`/`aud`/`exp`/`nbf` alone,
|
|
||||||
instead of being rejected (`jwt.go`, R126). The lenient-audience
|
|
||||||
access-token path (`accessTokenUnexpired` in `token_validation_rs.go`)
|
|
||||||
applies the same rule, because both paths call `verifyTimeClaims`
|
|
||||||
(`jwt.go`, FIX-27). Bearer-token auth still rejects a token with no
|
|
||||||
`iat`: it returns `"missing iat claim"` (`enforceIatAge` in
|
|
||||||
`bearer_auth.go`), because `maxTokenAgeSeconds` always applies — `0`
|
|
||||||
falls back to a 24h default (`main.go`).
|
|
||||||
- A numeric `sub` (bearer-auth identifier) claim is stringified instead of
|
|
||||||
rejected, so an IdP that emits a numeric subject still authenticates
|
|
||||||
(`bearer_auth.go` `resolveBearerIdentifier`, R102).
|
|
||||||
- A `groups`/`roles` claim that is neither a string nor an array (for
|
|
||||||
example a bare number or `null`) is treated as an empty list instead of
|
|
||||||
failing claim extraction outright, so a malformed claim on one side does
|
|
||||||
not suppress a valid claim on the other (`token_manager.go`
|
|
||||||
`extractGroupsAndRolesFromClaims`, R96).
|
|
||||||
- A numeric element inside a `groups`/`roles` array is stringified into
|
|
||||||
the list instead of being silently dropped, so a numeric group ID
|
|
||||||
survives extraction and can match an `allowedRolesAndGroups` entry
|
|
||||||
(`token_manager.go` `stringListFromClaim`, `utilities.go`
|
|
||||||
`claimScalarString`, R105).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- SSE and WebSocket requests now get the configured `headers`, as normal
|
|
||||||
requests do. The last release sent only `X-Forwarded-User` on these
|
|
||||||
requests, so a backend that checks a shared-secret header answered `401`
|
|
||||||
on WebSocket upgrades. The same requests now also honor back-channel and
|
|
||||||
front-channel logout (`middleware.go`, #162).
|
|
||||||
- The opaque bearer path rejects a token that `RevokeToken` added to the
|
|
||||||
blacklist, for example at logout. It also rejects an opaque token whose
|
|
||||||
`nbf` is in the future (`bearer_auth.go`).
|
|
||||||
- The backchannel-logout replay check treats a Redis error reply such as
|
|
||||||
`READONLY`, `OOM` or `MISCONF` as a failure, not as an unknown outcome. A
|
|
||||||
replayed logout token is now rejected while Redis refuses writes
|
|
||||||
(`internal/cache/backends/redis.go`).
|
|
||||||
- After a failed cache write, a replica no longer ignores a newer value that
|
|
||||||
another replica wrote, such as a later logout. The preference for the local
|
|
||||||
value expires after 5 seconds (`backendStaleMarkTTL`, `universal_cache.go`).
|
|
||||||
- The token circuit breaker treats HTTP 408 from the token endpoint as a
|
|
||||||
transient failure, the same as 429 (`error_recovery.go`).
|
|
||||||
- With `allowUnauthenticatedPreflight` enabled, a preflight response no longer
|
|
||||||
carries a stale `Content-Length` when the backend writes a body without
|
|
||||||
calling `WriteHeader` (`middleware.go`).
|
|
||||||
- A dropped `http://` revocation, end-session or introspection endpoint logs
|
|
||||||
the `SECURITY:` line when its override (`revocationURL`, `oidcEndSessionURL`
|
|
||||||
or `introspectionURL`) is not configured (`main.go`).
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -42,7 +42,7 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
moduleName: github.com/lukaszraczylo/traefikoidc
|
||||||
version: v1.0.29
|
version: v0.7.10
|
||||||
```
|
```
|
||||||
|
|
||||||
Then attach the middleware in your dynamic configuration (see
|
Then attach the middleware in your dynamic configuration (see
|
||||||
@@ -64,6 +64,28 @@ cosign verify-blob \
|
|||||||
traefikoidc_v<version>_checksums.txt
|
traefikoidc_v<version>_checksums.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Standalone binary (oidcgate)
|
||||||
|
|
||||||
|
If you don't run Traefik, `oidcgate` exposes the same middleware as a
|
||||||
|
forward-auth daemon for nginx, Caddy, Traefik ForwardAuth, HAProxy, and
|
||||||
|
Envoy. See [`docs/OIDCGATE.md`](docs/OIDCGATE.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From source
|
||||||
|
go build -o oidcgate ./cmd/oidcgate
|
||||||
|
./oidcgate --config examples/oidcgate.yaml
|
||||||
|
|
||||||
|
# Or pull the released image (multi-arch: linux/amd64, linux/arm64)
|
||||||
|
docker run --rm \
|
||||||
|
-v /path/to/config.yaml:/etc/oidcgate/config.yaml:ro \
|
||||||
|
-p 8080:8080 \
|
||||||
|
ghcr.io/lukaszraczylo/oidcgate:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tagged release publishes a Docker image at
|
||||||
|
`ghcr.io/lukaszraczylo/oidcgate:vX.Y.Z` (matching the release tag), plus
|
||||||
|
floating `:vX.Y`, `:vX`, and `:latest` aliases.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -111,8 +133,7 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
|||||||
| `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. |
|
| `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. |
|
||||||
| `postLogoutRedirectURI` | `/` | Where to send users after logout. |
|
| `postLogoutRedirectURI` | `/` | Where to send users after logout. |
|
||||||
| `scopes` | appended to `openid profile email` | Extra OAuth scopes. Set `overrideScopes: true` to replace defaults. |
|
| `scopes` | appended to `openid profile email` | Extra OAuth scopes. Set `overrideScopes: true` to replace defaults. |
|
||||||
| `extraAuthParams` | none | Map of extra query parameters appended to the authorization request (e.g. `screen_hint: signup`, `login_hint`, `ui_locales`, `prompt`). Plugin-managed params (`client_id`, `state`, `nonce`, `redirect_uri`, `code_challenge`, `scope`, `response_type`, …) cannot be overridden. |
|
| `excludedURLs` | none | Prefix-matched paths that bypass auth. |
|
||||||
| `excludedURLs` | none | Paths that bypass auth, matched at a path-segment or file-extension boundary (e.g. `/public` matches `/public`, `/public/sub` and `/public.json`, but **not** `/publicsecret`). |
|
|
||||||
| `allowedUserDomains` | none | Restrict to email domains. |
|
| `allowedUserDomains` | none | Restrict to email domains. |
|
||||||
| `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). |
|
| `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). |
|
||||||
| `allowedRolesAndGroups` | none | Require any of these roles/groups from ID-token claims. |
|
| `allowedRolesAndGroups` | none | Require any of these roles/groups from ID-token claims. |
|
||||||
@@ -121,25 +142,16 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
|||||||
| `enablePKCE` | `false` | PKCE on the auth code flow. |
|
| `enablePKCE` | `false` | PKCE on the auth code flow. |
|
||||||
| `cookieDomain` | auto | Set explicitly for multi-subdomain setups (`.example.com`). |
|
| `cookieDomain` | auto | Set explicitly for multi-subdomain setups (`.example.com`). |
|
||||||
| `cookiePrefix` | `_oidc_raczylo_` | Unique prefix per middleware instance to isolate sessions. |
|
| `cookiePrefix` | `_oidc_raczylo_` | Unique prefix per middleware instance to isolate sessions. |
|
||||||
| `cookiePath` | `/` | Restrict cookies to a path prefix. Set to the middleware's path (e.g. `/app`) to prevent the browser from sending OIDC cookies to unprotected paths, avoiding 431 "Request Header Or Cookie Too Large" errors on mixed-use domains. |
|
|
||||||
| `sessionMaxAge` | `86400` | Session lifetime in seconds. |
|
| `sessionMaxAge` | `86400` | Session lifetime in seconds. |
|
||||||
| `refreshGracePeriodSeconds` | `60` | Proactively refresh tokens this many seconds before expiry. |
|
| `refreshGracePeriodSeconds` | `60` | Proactively refresh tokens this many seconds before expiry. |
|
||||||
| `maxRefreshTokenAgeSeconds` | `21600` | Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set `0` to disable. Tune to match your IdP's RT TTL. |
|
| `maxRefreshTokenAgeSeconds` | `21600` | Heuristic max stored refresh-token lifetime (6h). Past this, the plugin treats the RT as expired without contacting the IdP — returns 401 to AJAX, full re-auth on navigations. Set `0` to disable. Tune to match your IdP's RT TTL. |
|
||||||
| `rateLimit` | `100` | Requests/sec. Min `10`. |
|
| `rateLimit` | `100` | Requests/sec. Min `10`. |
|
||||||
| `perSourceLoginRateLimit` | `0` (off) | Throttle OIDC auth events (authorization-code callback + login initiation) per external client source, auth events per minute. Keys and classifies the source by RemoteAddr only, never `X-Forwarded-For`, so a client cannot spoof it. Internal/loopback sources (proxies, in-cluster) are never throttled. `0` disables. RemoteAddr is the TCP peer. Behind a load balancer or CDN, the limiter keys on the proxy address: a private address is never throttled, and a public address puts every client in one shared bucket. |
|
|
||||||
| `logLevel` | `info` | `debug`, `info`, `error`. |
|
| `logLevel` | `info` | `debug`, `info`, `error`. |
|
||||||
| `audience` | `clientID`, or `resource` if set | Custom access-token audience (Auth0 custom APIs). |
|
| `audience` | `clientID` | Custom access-token audience (Auth0 custom APIs). |
|
||||||
| `resource` | none | RFC 8707 resource indicator, alternative to `audience` for supporting IdPs (see [docs/RFC8707.md](docs/RFC8707.md)). |
|
|
||||||
| `strictAudienceValidation` | `false` | Reject mismatched audiences. **Set `true` in production.** |
|
| `strictAudienceValidation` | `false` | Reject mismatched audiences. **Set `true` in production.** |
|
||||||
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
|
| `allowOpaqueTokens` / `requireTokenIntrospection` | `false` | Accept opaque access tokens via RFC 7662. |
|
||||||
| `revocationURL` / `oidcEndSessionURL` / `introspectionURL` | `""` | Override the discovered revocation / end-session / introspection endpoints; take precedence when discovery omits them. |
|
|
||||||
| `maxIdentifierLength` | `256` | Bound the post-sanitisation length of the bearer identity header (`X-Forwarded-User`). |
|
|
||||||
| `bearerFailureWindowSeconds` | `60` | Rolling window (s) over which per-IP bearer 401s are counted toward the throttle. |
|
|
||||||
| `bearerFailurePenaltySeconds` | `60` | Seconds an IP is kept in the 429 penalty box after the bearer failure threshold. |
|
|
||||||
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
|
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
|
||||||
| `allowPrivateIPAddresses` | `false` | Permit private-IP `providerURL` (internal Keycloak, etc.). |
|
| `allowPrivateIPAddresses` | `false` | Permit private-IP `providerURL` (internal Keycloak, etc.). |
|
||||||
| `allowUnauthenticatedPreflight` | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`); the response body is still discarded. Default requires auth for every `OPTIONS` request, preflight or not. |
|
|
||||||
| `streamingRefresh` | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request when the IdP revokes the grant. |
|
|
||||||
| `minimalHeaders` | `false` | Reduce forwarded headers (mitigates HTTP 431). |
|
| `minimalHeaders` | `false` | Reduce forwarded headers (mitigates HTTP 431). |
|
||||||
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
|
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
|
||||||
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
|
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
|
||||||
@@ -156,38 +168,6 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
|||||||
|
|
||||||
## Production gotchas
|
## Production gotchas
|
||||||
|
|
||||||
### Upgrading from an earlier release
|
|
||||||
|
|
||||||
- **Sessions are re-issued once.** Session cookies are now AES-256 encrypted
|
|
||||||
(previously signed only) and their cryptographic lifetime tracks
|
|
||||||
`sessionMaxAge` (previously a fixed 30 days). Existing cookies become invalid
|
|
||||||
on upgrade, so users re-authenticate one time.
|
|
||||||
- **A rollback to v1.0.25 or earlier also invalidates sessions.** Those
|
|
||||||
releases cannot read the encrypted cookies that v1.0.26 and later issue.
|
|
||||||
They log `securecookie: the value is not valid` and `No state in
|
|
||||||
callback`, and the browser loops through the login. After a rollback, tell
|
|
||||||
users to delete
|
|
||||||
the `_oidc_raczylo_*` cookies (or the cookies with your `cookiePrefix`)
|
|
||||||
for the affected hosts. The cookies are `HttpOnly`, so users must delete
|
|
||||||
them in the browser site-data settings.
|
|
||||||
- **Invalid configuration now fails closed at startup** instead of being
|
|
||||||
silently accepted: a `sessionEncryptionKey` shorter than 32 bytes, a
|
|
||||||
`rateLimit` below 10, a missing `callbackURL`, or a non-HTTPS remote
|
|
||||||
`providerURL` are rejected. Plaintext HTTP is permitted only for loopback
|
|
||||||
hosts (local development).
|
|
||||||
- **A `private_key_jwt` RSA key under 2048 bits now fails startup** instead
|
|
||||||
of signing locally and failing `invalid_client` at every token exchange
|
|
||||||
(RFC 7518 §3.3). Generate a 2048-bit-or-larger key before upgrading. No
|
|
||||||
override exists.
|
|
||||||
- **A discovered `http://` endpoint under an `https://` `providerURL` is now
|
|
||||||
dropped**, not used as-is: the plugin refuses to send a client secret or
|
|
||||||
token over plaintext HTTP even when the IdP's own discovery document
|
|
||||||
advertises it that way. A dropped `token`, `jwks_uri`, or `authorization`
|
|
||||||
endpoint breaks login; a `SECURITY:`-tagged log line names the endpoint.
|
|
||||||
See [Discovered Endpoint
|
|
||||||
Validation](docs/CONFIGURATION.md#discovered-endpoint-validation). No
|
|
||||||
override exists.
|
|
||||||
|
|
||||||
### TLS termination at a load balancer
|
### TLS termination at a load balancer
|
||||||
|
|
||||||
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
|
`forceHTTPS` defaults to `true`, so redirect URIs always use `https://`. This is
|
||||||
@@ -207,8 +187,6 @@ detected" when the same token hits different replicas. Two options:
|
|||||||
|
|
||||||
For IdP-initiated logout (back/front-channel) in multi-replica setups, Redis is
|
For IdP-initiated logout (back/front-channel) in multi-replica setups, Redis is
|
||||||
**required** so a logout on one instance invalidates sessions on the others.
|
**required** so a logout on one instance invalidates sessions on the others.
|
||||||
Front-channel logout requests must include a matching `iss` query parameter;
|
|
||||||
requests that omit it are rejected with `400`.
|
|
||||||
|
|
||||||
### Multiple middleware instances on the same host
|
### Multiple middleware instances on the same host
|
||||||
|
|
||||||
@@ -264,10 +242,9 @@ Hardening built in by default:
|
|||||||
`Authorization: Bearer` header arrive on the same request, the cookie path
|
`Authorization: Bearer` header arrive on the same request, the cookie path
|
||||||
runs (safer against browser/extension/proxy bearer injection). Set
|
runs (safer against browser/extension/proxy bearer injection). Set
|
||||||
`bearerOverridesCookie: true` for the AWS/GCP/Kubernetes convention.
|
`bearerOverridesCookie: true` for the AWS/GCP/Kubernetes convention.
|
||||||
- **Revocation enforced.** No access-token JTI is recorded for replay;
|
- **Replay protection preserved.** The bearer path skips the JTI **Set**
|
||||||
`RevokeToken` (called from logout) blacklists the raw token (and a JWT's
|
(so the same token can be reused) but the **Get** stays active —
|
||||||
`jti`), and both the JWT and the opaque bearer path reject a blacklisted
|
`RevokeToken` still terminates a bearer token immediately.
|
||||||
token.
|
|
||||||
- **Excluded URLs strip Authorization.** When `enableBearerAuth=true`,
|
- **Excluded URLs strip Authorization.** When `enableBearerAuth=true`,
|
||||||
excluded paths (e.g. `/health`, `/metrics`) get the `Authorization` header
|
excluded paths (e.g. `/health`, `/metrics`) get the `Authorization` header
|
||||||
removed before forwarding so the token can't leak into public endpoint
|
removed before forwarding so the token can't leak into public endpoint
|
||||||
@@ -313,17 +290,9 @@ WebSocket upgrade. The middleware handles this automatically:
|
|||||||
- They are **not** unauthenticated — a valid encrypted session cookie is
|
- They are **not** unauthenticated — a valid encrypted session cookie is
|
||||||
required, otherwise the request is rejected. The session must already exist
|
required, otherwise the request is rejected. The session must already exist
|
||||||
(i.e. the user logged in via a normal HTTP page first).
|
(i.e. the user logged in via a normal HTTP page first).
|
||||||
- `X-Forwarded-User` and the configured `headers` are forwarded from the
|
- `X-Forwarded-User` is forwarded from the session.
|
||||||
session. Header templates render from the claims in the session cookie.
|
|
||||||
- Validation is cookie-only (no JWK fetch), so streaming keeps working during
|
- Validation is cookie-only (no JWK fetch), so streaming keeps working during
|
||||||
brief IdP outages. Back-channel and front-channel logout still apply: a
|
brief IdP outages.
|
||||||
session that the IdP logged out is rejected.
|
|
||||||
- By default, the token expiry is not checked, so a session stays usable
|
|
||||||
for streaming until the session cookie expires. Set `streamingRefresh: true`
|
|
||||||
to refresh an expired token on the request. If the IdP rejects the refresh
|
|
||||||
(`invalid_grant`), the request gets `401`. If the IdP cannot be reached,
|
|
||||||
the request is forwarded as before. On a WebSocket upgrade, the refreshed
|
|
||||||
session cookie arrives on the `101 Switching Protocols` response.
|
|
||||||
|
|
||||||
No configuration needed — this is implicit behavior.
|
No configuration needed — this is implicit behavior.
|
||||||
|
|
||||||
@@ -410,76 +379,21 @@ Forward identity to backends via Go templates over ID-token claims and tokens:
|
|||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
- name: X-User-Email
|
- name: X-User-Email
|
||||||
value: "{{.Claims.email}}"
|
value: "{{{{.Claims.email}}}}"
|
||||||
- name: Authorization
|
- name: Authorization
|
||||||
value: "Bearer {{.AccessToken}}"
|
value: "Bearer {{{{.AccessToken}}}}"
|
||||||
- name: X-User-Roles
|
- 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}}}}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Available bindings: `.Claims.<field>`, `.AccessToken`, `.IdToken`
|
Available bindings: `.Claims.<field>`, `.AccessToken`, `.IdToken`,
|
||||||
(or `.IDToken`), `.RefreshToken`. Names are case-sensitive (`.Claims`, not
|
`.RefreshToken`. Names are case-sensitive (`.Claims`, not `.claims`).
|
||||||
`.claims`).
|
|
||||||
|
|
||||||
Header templates are validated at startup (a failing template stops the
|
> **Escape with quadruple braces.** If you see
|
||||||
middleware from loading). Only a fixed set of claim fields may be emitted —
|
> `can't evaluate field AccessToken in type bool`, Traefik's YAML parser ate
|
||||||
standard OIDC claims plus common provider claims (`email`, `name`,
|
> your `{{ }}`. The fix that actually works is `{{{{ }}}}` — the YAML pass
|
||||||
`given_name`, `family_name`, `preferred_username`, `sub`, `groups`, `roles`,
|
> turns it into `{{ }}` for the Go template engine. Other escaping tricks
|
||||||
`realm_access`, `resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`,
|
> (literal blocks, single quotes) do not work reliably.
|
||||||
`locale`, `email_verified`, and a few more; see `safeClaimsFields` in
|
|
||||||
`template_validation.go`). To emit a claim not on that list, add it to
|
|
||||||
`allowedClaims`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
allowedClaims:
|
|
||||||
- employee_id
|
|
||||||
headers:
|
|
||||||
- name: X-Employee-Id
|
|
||||||
value: "{{.Claims.employee_id}}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
|
|
||||||
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
|
|
||||||
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
|
|
||||||
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default`
|
|
||||||
and `toJson` are the only functions allowed.
|
|
||||||
|
|
||||||
### Forwarding an object claim as JSON
|
|
||||||
|
|
||||||
`toJson` serializes a claim (object, array or scalar) to compact JSON, so a
|
|
||||||
backend can parse the whole claim itself instead of the middleware flattening
|
|
||||||
it into one header per key:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
allowedClaims:
|
|
||||||
- tenant
|
|
||||||
headers:
|
|
||||||
- name: X-Tenant
|
|
||||||
value: '{{get .Claims "tenant" | toJson}}' # {"id":"t-1","plan":"pro"}
|
|
||||||
- name: X-Groups
|
|
||||||
value: "{{toJson .Claims.groups}}" # ["admin","users"]
|
|
||||||
```
|
|
||||||
|
|
||||||
- The claim must be on the whitelist (built-in or `allowedClaims`).
|
|
||||||
`{{toJson .Claims}}`, `{{toJson .}}` and non-listed claims are rejected.
|
|
||||||
- An absent or empty claim skips the header instead of sending `""`.
|
|
||||||
- Control characters are JSON-escaped, so a claim cannot inject extra header
|
|
||||||
lines.
|
|
||||||
- A rendered value over 8192 bytes, or one with bidi-override characters, is
|
|
||||||
dropped. The oversize case is logged at error level (header name only, never
|
|
||||||
the value); keep the claim small, since proxies commonly cap headers at
|
|
||||||
8-16 KB.
|
|
||||||
|
|
||||||
> **File-provider users: escape the braces.** Traefik's file provider runs
|
|
||||||
> every dynamic configuration file through Go templating before the plugin
|
|
||||||
> sees it. Plain `{{.AccessToken}}` then fails with
|
|
||||||
> `can't evaluate field AccessToken in type bool`. Wrap the expression in a
|
|
||||||
> raw string so the file provider emits it literally:
|
|
||||||
> ``value: "{{`{{.Claims.email}}`}}"``. All other providers (Kubernetes CRD,
|
|
||||||
> Docker labels, Consul, ...) pass the value through untouched — use the plain
|
|
||||||
> form there. Quadruple braces (`{{{{ }}}}`) do not work anywhere: the file
|
|
||||||
> provider fails to parse them, and every other path hands them to the plugin
|
|
||||||
> verbatim, where template validation rejects them (issues #149, #151).
|
|
||||||
|
|
||||||
## Default downstream headers
|
## Default downstream headers
|
||||||
|
|
||||||
@@ -507,7 +421,7 @@ section — see [docs/CONFIGURATION.md](docs/CONFIGURATION.md#security-headers).
|
|||||||
| `No matching public key found` | JWKS endpoint down, or `kid` mismatch. |
|
| `No matching public key found` | JWKS endpoint down, or `kid` mismatch. |
|
||||||
| `Access denied: Your email domain is not allowed` | User's domain not in `allowedUserDomains`. |
|
| `Access denied: Your email domain is not allowed` | User's domain not in `allowedUserDomains`. |
|
||||||
| `Access denied: You do not have any of the allowed roles or groups` | Claims missing or not in `allowedRolesAndGroups`. |
|
| `Access denied: You do not have any of the allowed roles or groups` | Claims missing or not in `allowedRolesAndGroups`. |
|
||||||
| `can't evaluate field AccessToken in type bool` | File provider templated your header value — escape it: ``"{{`{{.AccessToken}}`}}"`` (see "Templated headers"). |
|
| `can't evaluate field AccessToken in type bool` | Template not escaped — use `{{{{ }}}}`. |
|
||||||
| `tls: failed to verify certificate: x509: certificate signed by unknown authority` | Internal CA — set `caCertPath` / `caCertPEM`. |
|
| `tls: failed to verify certificate: x509: certificate signed by unknown authority` | Internal CA — set `caCertPath` / `caCertPEM`. |
|
||||||
| `invalid handler type: <nil>` | Env var name contains `API` — rename it. |
|
| `invalid handler type: <nil>` | Env var name contains `API` — rename it. |
|
||||||
| `false positive replay detected` | Multi-replica without Redis — see [Multi-replica deployments](#multi-replica-deployments). |
|
| `false positive replay detected` | Multi-replica without Redis — see [Multi-replica deployments](#multi-replica-deployments). |
|
||||||
@@ -519,19 +433,6 @@ namespaced claims, Cognito regions, GitLab self-hosted) live in
|
|||||||
|
|
||||||
Set `logLevel: debug` to surface detail.
|
Set `logLevel: debug` to surface detail.
|
||||||
|
|
||||||
## Telemetry
|
|
||||||
|
|
||||||
On first plugin instantiation this middleware sends a single anonymous
|
|
||||||
adoption ping — project name, version, timestamp; no identifiers, no
|
|
||||||
request data, no token contents. Fire-and-forget with a 2-second timeout;
|
|
||||||
cannot block plugin load or panic.
|
|
||||||
|
|
||||||
Local source: [`telemetry.go`](./telemetry.go). Disclosure mirrors
|
|
||||||
**[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**.
|
|
||||||
|
|
||||||
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
|
|
||||||
or `TRAEFIKOIDC_DISABLE_TELEMETRY=1`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-27 regression test.
|
|
||||||
//
|
|
||||||
// accessTokenUnexpired (the lenient-audience-path time-claims re-check)
|
|
||||||
// returned false whenever iat was absent, even though 8640451 made iat
|
|
||||||
// OPTIONAL in jwt.Verify itself (R126: "Hard-requiring it here also
|
|
||||||
// rejected otherwise-valid access ... tokens whose provider omits iat").
|
|
||||||
// The two paths had drifted onto different contracts for the same claim.
|
|
||||||
// A lenient (wrong-audience) access token without iat, in a session with
|
|
||||||
// no ID token to corroborate it, was always forced to refresh or
|
|
||||||
// re-authenticate, while the identical token with a matching audience
|
|
||||||
// authenticated fine via the main jwt.Verify path.
|
|
||||||
//
|
|
||||||
// Fail-on-old: a syntactically valid, unexpired token with no iat is
|
|
||||||
// rejected by accessTokenUnexpired (returns false).
|
|
||||||
func TestAccessTokenUnexpired_IatOptional(t *testing.T) {
|
|
||||||
tm := &TraefikOidc{}
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
noIat := makeTestJWT(t, map[string]interface{}{
|
|
||||||
"exp": float64(now.Add(time.Hour).Unix()),
|
|
||||||
// intentionally no "iat", matching jwt.Verify's R126 contract
|
|
||||||
})
|
|
||||||
if !tm.accessTokenUnexpired(noIat) {
|
|
||||||
t.Error("a token with a valid exp and no iat must be accepted, matching jwt.Verify's iat-optional contract (R126/FIX-27)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAccessTokenUnexpired_IatStillBoundedWhenPresent is the positive
|
|
||||||
// control: an iat far in the future (used-before-issued) must still be
|
|
||||||
// rejected when iat IS present — the fix makes iat optional, not ignored.
|
|
||||||
func TestAccessTokenUnexpired_IatStillBoundedWhenPresent(t *testing.T) {
|
|
||||||
tm := &TraefikOidc{}
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
futureIat := makeTestJWT(t, map[string]interface{}{
|
|
||||||
"exp": float64(now.Add(time.Hour).Unix()),
|
|
||||||
"iat": float64(now.Add(time.Hour).Unix()), // far in the future
|
|
||||||
})
|
|
||||||
if tm.accessTokenUnexpired(futureIat) {
|
|
||||||
t.Error("a present iat far in the future (used-before-issued) must still be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only a real audience mismatch may select the lenient-audience path; an
|
|
||||||
// error text that merely embeds the attacker-chosen kid must not.
|
|
||||||
func TestAudienceErrorClassifiedBySentinel(t *testing.T) {
|
|
||||||
if !errors.Is(fmt.Errorf("standard claim verification failed: %w", verifyAudience("x", "y")), errInvalidAudience) {
|
|
||||||
t.Fatal("audience mismatch not recognized through wrapping")
|
|
||||||
}
|
|
||||||
if errors.Is(verifyAudience("y", "y"), errInvalidAudience) {
|
|
||||||
t.Fatal("matching audience classified as mismatch")
|
|
||||||
}
|
|
||||||
for _, kid := range []string{"audience", "invalid audience"} {
|
|
||||||
err := fmt.Errorf("failed to get public key: no matching public key found for kid: %s", kid)
|
|
||||||
if errors.Is(err, errInvalidAudience) {
|
|
||||||
t.Errorf("kid %q fooled the audience classifier", kid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-8
@@ -288,10 +288,6 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
|||||||
|
|
||||||
customAudience := "https://my-api.example.com"
|
customAudience := "https://my-api.example.com"
|
||||||
ts.tOidc.audience = customAudience
|
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)
|
// Create ID token with aud = client_id (OIDC standard)
|
||||||
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
@@ -326,12 +322,16 @@ func TestAuth0Scenario1WithCustomAudience(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify ID token validates against client_id
|
// Verify ID token validates against client_id
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(idToken)
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
|
t.Errorf("ID token validation failed (should validate against client_id): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify access token validates against custom audience
|
// Verify access token validates against custom audience
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(accessToken)
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
|
t.Errorf("Access token validation failed (should validate against custom audience): %v", err)
|
||||||
@@ -395,6 +395,8 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify ID token validates
|
// Verify ID token validates
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(idToken)
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ID token validation failed: %v", err)
|
t.Errorf("ID token validation failed: %v", err)
|
||||||
@@ -402,6 +404,8 @@ func TestAuth0Scenario2DefaultAudience(t *testing.T) {
|
|||||||
|
|
||||||
// Access token won't have client_id in aud, so it will fail validation
|
// 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
|
// This is expected for scenario 2 - the session validation relies on ID token
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(accessToken)
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
|
t.Logf("Access token validation passed (unexpected but OK if client_id is in aud array)")
|
||||||
@@ -452,6 +456,8 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
|
|||||||
opaqueAccessToken := "opaque_access_token_random_string_12345"
|
opaqueAccessToken := "opaque_access_token_random_string_12345"
|
||||||
|
|
||||||
// Verify ID token validates
|
// Verify ID token validates
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(idToken)
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ID token validation failed: %v", err)
|
t.Errorf("ID token validation failed: %v", err)
|
||||||
@@ -478,8 +484,7 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
|
|||||||
session.SetAccessToken(opaqueAccessToken)
|
session.SetAccessToken(opaqueAccessToken)
|
||||||
session.SetIDToken(idToken)
|
session.SetIDToken(idToken)
|
||||||
|
|
||||||
rs := (&requestState{}).captureSession(session)
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
|
|
||||||
if !authenticated || needsRefresh || expired {
|
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",
|
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
|
||||||
authenticated, needsRefresh, expired)
|
authenticated, needsRefresh, expired)
|
||||||
@@ -514,6 +519,8 @@ func TestAuth0AudienceArrayValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should validate successfully - custom audience is in the array
|
// Should validate successfully - custom audience is in the array
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(accessToken)
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
|
t.Errorf("Access token with audience array should validate when custom audience is present: %v", err)
|
||||||
@@ -546,6 +553,8 @@ func TestAuth0MismatchedAudience(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should fail validation - audience doesn't match
|
// Should fail validation - audience doesn't match
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(accessToken)
|
err = ts.tOidc.VerifyToken(accessToken)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Access token with wrong audience should fail validation")
|
t.Error("Access token with wrong audience should fail validation")
|
||||||
@@ -614,8 +623,7 @@ func TestAuth0Scenario2StrictMode(t *testing.T) {
|
|||||||
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
|
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
|
||||||
|
|
||||||
// In strict mode, this should FAIL (no fallback to ID token)
|
// In strict mode, this should FAIL (no fallback to ID token)
|
||||||
rs := (&requestState{}).captureSession(session)
|
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
|
||||||
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
|
|
||||||
if authenticated {
|
if authenticated {
|
||||||
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
|
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
|
||||||
}
|
}
|
||||||
@@ -655,6 +663,8 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should validate successfully - ID tokens are checked against client_id
|
// Should validate successfully - ID tokens are checked against client_id
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(idToken)
|
err = ts.tOidc.VerifyToken(idToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
|
t.Errorf("ID token should validate against client_id even when custom audience is configured: %v", err)
|
||||||
@@ -676,6 +686,8 @@ func TestIDTokenAlwaysValidatesAgainstClientID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should fail - ID tokens must have client_id as audience
|
// Should fail - ID tokens must have client_id as audience
|
||||||
|
cleanupReplayCache()
|
||||||
|
initReplayCache()
|
||||||
err = ts.tOidc.VerifyToken(wrongIDToken)
|
err = ts.tOidc.VerifyToken(wrongIDToken)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ID token with custom audience (not client_id) should fail validation")
|
t.Error("ID token with custom audience (not client_id) should fail validation")
|
||||||
|
|||||||
+40
-214
@@ -1,37 +1,12 @@
|
|||||||
package traefikoidc
|
package traefikoidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// validateRedirectCount checks if redirect limit is exceeded and handles the error
|
||||||
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
|
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
|
||||||
const maxRedirects = 5
|
const maxRedirects = 5
|
||||||
@@ -39,16 +14,6 @@ func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.Respon
|
|||||||
if redirectCount >= maxRedirects {
|
if redirectCount >= maxRedirects {
|
||||||
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
|
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
|
||||||
session.ResetRedirectCount()
|
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)
|
t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected)
|
||||||
return fmt.Errorf("redirect limit exceeded")
|
return fmt.Errorf("redirect limit exceeded")
|
||||||
}
|
}
|
||||||
@@ -103,33 +68,8 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
|
|||||||
// - req: The HTTP request initiating authentication.
|
// - req: The HTTP request initiating authentication.
|
||||||
// - session: The session data to prepare for authentication.
|
// - session: The session data to prepare for authentication.
|
||||||
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
|
// - 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) {
|
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())
|
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", t.originalRequestURI(req))
|
||||||
|
|
||||||
// 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
|
// Check and handle redirect limits
|
||||||
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
||||||
@@ -157,41 +97,8 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
return
|
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
|
// Clear existing session data and set new authentication state
|
||||||
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
|
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, t.originalRequestURI(req))
|
||||||
|
|
||||||
// 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()
|
session.MarkDirty()
|
||||||
|
|
||||||
@@ -201,17 +108,12 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF/state and nonce are single-use security credentials; do not log
|
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
|
||||||
// them in the clear (FIX-29 correction of the R153 redaction claim).
|
csrfToken, nonce)
|
||||||
t.logger.Debug("Session saved before redirect")
|
|
||||||
|
|
||||||
t.logger.Debugf("Redirecting user to OIDC provider: %s", redactAuthorizeURLForLog(authURL))
|
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
||||||
|
t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
|
||||||
|
|
||||||
// 302s into the IdP must not be heuristically cacheable (RFC 7234):
|
|
||||||
// a stale, cached redirect could replay the pre-re-authentication URL.
|
|
||||||
// Every other auth response sets no-store; these redirects were missed
|
|
||||||
// (R147).
|
|
||||||
rw.Header().Set("Cache-Control", "no-store")
|
|
||||||
http.Redirect(rw, req, authURL, http.StatusFound)
|
http.Redirect(rw, req, authURL, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,31 +124,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
// - rw: The HTTP response writer.
|
// - rw: The HTTP response writer.
|
||||||
// - req: The callback request containing authorization code and state.
|
// - req: The callback request containing authorization code and state.
|
||||||
// - redirectURL: The fully qualified callback URL (used in the token exchange request).
|
// - 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) {
|
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)
|
session, err := t.sessionManager.GetSession(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Session error during callback: %v", err)
|
t.logger.Errorf("Session error during callback: %v", err)
|
||||||
@@ -255,28 +133,15 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
}
|
}
|
||||||
defer session.returnToPoolSafely()
|
defer session.returnToPoolSafely()
|
||||||
|
|
||||||
t.logger.Debugf("Handling callback, URL: %s", redactCallbackURL(req.URL.String()))
|
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
|
||||||
|
|
||||||
if req.URL.Query().Get("error") != "" {
|
if req.URL.Query().Get("error") != "" {
|
||||||
errorDescription := req.URL.Query().Get("error_description")
|
errorDescription := req.URL.Query().Get("error_description")
|
||||||
if errorDescription == "" {
|
if errorDescription == "" {
|
||||||
errorDescription = req.URL.Query().Get("error")
|
errorDescription = req.URL.Query().Get("error")
|
||||||
}
|
}
|
||||||
// error / error_description are attacker-controlled query params.
|
t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
|
||||||
// Sanitize before logging and before echoing back to the client:
|
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +155,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
csrfToken := session.GetCSRF()
|
csrfToken := session.GetCSRF()
|
||||||
if csrfToken == "" {
|
if csrfToken == "" {
|
||||||
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
|
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
|
||||||
session.GetAuthenticated(), redactCallbackURL(req.URL.String()))
|
session.GetAuthenticated(), req.URL.String())
|
||||||
|
|
||||||
cookie, err := req.Cookie("_oidc_raczylo_m")
|
cookie, err := req.Cookie("_oidc_raczylo_m")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -303,7 +168,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !constantTimeStringCompare(state, csrfToken) {
|
if state != csrfToken {
|
||||||
t.logger.Error("State parameter does not match CSRF token in session during callback")
|
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)
|
t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -317,55 +182,16 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
}
|
}
|
||||||
|
|
||||||
codeVerifier := session.GetCodeVerifier()
|
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)
|
tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Failed to exchange code for token during callback: %v", err)
|
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)
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err = t.verifyToken(tokenResponse.IDToken); err != nil {
|
||||||
t.logger.Errorf("Failed to verify id_token during callback: %v", err)
|
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)
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -373,7 +199,6 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
|
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Failed to extract claims during callback: %v", err)
|
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)
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -381,57 +206,41 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
nonceClaim, ok := claims["nonce"].(string)
|
nonceClaim, ok := claims["nonce"].(string)
|
||||||
if !ok || nonceClaim == "" {
|
if !ok || nonceClaim == "" {
|
||||||
t.logger.Error("Nonce claim missing in id_token during callback")
|
t.logger.Error("Nonce claim missing in id_token during callback")
|
||||||
t.clearOneTimeAuthState(session, req, rw)
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
|
||||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionNonce := session.GetNonce()
|
sessionNonce := session.GetNonce()
|
||||||
if sessionNonce == "" {
|
if sessionNonce == "" {
|
||||||
t.logger.Error("Nonce not found in session during callback")
|
t.logger.Error("Nonce not found in session during callback")
|
||||||
t.clearOneTimeAuthState(session, req, rw)
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
|
||||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !constantTimeStringCompare(nonceClaim, sessionNonce) {
|
if nonceClaim != sessionNonce {
|
||||||
t.logger.Error("Nonce claim does not match session nonce during callback")
|
t.logger.Error("Nonce claim does not match session nonce during callback")
|
||||||
t.clearOneTimeAuthState(session, req, rw)
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
|
||||||
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
||||||
userIdentifier, _ := claimScalarString(claims[t.userIdentifierClaim])
|
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
|
||||||
if userIdentifier == "" {
|
if userIdentifier == "" {
|
||||||
// Try "sub" as fallback since it's required by OIDC spec
|
// Try "sub" as fallback since it's required by OIDC spec
|
||||||
if t.userIdentifierClaim != "sub" {
|
if t.userIdentifierClaim != "sub" {
|
||||||
userIdentifier, _ = claimScalarString(claims["sub"])
|
userIdentifier, _ = claims["sub"].(string)
|
||||||
}
|
}
|
||||||
if userIdentifier == "" {
|
if userIdentifier == "" {
|
||||||
t.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", t.userIdentifierClaim)
|
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)
|
t.sendErrorResponse(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", t.userIdentifierClaim)
|
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
|
// Validate user authorization
|
||||||
if !t.isAllowedUser(userIdentifier) {
|
if !t.isAllowedUser(userIdentifier) {
|
||||||
t.logger.Errorf("User not authorized during callback: %s", 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)
|
t.sendErrorResponse(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -454,10 +263,7 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
|
|
||||||
redirectPath := "/"
|
redirectPath := "/"
|
||||||
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
|
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
|
||||||
// Neutralize open-redirect payloads (e.g. //evil.com, /\evil.com) stored
|
redirectPath = incomingPath
|
||||||
// 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("")
|
session.SetIncomingPath("")
|
||||||
|
|
||||||
@@ -468,8 +274,6 @@ func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request,
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
|
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)
|
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +305,28 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
|
|||||||
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
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
|
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
|
||||||
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
|
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
|
||||||
xhr := req.Header.Get("X-Requested-With")
|
xhr := req.Header.Get("X-Requested-With")
|
||||||
|
|||||||
@@ -957,10 +957,8 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() {
|
|||||||
// Call handleCallback
|
// Call handleCallback
|
||||||
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
|
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
|
||||||
|
|
||||||
// R161: nonce mismatch is a client-side authorization-request
|
// Should return internal server error due to nonce mismatch
|
||||||
// integrity failure (same class as state/CSRF mismatch), so it returns
|
s.Equal(http.StatusInternalServerError, rw2.Code)
|
||||||
// 400, not 500.
|
|
||||||
s.Equal(http.StatusBadRequest, rw2.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
|
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
|
||||||
|
|||||||
+24
-118
@@ -28,12 +28,6 @@ type BackgroundTask struct {
|
|||||||
stopped int32 // 1 = stopped, 0 = not stopped
|
stopped int32 // 1 = stopped, 0 = not stopped
|
||||||
started int32 // 1 = started, 0 = not started
|
started int32 // 1 = started, 0 = not started
|
||||||
doneClosed int32 // 1 = doneChan closed, 0 = not closed
|
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.
|
// NewBackgroundTask creates a new background task with the specified configuration.
|
||||||
@@ -52,12 +46,6 @@ func NewBackgroundTask(name string, interval time.Duration, taskFunc func(), log
|
|||||||
if len(wg) > 0 {
|
if len(wg) > 0 {
|
||||||
externalWG = 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{
|
return &BackgroundTask{
|
||||||
name: name,
|
name: name,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
@@ -93,7 +81,6 @@ func (bt *BackgroundTask) Start() {
|
|||||||
if bt.logger != nil {
|
if bt.logger != nil {
|
||||||
bt.logger.Debugf("Cannot start task %s: %v (circuit breaker protection working as expected)", bt.name, err)
|
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
|
// Close doneChan since the task won't run
|
||||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||||
close(bt.doneChan)
|
close(bt.doneChan)
|
||||||
@@ -101,21 +88,8 @@ func (bt *BackgroundTask) Start() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve the task slot immediately when starting. OnTaskStart
|
// Reserve the task slot immediately when starting
|
||||||
// is the authoritative concurrency gate (R165): it returns false
|
registry.cb.OnTaskStart(bt.name)
|
||||||
// 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)
|
atomic.StoreInt32(&bt.started, 1)
|
||||||
bt.internalWG.Add(1)
|
bt.internalWG.Add(1)
|
||||||
@@ -140,14 +114,6 @@ func (bt *BackgroundTask) Stop() {
|
|||||||
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
if atomic.CompareAndSwapInt32(&bt.doneClosed, 0, 1) {
|
||||||
close(bt.doneChan)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,35 +134,16 @@ func (bt *BackgroundTask) Stop() {
|
|||||||
// This avoids the race condition with WaitGroup
|
// This avoids the race condition with WaitGroup
|
||||||
select {
|
select {
|
||||||
case <-bt.doneChan:
|
case <-bt.doneChan:
|
||||||
// Normal completion: the goroutine has returned, so it is safe
|
// Normal completion
|
||||||
// to wait on the WaitGroup.
|
|
||||||
bt.internalWG.Wait()
|
|
||||||
case <-time.After(5 * time.Second):
|
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 {
|
if bt.logger != nil {
|
||||||
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
|
bt.logger.Errorf("Timeout waiting for background task %s to stop", bt.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeRunTask executes the task function, recovering from any panic so that a
|
// Wait for the internal WaitGroup synchronously after doneChan signals
|
||||||
// single panicking run does not permanently kill the background goroutine and its
|
bt.internalWG.Wait()
|
||||||
// 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.
|
// run is the main loop for the background task.
|
||||||
@@ -240,7 +187,7 @@ func (bt *BackgroundTask) run() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
bt.safeRunTask()
|
bt.taskFunc()
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -259,7 +206,7 @@ func (bt *BackgroundTask) run() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
bt.safeRunTask()
|
bt.taskFunc()
|
||||||
}
|
}
|
||||||
case <-bt.stopChan:
|
case <-bt.stopChan:
|
||||||
if bt.logger != nil {
|
if bt.logger != nil {
|
||||||
@@ -382,30 +329,9 @@ func (cb *TaskCircuitBreaker) CanCreateTask(taskName string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnTaskStart records a task starting execution. It atomically reserves a
|
// OnTaskStart records a task starting execution
|
||||||
// concurrency slot and returns whether one was available. The reservation
|
func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) {
|
||||||
// is fundamental (R165): CanCreateTask's limit check is only advisory —
|
atomic.AddInt32(&cb.concurrentTasks, 1)
|
||||||
// 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.tasksMu.Lock()
|
||||||
cb.activeTasks[taskName] = struct{}{}
|
cb.activeTasks[taskName] = struct{}{}
|
||||||
cb.tasksMu.Unlock()
|
cb.tasksMu.Unlock()
|
||||||
@@ -416,7 +342,6 @@ func (cb *TaskCircuitBreaker) OnTaskStart(taskName string) bool {
|
|||||||
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
|
cb.logger.Debug("Task started, concurrent count: %d, task: %s",
|
||||||
atomic.LoadInt32(&cb.concurrentTasks), taskName)
|
atomic.LoadInt32(&cb.concurrentTasks), taskName)
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnTaskComplete records a task completing execution
|
// OnTaskComplete records a task completing execution
|
||||||
@@ -538,13 +463,10 @@ func (tr *TaskRegistry) RegisterTask(name string, task *BackgroundTask) error {
|
|||||||
tr.mu.Lock()
|
tr.mu.Lock()
|
||||||
defer tr.mu.Unlock()
|
defer tr.mu.Unlock()
|
||||||
|
|
||||||
|
// Task execution tracking is now handled in the run() method
|
||||||
|
|
||||||
tr.tasks[name] = task
|
tr.tasks[name] = task
|
||||||
// Execution tracking (the circuit-breaker concurrent count) is handled by
|
tr.cb.OnTaskSuccess(name)
|
||||||
// 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 {
|
if tr.logger != nil {
|
||||||
tr.logger.Debug("Registered background task: %s", name)
|
tr.logger.Debug("Registered background task: %s", name)
|
||||||
@@ -577,18 +499,9 @@ func (tr *TaskRegistry) GetTask(name string) (*BackgroundTask, bool) {
|
|||||||
return task, exists
|
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() {
|
func (tr *TaskRegistry) StopAllTasks() {
|
||||||
// First, stop tasks registered directly in this registry.
|
// First, copy the tasks map to avoid deadlock with GetTaskCount()
|
||||||
// Copy the tasks map to avoid deadlock with GetTaskCount().
|
|
||||||
tr.mu.Lock()
|
tr.mu.Lock()
|
||||||
tasksCopy := make(map[string]*BackgroundTask, len(tr.tasks))
|
tasksCopy := make(map[string]*BackgroundTask, len(tr.tasks))
|
||||||
for name, task := range tr.tasks {
|
for name, task := range tr.tasks {
|
||||||
@@ -598,15 +511,13 @@ func (tr *TaskRegistry) StopAllTasks() {
|
|||||||
tr.tasks = make(map[string]*BackgroundTask)
|
tr.tasks = make(map[string]*BackgroundTask)
|
||||||
tr.mu.Unlock()
|
tr.mu.Unlock()
|
||||||
|
|
||||||
for _, task := range tasksCopy {
|
// Now stop all tasks without holding the lock
|
||||||
|
for name, task := range tasksCopy {
|
||||||
task.Stop()
|
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
|
// GetTaskCount returns the number of active tasks
|
||||||
@@ -810,14 +721,9 @@ func (mm *TaskMemoryMonitor) checkForMemoryIssues(stats TaskMemoryStats) {
|
|||||||
historyLen := len(mm.statsHistory)
|
historyLen := len(mm.statsHistory)
|
||||||
if historyLen >= 2 {
|
if historyLen >= 2 {
|
||||||
prev := mm.statsHistory[historyLen-2]
|
prev := mm.statsHistory[historyLen-2]
|
||||||
// Guard against div-by-zero (prev.HeapAlloc == 0) which would make
|
heapGrowth := float64(stats.HeapAlloc) / float64(prev.HeapAlloc)
|
||||||
// heapGrowth == +Inf and fire a spurious "memory leak" alert for a
|
if heapGrowth > 2.0 && stats.NumGC == prev.NumGC {
|
||||||
// first-ever measurement (R103).
|
mm.logger.Infof("Potential memory leak: heap grew %.2fx without GC", heapGrowth)
|
||||||
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()
|
mm.mu.RUnlock()
|
||||||
|
|||||||
+13
-17
@@ -262,17 +262,16 @@ func TestAzureOIDCRegression(t *testing.T) {
|
|||||||
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
|
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
|
||||||
|
|
||||||
// Test that CSRF is preserved during Azure validation failures
|
// Test that CSRF is preserved during Azure validation failures
|
||||||
rs := (&requestState{}).captureSession(session)
|
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
|
||||||
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
|
|
||||||
|
|
||||||
// Should not be authenticated due to validation failure, and with no
|
// Should not be authenticated due to validation failure
|
||||||
// refresh token and no tokens to probe, should not claim an expiry
|
|
||||||
// state (matches the standard path: unauthenticated, no refresh).
|
|
||||||
if authenticated {
|
if authenticated {
|
||||||
t.Error("Should not be authenticated when token validation fails")
|
t.Error("Should not be authenticated when token validation fails")
|
||||||
}
|
}
|
||||||
if needsRefresh || expired {
|
|
||||||
t.Error("With no refresh token and no tokens to probe, should be (false,false,false)")
|
// Should be marked as expired since no tokens work
|
||||||
|
if !expired && !needsRefresh {
|
||||||
|
t.Error("Should be marked as needing refresh or expired when validation fails")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify CSRF token is still preserved in session
|
// Verify CSRF token is still preserved in session
|
||||||
@@ -454,8 +453,7 @@ func TestValidateGoogleTokens(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
session := tt.setupSession()
|
session := tt.setupSession()
|
||||||
|
|
||||||
rs := (&requestState{}).captureSession(session)
|
auth, refresh, expired := ts.tOidc.validateGoogleTokens(session)
|
||||||
auth, refresh, expired := ts.tOidc.validateGoogleTokensRS(rs)
|
|
||||||
|
|
||||||
if auth != tt.expectedAuth {
|
if auth != tt.expectedAuth {
|
||||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||||
@@ -639,8 +637,7 @@ func TestIsUserAuthenticated(t *testing.T) {
|
|||||||
defer func() { ts.tOidc.issuerURL = originalIssuer }()
|
defer func() { ts.tOidc.issuerURL = originalIssuer }()
|
||||||
|
|
||||||
session := tt.setupSession()
|
session := tt.setupSession()
|
||||||
rs := (&requestState{}).captureSession(session)
|
auth, refresh, expired := ts.tOidc.isUserAuthenticated(session)
|
||||||
auth, refresh, expired := ts.tOidc.isUserAuthenticatedRS(rs)
|
|
||||||
|
|
||||||
if auth != tt.expectedAuth {
|
if auth != tt.expectedAuth {
|
||||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||||
@@ -691,7 +688,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
|||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
expectedAuth: false,
|
expectedAuth: false,
|
||||||
expectedRefresh: false,
|
expectedRefresh: true,
|
||||||
expectedExpired: false,
|
expectedExpired: false,
|
||||||
description: "Unauthenticated Azure session without refresh token",
|
description: "Unauthenticated Azure session without refresh token",
|
||||||
},
|
},
|
||||||
@@ -725,10 +722,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
|||||||
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
|
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
expectedAuth: false,
|
expectedAuth: true,
|
||||||
expectedRefresh: false,
|
expectedRefresh: false,
|
||||||
expectedExpired: true,
|
expectedExpired: false,
|
||||||
description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
|
description: "Azure session with opaque access token",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AuthenticatedWithBothTokensInvalid",
|
name: "AuthenticatedWithBothTokensInvalid",
|
||||||
@@ -765,8 +762,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
session := tt.setupSession()
|
session := tt.setupSession()
|
||||||
|
|
||||||
rs := (&requestState{}).captureSession(session)
|
auth, refresh, expired := ts.tOidc.validateAzureTokens(session)
|
||||||
auth, refresh, expired := ts.tOidc.validateAzureTokensRS(rs)
|
|
||||||
|
|
||||||
if auth != tt.expectedAuth {
|
if auth != tt.expectedAuth {
|
||||||
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
|
||||||
|
|||||||
+35
-543
@@ -12,13 +12,8 @@
|
|||||||
// - iat upper-age cap bounds clock-skew / forever-token abuse.
|
// - iat upper-age cap bounds clock-skew / forever-token abuse.
|
||||||
// - Multi-audience tokens require matching azp.
|
// - Multi-audience tokens require matching azp.
|
||||||
// - Per-IP 401 throttle returns 429 + Retry-After after a threshold.
|
// - Per-IP 401 throttle returns 429 + Retry-After after a threshold.
|
||||||
// - No path writes an access-token JTI to a replay cache (FIX-17 removed
|
// - JTI Set is suppressed (skipReplayMarking) but JTI Get stays — revoked
|
||||||
// that writer; the only JTI replay tracking left is for backchannel
|
// tokens (RevokeToken adds to blacklist) are still rejected.
|
||||||
// logout tokens, logout.go checkAndMarkLogoutJTIProcessed). RevokeToken
|
|
||||||
// (called from logout) still blacklists the raw token and, for a JWT,
|
|
||||||
// its jti; both the JWT and opaque-introspection bearer paths check
|
|
||||||
// that blacklist before accepting a token, so revocation stays
|
|
||||||
// effective without replay-detection overhead.
|
|
||||||
// - Identifier is read from BearerIdentifierClaim (default "sub"), never
|
// - Identifier is read from BearerIdentifierClaim (default "sub"), never
|
||||||
// from UserIdentifierClaim, to avoid the unverified-email spoofing path.
|
// from UserIdentifierClaim, to avoid the unverified-email spoofing path.
|
||||||
// - Identifier is sanitized: length cap, control chars, bidi-override,
|
// - Identifier is sanitized: length cap, control chars, bidi-override,
|
||||||
@@ -98,10 +93,6 @@ const (
|
|||||||
type bearerError struct {
|
type bearerError struct {
|
||||||
kind bearerErrorKind
|
kind bearerErrorKind
|
||||||
reason string
|
reason string
|
||||||
// retryAfter carries the actual remaining penalty when known (e.g. the
|
|
||||||
// per-IP penalty box), so writeBearerError emits the true value instead
|
|
||||||
// of the full configured penalty.
|
|
||||||
retryAfter time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *bearerError) Error() string { return e.reason }
|
func (e *bearerError) Error() string { return e.reason }
|
||||||
@@ -119,32 +110,6 @@ type joseHeader struct {
|
|||||||
Typ string `json:"typ"`
|
Typ string `json:"typ"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// joseHeaderWellFormed reports whether token is structurally a JWT: exactly
|
|
||||||
// two dot-separated segments whose header decodes as a JSON JOSE header.
|
|
||||||
// It intentionally applies NO alg/kid policy (unlike parseBearerJOSEHeader)
|
|
||||||
// so the JWT-vs-opaque routing gate classifies purely on structure: a
|
|
||||||
// structurally-valid JWT with a disallowed alg stays on the JWT path
|
|
||||||
// (where local policy rejects it), while an opaque token whose two dots
|
|
||||||
// are coincidental falls through to opaque introspection (R166).
|
|
||||||
func joseHeaderWellFormed(token string) bool {
|
|
||||||
dot := strings.IndexByte(token, '.')
|
|
||||||
if dot <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.Count(token[dot+1:], ".") != 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(token[:dot])
|
|
||||||
if err != nil {
|
|
||||||
raw, err = base64.URLEncoding.DecodeString(token[:dot])
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var hdr joseHeader
|
|
||||||
return json.Unmarshal(raw, &hdr) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBearerJOSEHeader decodes the first JWT segment for early alg/kid pinning.
|
// parseBearerJOSEHeader decodes the first JWT segment for early alg/kid pinning.
|
||||||
// Does not touch the payload or signature — those are the verifier's job.
|
// Does not touch the payload or signature — those are the verifier's job.
|
||||||
// Returns nil on success; *bearerError on rejection so the handler can map
|
// Returns nil on success; *bearerError on rejection so the handler can map
|
||||||
@@ -184,100 +149,6 @@ func parseBearerJOSEHeader(token string) *bearerError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerClaimRuneReason reports why a rune is unsafe to inject into a request
|
|
||||||
// header value, or "" if the rune is acceptable. Shared core of the bearer-path
|
|
||||||
// identifier sanitizer and the cookie-path header claim sanitizer: rejects
|
|
||||||
// control chars (CRLF/header injection), Unicode bidi-override runes (RTL
|
|
||||||
// spoofing of admin UI / SIEM), and the delimiters , ; = plus the bracketing
|
|
||||||
// characters " { } — each of these in a group name / identifier would break a
|
|
||||||
// downstream comma-joined list or a naive CSV/JSON-style parser consuming the
|
|
||||||
// header (a comma would inject extra entries into a comma-joined header).
|
|
||||||
func headerClaimRuneReason(r rune) string {
|
|
||||||
if reason := headerInjectionRuneReason(r); reason != "" {
|
|
||||||
return reason
|
|
||||||
}
|
|
||||||
// The , ; = delimiters and the " { } bracketing chars are only unsafe for
|
|
||||||
// values placed into delimited or list contexts (a comma-joined header, or
|
|
||||||
// an identifier downstreams may split). They are valid in arbitrary single
|
|
||||||
// header values, so this stricter check is used for the cookie-path
|
|
||||||
// identifier and the group/role list, NOT for free-form templated header
|
|
||||||
// output (see headerValueReason).
|
|
||||||
if r == ',' || r == ';' || r == '=' || r == '"' || r == '{' || r == '}' {
|
|
||||||
return "delimiter or bracketing character"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerInjectionRuneReason reports why a rune is unsafe in ANY HTTP header
|
|
||||||
// value, or "" if acceptable. Rejects control characters (CR/LF header
|
|
||||||
// injection) and Unicode bidi-override runes (RTL spoofing of admin UIs/SIEMs).
|
|
||||||
// Unlike headerClaimRuneReason it does NOT reject , ; = which are legitimate in
|
|
||||||
// free-form header values (e.g. an opaque "Authorization: Bearer <token>").
|
|
||||||
func headerInjectionRuneReason(r rune) string {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return "control character"
|
|
||||||
}
|
|
||||||
if (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
|
||||||
return "bidi-override character"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerReasonTooLong is the headerValueReason result for an over-long value.
|
|
||||||
const headerReasonTooLong = "exceeds max length"
|
|
||||||
|
|
||||||
// headerValueReason reports why value is unsafe to forward as a free-form HTTP
|
|
||||||
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
|
|
||||||
// disables the check) and values containing control or bidi-override runes, but
|
|
||||||
// permits , ; = (valid in header values). Empty is allowed. The reason string
|
|
||||||
// never includes the value, so it is safe to log.
|
|
||||||
func headerValueReason(value string, maxLen int) string {
|
|
||||||
if maxLen > 0 && len(value) > maxLen {
|
|
||||||
return headerReasonTooLong
|
|
||||||
}
|
|
||||||
for _, r := range value {
|
|
||||||
if reason := headerInjectionRuneReason(r); reason != "" {
|
|
||||||
return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerClaimValueReason reports why value is unsafe to inject into a
|
|
||||||
// downstream request header, or "" if it is acceptable. It rejects empty
|
|
||||||
// values, values exceeding maxLen (maxLen<=0 disables the length check), and
|
|
||||||
// values containing any rune rejected by headerClaimRuneReason. The reason
|
|
||||||
// string is safe to log (it never includes the value itself).
|
|
||||||
func headerClaimValueReason(value string, maxLen int) string {
|
|
||||||
if value == "" {
|
|
||||||
return "empty value"
|
|
||||||
}
|
|
||||||
if maxLen > 0 && len(value) > maxLen {
|
|
||||||
return "exceeds max length"
|
|
||||||
}
|
|
||||||
for _, r := range value {
|
|
||||||
if reason := headerClaimRuneReason(r); reason != "" {
|
|
||||||
return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeHeaderClaimValue validates a claim-derived value before it is
|
|
||||||
// injected into a downstream request header. It trims surrounding whitespace
|
|
||||||
// and fails closed (ok=false) on empty values, values exceeding maxLen
|
|
||||||
// (maxLen<=0 disables the length check), or values containing any rune rejected
|
|
||||||
// by headerClaimRuneReason. Used by the cookie/session path, which — unlike the
|
|
||||||
// bearer path — does not otherwise sanitize the principal identifier or the
|
|
||||||
// group/role strings joined into X-User-Groups / X-User-Roles.
|
|
||||||
func sanitizeHeaderClaimValue(raw string, maxLen int) (string, bool) {
|
|
||||||
value := strings.TrimSpace(raw)
|
|
||||||
if headerClaimValueReason(value, maxLen) != "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return value, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeBearerIdentifier validates and trims a principal identifier before
|
// sanitizeBearerIdentifier validates and trims a principal identifier before
|
||||||
// it is injected into request headers. Layered defense: net/http will reject
|
// it is injected into request headers. Layered defense: net/http will reject
|
||||||
// CRLF on the wire too, but rejecting early gives clearer error logs and
|
// CRLF on the wire too, but rejecting early gives clearer error logs and
|
||||||
@@ -292,8 +163,15 @@ func sanitizeBearerIdentifier(raw string, maxLen int) (string, *bearerError) {
|
|||||||
return "", newBearerError(bearerErrInvalidIdentifier, "identifier exceeds max length")
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier exceeds max length")
|
||||||
}
|
}
|
||||||
for _, r := range identifier {
|
for _, r := range identifier {
|
||||||
if reason := headerClaimRuneReason(r); reason != "" {
|
if unicode.IsControl(r) {
|
||||||
return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains "+reason)
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains control character")
|
||||||
|
}
|
||||||
|
// Unicode bidi-override range (RTL spoofing of admin UI / SIEM).
|
||||||
|
if (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
|
||||||
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains bidi-override character")
|
||||||
|
}
|
||||||
|
if r == ',' || r == ';' || r == '=' {
|
||||||
|
return "", newBearerError(bearerErrInvalidIdentifier, "identifier contains delimiter character")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return identifier, nil
|
return identifier, nil
|
||||||
@@ -311,12 +189,11 @@ func resolveBearerIdentifier(claims map[string]interface{}, claimName string) (s
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("missing claim %q", claimName))
|
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("missing claim %q", claimName))
|
||||||
}
|
}
|
||||||
// Accept scalar numbers (e.g. a numeric sub) by stringifying them,
|
str, ok := raw.(string)
|
||||||
// so a numeric identifier claim doesn't 401 a valid principal.
|
if !ok {
|
||||||
if s, isScalar := claimScalarString(raw); isScalar {
|
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q not a string", claimName))
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
return "", newBearerError(bearerErrInvalidIdentifier, fmt.Sprintf("claim %q is not a scalar string or number", claimName))
|
return str, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enforceMultiAudienceAzp implements the spec hardening: when aud is a
|
// enforceMultiAudienceAzp implements the spec hardening: when aud is a
|
||||||
@@ -357,25 +234,13 @@ func enforceIatAge(claims map[string]interface{}, maxAge time.Duration) *bearerE
|
|||||||
}
|
}
|
||||||
iatRaw, ok := claims["iat"].(float64)
|
iatRaw, ok := claims["iat"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
// iat is OPTIONAL per RFC 7519 §4.1.6, and jwt.Verify validates it
|
// jwt.Verify already requires iat; this branch shouldn't be reached.
|
||||||
// only when present (R126) — this branch IS reachable. maxAge is NOT
|
|
||||||
// an operator opt-in: New() (main.go's config-default closure) always
|
|
||||||
// sets maxTokenAge > 0 — 0/unset becomes 24h — so this branch rejects
|
|
||||||
// every iat-less bearer JWT in production, not just when an operator
|
|
||||||
// has explicitly configured maxTokenAgeSeconds. A token with no iat
|
|
||||||
// has nothing to bound its age against, so fail closed rather than
|
|
||||||
// silently skip the check.
|
|
||||||
return newBearerError(bearerErrInvalidToken, "missing iat claim")
|
return newBearerError(bearerErrInvalidToken, "missing iat claim")
|
||||||
}
|
}
|
||||||
iat := time.Unix(int64(iatRaw), 0)
|
iat := time.Unix(int64(iatRaw), 0)
|
||||||
if time.Since(iat) > maxAge {
|
if time.Since(iat) > maxAge {
|
||||||
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
|
return newBearerError(bearerErrInvalidToken, "token iat outside age bound")
|
||||||
}
|
}
|
||||||
// The opaque introspection path never runs jwt.Verify, so a future iat
|
|
||||||
// would otherwise defeat the age bound for the token's whole lifetime.
|
|
||||||
if time.Until(iat) > ClockSkewToleranceFuture {
|
|
||||||
return newBearerError(bearerErrInvalidToken, "token iat in the future")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,20 +269,6 @@ type bearerFailureTracker struct {
|
|||||||
threshold int
|
threshold int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
penalty time.Duration
|
penalty time.Duration
|
||||||
// nextSweepAt is the map size recordFailure next scans the map at. A
|
|
||||||
// static per-call gate (len > defaultBearerEntrySweepThreshold) made
|
|
||||||
// EVERY recordFailure call re-scan the entire map once a flood of
|
|
||||||
// distinct source IPs crossed the threshold, turning attacker-controlled
|
|
||||||
// traffic into O(n^2) work under b.mu while blocked() (called on every
|
|
||||||
// bearer request) waits on the same lock (FIX-28). nextSweepAt doubles
|
|
||||||
// after each sweep (bounded below by defaultBearerEntrySweepThreshold),
|
|
||||||
// amortizing the scan so its frequency grows logarithmically with the
|
|
||||||
// flood size instead of linearly.
|
|
||||||
nextSweepAt int
|
|
||||||
// sweepPasses counts how many times recordFailure has run the full-map
|
|
||||||
// staleness scan. Exposed for tests to confirm the amortization actually
|
|
||||||
// bounds scan frequency.
|
|
||||||
sweepPasses int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type bearerFailureEntry struct {
|
type bearerFailureEntry struct {
|
||||||
@@ -426,10 +277,6 @@ type bearerFailureEntry struct {
|
|||||||
count int
|
count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBearerEntrySweepThreshold is the map size above which recordFailure
|
|
||||||
// sweeps stale entries, bounding memory under a flood of distinct source IPs.
|
|
||||||
const defaultBearerEntrySweepThreshold = 1024
|
|
||||||
|
|
||||||
func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bearerFailureTracker {
|
func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bearerFailureTracker {
|
||||||
if threshold <= 0 {
|
if threshold <= 0 {
|
||||||
threshold = 20
|
threshold = 20
|
||||||
@@ -441,11 +288,10 @@ func newBearerFailureTracker(threshold int, window, penalty time.Duration) *bear
|
|||||||
penalty = 60 * time.Second
|
penalty = 60 * time.Second
|
||||||
}
|
}
|
||||||
return &bearerFailureTracker{
|
return &bearerFailureTracker{
|
||||||
entries: make(map[string]*bearerFailureEntry),
|
entries: make(map[string]*bearerFailureEntry),
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
window: window,
|
window: window,
|
||||||
penalty: penalty,
|
penalty: penalty,
|
||||||
nextSweepAt: defaultBearerEntrySweepThreshold,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,43 +323,6 @@ func (b *bearerFailureTracker) recordFailure(ip string) {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// Bound per-IP map growth (R117): entries for source IPs that fail once
|
|
||||||
// and never return were never removed. Sweep stale entries (no active
|
|
||||||
// penalty box, too old to matter) once the map grows past a nominal
|
|
||||||
// size, keeping steady-state memory bounded.
|
|
||||||
//
|
|
||||||
// An entry that has not tripped the penalty box yet has a ZERO
|
|
||||||
// penaltyUntil, which is always Before(cutoff) — so checking only
|
|
||||||
// e.penaltyUntil.Before(cutoff) discarded every in-progress (not yet
|
|
||||||
// tripped) counter on every sweep, regardless of how recently it
|
|
||||||
// started. Once the map passed the threshold, a source whose failures
|
|
||||||
// interleaved with sweeps could never accumulate enough to trip (FIX-28).
|
|
||||||
// Only remove an entry once it is BOTH untripped-or-expired AND outside
|
|
||||||
// the counting window, so a fresh, still-accumulating counter survives.
|
|
||||||
//
|
|
||||||
// The sweep itself is gated on b.nextSweepAt, not the static threshold:
|
|
||||||
// a flood of distinct, still-fresh source IPs (attacker-controlled) that
|
|
||||||
// crosses defaultBearerEntrySweepThreshold and stays above it made every
|
|
||||||
// subsequent recordFailure call re-scan the whole map for nothing (the
|
|
||||||
// predicate above finds nothing to delete while entries stay fresh),
|
|
||||||
// which is O(n) work under b.mu on every call — O(n^2) to fill the map —
|
|
||||||
// while blocked() waits on the same mutex on every bearer request
|
|
||||||
// (FIX-28). nextSweepAt doubles after each sweep so the scan frequency
|
|
||||||
// grows logarithmically with the flood size instead of on every call.
|
|
||||||
if len(b.entries) > b.nextSweepAt {
|
|
||||||
cutoff := now.Add(-(b.window + b.penalty))
|
|
||||||
for k, e := range b.entries {
|
|
||||||
if e.penaltyUntil.Before(cutoff) && now.Sub(e.firstFailureAt) > b.window {
|
|
||||||
delete(b.entries, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.sweepPasses++
|
|
||||||
next := 2 * len(b.entries)
|
|
||||||
if next < defaultBearerEntrySweepThreshold {
|
|
||||||
next = defaultBearerEntrySweepThreshold
|
|
||||||
}
|
|
||||||
b.nextSweepAt = next
|
|
||||||
}
|
|
||||||
e, ok := b.entries[ip]
|
e, ok := b.entries[ip]
|
||||||
if !ok || now.Sub(e.firstFailureAt) > b.window {
|
if !ok || now.Sub(e.firstFailureAt) > b.window {
|
||||||
e = &bearerFailureEntry{firstFailureAt: now}
|
e = &bearerFailureEntry{firstFailureAt: now}
|
||||||
@@ -533,17 +342,7 @@ func (b *bearerFailureTracker) recordSuccess(ip string) {
|
|||||||
}
|
}
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
e, ok := b.entries[ip]
|
delete(b.entries, ip)
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Preserve an active penalty so a single success cannot wipe an in-effect
|
|
||||||
// lockout; only reset the counter when no penalty is active or it has expired.
|
|
||||||
now := time.Now()
|
|
||||||
if e.penaltyUntil.IsZero() || now.After(e.penaltyUntil) {
|
|
||||||
e.count = 0
|
|
||||||
e.firstFailureAt = now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIPForBearer returns the source IP used to key the failure tracker.
|
// clientIPForBearer returns the source IP used to key the failure tracker.
|
||||||
@@ -558,10 +357,9 @@ func clientIPForBearer(req *http.Request) string {
|
|||||||
}
|
}
|
||||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
host = req.RemoteAddr
|
return req.RemoteAddr
|
||||||
}
|
}
|
||||||
// One IPv6 /64 shares a key, so a single host cannot mint 2^64 entries.
|
return host
|
||||||
return limiterKey(host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bearer auth entrypoint ---
|
// --- Bearer auth entrypoint ---
|
||||||
@@ -631,10 +429,7 @@ func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request
|
|||||||
case bearerErrThrottled:
|
case bearerErrThrottled:
|
||||||
status = http.StatusTooManyRequests
|
status = http.StatusTooManyRequests
|
||||||
body = "Too Many Requests"
|
body = "Too Many Requests"
|
||||||
retryAfter = err.retryAfter
|
retryAfter = t.bearerFailurePenalty
|
||||||
if retryAfter <= 0 {
|
|
||||||
retryAfter = t.bearerFailurePenalty
|
|
||||||
}
|
|
||||||
case bearerErrIntrospectionUnavailable:
|
case bearerErrIntrospectionUnavailable:
|
||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
body = "Service Unavailable"
|
body = "Service Unavailable"
|
||||||
@@ -649,10 +444,6 @@ func (t *TraefikOidc) writeBearerError(rw http.ResponseWriter, req *http.Request
|
|||||||
if retryAfter > 0 {
|
if retryAfter > 0 {
|
||||||
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
|
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
|
||||||
}
|
}
|
||||||
// Auth-rejection responses must never be cached (R101 contract): a
|
|
||||||
// cached 429/401 could be replayed to a client well after the
|
|
||||||
// penalty box expired or the session recovered.
|
|
||||||
rw.Header().Set("Cache-Control", "no-store")
|
|
||||||
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
rw.WriteHeader(status)
|
rw.WriteHeader(status)
|
||||||
_, _ = rw.Write([]byte(body)) // Safe to ignore: best-effort error body write
|
_, _ = rw.Write([]byte(body)) // Safe to ignore: best-effort error body write
|
||||||
@@ -671,11 +462,12 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
|
|||||||
ip := clientIPForBearer(req)
|
ip := clientIPForBearer(req)
|
||||||
|
|
||||||
if blocked, retryAfter := t.bearerFailureTracker.blocked(ip); blocked {
|
if blocked, retryAfter := t.bearerFailureTracker.blocked(ip); blocked {
|
||||||
// Carry the actual remaining penalty (diverges from the configured
|
|
||||||
// default on clock-skew, partial-window expiry) so writeBearerError
|
|
||||||
// emits the true Retry-After rather than the full penalty.
|
|
||||||
throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
|
throttled := newBearerError(bearerErrThrottled, "ip in penalty box")
|
||||||
throttled.retryAfter = retryAfter
|
// Preserve the actual retry-after even if it diverged from the
|
||||||
|
// configured default (clock-skew, partial-window expiry).
|
||||||
|
if retryAfter > 0 {
|
||||||
|
rw.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
|
||||||
|
}
|
||||||
t.writeBearerError(rw, req, throttled)
|
t.writeBearerError(rw, req, throttled)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -691,51 +483,12 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
|
|||||||
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token exceeds max length"))
|
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token exceeds max length"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Determine whether this is genuinely a JWT. RFC 7662 token
|
if strings.Count(token, ".") != 2 {
|
||||||
// introspection covers opaque access tokens; when the operator
|
|
||||||
// requires live introspection (requireTokenIntrospection, whose
|
|
||||||
// documented purpose is exactly opaque-token support via
|
|
||||||
// client_secret_basic), introspect the opaque token on the bearer
|
|
||||||
// path instead of rejecting it — the session path already does
|
|
||||||
// (validateOpaqueToken). Previously the JWT-shape gate ran before
|
|
||||||
// the introspection branch, so every opaque token got a 401 even
|
|
||||||
// with introspection on (R159).
|
|
||||||
//
|
|
||||||
// A token is only treated as a JWT when it has exactly two dots AND
|
|
||||||
// its header actually parses as a JOSE header. Dot count alone is
|
|
||||||
// insufficient: an opaque token that coincidentally contains exactly
|
|
||||||
// two dots used to be routed onto the JWT path and rejected even
|
|
||||||
// under requireTokenIntrospection (header parse would fail) — it must
|
|
||||||
// instead be introspected like any other opaque token (R166).
|
|
||||||
isJWT := joseHeaderWellFormed(token)
|
|
||||||
if !isJWT {
|
|
||||||
if t.requireTokenIntrospection {
|
|
||||||
p, bErr := t.buildPrincipalFromOpaqueIntrospection(token)
|
|
||||||
if bErr != nil {
|
|
||||||
if bErr.kind != bearerErrIntrospectionUnavailable {
|
|
||||||
t.bearerFailureTracker.recordFailure(ip)
|
|
||||||
}
|
|
||||||
t.writeBearerError(rw, req, bErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.bearerFailureTracker.recordSuccess(ip)
|
|
||||||
if t.logger != nil {
|
|
||||||
t.logger.Debugf("bearer auth success (introspected opaque): identifier_hash=%s path=%s",
|
|
||||||
hashIdentifierForLog(p.Identifier), req.URL.Path)
|
|
||||||
}
|
|
||||||
t.forwardAuthorized(rw, req, p)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.bearerFailureTracker.recordFailure(ip)
|
t.bearerFailureTracker.recordFailure(ip)
|
||||||
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a valid JWT"))
|
t.writeBearerError(rw, req, newBearerError(bearerErrInvalidToken, "token is not a 3-segment JWT"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structurally a JWT: enforce the alg/kid policy EARLY (before the
|
|
||||||
// cached fast-path in buildPrincipalFromBearerToken can short-circuit
|
|
||||||
// on a prior positive verdict). Without this pin an alg=none or
|
|
||||||
// oversized-kid token that had been cached as verified would sail
|
|
||||||
// through without re-checking.
|
|
||||||
if bErr := parseBearerJOSEHeader(token); bErr != nil {
|
if bErr := parseBearerJOSEHeader(token); bErr != nil {
|
||||||
t.bearerFailureTracker.recordFailure(ip)
|
t.bearerFailureTracker.recordFailure(ip)
|
||||||
t.writeBearerError(rw, req, bErr)
|
t.writeBearerError(rw, req, bErr)
|
||||||
@@ -744,13 +497,7 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
|
|||||||
|
|
||||||
p, bErr := t.buildPrincipalFromBearerToken(token)
|
p, bErr := t.buildPrincipalFromBearerToken(token)
|
||||||
if bErr != nil {
|
if bErr != nil {
|
||||||
// Count only authentication failures (401/403) toward the per-IP
|
t.bearerFailureTracker.recordFailure(ip)
|
||||||
// throttle. An introspection outage (503) is infrastructure, not a
|
|
||||||
// client failure; recording it would trip a 429 penalty that a
|
|
||||||
// single success can't clear even after the endpoint recovers.
|
|
||||||
if bErr.kind != bearerErrIntrospectionUnavailable {
|
|
||||||
t.bearerFailureTracker.recordFailure(ip)
|
|
||||||
}
|
|
||||||
t.writeBearerError(rw, req, bErr)
|
t.writeBearerError(rw, req, bErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -767,7 +514,7 @@ func (t *TraefikOidc) handleBearerRequest(rw http.ResponseWriter, req *http.Requ
|
|||||||
// described in spec §7.3 and returns a principal ready for forwardAuthorized.
|
// described in spec §7.3 and returns a principal ready for forwardAuthorized.
|
||||||
// Returns a typed *bearerError on failure so the caller can map to status.
|
// Returns a typed *bearerError on failure so the caller can map to status.
|
||||||
func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *bearerError) {
|
func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *bearerError) {
|
||||||
if err := t.verifyTokenWithOpts(token); err != nil {
|
if err := t.verifyTokenWithOpts(token, verifyOpts{skipReplayMarking: true}); err != nil {
|
||||||
return nil, newBearerError(bearerErrInvalidToken, "token verification failed: "+err.Error())
|
return nil, newBearerError(bearerErrInvalidToken, "token verification failed: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,9 +538,7 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
|
|||||||
return nil, newBearerError(bearerErrInvalidToken, "token_use=id rejected")
|
return nil, newBearerError(bearerErrInvalidToken, "token_use=id rejected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137).
|
if bErr := enforceMultiAudienceAzp(claims, t.clientID); bErr != nil {
|
||||||
clientID, _, _, _, _ := t.clientCredentials()
|
|
||||||
if bErr := enforceMultiAudienceAzp(claims, clientID); bErr != nil {
|
|
||||||
return nil, bErr
|
return nil, bErr
|
||||||
}
|
}
|
||||||
if bErr := enforceIatAge(claims, t.maxTokenAge); bErr != nil {
|
if bErr := enforceIatAge(claims, t.maxTokenAge); bErr != nil {
|
||||||
@@ -806,40 +551,6 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Honor IdP-initiated (backchannel/front-channel) logout. The cookie
|
|
||||||
// path re-checks sessionInvalidationCache on every request; the bearer
|
|
||||||
// path previously did not, so a still-cryptographically-valid access
|
|
||||||
// token for a logged-out subject kept returning 200 (and, with
|
|
||||||
// requireTokenIntrospection, a cached positive verdict extended the
|
|
||||||
// stale window). Reject here, mirroring the cookie path: use the
|
|
||||||
// token's iat as its creation time so a token issued before the
|
|
||||||
// logout is invalidated, while a legitimately freshly-issued token
|
|
||||||
// (iat after logout) still passes (R146). iat is OPTIONAL per RFC 7519
|
|
||||||
// §4.1.6 and jwt.Verify no longer requires it (R126), so an iat-less
|
|
||||||
// token DOES reach here now. Falling back to time.Now() made such a
|
|
||||||
// token always look newer than any logout, so IdP-initiated logout
|
|
||||||
// never revoked it — reintroducing the R98 defect the cookie path's
|
|
||||||
// own fallback (sessionCreatedAtForInvalidation) avoids by using the
|
|
||||||
// zero time instead: fail closed rather than open (FIX-24). In
|
|
||||||
// production this branch is mostly defense in depth: enforceIatAge
|
|
||||||
// above already rejects an iat-less token before reaching here whenever
|
|
||||||
// maxTokenAge > 0. New() always sets it so: config.MaxTokenAgeSeconds
|
|
||||||
// <= 0 (unset, or an operator explicitly configuring 0) maps to a 24h
|
|
||||||
// default (main.go:359-364), it is never mapped to "disabled" — there
|
|
||||||
// is no config value that makes maxTokenAge 0. This fallback protects
|
|
||||||
// an iat-less token's logout check only in the case New()'s mapping
|
|
||||||
// doesn't reach: a test that constructs TraefikOidc with maxTokenAge=0
|
|
||||||
// directly, bypassing New().
|
|
||||||
subjectForInvalidation, _ := claims["sub"].(string)
|
|
||||||
sidForInvalidation, _ := claims["sid"].(string)
|
|
||||||
var createdAt time.Time
|
|
||||||
if iat, ok := claims["iat"].(float64); ok {
|
|
||||||
createdAt = time.Unix(int64(iat), 0)
|
|
||||||
}
|
|
||||||
if t.isSessionInvalidated(sidForInvalidation, subjectForInvalidation, createdAt) {
|
|
||||||
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
|
|
||||||
}
|
|
||||||
|
|
||||||
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
|
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
|
||||||
if bErr != nil {
|
if bErr != nil {
|
||||||
return nil, bErr
|
return nil, bErr
|
||||||
@@ -850,7 +561,7 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
|
|||||||
}
|
}
|
||||||
|
|
||||||
subject, _ := claims["sub"].(string)
|
subject, _ := claims["sub"].(string)
|
||||||
clientID, _ = claims["azp"].(string)
|
clientID, _ := claims["azp"].(string)
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID, _ = claims["client_id"].(string)
|
clientID, _ = claims["client_id"].(string)
|
||||||
}
|
}
|
||||||
@@ -865,178 +576,6 @@ func (t *TraefikOidc) buildPrincipalFromBearerToken(token string) (*principal, *
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPrincipalFromOpaqueIntrospection authenticates a bearer access
|
|
||||||
// token that has no JWT shape by live RFC 7662 introspection, and builds
|
|
||||||
// a principal whose identifier is the introspected subject. It mirrors
|
|
||||||
// introspectOnBearerPath's classification (active, token_type, expiry,
|
|
||||||
// audience) and binds the compliant subject. Only used on the bearer
|
|
||||||
// path when requireTokenIntrospection is enabled (R159).
|
|
||||||
//
|
|
||||||
// Unlike a JWT bearer token — whose signature, aud and azp are already
|
|
||||||
// bound to this client by jwt.Verify/enforceMultiAudienceAzp before this
|
|
||||||
// function's counterpart ever runs — an opaque token has no local
|
|
||||||
// verification at all; introspection is the only gate. So this function
|
|
||||||
// re-applies every JWT-path gate the introspection branch used to skip
|
|
||||||
// (R159 gap): allowOpaqueTokens, client binding, bearerIdentifierClaim +
|
|
||||||
// sanitizeBearerIdentifier, maxTokenAge, isSessionInvalidated, the local
|
|
||||||
// tokenBlacklist (raw token and jti) and nbf.
|
|
||||||
func (t *TraefikOidc) buildPrincipalFromOpaqueIntrospection(token string) (*principal, *bearerError) {
|
|
||||||
if !t.allowOpaqueTokens {
|
|
||||||
return nil, newBearerError(bearerErrInvalidToken, "opaque tokens are not enabled (set allowOpaqueTokens to true)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same local-revocation gate the JWT path applies first
|
|
||||||
// (verifyTokenWithOpts, token_manager.go:71-75): handleLogout calls
|
|
||||||
// RevokeToken on the session's access token specifically so a token
|
|
||||||
// captured before logout cannot be reused (helpers.go). Without this
|
|
||||||
// check here, a captured opaque bearer token kept authenticating as the
|
|
||||||
// revoked subject for as long as the IdP still reported it active —
|
|
||||||
// provider-side revocation is best-effort (RevokeTokenWithProvider logs
|
|
||||||
// and continues on failure) and is not configured for most deployments.
|
|
||||||
if t.tokenBlacklist != nil {
|
|
||||||
if b, exists := t.tokenBlacklist.Get(token); exists && b != nil {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "token has been revoked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := t.introspectToken(token)
|
|
||||||
if err != nil {
|
|
||||||
// Any introspection transport/HTTP error — including a 4xx, which
|
|
||||||
// per RFC 7662 s2.3 means OUR OWN client credentials were rejected,
|
|
||||||
// not that the presented token is bad — is an availability problem,
|
|
||||||
// not a verdict on the token. Only a 200 response with active=false
|
|
||||||
// is (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping).
|
|
||||||
return nil, newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
|
|
||||||
}
|
|
||||||
if !resp.Active {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
|
|
||||||
}
|
|
||||||
// Mirror introspectOnBearerPath (R149): reject a definite non-access
|
|
||||||
// token_type (e.g. refresh_token); accept "access_token" and "Bearer".
|
|
||||||
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
|
|
||||||
}
|
|
||||||
if resp.Exp > 0 && time.Now().After(time.Unix(resp.Exp, 0)) {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token expired")
|
|
||||||
}
|
|
||||||
// Mirrors the session path's strict nbf comparison in
|
|
||||||
// validateOpaqueToken (token_introspection.go:258-263): no clock-skew
|
|
||||||
// tolerance. This is NOT the same check the JWT path applies — its
|
|
||||||
// verifyNotBefore (jwt.go:281, via verifyTimeConstraint) allows
|
|
||||||
// ClockSkewTolerancePast (jwt.go:23, 10s) before failing. RFC 7662
|
|
||||||
// s2.2 defines nbf with the same semantics as RFC 7519's nbf claim.
|
|
||||||
if resp.Nbf > 0 && time.Now().Before(time.Unix(resp.Nbf, 0)) {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection reports token not yet valid (nbf)")
|
|
||||||
}
|
|
||||||
// Same jti-blacklist gate the JWT path applies on a cached-token hit
|
|
||||||
// (token_manager.go:90-96): RevokeToken blacklists a JWT's jti too, and
|
|
||||||
// an IdP may echo that same jti in an opaque token's introspection
|
|
||||||
// response (e.g. a rotated token sharing the revoked JTI). Gated on
|
|
||||||
// disableReplayDetection like the JWT path, so an operator who has
|
|
||||||
// disabled replay/JTI tracking is not surprised by this new check.
|
|
||||||
if resp.Jti != "" && !t.disableReplayDetection && t.tokenBlacklist != nil {
|
|
||||||
if b, exists := t.tokenBlacklist.Get(resp.Jti); exists && b != nil {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "token replay detected (jti blacklisted)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clientID, _, _, audience, _ := t.clientCredentials()
|
|
||||||
if audience != "" && audience != clientID {
|
|
||||||
// A distinct API audience is configured: the introspection response
|
|
||||||
// MUST carry a matching audience.
|
|
||||||
if resp.Aud == nil || verifyAudience(resp.Aud, audience) != nil {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// audience == clientID (the common single-app default): RFC 7662
|
|
||||||
// s2.2 leaves client scoping to the authorization server's own
|
|
||||||
// policy, so without an explicit check here an opaque token issued
|
|
||||||
// to a completely different client at the same IdP would still
|
|
||||||
// report active=true and pass. Bind it to this client via either
|
|
||||||
// client_id or aud (client_id is RFC-optional).
|
|
||||||
clientBound := resp.ClientID == clientID ||
|
|
||||||
(resp.Aud != nil && verifyAudience(resp.Aud, clientID) == nil)
|
|
||||||
if !clientBound {
|
|
||||||
return nil, newBearerError(bearerErrTokenInactive, "introspection client_id/audience does not match this client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxTokenAge bound on iat, via the SAME enforceIatAge the JWT path
|
|
||||||
// uses (not a hand-rolled duplicate): RFC 7662 iat is optional, but
|
|
||||||
// New() always sets maxTokenAge > 0 (0/unset becomes 24h,
|
|
||||||
// main.go's config-default closure), so a response with no iat gives
|
|
||||||
// nothing to bound the token's age against and must fail closed —
|
|
||||||
// exactly like enforceIatAge already does for a JWT with no iat
|
|
||||||
// (R126/FIX-27 made iat optional on the JWT path too, and that path
|
|
||||||
// fails closed rather than silently skipping the bound).
|
|
||||||
iatClaims := map[string]interface{}{}
|
|
||||||
if resp.Iat > 0 {
|
|
||||||
iatClaims["iat"] = float64(resp.Iat)
|
|
||||||
}
|
|
||||||
if bErr := enforceIatAge(iatClaims, t.maxTokenAge); bErr != nil {
|
|
||||||
return nil, bErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Honor IdP-initiated (backchannel/front-channel) logout, mirroring the
|
|
||||||
// JWT bearer path (R146). RFC 7662 does not define sid, but some
|
|
||||||
// providers return it anyway (FIX-03): when the response carries one,
|
|
||||||
// check it exactly like the JWT path does, so a sid-only (front-channel)
|
|
||||||
// logout — front-channel logout always records by sid only, and so does
|
|
||||||
// a backchannel logout token that carries only sid — can revoke an
|
|
||||||
// opaque token too, not just a backchannel logout recorded by sub. iat
|
|
||||||
// absent -> zero time (fail closed, same as the JWT path's FIX-24
|
|
||||||
// contract), so a token whose age cannot be bounded is treated as
|
|
||||||
// pre-dating any logout rather than as freshly issued.
|
|
||||||
var createdAt time.Time
|
|
||||||
if resp.Iat > 0 {
|
|
||||||
createdAt = time.Unix(resp.Iat, 0)
|
|
||||||
}
|
|
||||||
if t.isSessionInvalidated(resp.Sid, resp.Sub, createdAt) {
|
|
||||||
return nil, newBearerError(bearerErrInvalidToken, "session has been invalidated (logout)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate every non-empty IntrospectionResponse member the RFC 7662
|
|
||||||
// response can carry, not just sub/username, so bearerIdentifierClaim
|
|
||||||
// can resolve the identifier from any of them (FIX-03) — mirroring how
|
|
||||||
// the JWT bearer path resolves against the full decoded claim set.
|
|
||||||
claims := map[string]interface{}{}
|
|
||||||
if resp.Sub != "" {
|
|
||||||
claims["sub"] = resp.Sub
|
|
||||||
}
|
|
||||||
if resp.Username != "" {
|
|
||||||
claims["username"] = resp.Username
|
|
||||||
}
|
|
||||||
if resp.ClientID != "" {
|
|
||||||
claims["client_id"] = resp.ClientID
|
|
||||||
}
|
|
||||||
if resp.Scope != "" {
|
|
||||||
claims["scope"] = resp.Scope
|
|
||||||
}
|
|
||||||
if resp.Iss != "" {
|
|
||||||
claims["iss"] = resp.Iss
|
|
||||||
}
|
|
||||||
if resp.Jti != "" {
|
|
||||||
claims["jti"] = resp.Jti
|
|
||||||
}
|
|
||||||
if resp.Aud != nil {
|
|
||||||
claims["aud"] = resp.Aud
|
|
||||||
}
|
|
||||||
rawIdentifier, bErr := resolveBearerIdentifier(claims, t.bearerIdentifierClaim)
|
|
||||||
if bErr != nil {
|
|
||||||
return nil, bErr
|
|
||||||
}
|
|
||||||
identifier, bErr := sanitizeBearerIdentifier(rawIdentifier, t.maxIdentifierLength)
|
|
||||||
if bErr != nil {
|
|
||||||
return nil, bErr
|
|
||||||
}
|
|
||||||
return &principal{
|
|
||||||
Claims: claims,
|
|
||||||
Identifier: identifier,
|
|
||||||
Subject: resp.Sub,
|
|
||||||
AccessToken: token,
|
|
||||||
Source: sourceBearer,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// introspectOnBearerPath calls the existing RFC 7662 introspector when the
|
// introspectOnBearerPath calls the existing RFC 7662 introspector when the
|
||||||
// operator demands real-time revocation. Distinguishes "token revoked" (401)
|
// operator demands real-time revocation. Distinguishes "token revoked" (401)
|
||||||
// from "endpoint unavailable" (503) so transient infra failures don't look
|
// from "endpoint unavailable" (503) so transient infra failures don't look
|
||||||
@@ -1044,57 +583,10 @@ func (t *TraefikOidc) buildPrincipalFromOpaqueIntrospection(token string) (*prin
|
|||||||
func (t *TraefikOidc) introspectOnBearerPath(token string) *bearerError {
|
func (t *TraefikOidc) introspectOnBearerPath(token string) *bearerError {
|
||||||
resp, err := t.introspectToken(token)
|
resp, err := t.introspectToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Any introspection transport/HTTP error is an availability
|
|
||||||
// problem, never a verdict on the token: RFC 7662 s2.3 defines a
|
|
||||||
// 401/403 from the introspection endpoint as OUR OWN client
|
|
||||||
// credentials being rejected (e.g. a rotated client_secret), and
|
|
||||||
// 408/429 as the endpoint throttling or timing out. Only a 200
|
|
||||||
// response with active=false is a definite "not active" (s2.2).
|
|
||||||
// (FIX-13, supersedes R156's blanket 4xx-as-invalid mapping — a
|
|
||||||
// misconfigured or throttled endpoint must not turn every valid
|
|
||||||
// token into a 401.)
|
|
||||||
return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
|
return newBearerError(bearerErrIntrospectionUnavailable, "introspection failed: "+err.Error())
|
||||||
}
|
}
|
||||||
if !resp.Active {
|
if !resp.Active {
|
||||||
return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
|
return newBearerError(bearerErrTokenInactive, "introspection reports token inactive")
|
||||||
}
|
}
|
||||||
// Mirror the session path (validateOpaqueToken): an opaque-or-any
|
|
||||||
// token whose RFC 7662 token_type classifies it as a refresh token
|
|
||||||
// must not be honored as a bearer access token (R149). Only reject on
|
|
||||||
// a definite non-access match; compliant providers may omit it. RFC
|
|
||||||
// 7662's token_type is the RFC 6749 token type, whose value for an
|
|
||||||
// access token is "Bearer" (RFC 6750) — accept both spellings
|
|
||||||
// providers use (R156).
|
|
||||||
if resp.TokenType != "" && resp.TokenType != "access_token" && resp.TokenType != "Bearer" {
|
|
||||||
return newBearerError(bearerErrTokenInactive, "introspection token_type is not a bearer access token")
|
|
||||||
}
|
|
||||||
// Mirror the session path (validateOpaqueToken): an active but
|
|
||||||
// already-expired result must not pass. The positive-only cache is
|
|
||||||
// capped by time-until-exp when Exp is present, but a provider
|
|
||||||
// returning active=1 past exp would otherwise let the token pass on
|
|
||||||
// the bearer path while the identical token is rejected on the
|
|
||||||
// session path.
|
|
||||||
if resp.Exp > 0 {
|
|
||||||
expTime := time.Unix(resp.Exp, 0)
|
|
||||||
if time.Now().After(expTime) {
|
|
||||||
return newBearerError(bearerErrTokenInactive, "introspection reports token expired")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Mirror the session path (validateOpaqueToken): when a distinct API
|
|
||||||
// audience is configured (audience != clientID), the introspection
|
|
||||||
// response MUST carry a matching audience. Fail closed on a missing or
|
|
||||||
// mismatched aud, otherwise a token minted for a different audience
|
|
||||||
// would pass the bearer path while the identical token is rejected on
|
|
||||||
// the session path. aud may be a single string or an array (RFC 7662).
|
|
||||||
// (R143)
|
|
||||||
clientID, _, _, audience, _ := t.clientCredentials()
|
|
||||||
if audience != "" && audience != clientID {
|
|
||||||
if resp.Aud == nil {
|
|
||||||
return newBearerError(bearerErrTokenInactive, "introspection reports no audience")
|
|
||||||
}
|
|
||||||
if err := verifyAudience(resp.Aud, audience); err != nil {
|
|
||||||
return newBearerError(bearerErrTokenInactive, "introspection audience mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// FuzzParseBearerJOSEHeader verifies the attacker-controlled JWT header parser
|
|
||||||
// never panics on arbitrary input (no slice OOB, no unbounded alloc, no
|
|
||||||
// JSON-engine surprise) regardless of token contents.
|
|
||||||
func FuzzParseBearerJOSEHeader(f *testing.F) {
|
|
||||||
seeds := []string{
|
|
||||||
"",
|
|
||||||
"...",
|
|
||||||
"abc.def.ghi",
|
|
||||||
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZDEifQ.sig.sig", // alg=RS256 kid=kid1
|
|
||||||
"::",
|
|
||||||
"snapshot=",
|
|
||||||
"a,b.zzz",
|
|
||||||
}
|
|
||||||
for _, s := range seeds {
|
|
||||||
f.Add(s)
|
|
||||||
}
|
|
||||||
f.Fuzz(func(t *testing.T, token string) {
|
|
||||||
parseBearerJOSEHeader(token) // must not panic
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuzzSanitizeBearerIdentifier verifies the identifier sanitizer never panics on
|
|
||||||
// arbitrary strings and always returns either a clean value or a *bearerError.
|
|
||||||
func FuzzSanitizeBearerIdentifier(f *testing.F) {
|
|
||||||
seeds := []string{
|
|
||||||
"",
|
|
||||||
"alice",
|
|
||||||
"user,group=admin",
|
|
||||||
"a\u202eb\u202c",
|
|
||||||
"\t \n",
|
|
||||||
"verylongidentifier-padding-padding-padding-padding",
|
|
||||||
}
|
|
||||||
for _, s := range seeds {
|
|
||||||
f.Add(s)
|
|
||||||
}
|
|
||||||
f.Fuzz(func(t *testing.T, raw string) {
|
|
||||||
id, berr := sanitizeBearerIdentifier(raw, 256)
|
|
||||||
if berr == nil && id == "" {
|
|
||||||
t.Fatalf("sanitize ok but empty identifier for %q", raw)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+29
-103
@@ -67,31 +67,31 @@ func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
sm := createTestSessionManager(t)
|
sm := createTestSessionManager(t)
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: next,
|
next: next,
|
||||||
logger: NewLogger("error"),
|
logger: NewLogger("error"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: sm,
|
sessionManager: sm,
|
||||||
firstRequestStarted: 1,
|
firstRequestReceived: true,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStarted: true,
|
||||||
issuerURL: "https://issuer.example.com",
|
issuerURL: "https://issuer.example.com",
|
||||||
audience: "https://api.example.com",
|
audience: "https://api.example.com",
|
||||||
clientID: "https://api.example.com",
|
clientID: "https://api.example.com",
|
||||||
tokenCache: NewTokenCache(),
|
tokenCache: NewTokenCache(),
|
||||||
excludedURLs: map[string]struct{}{"/favicon.ico": {}},
|
excludedURLs: map[string]struct{}{"/favicon.ico": {}},
|
||||||
allowedRolesAndGroups: map[string]struct{}{},
|
allowedRolesAndGroups: map[string]struct{}{},
|
||||||
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
|
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
enableBearerAuth: true,
|
enableBearerAuth: true,
|
||||||
stripAuthorizationHeader: true,
|
stripAuthorizationHeader: true,
|
||||||
bearerEmitWWWAuthenticate: true,
|
bearerEmitWWWAuthenticate: true,
|
||||||
bearerOverridesCookie: false,
|
bearerOverridesCookie: false,
|
||||||
bearerIdentifierClaim: "sub",
|
bearerIdentifierClaim: "sub",
|
||||||
maxIdentifierLength: 256,
|
maxIdentifierLength: 256,
|
||||||
maxTokenAge: 24 * time.Hour,
|
maxTokenAge: 24 * time.Hour,
|
||||||
bearerFailureThreshold: 20,
|
bearerFailureThreshold: 20,
|
||||||
bearerFailureWindow: 60 * time.Second,
|
bearerFailureWindow: 60 * time.Second,
|
||||||
bearerFailurePenalty: 60 * time.Second,
|
bearerFailurePenalty: 60 * time.Second,
|
||||||
bearerFailureTracker: newBearerFailureTracker(20, 60*time.Second, 60*time.Second),
|
bearerFailureTracker: newBearerFailureTracker(20, 60*time.Second, 60*time.Second),
|
||||||
}
|
}
|
||||||
oidc.extractClaimsFunc = extractClaims
|
oidc.extractClaimsFunc = extractClaims
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -222,8 +222,7 @@ func TestResolveBearerIdentifier(t *testing.T) {
|
|||||||
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", 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: "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: "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: "non-string claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", wantErr: true},
|
||||||
{name: "object claim", claims: map[string]interface{}{"sub": map[string]interface{}{"a": 1}}, claim: "sub", wantErr: true},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -304,33 +303,15 @@ func TestBearerFailureTracker(t *testing.T) {
|
|||||||
if b, retry := tr.blocked(ip); !b || retry <= 0 {
|
if b, retry := tr.blocked(ip); !b || retry <= 0 {
|
||||||
t.Fatalf("expected blocked with positive retry, got=%v retry=%v", b, retry)
|
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
|
// Success clears the counter.
|
||||||
// (otherwise a single success could clear an attacker's penalty).
|
|
||||||
tr.recordSuccess(ip)
|
tr.recordSuccess(ip)
|
||||||
if b, _ := tr.blocked(ip); !b {
|
if b, _ := tr.blocked(ip); b {
|
||||||
t.Fatalf("expected still blocked after success while penalty active")
|
t.Fatalf("expected unblocked after success")
|
||||||
}
|
}
|
||||||
// Other IPs are unaffected.
|
// Other IPs are unaffected.
|
||||||
if b, _ := tr.blocked("10.0.0.2"); b {
|
if b, _ := tr.blocked("10.0.0.2"); b {
|
||||||
t.Fatalf("unrelated IP should not be blocked")
|
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -616,43 +597,6 @@ func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeHTTP_Bearer_Introspection503_NotThrottleFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
t.Fatalf("next must not run: introspection 503 should reject request")
|
|
||||||
})
|
|
||||||
oidc := makeBearerOIDC(t, next)
|
|
||||||
oidc.requireTokenIntrospection = true
|
|
||||||
// Unreachable introspection endpoint -> every request returns 503.
|
|
||||||
oidc.introspectionURL = "http://127.0.0.1:1/"
|
|
||||||
oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
|
|
||||||
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
|
||||||
|
|
||||||
claims := defaultBearerClaims()
|
|
||||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
||||||
seedVerified(t, oidc, token, claims)
|
|
||||||
|
|
||||||
send := func() *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequest("GET", "/api/work", nil)
|
|
||||||
req.RemoteAddr = "10.0.0.7:1234"
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
oidc.ServeHTTP(rw, req)
|
|
||||||
return rw
|
|
||||||
}
|
|
||||||
|
|
||||||
// Four requests during an introspection outage. Each is a 503 and the
|
|
||||||
// per-IP failure count must stay at 0 so the threshold (3) never trips.
|
|
||||||
// On OLD code each 503 was counted as a throttle failure, so the 4th
|
|
||||||
// request would come back 429 (penalty box) instead of 503.
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
rw := send()
|
|
||||||
if rw.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("iteration %d: status=%d, want %d", i, rw.Code, http.StatusServiceUnavailable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var capturedAuth string
|
var capturedAuth string
|
||||||
@@ -838,24 +782,6 @@ func TestStartupValidation_BearerRequiresAudience(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestStartupValidation_BearerRejectsEmailIdentifier(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cfg := CreateConfig()
|
cfg := CreateConfig()
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Addresses in one IPv6 /64 must share one failure-tracker key.
|
|
||||||
func TestClientIPForBearerCollapsesIPv6Prefix(t *testing.T) {
|
|
||||||
key := func(addr string) string {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.RemoteAddr = addr
|
|
||||||
return clientIPForBearer(r)
|
|
||||||
}
|
|
||||||
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:2:ffff::9]:2"); a != b {
|
|
||||||
t.Fatalf("same /64 got different keys: %q vs %q", a, b)
|
|
||||||
}
|
|
||||||
if a, b := key("[2001:db8:1:2::1]:1"), key("[2001:db8:1:3::1]:1"); a == b {
|
|
||||||
t.Fatalf("different /64 shared key %q", a)
|
|
||||||
}
|
|
||||||
if got := key("192.0.2.7:80"); got != "192.0.2.7" {
|
|
||||||
t.Fatalf("ipv4 key = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-28 regression tests.
|
|
||||||
//
|
|
||||||
// R117's map-growth sweep in recordFailure deleted every entry whose
|
|
||||||
// penaltyUntil was before the cutoff. An entry that has not tripped the
|
|
||||||
// penalty box yet has a ZERO penaltyUntil, which is always before any
|
|
||||||
// cutoff — so once the map passed defaultBearerEntrySweepThreshold, every
|
|
||||||
// accumulating (not-yet-tripped) counter was discarded regardless of how
|
|
||||||
// recently it started, no matter how recent firstFailureAt was. A source
|
|
||||||
// whose failures interleave with sweeps could never reach the threshold.
|
|
||||||
|
|
||||||
// TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters guards the
|
|
||||||
// exact scenario from the finding: threshold 3, 2 failures for a target
|
|
||||||
// IP, then 1025 other IPs failing once each (crossing the sweep
|
|
||||||
// threshold), then a 3rd failure for the target IP. The target's count
|
|
||||||
// must survive the sweep and trip the penalty box.
|
|
||||||
// Fail-on-old: the sweep discards the target's in-progress counter, so the
|
|
||||||
// 3rd failure restarts it at count=1 and blocked() reports false.
|
|
||||||
func TestBearerFailureTracker_SweepDoesNotDiscardFreshCounters(t *testing.T) {
|
|
||||||
b := newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
|
||||||
|
|
||||||
b.recordFailure("target-ip")
|
|
||||||
b.recordFailure("target-ip")
|
|
||||||
|
|
||||||
// Push the map past defaultBearerEntrySweepThreshold with distinct,
|
|
||||||
// equally-fresh IPs so recordFailure's sweep branch runs.
|
|
||||||
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
|
|
||||||
b.recordFailure(fmt.Sprintf("other-ip-%d", i))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.recordFailure("target-ip")
|
|
||||||
|
|
||||||
blocked, _ := b.blocked("target-ip")
|
|
||||||
if !blocked {
|
|
||||||
t.Fatal("a source whose failures are recent (within the window) must not lose its count to the map-growth sweep, and must trip the penalty box on its 3rd failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerFailureTracker_SweepRemovesTrulyStaleEntries is the positive
|
|
||||||
// control: an entry that is BOTH untripped AND outside the window (a
|
|
||||||
// source that failed once, long ago, and never returned) must still be
|
|
||||||
// swept once the map grows past the threshold — otherwise the fix would
|
|
||||||
// defeat the sweep's original memory-bound purpose entirely.
|
|
||||||
func TestBearerFailureTracker_SweepRemovesTrulyStaleEntries(t *testing.T) {
|
|
||||||
b := newBearerFailureTracker(20, 60*time.Second, 60*time.Second)
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
b.entries["stale-ip"] = &bearerFailureEntry{
|
|
||||||
firstFailureAt: time.Now().Add(-time.Hour), // well outside the 60s window
|
|
||||||
count: 1,
|
|
||||||
// penaltyUntil left zero: never tripped.
|
|
||||||
}
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
for i := 0; i < defaultBearerEntrySweepThreshold+1; i++ {
|
|
||||||
b.recordFailure(fmt.Sprintf("filler-ip-%d", i))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
_, stillPresent := b.entries["stale-ip"]
|
|
||||||
b.mu.Unlock()
|
|
||||||
if stillPresent {
|
|
||||||
t.Fatal("an untripped entry whose firstFailureAt is outside the window must still be swept once the map grows past the threshold")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-24 regression test.
|
|
||||||
//
|
|
||||||
// buildPrincipalFromBearerToken derived the IdP-logout-invalidation
|
|
||||||
// "createdAt" from the token's iat claim, falling back to time.Now() when
|
|
||||||
// iat was absent. Since 8640451 made iat OPTIONAL in jwt.Verify (R126), an
|
|
||||||
// access token without iat now reaches this check. time.Now() makes such a
|
|
||||||
// token always look newer than any logout event (isSessionInvalidated only
|
|
||||||
// matches when invalidationTime >= createdAt), so IdP-initiated
|
|
||||||
// (backchannel) logout never revokes it — reintroducing the R98 defect the
|
|
||||||
// cookie path already fixed by falling back to zero time instead.
|
|
||||||
//
|
|
||||||
// Fail-on-old: a bearer token without iat, for a sid invalidated by
|
|
||||||
// backchannel logout, still authenticates (200) after the logout.
|
|
||||||
func TestBearerLogoutInvalidation_NoIatFallsClosedNotOpen(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
oidc := makeBearerOIDC(t, nil)
|
|
||||||
oidc.sessionInvalidationCache = cache
|
|
||||||
// Disable the unrelated maxTokenAge/enforceIatAge bound (a DIFFERENT
|
|
||||||
// check that also rejects a missing iat, and that New() always turns on
|
|
||||||
// in production — 0/unset becomes 24h, main.go:357-362 — this override
|
|
||||||
// only exists because this test constructs *TraefikOidc directly,
|
|
||||||
// bypassing New()) so this test isolates the isSessionInvalidated
|
|
||||||
// createdAt fallback under test. That fallback is what actually matters
|
|
||||||
// once maxTokenAge is 0: with maxTokenAge > 0 (the production default),
|
|
||||||
// enforceIatAge rejects an iat-less token before this logout check is
|
|
||||||
// even reached.
|
|
||||||
oidc.maxTokenAge = 0
|
|
||||||
|
|
||||||
claims := defaultBearerClaims()
|
|
||||||
delete(claims, "iat") // provider omits iat (now legal per R126)
|
|
||||||
claims["sub"] = "user-1"
|
|
||||||
claims["sid"] = "session-1"
|
|
||||||
|
|
||||||
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
|
||||||
seedVerified(t, oidc, token, claims)
|
|
||||||
|
|
||||||
// Backchannel logout invalidates the session a full hour before this
|
|
||||||
// request (set directly rather than via invalidateSession's real-clock
|
|
||||||
// Unix() call, so the comparison is deterministic regardless of
|
|
||||||
// test-execution timing rather than depending on two time.Now() calls
|
|
||||||
// landing in the same second). The buggy time.Now() fallback for
|
|
||||||
// createdAt makes the token look newer than this invalidation
|
|
||||||
// (invalidationTime.Before(createdAt) == true -> NOT invalidated); the
|
|
||||||
// fix's zero-time fallback is never after any invalidation timestamp.
|
|
||||||
invalidatedAt := time.Now().Add(-time.Hour).Unix()
|
|
||||||
cache.Set(oidc.buildSessionInvalidationKey("sid", "session-1"), invalidatedAt, time.Hour)
|
|
||||||
|
|
||||||
_, bErr := oidc.buildPrincipalFromBearerToken(token)
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("a bearer token without iat, whose sid was invalidated by backchannel logout, must be rejected (fail closed), not authenticated as if issued after the logout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerLogoutInvalidation_WithIatStillHonored is the positive control:
|
|
||||||
// a token WITH iat issued before the logout must still be invalidated (the
|
|
||||||
// existing R146 behavior), and one issued after must still pass.
|
|
||||||
func TestBearerLogoutInvalidation_WithIatStillHonored(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
oidc := makeBearerOIDC(t, nil)
|
|
||||||
oidc.sessionInvalidationCache = cache
|
|
||||||
|
|
||||||
if err := oidc.invalidateSession("session-2", ""); err != nil {
|
|
||||||
t.Fatalf("invalidateSession: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issued well before the logout above.
|
|
||||||
staleClaims := defaultBearerClaims()
|
|
||||||
staleClaims["sub"] = "user-2"
|
|
||||||
staleClaims["sid"] = "session-2"
|
|
||||||
staleClaims["iat"] = float64(time.Now().Add(-time.Hour).Unix())
|
|
||||||
staleToken := makeBearerJWT(t, defaultBearerHeader(), staleClaims)
|
|
||||||
seedVerified(t, oidc, staleToken, staleClaims)
|
|
||||||
|
|
||||||
if _, bErr := oidc.buildPrincipalFromBearerToken(staleToken); bErr == nil {
|
|
||||||
t.Fatal("a token issued before the logout must still be invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freshly issued after the logout, for an unrelated sid.
|
|
||||||
freshClaims := defaultBearerClaims()
|
|
||||||
freshClaims["sub"] = "user-3"
|
|
||||||
freshClaims["sid"] = "session-3"
|
|
||||||
freshClaims["iat"] = float64(time.Now().Unix())
|
|
||||||
freshToken := makeBearerJWT(t, defaultBearerHeader(), freshClaims)
|
|
||||||
seedVerified(t, oidc, freshToken, freshClaims)
|
|
||||||
|
|
||||||
if _, bErr := oidc.buildPrincipalFromBearerToken(freshToken); bErr != nil {
|
|
||||||
t.Fatalf("a freshly issued token for an uninvalidated sid must pass, got: %v", bErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-03 regression tests.
|
|
||||||
//
|
|
||||||
// R159 sent every opaque (non-JWT) bearer token to
|
|
||||||
// buildPrincipalFromOpaqueIntrospection whenever requireTokenIntrospection
|
|
||||||
// was enabled, with none of the JWT-path gates: allowOpaqueTokens was never
|
|
||||||
// checked, the introspected client_id/audience was never bound to this
|
|
||||||
// client when the operator left audience==clientID (the common default),
|
|
||||||
// and IdP-initiated logout (isSessionInvalidated) was never consulted. Any
|
|
||||||
// introspected active=true token from the same IdP therefore authenticated,
|
|
||||||
// including one issued to a completely different client.
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens guards the
|
|
||||||
// allowOpaqueTokens gate: with it left at its default (false), an opaque
|
|
||||||
// bearer token must be rejected even when requireTokenIntrospection is on
|
|
||||||
// and the introspection endpoint reports the token active.
|
|
||||||
func TestBearerOpaqueIntrospection_RequiresAllowOpaqueTokens(t *testing.T) {
|
|
||||||
active := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: active},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: false, // default
|
|
||||||
clientID: "my-client",
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("opaque bearer token must be rejected when allowOpaqueTokens is false, even with requireTokenIntrospection on")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_RejectsForeignClient guards the client
|
|
||||||
// binding: when audience==clientID (no distinct API audience configured),
|
|
||||||
// an introspected token whose client_id names another client, and whose
|
|
||||||
// aud does not contain this client either, must be rejected.
|
|
||||||
func TestBearerOpaqueIntrospection_RejectsForeignClient(t *testing.T) {
|
|
||||||
foreign := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "other-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: foreign},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected token issued to a different client_id must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID is the positive
|
|
||||||
// control: client_id matching this client must still authenticate.
|
|
||||||
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaClientID(t *testing.T) {
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
}
|
|
||||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("a token issued to this client_id must be accepted, got: %v", bErr)
|
|
||||||
}
|
|
||||||
if p.Identifier != "user-1" {
|
|
||||||
t.Fatalf("expected identifier %q, got %q", "user-1", p.Identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud is the positive
|
|
||||||
// control for providers that omit client_id from the introspection
|
|
||||||
// response but carry it in aud instead (RFC 7662 leaves client_id
|
|
||||||
// optional).
|
|
||||||
func TestBearerOpaqueIntrospection_AcceptsOwnClientViaAud(t *testing.T) {
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", Aud: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("a token whose aud contains this client_id must be accepted, got: %v", bErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_InvalidatedSessionRejected guards the
|
|
||||||
// IdP-initiated-logout check: a subject invalidated via backchannel logout
|
|
||||||
// must be rejected even though the introspected token itself reports
|
|
||||||
// active=true. The introspection response carries no sid (RFC 7662 does
|
|
||||||
// not define one), so this checks invalidation by sub, mirroring the JWT
|
|
||||||
// bearer path's isSessionInvalidated(sid, sub, createdAt) call with an
|
|
||||||
// empty sid.
|
|
||||||
func TestBearerOpaqueIntrospection_InvalidatedSessionRejected(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
sessionInvalidationCache: cache,
|
|
||||||
}
|
|
||||||
if err := tObj.invalidateSession("", "user-1"); err != nil {
|
|
||||||
t.Fatalf("invalidateSession: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected token for a subject invalidated by backchannel logout must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted is the positive
|
|
||||||
// control: a subject with no invalidation entry must still authenticate.
|
|
||||||
func TestBearerOpaqueIntrospection_NotYetInvalidatedAccepted(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
sessionInvalidationCache: cache,
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("a subject with no invalidation entry must authenticate, got: %v", bErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim guards that the
|
|
||||||
// identifier is resolved through bearerIdentifierClaim + sanitizeBearerIdentifier
|
|
||||||
// like the JWT bearer path, instead of always preferring sub.
|
|
||||||
func TestBearerOpaqueIntrospection_UsesBearerIdentifierClaim(t *testing.T) {
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", Username: "svc-account", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
bearerIdentifierClaim: "username",
|
|
||||||
}
|
|
||||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", bErr)
|
|
||||||
}
|
|
||||||
if p.Identifier != "svc-account" {
|
|
||||||
t.Fatalf("expected identifier from configured claim %q, got %q", "username", p.Identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_MaxTokenAgeEnforced guards that maxTokenAge
|
|
||||||
// bounds the introspected token's iat like enforceIatAge does for the JWT
|
|
||||||
// path, when the AS returns one.
|
|
||||||
func TestBearerOpaqueIntrospection_MaxTokenAgeEnforced(t *testing.T) {
|
|
||||||
stale := &IntrospectionResponse{
|
|
||||||
Active: true,
|
|
||||||
Sub: "user-1",
|
|
||||||
ClientID: "my-client",
|
|
||||||
Iat: time.Now().Add(-2 * time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: stale},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
maxTokenAge: time.Hour,
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected token whose iat is older than maxTokenAge must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge guards
|
|
||||||
// that an opaque token whose introspection response carries no iat is
|
|
||||||
// bound by maxTokenAge exactly like enforceIatAge binds the JWT path: when
|
|
||||||
// maxTokenAge > 0 (New() always sets it so — 0/unset becomes 24h,
|
|
||||||
// main.go's config-default closure), a missing iat gives nothing to bound
|
|
||||||
// the token's age against, so it must fail closed rather than bypass the
|
|
||||||
// age check entirely.
|
|
||||||
func TestBearerOpaqueIntrospection_MissingIatRejectedWithMaxTokenAge(t *testing.T) {
|
|
||||||
noIat := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"} // Iat left zero
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: noIat},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
maxTokenAge: 24 * time.Hour,
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected opaque token with no iat must be rejected when maxTokenAge is set, matching enforceIatAge's fail-closed contract on the JWT path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_SidOnlyLogoutRejected guards front-channel
|
|
||||||
// (sid-only) logout reaching an opaque bearer token. isSessionInvalidated
|
|
||||||
// used to be called with a hardcoded empty sid, so a logout recorded only
|
|
||||||
// by sid (front-channel logout always records this way; so does a
|
|
||||||
// back-channel logout token carrying only sid) never revoked an opaque
|
|
||||||
// token even when the introspection response itself carried a sid.
|
|
||||||
func TestBearerOpaqueIntrospection_SidOnlyLogoutRejected(t *testing.T) {
|
|
||||||
cache := NewCache()
|
|
||||||
defer cache.Close()
|
|
||||||
|
|
||||||
withSid := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Sid: "session-1"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: withSid},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
sessionInvalidationCache: cache,
|
|
||||||
}
|
|
||||||
if err := tObj.invalidateSession("session-1", ""); err != nil {
|
|
||||||
t.Fatalf("invalidateSession: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected token whose sid was invalidated by a sid-only (front-channel) logout must be rejected, when the introspection response carries that sid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_RevokedTokenRejected guards the local
|
|
||||||
// revocation blacklist: the JWT bearer path rejects a raw token that
|
|
||||||
// RevokeToken blacklisted (verifyTokenWithOpts, token_manager.go:71-75), and
|
|
||||||
// handleLogout calls RevokeToken on the session's access token specifically
|
|
||||||
// so "a token captured before logout ... cannot be reused" (helpers.go).
|
|
||||||
// buildPrincipalFromOpaqueIntrospection never consulted t.tokenBlacklist, so
|
|
||||||
// a captured opaque bearer token kept authenticating as the victim after
|
|
||||||
// logout for as long as the IdP still reported it active.
|
|
||||||
func TestBearerOpaqueIntrospection_RevokedTokenRejected(t *testing.T) {
|
|
||||||
blacklist := NewCache()
|
|
||||||
defer blacklist.Close()
|
|
||||||
|
|
||||||
active := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: active},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
tokenBlacklist: blacklist,
|
|
||||||
tokenCache: NewTokenCache(),
|
|
||||||
}
|
|
||||||
defer tObj.tokenCache.Close()
|
|
||||||
|
|
||||||
const token = "opaque-victim-token"
|
|
||||||
tObj.RevokeToken(token)
|
|
||||||
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection(token)
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an opaque bearer token blacklisted by RevokeToken (logout) must be rejected, even though introspection still reports it active")
|
|
||||||
}
|
|
||||||
if bErr.kind != bearerErrTokenInactive {
|
|
||||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_BlacklistedJtiRejected guards the jti side
|
|
||||||
// of the same gate: when the introspection response carries a jti and that
|
|
||||||
// jti is blacklisted (e.g. the IdP echoes the same jti it minted for a JWT
|
|
||||||
// RevokeToken already blacklisted by jti), the opaque path must reject it
|
|
||||||
// too, unless disableReplayDetection is set — mirroring the JWT path's
|
|
||||||
// tokenCache-hit jti check (token_manager.go:90-96).
|
|
||||||
func TestBearerOpaqueIntrospection_BlacklistedJtiRejected(t *testing.T) {
|
|
||||||
blacklist := NewCache()
|
|
||||||
defer blacklist.Close()
|
|
||||||
blacklist.Set("revoked-jti-1", true, time.Hour)
|
|
||||||
|
|
||||||
withJti := &IntrospectionResponse{Active: true, Sub: "victim", ClientID: "my-client", Jti: "revoked-jti-1"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: withJti},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
tokenBlacklist: blacklist,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an opaque bearer token whose introspection jti is blacklisted must be rejected")
|
|
||||||
}
|
|
||||||
if bErr.kind != bearerErrTokenInactive {
|
|
||||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled
|
|
||||||
// guards the disableReplayDetection escape hatch on the jti-blacklist gate
|
|
||||||
// (bearer_auth.go:928): an operator who has turned off replay/JTI tracking
|
|
||||||
// must not be surprised by this gate rejecting an otherwise-valid token
|
|
||||||
// whose jti happens to collide with a blacklisted one. The raw token itself
|
|
||||||
// is NOT blacklisted here — only its jti is — so this isolates the jti
|
|
||||||
// branch from the raw-token blacklist check covered by
|
|
||||||
// _RevokedTokenRejected above.
|
|
||||||
func TestBearerOpaqueIntrospection_BlacklistedJtiIgnoredWhenReplayDetectionDisabled(t *testing.T) {
|
|
||||||
blacklist := NewCache()
|
|
||||||
defer blacklist.Close()
|
|
||||||
blacklist.Set("revoked-jti-1", true, time.Hour)
|
|
||||||
|
|
||||||
withJti := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client", Jti: "revoked-jti-1"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: withJti},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
tokenBlacklist: blacklist,
|
|
||||||
disableReplayDetection: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("a blacklisted jti must be ignored when disableReplayDetection is true, got: %v", bErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_NbfFutureRejected guards the not-before
|
|
||||||
// check the JWT path applies through verifyTimeClaims and the session path
|
|
||||||
// applies through validateOpaqueToken (token_introspection.go:258-263): an
|
|
||||||
// introspection response reporting a future nbf must be rejected on the
|
|
||||||
// bearer path too.
|
|
||||||
func TestBearerOpaqueIntrospection_NbfFutureRejected(t *testing.T) {
|
|
||||||
notYetValid := &IntrospectionResponse{
|
|
||||||
Active: true,
|
|
||||||
Sub: "user-1",
|
|
||||||
ClientID: "my-client",
|
|
||||||
Nbf: time.Now().Add(time.Hour).Unix(),
|
|
||||||
}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: notYetValid},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
}
|
|
||||||
_, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr == nil {
|
|
||||||
t.Fatal("an introspected token with nbf in the future must be rejected")
|
|
||||||
}
|
|
||||||
if bErr.kind != bearerErrTokenInactive {
|
|
||||||
t.Fatalf("want bearerErrTokenInactive (401), got kind %v", bErr.kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBearerOpaqueIntrospection_UsesClientIDClaim guards that
|
|
||||||
// bearerIdentifierClaim can resolve against any IntrospectionResponse
|
|
||||||
// member, not just sub/username — client_id, scope, iss, jti and aud must
|
|
||||||
// all be available too.
|
|
||||||
func TestBearerOpaqueIntrospection_UsesClientIDClaim(t *testing.T) {
|
|
||||||
own := &IntrospectionResponse{Active: true, Sub: "user-1", ClientID: "my-client"}
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: newNoOpLogger(),
|
|
||||||
introspectionCache: &stubIntrospectionCache{v: own},
|
|
||||||
requireTokenIntrospection: true,
|
|
||||||
allowOpaqueTokens: true,
|
|
||||||
clientID: "my-client",
|
|
||||||
bearerIdentifierClaim: "client_id",
|
|
||||||
}
|
|
||||||
p, bErr := tObj.buildPrincipalFromOpaqueIntrospection("opaque-token")
|
|
||||||
if bErr != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", bErr)
|
|
||||||
}
|
|
||||||
if p.Identifier != "my-client" {
|
|
||||||
t.Fatalf("expected identifier from configured claim %q, got %q", "client_id", p.Identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package traefikoidc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -160,3 +161,81 @@ func BenchmarkLRUStrategy_EstimateSize(b *testing.B) {
|
|||||||
strategy.EstimateSize(item)
|
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++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+7
-31
@@ -5,6 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBlacklistDuration = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// CacheManager manages all caching components using the universal cache
|
// CacheManager manages all caching components using the universal cache
|
||||||
type CacheManager struct {
|
type CacheManager struct {
|
||||||
manager *UniversalCacheManager
|
manager *UniversalCacheManager
|
||||||
@@ -12,9 +16,8 @@ type CacheManager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalCacheManagerInstance *CacheManager
|
globalCacheManagerInstance *CacheManager
|
||||||
cacheManagerInitOnce sync.Once
|
cacheManagerInitOnce sync.Once
|
||||||
cacheManagerActiveFingerprint string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetGlobalCacheManager returns a singleton CacheManager instance.
|
// GetGlobalCacheManager returns a singleton CacheManager instance.
|
||||||
@@ -26,9 +29,7 @@ func GetGlobalCacheManager(wg *sync.WaitGroup) *CacheManager {
|
|||||||
|
|
||||||
// GetGlobalCacheManagerWithConfig returns a singleton CacheManager instance with optional Redis configuration
|
// GetGlobalCacheManagerWithConfig returns a singleton CacheManager instance with optional Redis configuration
|
||||||
func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheManager {
|
func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheManager {
|
||||||
fp := redisFingerprint(config)
|
|
||||||
cacheManagerInitOnce.Do(func() {
|
cacheManagerInitOnce.Do(func() {
|
||||||
cacheManagerActiveFingerprint = fp
|
|
||||||
var redisConfig *RedisConfig
|
var redisConfig *RedisConfig
|
||||||
var logger *Logger
|
var logger *Logger
|
||||||
|
|
||||||
@@ -54,27 +55,9 @@ func GetGlobalCacheManagerWithConfig(wg *sync.WaitGroup, config *Config) *CacheM
|
|||||||
manager: GetUniversalCacheManagerWithConfig(logger, redisConfig),
|
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
|
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
|
// GetSharedTokenBlacklist returns the shared token blacklist cache
|
||||||
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
func (cm *CacheManager) GetSharedTokenBlacklist() CacheInterface {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
@@ -103,7 +86,7 @@ func (cm *CacheManager) GetSharedMetadataCache() *MetadataCache {
|
|||||||
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
func (cm *CacheManager) GetSharedJWKCache() JWKCacheInterface {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
defer cm.mu.RUnlock()
|
defer cm.mu.RUnlock()
|
||||||
return &JWKCache{cache: cm.manager.GetJWKCache(), lastForceRefresh: make(map[string]time.Time)}
|
return &JWKCache{cache: cm.manager.GetJWKCache()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedIntrospectionCache returns the shared token introspection cache
|
// GetSharedIntrospectionCache returns the shared token introspection cache
|
||||||
@@ -169,13 +152,6 @@ func (c *CacheInterfaceWrapper) Get(key string) (interface{}, bool) {
|
|||||||
return c.cache.Get(key)
|
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
|
// Delete removes a key
|
||||||
func (c *CacheInterfaceWrapper) Delete(key string) {
|
func (c *CacheInterfaceWrapper) Delete(key string) {
|
||||||
c.cache.Delete(key)
|
c.cache.Delete(key)
|
||||||
|
|||||||
+308
-5
@@ -178,11 +178,6 @@ func TestCacheInterfaceWrapper_Clear(t *testing.T) {
|
|||||||
cm := getTestCacheManager(t)
|
cm := getTestCacheManager(t)
|
||||||
cache := cm.GetSharedTokenBlacklist()
|
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("key1", "value1", time.Hour)
|
||||||
cache.Set("key2", "value2", time.Hour)
|
cache.Set("key2", "value2", time.Hour)
|
||||||
|
|
||||||
@@ -470,6 +465,314 @@ func TestConcurrentCacheAccess(t *testing.T) {
|
|||||||
wg.Wait()
|
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
|
// CACHE COMPATIBILITY TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Shared CacheInterface / cache-backend test doubles for round-regression
|
|
||||||
// tests.
|
|
||||||
//
|
|
||||||
// Moved here for FIX-41: NewMemoryBackendForTest, mapCache/newMapCache and
|
|
||||||
// serializingCache were each declared inside a single round file (review_r124,
|
|
||||||
// review_r146, review_r159) but consumed by other round files, so deleting or
|
|
||||||
// renaming the declaring file silently broke the consumer. Every
|
|
||||||
// review_rNN*_test.go file must depend only on shared helpers like this one,
|
|
||||||
// never on another round file.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewMemoryBackendForTest returns a cache backend backed by an in-process
|
|
||||||
// miniredis instance, for white-box tests that need a real CacheBackend
|
|
||||||
// without depending on a live Redis server. internal/cache's in-memory
|
|
||||||
// backend was removed as unreachable dead code (FIX-43); RedisBackend
|
|
||||||
// against miniredis is the project's standard lightweight substitute.
|
|
||||||
//
|
|
||||||
// The miniredis server is registered against t.Cleanup so it (and its
|
|
||||||
// listener goroutine) is closed when t ends. Callers still defer
|
|
||||||
// backend.Close() to close the RedisBackend connection pool.
|
|
||||||
func NewMemoryBackendForTest(t *testing.T) (backends.CacheBackend, error) {
|
|
||||||
t.Helper()
|
|
||||||
mr, err := miniredis.Run()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.Cleanup(mr.Close)
|
|
||||||
return backends.NewRedisBackend(backends.DefaultRedisConfig(mr.Addr()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapCache is a minimal thread-safe CacheInterface for seeding caches in
|
|
||||||
// bearer-invalidation tests.
|
|
||||||
type mapCache struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMapCache() *mapCache { return &mapCache{m: map[string]any{}} }
|
|
||||||
func (c *mapCache) Set(key string, value any, ttl time.Duration) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.m[key] = value
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
func (c *mapCache) Get(key string) (any, bool) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
v, ok := c.m[key]
|
|
||||||
return v, ok
|
|
||||||
}
|
|
||||||
func (c *mapCache) Delete(key string) { c.mu.Lock(); delete(c.m, key); c.mu.Unlock() }
|
|
||||||
func (c *mapCache) SetMaxSize(size int) {}
|
|
||||||
func (c *mapCache) Size() int { return 0 }
|
|
||||||
func (c *mapCache) Clear() {}
|
|
||||||
func (c *mapCache) Cleanup() {}
|
|
||||||
func (c *mapCache) Close() {}
|
|
||||||
func (c *mapCache) GetStats() map[string]any { return nil }
|
|
||||||
|
|
||||||
// serializingCache simulates a UniversalCache backed by a serializing
|
|
||||||
// store (e.g. Redis): Set JSON-round-trips the value, so Get returns a
|
|
||||||
// generic map, never the original concrete type. This is exactly what
|
|
||||||
// production sees on the distributed path.
|
|
||||||
type serializingCache struct {
|
|
||||||
m map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serializingCache) Set(key string, value any, ttl time.Duration) {
|
|
||||||
b, _ := json.Marshal(value)
|
|
||||||
var v interface{}
|
|
||||||
_ = json.Unmarshal(b, &v)
|
|
||||||
c.m[key] = v
|
|
||||||
}
|
|
||||||
func (c *serializingCache) Get(key string) (any, bool) {
|
|
||||||
v, ok := c.m[key]
|
|
||||||
return v, ok
|
|
||||||
}
|
|
||||||
func (c *serializingCache) Delete(key string) { delete(c.m, key) }
|
|
||||||
func (c *serializingCache) SetMaxSize(int) {}
|
|
||||||
func (c *serializingCache) Size() int { return len(c.m) }
|
|
||||||
func (c *serializingCache) Clear() { c.m = map[string]interface{}{} }
|
|
||||||
func (c *serializingCache) Cleanup() {}
|
|
||||||
func (c *serializingCache) Close() {}
|
|
||||||
func (c *serializingCache) GetStats() map[string]any { return nil }
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests pin the token-content validators' results, including for
|
|
||||||
// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where
|
|
||||||
// ranging over a string allocates O(n²)) without changing behavior.
|
|
||||||
|
|
||||||
func TestDetectRepeatedCharactersPinned(t *testing.T) {
|
|
||||||
cm := NewChunkManager(NewLogger(""))
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"short token skipped", "aaaaaaaaa", ""},
|
|
||||||
{"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""},
|
|
||||||
{"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""},
|
|
||||||
{"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
|
|
||||||
{"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
|
|
||||||
// Rune counts are divided by the byte length, so a multi-byte rune
|
|
||||||
// cannot reach the 70% threshold. Pinned as-is.
|
|
||||||
{"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""},
|
|
||||||
{"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"},
|
|
||||||
{"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig)
|
|
||||||
assertPinnedErr(t, err, tc.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateJWTFormatPinned(t *testing.T) {
|
|
||||||
cm := NewChunkManager(NewLogger(""))
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""},
|
|
||||||
{"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""},
|
|
||||||
{"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
|
|
||||||
{"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
|
|
||||||
{"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
err := cm.validateJWTFormat(tc.token, "access")
|
|
||||||
assertPinnedErr(t, err, tc.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateTokenSanitizationPinned(t *testing.T) {
|
|
||||||
cm := NewChunkManager(NewLogger(""))
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""},
|
|
||||||
{"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""},
|
|
||||||
{"control char byte offset", "abcé\x01def", "control character at position 5"},
|
|
||||||
{"delete char", "abcdef\x7fgh", "control character at position 6"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
err := cm.validateTokenSanitization(tc.token, AccessTokenConfig)
|
|
||||||
assertPinnedErr(t, err, tc.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the
|
|
||||||
// pre-commit secret scanner.
|
|
||||||
func jwtLike(parts ...string) string { return strings.Join(parts, ".") }
|
|
||||||
|
|
||||||
func assertPinnedErr(t *testing.T, err error, want string) {
|
|
||||||
t.Helper()
|
|
||||||
if want == "" {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err == nil || !strings.Contains(err.Error(), want) {
|
|
||||||
t.Fatalf("error = %v, want containing %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-49
@@ -3,7 +3,6 @@ package traefikoidc
|
|||||||
import (
|
import (
|
||||||
"crypto"
|
"crypto"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -21,13 +19,6 @@ import (
|
|||||||
"time"
|
"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
|
// isSupportedClientAssertionAlg reports whether alg is a recognized JWS
|
||||||
// algorithm for private_key_jwt (RFC 7523 §2.2).
|
// algorithm for private_key_jwt (RFC 7523 §2.2).
|
||||||
func isSupportedClientAssertionAlg(alg string) bool {
|
func isSupportedClientAssertionAlg(alg string) bool {
|
||||||
@@ -103,43 +94,13 @@ func NewClientAssertionSigner(pemBytes []byte, alg, kid string) (*ClientAssertio
|
|||||||
func validateAlgKeyMatch(alg string, key crypto.PrivateKey) error {
|
func validateAlgKeyMatch(alg string, key crypto.PrivateKey) error {
|
||||||
switch alg[0] {
|
switch alg[0] {
|
||||||
case 'R', 'P': // RS* or PS*
|
case 'R', 'P': // RS* or PS*
|
||||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
if _, ok := key.(*rsa.PrivateKey); !ok {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("alg %q requires an RSA key, got %T", alg, key)
|
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*
|
case 'E': // ES*
|
||||||
ecKey, ok := key.(*ecdsa.PrivateKey)
|
if _, ok := key.(*ecdsa.PrivateKey); !ok {
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("alg %q requires an EC key, got %T", alg, key)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,14 +120,6 @@ func (s *ClientAssertionSigner) Sign(audience, clientID string) (string, error)
|
|||||||
|
|
||||||
now := nowFn()
|
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.
|
// 16 random bytes as lowercase hex for jti uniqueness.
|
||||||
jtiBytes := make([]byte, 16)
|
jtiBytes := make([]byte, 16)
|
||||||
if _, err := io.ReadFull(rander, jtiBytes); err != nil {
|
if _, err := io.ReadFull(rander, jtiBytes); err != nil {
|
||||||
@@ -337,3 +290,6 @@ func buildClientAssertionSignerFromConfig(config *Config) (*ClientAssertionSigne
|
|||||||
|
|
||||||
return NewClientAssertionSigner(pemBytes, alg, config.ClientAssertionKeyID)
|
return NewClientAssertionSigner(pemBytes, alg, config.ClientAssertionKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# This Dockerfile is consumed by GoReleaser. The binary is built outside
|
||||||
|
# the Docker context (by goreleaser's Go cross-compile) and placed in the
|
||||||
|
# build context as ./oidcgate before `docker buildx build` runs.
|
||||||
|
#
|
||||||
|
# To build locally without goreleaser:
|
||||||
|
# go build -o oidcgate ./cmd/oidcgate
|
||||||
|
# docker build -f cmd/oidcgate/Dockerfile -t oidcgate:dev .
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="oidcgate"
|
||||||
|
LABEL org.opencontainers.image.description="Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/lukaszraczylo/traefikoidc"
|
||||||
|
LABEL org.opencontainers.image.documentation="https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
|
||||||
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
COPY oidcgate /usr/local/bin/oidcgate
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
USER nonroot:nonroot
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/oidcgate"]
|
||||||
|
CMD ["--config", "/etc/oidcgate/config.yaml"]
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the top-level oidcgate configuration. The OIDC subtree maps 1:1
|
||||||
|
// onto traefikoidc.Config; the few extra fields configure the daemon itself.
|
||||||
|
type Config struct {
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
AuthPath string `json:"authPath"`
|
||||||
|
StartPath string `json:"startPath"`
|
||||||
|
OIDC traefikoidc.Config `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// envScalarFields lists Config field names (within OIDC and top-level)
|
||||||
|
// that may be overridden via OIDCGATE_<UPPER_SNAKE_CASE> environment
|
||||||
|
// variables. Only scalar strings/ints/bools are supported; nested structs
|
||||||
|
// (Redis, SecurityHeaders, DynamicClientRegistration) stay YAML-only.
|
||||||
|
var envScalarFields = []string{
|
||||||
|
"Listen", "AuthPath", "StartPath",
|
||||||
|
"ProviderURL", "ClientID", "ClientSecret", "Audience",
|
||||||
|
"CallbackURL", "LogoutURL", "PostLogoutRedirectURI",
|
||||||
|
"SessionEncryptionKey", "CookiePrefix", "CookieDomain",
|
||||||
|
"LogLevel", "RevocationURL", "OIDCEndSessionURL",
|
||||||
|
"UserIdentifierClaim", "GroupClaimName", "RoleClaimName",
|
||||||
|
"ClientAuthMethod", "ClientAssertionPrivateKey",
|
||||||
|
"ClientAssertionKeyPath", "ClientAssertionKeyID", "ClientAssertionAlg",
|
||||||
|
"CACertPath", "CACertPEM",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads YAML from path, applies env-var overrides, fills defaults,
|
||||||
|
// and forces TrustForwardedURI=true so the library honors X-Forwarded-Uri.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
// Clean the operator-supplied path to satisfy gosec G304 (file inclusion
|
||||||
|
// via variable). filepath.Clean strips traversal sequences and normalises
|
||||||
|
// the path; this is canonical mitigation for config files supplied via a
|
||||||
|
// CLI flag — the operator runs the daemon, so the input is trusted, but
|
||||||
|
// gosec's static analysis still flags variable paths without the cleanup.
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
data, err := os.ReadFile(clean) // #nosec G304 -- operator-supplied config path, cleaned above
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: YAML → generic map.
|
||||||
|
var raw map[string]any
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("yaml parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the top-level oidcgate-specific keys away from the OIDC subtree.
|
||||||
|
listen, _ := raw["listen"].(string)
|
||||||
|
authPath, _ := raw["authPath"].(string)
|
||||||
|
startPath, _ := raw["startPath"].(string)
|
||||||
|
delete(raw, "listen")
|
||||||
|
delete(raw, "authPath")
|
||||||
|
delete(raw, "startPath")
|
||||||
|
|
||||||
|
// Pass 2: remaining map → JSON → traefikoidc.Config (uses existing json tags).
|
||||||
|
jsonBytes, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("yaml→json: %w", err)
|
||||||
|
}
|
||||||
|
var oidcCfg traefikoidc.Config
|
||||||
|
if err := json.Unmarshal(jsonBytes, &oidcCfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc config parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Listen: listen,
|
||||||
|
AuthPath: authPath,
|
||||||
|
StartPath: startPath,
|
||||||
|
OIDC: oidcCfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEnvOverrides(cfg)
|
||||||
|
applyDefaults(cfg)
|
||||||
|
|
||||||
|
if cfg.Listen == "" {
|
||||||
|
return nil, fmt.Errorf("config: missing required 'listen' (or OIDCGATE_LISTEN env var)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(cfg.OIDC.CallbackURL, "/") {
|
||||||
|
return nil, fmt.Errorf("config: callbackURL must be a path starting with '/', got %q", cfg.OIDC.CallbackURL)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.OIDC.LogoutURL, "/") {
|
||||||
|
return nil, fmt.Errorf("config: logoutURL must be a path starting with '/', got %q", cfg.OIDC.LogoutURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
reserved := []string{
|
||||||
|
sentinelPath,
|
||||||
|
cfg.AuthPath,
|
||||||
|
cfg.StartPath,
|
||||||
|
cfg.OIDC.CallbackURL,
|
||||||
|
cfg.OIDC.LogoutURL,
|
||||||
|
}
|
||||||
|
for _, ex := range cfg.OIDC.ExcludedURLs {
|
||||||
|
for _, r := range reserved {
|
||||||
|
if r != "" && strings.HasPrefix(r, ex) {
|
||||||
|
return nil, fmt.Errorf("config: excludedURL %q would bypass reserved oidcgate path %q", ex, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force standalone semantics: trust X-Forwarded-Uri.
|
||||||
|
cfg.OIDC.TrustForwardedURI = true
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvOverrides walks the allow-listed scalar fields and replaces any
|
||||||
|
// non-empty OIDCGATE_<UPPER_SNAKE_CASE> env var. Field name "ClientID"
|
||||||
|
// becomes "OIDCGATE_CLIENT_ID"; "SessionEncryptionKey" becomes
|
||||||
|
// "OIDCGATE_SESSION_ENCRYPTION_KEY".
|
||||||
|
func applyEnvOverrides(cfg *Config) {
|
||||||
|
for _, field := range envScalarFields {
|
||||||
|
env := os.Getenv("OIDCGATE_" + camelToSnakeUpper(field))
|
||||||
|
if env == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
setScalarField(cfg, field, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setScalarField(cfg *Config, field, value string) {
|
||||||
|
switch field {
|
||||||
|
case "Listen":
|
||||||
|
cfg.Listen = value
|
||||||
|
case "AuthPath":
|
||||||
|
cfg.AuthPath = value
|
||||||
|
case "StartPath":
|
||||||
|
cfg.StartPath = value
|
||||||
|
case "ProviderURL":
|
||||||
|
cfg.OIDC.ProviderURL = value
|
||||||
|
case "ClientID":
|
||||||
|
cfg.OIDC.ClientID = value
|
||||||
|
case "ClientSecret":
|
||||||
|
cfg.OIDC.ClientSecret = value
|
||||||
|
case "Audience":
|
||||||
|
cfg.OIDC.Audience = value
|
||||||
|
case "CallbackURL":
|
||||||
|
cfg.OIDC.CallbackURL = value
|
||||||
|
case "LogoutURL":
|
||||||
|
cfg.OIDC.LogoutURL = value
|
||||||
|
case "PostLogoutRedirectURI":
|
||||||
|
cfg.OIDC.PostLogoutRedirectURI = value
|
||||||
|
case "SessionEncryptionKey":
|
||||||
|
cfg.OIDC.SessionEncryptionKey = value
|
||||||
|
case "CookiePrefix":
|
||||||
|
cfg.OIDC.CookiePrefix = value
|
||||||
|
case "CookieDomain":
|
||||||
|
cfg.OIDC.CookieDomain = value
|
||||||
|
case "LogLevel":
|
||||||
|
cfg.OIDC.LogLevel = value
|
||||||
|
case "RevocationURL":
|
||||||
|
cfg.OIDC.RevocationURL = value
|
||||||
|
case "OIDCEndSessionURL":
|
||||||
|
cfg.OIDC.OIDCEndSessionURL = value
|
||||||
|
case "UserIdentifierClaim":
|
||||||
|
cfg.OIDC.UserIdentifierClaim = value
|
||||||
|
case "GroupClaimName":
|
||||||
|
cfg.OIDC.GroupClaimName = value
|
||||||
|
case "RoleClaimName":
|
||||||
|
cfg.OIDC.RoleClaimName = value
|
||||||
|
case "ClientAuthMethod":
|
||||||
|
cfg.OIDC.ClientAuthMethod = value
|
||||||
|
case "ClientAssertionPrivateKey":
|
||||||
|
cfg.OIDC.ClientAssertionPrivateKey = value
|
||||||
|
case "ClientAssertionKeyPath":
|
||||||
|
cfg.OIDC.ClientAssertionKeyPath = value
|
||||||
|
case "ClientAssertionKeyID":
|
||||||
|
cfg.OIDC.ClientAssertionKeyID = value
|
||||||
|
case "ClientAssertionAlg":
|
||||||
|
cfg.OIDC.ClientAssertionAlg = value
|
||||||
|
case "CACertPath":
|
||||||
|
cfg.OIDC.CACertPath = value
|
||||||
|
case "CACertPEM":
|
||||||
|
cfg.OIDC.CACertPEM = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.AuthPath == "" {
|
||||||
|
cfg.AuthPath = "/oauth2/auth"
|
||||||
|
}
|
||||||
|
if cfg.StartPath == "" {
|
||||||
|
cfg.StartPath = "/oauth2/start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// camelToSnakeUpper turns "ClientSecret" into "CLIENT_SECRET",
|
||||||
|
// "SessionEncryptionKey" into "SESSION_ENCRYPTION_KEY", etc.
|
||||||
|
// Multi-letter acronyms keep their grouping: "OIDCEndSessionURL" →
|
||||||
|
// "OIDC_END_SESSION_URL", "CACertPEM" → "CA_CERT_PEM".
|
||||||
|
func camelToSnakeUpper(s string) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
var b strings.Builder
|
||||||
|
for i, r := range runes {
|
||||||
|
if i > 0 && isUpper(r) {
|
||||||
|
prev := runes[i-1]
|
||||||
|
next := rune(0)
|
||||||
|
if i+1 < len(runes) {
|
||||||
|
next = runes[i+1]
|
||||||
|
}
|
||||||
|
if !isUpper(prev) || (next != 0 && !isUpper(next)) {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(unicode.ToUpper(r))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minimalYAML is a base config accepted by Load with no surprises.
|
||||||
|
const minimalYAML = `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_YAMLRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(`
|
||||||
|
listen: ":9090"
|
||||||
|
authPath: "/auth"
|
||||||
|
startPath: "/start"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Listen != ":9090" {
|
||||||
|
t.Errorf("listen: want :9090, got %q", cfg.Listen)
|
||||||
|
}
|
||||||
|
if cfg.AuthPath != "/auth" {
|
||||||
|
t.Errorf("authPath: want /auth, got %q", cfg.AuthPath)
|
||||||
|
}
|
||||||
|
if cfg.StartPath != "/start" {
|
||||||
|
t.Errorf("startPath: want /start, got %q", cfg.StartPath)
|
||||||
|
}
|
||||||
|
if cfg.OIDC.ClientID != "abc" {
|
||||||
|
t.Errorf("clientID: want abc, got %q", cfg.OIDC.ClientID)
|
||||||
|
}
|
||||||
|
if cfg.OIDC.ClientSecret != "secret" {
|
||||||
|
t.Errorf("clientSecret: want secret, got %q", cfg.OIDC.ClientSecret)
|
||||||
|
}
|
||||||
|
if !cfg.OIDC.TrustForwardedURI {
|
||||||
|
t.Errorf("TrustForwardedURI should be forced true by Load")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_EnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(`
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "from-file"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("OIDCGATE_CLIENT_SECRET", "from-env")
|
||||||
|
t.Setenv("OIDCGATE_LISTEN", ":9999")
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.OIDC.ClientSecret != "from-env" {
|
||||||
|
t.Errorf("env override (clientSecret): want from-env, got %q", cfg.OIDC.ClientSecret)
|
||||||
|
}
|
||||||
|
if cfg.Listen != ":9999" {
|
||||||
|
t.Errorf("env override (listen): want :9999, got %q", cfg.Listen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_Defaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(`
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.AuthPath != "/oauth2/auth" {
|
||||||
|
t.Errorf("AuthPath default: want /oauth2/auth, got %q", cfg.AuthPath)
|
||||||
|
}
|
||||||
|
if cfg.StartPath != "/oauth2/start" {
|
||||||
|
t.Errorf("StartPath default: want /oauth2/start, got %q", cfg.StartPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MissingFile(t *testing.T) {
|
||||||
|
if _, err := Load("/nonexistent/config.yaml"); err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_NestedStructRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(`
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
redis:
|
||||||
|
address: "redis:6379"
|
||||||
|
password: "redispw"
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.OIDC.Redis == nil {
|
||||||
|
t.Fatal("redis block should populate cfg.OIDC.Redis")
|
||||||
|
}
|
||||||
|
if cfg.OIDC.Redis.Address != "redis:6379" {
|
||||||
|
t.Errorf("redis address: want redis:6379, got %q", cfg.OIDC.Redis.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 5: callbackURL / logoutURL must start with "/"
|
||||||
|
func TestLoad_RejectsAbsoluteCallbackURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "https://app.example.com/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("callbackURL with absolute URL must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RejectsAbsoluteLogoutURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "https://app.example.com/oauth2/logout"
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("logoutURL with absolute URL must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 2: excludedURLs must not prefix reserved paths
|
||||||
|
func TestLoad_RejectsExcludedURLPrefixingReservedPath(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://idp.example"
|
||||||
|
clientID: "abc"
|
||||||
|
clientSecret: "secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
excludedURLs: ["/"]
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("excludedURLs: ['/'] must be rejected (bypasses all reserved paths)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_AllowsNonOverlappingExcludedURL(t *testing.T) {
|
||||||
|
path := writeConfig(t, minimalYAML+`excludedURLs: ["/public"]
|
||||||
|
`)
|
||||||
|
if _, err := Load(path); err != nil {
|
||||||
|
t.Fatalf("non-overlapping excludedURL must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 3: env override coverage — every envScalarFields entry must have a
|
||||||
|
// matching case in setScalarField. isAllZeroForField detects drift.
|
||||||
|
func TestEnvOverrideCoverage(t *testing.T) {
|
||||||
|
for _, field := range envScalarFields {
|
||||||
|
field := field
|
||||||
|
t.Run(field, func(t *testing.T) {
|
||||||
|
probe := "/safe/probe-" + field
|
||||||
|
if field == "SessionEncryptionKey" {
|
||||||
|
probe = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||||
|
}
|
||||||
|
if field == "LogLevel" {
|
||||||
|
probe = "debug"
|
||||||
|
}
|
||||||
|
if field == "ClientAuthMethod" {
|
||||||
|
probe = "client_secret_post"
|
||||||
|
}
|
||||||
|
if field == "ClientAssertionAlg" {
|
||||||
|
probe = "RS256"
|
||||||
|
}
|
||||||
|
|
||||||
|
var fresh Config
|
||||||
|
setScalarField(&fresh, field, probe)
|
||||||
|
if isAllZeroForField(&fresh, field, probe) {
|
||||||
|
t.Fatalf("envScalarFields includes %q but setScalarField has no matching case (drift)", field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllZeroForField returns true when setScalarField did NOT set the expected
|
||||||
|
// field — i.e., the switch is missing a case for `field`.
|
||||||
|
func isAllZeroForField(cfg *Config, field, probe string) bool {
|
||||||
|
switch field {
|
||||||
|
case "Listen":
|
||||||
|
return cfg.Listen != probe
|
||||||
|
case "AuthPath":
|
||||||
|
return cfg.AuthPath != probe
|
||||||
|
case "StartPath":
|
||||||
|
return cfg.StartPath != probe
|
||||||
|
case "ProviderURL":
|
||||||
|
return cfg.OIDC.ProviderURL != probe
|
||||||
|
case "ClientID":
|
||||||
|
return cfg.OIDC.ClientID != probe
|
||||||
|
case "ClientSecret":
|
||||||
|
return cfg.OIDC.ClientSecret != probe
|
||||||
|
case "Audience":
|
||||||
|
return cfg.OIDC.Audience != probe
|
||||||
|
case "CallbackURL":
|
||||||
|
return cfg.OIDC.CallbackURL != probe
|
||||||
|
case "LogoutURL":
|
||||||
|
return cfg.OIDC.LogoutURL != probe
|
||||||
|
case "PostLogoutRedirectURI":
|
||||||
|
return cfg.OIDC.PostLogoutRedirectURI != probe
|
||||||
|
case "SessionEncryptionKey":
|
||||||
|
return cfg.OIDC.SessionEncryptionKey != probe
|
||||||
|
case "CookiePrefix":
|
||||||
|
return cfg.OIDC.CookiePrefix != probe
|
||||||
|
case "CookieDomain":
|
||||||
|
return cfg.OIDC.CookieDomain != probe
|
||||||
|
case "LogLevel":
|
||||||
|
return cfg.OIDC.LogLevel != probe
|
||||||
|
case "RevocationURL":
|
||||||
|
return cfg.OIDC.RevocationURL != probe
|
||||||
|
case "OIDCEndSessionURL":
|
||||||
|
return cfg.OIDC.OIDCEndSessionURL != probe
|
||||||
|
case "UserIdentifierClaim":
|
||||||
|
return cfg.OIDC.UserIdentifierClaim != probe
|
||||||
|
case "GroupClaimName":
|
||||||
|
return cfg.OIDC.GroupClaimName != probe
|
||||||
|
case "RoleClaimName":
|
||||||
|
return cfg.OIDC.RoleClaimName != probe
|
||||||
|
case "ClientAuthMethod":
|
||||||
|
return cfg.OIDC.ClientAuthMethod != probe
|
||||||
|
case "ClientAssertionPrivateKey":
|
||||||
|
return cfg.OIDC.ClientAssertionPrivateKey != probe
|
||||||
|
case "ClientAssertionKeyPath":
|
||||||
|
return cfg.OIDC.ClientAssertionKeyPath != probe
|
||||||
|
case "ClientAssertionKeyID":
|
||||||
|
return cfg.OIDC.ClientAssertionKeyID != probe
|
||||||
|
case "ClientAssertionAlg":
|
||||||
|
return cfg.OIDC.ClientAssertionAlg != probe
|
||||||
|
case "CACertPath":
|
||||||
|
return cfg.OIDC.CACertPath != probe
|
||||||
|
case "CACertPEM":
|
||||||
|
return cfg.OIDC.CACertPEM != probe
|
||||||
|
}
|
||||||
|
return true // unknown field → drift
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// sentinelPath is the synthetic request path used when delegating /oauth2/auth
|
||||||
|
// and /oauth2/start into the traefikoidc middleware. It must NOT collide with
|
||||||
|
// callbackURL, logoutURL, /health*, or any plausible excludedURLs entry —
|
||||||
|
// the underscores and double-prefixing make accidental matches near-impossible.
|
||||||
|
const sentinelPath = "/__oidcgate_protected__"
|
||||||
|
|
||||||
|
// newAuthHandler builds the /oauth2/auth (silent probe) handler.
|
||||||
|
// Rewrites the request path to sentinelPath, wraps the ResponseWriter to
|
||||||
|
// convert the middleware's 302→IdP into 401, and delegates.
|
||||||
|
func newAuthHandler(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
ic := newAuthInterceptor(rw)
|
||||||
|
defer ic.Finalize()
|
||||||
|
r2 := cloneAndRewrite(req, sentinelPath)
|
||||||
|
next.ServeHTTP(ic, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newStartHandler builds the /oauth2/start (visible sign-in) handler.
|
||||||
|
// Rewrites the path to sentinelPath, forwards any ?rd= query as
|
||||||
|
// X-Forwarded-Uri so the middleware (with TrustForwardedURI=true) captures
|
||||||
|
// the right post-login redirect target, then delegates. The middleware's
|
||||||
|
// natural 302→IdP flows through unchanged.
|
||||||
|
func newStartHandler(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
r2 := cloneAndRewrite(req, sentinelPath)
|
||||||
|
// Precedence: explicit ?rd= wins over an ambient upstream
|
||||||
|
// X-Forwarded-Uri so /oauth2/start?rd=/dashboard does not get
|
||||||
|
// silently overridden by the proxy's current-URL forwarding.
|
||||||
|
if rd := req.URL.Query().Get("rd"); rd != "" {
|
||||||
|
r2.Header.Set("X-Forwarded-Uri", rd)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(rw, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCallbackHandler builds the IdP callback endpoint.
|
||||||
|
// Rewrites the request path to the configured callbackURL so the middleware's
|
||||||
|
// path-match at the top of ServeHTTP triggers the callback flow.
|
||||||
|
func newCallbackHandler(next http.Handler, callbackURL string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
r2 := cloneAndRewrite(req, callbackURL)
|
||||||
|
next.ServeHTTP(rw, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLogoutHandler builds the logout endpoint.
|
||||||
|
// Rewrites the request path to the configured logoutURL so the middleware's
|
||||||
|
// path-match at the top of ServeHTTP triggers the logout flow.
|
||||||
|
func newLogoutHandler(next http.Handler, logoutURL string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
r2 := cloneAndRewrite(req, logoutURL)
|
||||||
|
next.ServeHTTP(rw, r2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneAndRewrite returns a clone of req with URL.Path set to newPath.
|
||||||
|
// req.Clone deep-copies URL via net/http's cloneURL, so mutating
|
||||||
|
// r2.URL.Path does not affect the original req. RawQuery, Host,
|
||||||
|
// Fragment, RawPath are preserved unchanged.
|
||||||
|
func cloneAndRewrite(req *http.Request, newPath string) *http.Request {
|
||||||
|
r2 := req.Clone(req.Context())
|
||||||
|
r2.URL.Path = newPath
|
||||||
|
return r2
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubMiddleware lets us test endpoint wiring without spinning up a full
|
||||||
|
// traefikoidc instance. Each test injects the behavior it wants.
|
||||||
|
type stubMiddleware struct {
|
||||||
|
calls []stubCall
|
||||||
|
fn func(rw http.ResponseWriter, req *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubCall struct {
|
||||||
|
path string
|
||||||
|
header http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubMiddleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
s.calls = append(s.calls, stubCall{path: req.URL.Path, header: req.Header.Clone()})
|
||||||
|
if s.fn != nil {
|
||||||
|
s.fn(rw, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_RewritesToSentinel_AndConverts302To401(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
rw.Header().Set("Location", "https://idp.example/authorize?state=abc")
|
||||||
|
rw.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/")
|
||||||
|
rw.WriteHeader(http.StatusFound)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newAuthHandler(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/protected/page")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status: want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(stub.calls) != 1 || stub.calls[0].path != sentinelPath {
|
||||||
|
t.Fatalf("middleware path: want %q, got %v", sentinelPath, stub.calls)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("X-Auth-Redirect") == "" {
|
||||||
|
t.Error("X-Auth-Redirect should carry Location")
|
||||||
|
}
|
||||||
|
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/protected/page" {
|
||||||
|
t.Errorf("X-Forwarded-Uri must pass through to middleware: want /protected/page, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth_AuthenticatedReturnsHeadersAnd200(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
// Middleware would stamp X-Forwarded-User on req then call next.
|
||||||
|
req.Header.Set("X-Forwarded-User", "alice")
|
||||||
|
newSuccessHandler().ServeHTTP(rw, req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newAuthHandler(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
|
||||||
|
t.Errorf("X-Forwarded-User mirrored: want alice, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_DelegatesWithSentinel_NoInterception(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
rw.Header().Set("Location", "https://idp.example/authorize")
|
||||||
|
rw.WriteHeader(http.StatusFound)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newStartHandler(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("start: 302 must flow through, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if stub.calls[0].path != sentinelPath {
|
||||||
|
t.Fatalf("start path rewrite: want %q, got %q", sentinelPath, stub.calls[0].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_ForwardsRdAsXForwardedURI(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusFound) },
|
||||||
|
}
|
||||||
|
h := newStartHandler(stub)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back/here", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/back/here" {
|
||||||
|
t.Fatalf("?rd should become X-Forwarded-Uri: want /back/here, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_RdQueryWinsOverUpstreamHeader(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusFound) },
|
||||||
|
}
|
||||||
|
h := newStartHandler(stub)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/explicit", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/ambient")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/explicit" {
|
||||||
|
t.Fatalf("?rd= must win over upstream X-Forwarded-Uri: want /explicit, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallback_RewritesToConfiguredCallbackURL(t *testing.T) {
|
||||||
|
var seenPath, seenQuery string
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
seenPath = req.URL.Path
|
||||||
|
seenQuery = req.URL.RawQuery
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newCallbackHandler(stub, "/oauth2/callback")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/callback?code=abc&state=xyz", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if seenPath != "/oauth2/callback" {
|
||||||
|
t.Fatalf("callback path: want /oauth2/callback, got %q", seenPath)
|
||||||
|
}
|
||||||
|
if seenQuery != "code=abc&state=xyz" {
|
||||||
|
t.Fatalf("callback query must survive rewrite: want code=abc&state=xyz, got %q", seenQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout_RewritesToConfiguredLogoutURL(t *testing.T) {
|
||||||
|
var seenPath string
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
seenPath = req.URL.Path
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newLogoutHandler(stub, "/oauth2/logout")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/oauth2/logout", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if seenPath != "/oauth2/logout" {
|
||||||
|
t.Fatalf("logout path: want /oauth2/logout, got %q", seenPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// readyReporter is satisfied by *traefikoidc.TraefikOidc via its Ready() method.
|
||||||
|
type readyReporter interface {
|
||||||
|
Ready() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHealthzHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReadyzHandler(r readyReporter) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
if r.Ready() {
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type readyStub struct{ ready bool }
|
||||||
|
|
||||||
|
func (r *readyStub) Ready() bool { return r.ready }
|
||||||
|
|
||||||
|
func TestHealthz_Always200(t *testing.T) {
|
||||||
|
h := newHealthzHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadyz_503BeforeDiscovery(t *testing.T) {
|
||||||
|
h := newReadyzHandler(&readyStub{ready: false})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("readyz pre-discovery: want 503, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadyz_200AfterDiscovery(t *testing.T) {
|
||||||
|
h := newReadyzHandler(&readyStub{ready: true})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/lukaszraczylo/traefikoidc"
|
||||||
|
|
||||||
|
type traefikoidcConfigStub struct {
|
||||||
|
callbackURL string
|
||||||
|
logoutURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s traefikoidcConfigStub) AsOIDC() traefikoidc.Config {
|
||||||
|
return traefikoidc.Config{
|
||||||
|
CallbackURL: s.callbackURL,
|
||||||
|
LogoutURL: s.logoutURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProviderHost is a synthetic hostname used in place of the httptest.Server's
|
||||||
|
// 127.0.0.1 address. traefikoidc's URL validator blocks loopback IPs
|
||||||
|
// unconditionally; a non-loopback hostname passes the check. The custom HTTP
|
||||||
|
// client returned by mockHTTPClient rewires all dials for this host to the
|
||||||
|
// actual test-server port, so the mock IdP still receives every request.
|
||||||
|
const fakeProviderHost = "test-oidc-provider.local"
|
||||||
|
|
||||||
|
// mockHTTPClient returns an *http.Client whose dialer transparently redirects
|
||||||
|
// connections to fakeProviderHost to the real httptest.Server address.
|
||||||
|
func mockHTTPClient(realAddr string) *http.Client {
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
if host == fakeProviderHost {
|
||||||
|
addr = realAddr
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: transport}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMockIdP returns an httptest.Server that serves the minimal OIDC
|
||||||
|
// discovery surface required by traefikoidc.NewWithContext to bootstrap
|
||||||
|
// — discovery doc + an empty JWKS. All URLs in the discovery doc use
|
||||||
|
// fakeProviderHost so they pass the middleware's URL security validator.
|
||||||
|
func newMockIdP(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
fakeBase := "http://" + fakeProviderHost
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
discovery := map[string]any{
|
||||||
|
"issuer": fakeBase,
|
||||||
|
"authorization_endpoint": fakeBase + "/authorize",
|
||||||
|
"token_endpoint": fakeBase + "/token",
|
||||||
|
"jwks_uri": fakeBase + "/jwks",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
}
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(rw).Encode(discovery)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(rw http.ResponseWriter, _ *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = rw.Write([]byte(`{"keys":[]}`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestConfig produces a Config that points at the fake provider hostname
|
||||||
|
// (which the custom HTTP client redirects to the real mock server) and uses
|
||||||
|
// a known-good SessionEncryptionKey + safe path defaults.
|
||||||
|
func buildTestConfig(srv *httptest.Server) *Config {
|
||||||
|
// realAddr is HOST:PORT of the httptest server (e.g. "127.0.0.1:56789").
|
||||||
|
realAddr := srv.Listener.Addr().String()
|
||||||
|
cfg := &Config{
|
||||||
|
Listen: "127.0.0.1:0", // unused — we drive the mux directly via httptest
|
||||||
|
AuthPath: "/oauth2/auth",
|
||||||
|
StartPath: "/oauth2/start",
|
||||||
|
OIDC: traefikoidc.Config{
|
||||||
|
ProviderURL: "http://" + fakeProviderHost,
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
SessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
CallbackURL: "/oauth2/callback",
|
||||||
|
LogoutURL: "/oauth2/logout",
|
||||||
|
TrustForwardedURI: true,
|
||||||
|
EnablePKCE: true,
|
||||||
|
HTTPClient: mockHTTPClient(realAddr),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIntegrationStack builds the same wiring main.go builds: real
|
||||||
|
// middleware constructed against the mock IdP, success handler as next,
|
||||||
|
// mux on top.
|
||||||
|
func buildIntegrationStack(t *testing.T, idp *httptest.Server) (http.Handler, *traefikoidc.TraefikOidc) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := buildTestConfig(idp)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
mw, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, newSuccessHandler(), "oidcgate-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWithContext: %v", err)
|
||||||
|
}
|
||||||
|
mux := buildMux(cfg, mw, mw)
|
||||||
|
return mux, mw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_UnauthenticatedAuthReturns401WithRedirect(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status: want 401, got %d (body=%q)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("X-Auth-Redirect")
|
||||||
|
if loc == "" {
|
||||||
|
t.Fatal("X-Auth-Redirect should carry the IdP authorize URL")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
|
||||||
|
t.Errorf("X-Auth-Redirect should point at the mock IdP authorize endpoint, got %q", loc)
|
||||||
|
}
|
||||||
|
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) == 0 {
|
||||||
|
t.Error("expected at least one Set-Cookie (state/PKCE/nonce) on 401")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_StartRedirectsToIdPWithStateAndPKCE(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/dashboard", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status: want 302, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
loc := rec.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
|
||||||
|
t.Fatalf("Location: want prefix http://%s/authorize, got %q", fakeProviderHost, loc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(loc, "state=") {
|
||||||
|
t.Errorf("Location should include state= param, got %q", loc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(loc, "code_challenge=") {
|
||||||
|
t.Errorf("Location should include code_challenge= param (PKCE), got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_HealthzAlways200(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, _ := buildIntegrationStack(t, idp)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ReadyzBecomes200AfterDiscovery(t *testing.T) {
|
||||||
|
idp := newMockIdP(t)
|
||||||
|
mux, mw := buildIntegrationStack(t, idp)
|
||||||
|
|
||||||
|
// Hit /oauth2/auth once to trigger metadata discovery (the middleware
|
||||||
|
// performs discovery lazily on first request).
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil))
|
||||||
|
|
||||||
|
// Poll Ready() until true or timeout.
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if mw.Ready() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !mw.Ready() {
|
||||||
|
t.Fatal("middleware should be Ready() within 3s after first request triggered discovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// authInterceptor wraps a ResponseWriter for the /oauth2/auth endpoint.
|
||||||
|
// The traefikoidc middleware emits an HTTP 302 to the IdP authorize URL
|
||||||
|
// when a request is unauthenticated, but nginx auth_request and similar
|
||||||
|
// silent-probe contracts cannot follow redirects. authInterceptor buffers
|
||||||
|
// the header/body and, at Finalize() time:
|
||||||
|
//
|
||||||
|
// - if status was a redirect class (302, 303, 307, 308), rewrites it
|
||||||
|
// to 401, moves the original Location header to X-Auth-Redirect
|
||||||
|
// (advisory), strips Location, preserves Set-Cookie headers (state,
|
||||||
|
// PKCE, nonce — the browser will carry them into the next request),
|
||||||
|
// and writes an empty body.
|
||||||
|
// - otherwise: passes through verbatim.
|
||||||
|
type authInterceptor struct {
|
||||||
|
inner http.ResponseWriter
|
||||||
|
headers http.Header
|
||||||
|
status int
|
||||||
|
body []byte
|
||||||
|
wroteHeader bool
|
||||||
|
finalized bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthInterceptor(inner http.ResponseWriter) *authInterceptor {
|
||||||
|
return &authInterceptor{
|
||||||
|
inner: inner,
|
||||||
|
headers: http.Header{},
|
||||||
|
status: http.StatusOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *authInterceptor) Header() http.Header { return w.headers }
|
||||||
|
|
||||||
|
func (w *authInterceptor) WriteHeader(status int) {
|
||||||
|
if w.wroteHeader {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.status = status
|
||||||
|
w.wroteHeader = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *authInterceptor) Write(b []byte) (int, error) { //nolint:unparam // signature mandated by http.ResponseWriter
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
w.body = append(w.body, b...)
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize flushes the buffered response, applying the 302/303 → 401 rewrite.
|
||||||
|
// Must be called exactly once after the wrapped handler returns.
|
||||||
|
func (w *authInterceptor) Finalize() {
|
||||||
|
if w.finalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finalized = true
|
||||||
|
switch w.status {
|
||||||
|
case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||||
|
// Move Location → X-Auth-Redirect, strip Location, force 401, drop body.
|
||||||
|
if loc := w.headers.Get("Location"); loc != "" {
|
||||||
|
w.headers.Set("X-Auth-Redirect", loc)
|
||||||
|
w.headers.Del("Location")
|
||||||
|
}
|
||||||
|
copyHeaders(w.inner.Header(), w.headers)
|
||||||
|
w.inner.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copyHeaders(w.inner.Header(), w.headers)
|
||||||
|
w.inner.WriteHeader(w.status)
|
||||||
|
if len(w.body) > 0 {
|
||||||
|
_, _ = w.inner.Write(w.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyHeaders(dst, src http.Header) {
|
||||||
|
for k, vs := range src {
|
||||||
|
for _, v := range vs {
|
||||||
|
dst.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInterceptor_302BecomesNot401(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
|
||||||
|
w.Header().Set("Location", "https://idp.example/authorize?state=abc")
|
||||||
|
w.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/; HttpOnly")
|
||||||
|
w.Header().Add("Set-Cookie", "_oidc_pkce=xyz; Path=/; HttpOnly")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
_, _ = w.Write([]byte("ignored body"))
|
||||||
|
|
||||||
|
w.Finalize()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status: want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Auth-Redirect"); got != "https://idp.example/authorize?state=abc" {
|
||||||
|
t.Errorf("X-Auth-Redirect: want preserved Location, got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "" {
|
||||||
|
t.Errorf("Location must be stripped on 401, got %q", got)
|
||||||
|
}
|
||||||
|
cookies := rec.Header().Values("Set-Cookie")
|
||||||
|
if len(cookies) != 2 {
|
||||||
|
t.Fatalf("Set-Cookie count: want 2, got %d (%v)", len(cookies), cookies)
|
||||||
|
}
|
||||||
|
if body := strings.TrimSpace(rec.Body.String()); body != "" {
|
||||||
|
t.Errorf("body must be empty on 401, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_NonRedirectPassthrough(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
|
||||||
|
w.Header().Set("X-Forwarded-User", "alice")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
|
||||||
|
w.Finalize()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
|
||||||
|
t.Errorf("X-Forwarded-User: want preserved, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "ok") {
|
||||||
|
t.Errorf("body: want 'ok' preserved, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_303SeeOtherAlsoIntercepted(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusSeeOther)
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("303 should be intercepted to 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_307TemporaryRedirectIntercepted(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("307 should be intercepted to 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_308PermanentRedirectIntercepted(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("Location", "/elsewhere")
|
||||||
|
w.WriteHeader(http.StatusPermanentRedirect)
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("308 should be intercepted to 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterceptor_DoubleFinalizeIsNoop(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := newAuthInterceptor(rec)
|
||||||
|
w.Header().Set("X-Forwarded-User", "alice")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Finalize()
|
||||||
|
// Second call must not panic, must not change anything observable.
|
||||||
|
w.Finalize()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("double Finalize must not change status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
|
||||||
|
t.Errorf("double Finalize must not duplicate headers, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "/etc/oidcgate/config.yaml", "Path to YAML config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("oidcgate: load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
success := newSuccessHandler()
|
||||||
|
middleware, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, success, "oidcgate")
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Fatalf("oidcgate: build middleware: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := buildMux(cfg, middleware, middleware)
|
||||||
|
srv := buildServer(cfg, mux)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigs
|
||||||
|
log.Println("oidcgate: shutdown signal received")
|
||||||
|
if err := shutdown(srv); err != nil {
|
||||||
|
log.Printf("oidcgate: shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("oidcgate: listening on %s", cfg.Listen)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
cancel()
|
||||||
|
log.Fatalf("oidcgate: serve: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("oidcgate: stopped")
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildMux wires all six routes onto a single ServeMux:
|
||||||
|
//
|
||||||
|
// /healthz, /readyz, AuthPath, StartPath, OIDC.CallbackURL, OIDC.LogoutURL.
|
||||||
|
//
|
||||||
|
// The same `middleware` instance is delegated to by all four OIDC routes;
|
||||||
|
// the synthetic success handler is wired into the middleware at construction
|
||||||
|
// time (in main.go) so it doesn't appear here.
|
||||||
|
func buildMux(cfg *Config, middleware http.Handler, ready readyReporter) *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/healthz", newHealthzHandler())
|
||||||
|
mux.Handle("/readyz", newReadyzHandler(ready))
|
||||||
|
mux.Handle(cfg.AuthPath, newAuthHandler(middleware))
|
||||||
|
mux.Handle(cfg.StartPath, newStartHandler(middleware))
|
||||||
|
mux.Handle(cfg.OIDC.CallbackURL, newCallbackHandler(middleware, cfg.OIDC.CallbackURL))
|
||||||
|
mux.Handle(cfg.OIDC.LogoutURL, newLogoutHandler(middleware, cfg.OIDC.LogoutURL))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer wraps the mux in an http.Server with sensible timeouts.
|
||||||
|
func buildServer(cfg *Config, mux http.Handler) *http.Server { //nolint:unused // consumed by main.go in Task 9
|
||||||
|
return &http.Server{
|
||||||
|
Addr: cfg.Listen,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown gracefully stops the server with a 15s deadline.
|
||||||
|
func shutdown(srv *http.Server) error { //nolint:unused // consumed by main.go in Task 9
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMux_RoutesAllEndpoints(t *testing.T) {
|
||||||
|
stub := &stubMiddleware{
|
||||||
|
fn: func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusOK) },
|
||||||
|
}
|
||||||
|
mux := buildMux(&Config{
|
||||||
|
Listen: ":0",
|
||||||
|
AuthPath: "/oauth2/auth",
|
||||||
|
StartPath: "/oauth2/start",
|
||||||
|
OIDC: traefikoidcConfigStub{
|
||||||
|
callbackURL: "/oauth2/callback",
|
||||||
|
logoutURL: "/oauth2/logout",
|
||||||
|
}.AsOIDC(),
|
||||||
|
}, stub, &readyStub{ready: true})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
method string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"/healthz", http.MethodGet, http.StatusOK},
|
||||||
|
{"/readyz", http.MethodGet, http.StatusOK},
|
||||||
|
{"/oauth2/auth", http.MethodGet, http.StatusOK},
|
||||||
|
{"/oauth2/start", http.MethodGet, http.StatusOK},
|
||||||
|
{"/oauth2/callback", http.MethodGet, http.StatusOK},
|
||||||
|
{"/oauth2/logout", http.MethodPost, http.StatusOK},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s %s: want %d, got %d", c.method, c.path, c.want, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mirrorAllowedHeaders is the set of NON-X-prefixed request headers that the
|
||||||
|
// success handler copies onto the response. The traefikoidc middleware sets
|
||||||
|
// "Authorization: Bearer ..." via the templated-header feature when operators
|
||||||
|
// configure it, and proxies need that to flow upstream.
|
||||||
|
var mirrorAllowedHeaders = map[string]struct{}{
|
||||||
|
"Authorization": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// successHandler is the http.Handler installed as the middleware's `next`.
|
||||||
|
// When the middleware reaches this handler the request is authenticated; we
|
||||||
|
// mirror the X-* (and a small allow-list of non-X-*) headers the middleware
|
||||||
|
// stamped onto req.Header back onto the response so upstream proxies can
|
||||||
|
// capture them via auth_request_set / authResponseHeaders / copy_headers,
|
||||||
|
// then write 200 with an empty body.
|
||||||
|
func newSuccessHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
for name, values := range req.Header {
|
||||||
|
if !shouldMirror(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
rw.Header().Add(name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldMirror(name string) bool {
|
||||||
|
if strings.HasPrefix(name, "X-") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
canonical := http.CanonicalHeaderKey(name)
|
||||||
|
_, ok := mirrorAllowedHeaders[canonical]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSuccessHandler_Writes200(t *testing.T) {
|
||||||
|
h := newSuccessHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessHandler_MirrorsForwardedHeaders(t *testing.T) {
|
||||||
|
h := newSuccessHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.Header.Set("X-Forwarded-User", "[email protected]")
|
||||||
|
req.Header.Set("X-Forwarded-Email", "[email protected]")
|
||||||
|
req.Header.Set("X-Custom-Templated", "value")
|
||||||
|
req.Header.Set("Authorization", "Bearer token-from-template")
|
||||||
|
req.Header.Set("Cookie", "session=should-NOT-mirror")
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if got := rec.Header().Get("X-Forwarded-User"); got != "[email protected]" {
|
||||||
|
t.Errorf("X-Forwarded-User: want mirrored, got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Forwarded-Email"); got != "[email protected]" {
|
||||||
|
t.Errorf("X-Forwarded-Email: want mirrored, got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Custom-Templated"); got != "value" {
|
||||||
|
t.Errorf("X-Custom-Templated: want mirrored (X- prefix), got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Authorization"); got != "Bearer token-from-template" {
|
||||||
|
t.Errorf("Authorization: want mirrored (templated bearer), got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cookie"); got != "" {
|
||||||
|
t.Errorf("Cookie must NOT be mirrored, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessHandler_EmptyBody(t *testing.T) {
|
||||||
|
h := newSuccessHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if body := strings.TrimSpace(rec.Body.String()); body != "" {
|
||||||
|
t.Fatalf("body: want empty, got %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessHandler_MultiValueHeader(t *testing.T) {
|
||||||
|
h := newSuccessHandler()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.Header.Add("X-Role", "admin")
|
||||||
|
req.Header.Add("X-Role", "editor")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
got := rec.Header()["X-Role"]
|
||||||
|
if len(got) != 2 || got[0] != "admin" || got[1] != "editor" {
|
||||||
|
t.Errorf("X-Role multi-value: want [admin editor], got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDecompressCombinedPayloadReusesReaders(t *testing.T) {
|
|
||||||
var readers sync.Pool
|
|
||||||
first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compress first: %v", err)
|
|
||||||
}
|
|
||||||
second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compress second: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantUser string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"first payload", first, "[email protected]", false},
|
|
||||||
{"corrupt payload rejected", "bm90LWd6aXA=", "", true},
|
|
||||||
{"second payload after corrupt one", second, "[email protected]", false},
|
|
||||||
{"first payload again", first, "[email protected]", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
got, err := decompressCombinedPayload(tc.input, &readers)
|
|
||||||
if tc.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%s: expected error", tc.name)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", tc.name, err)
|
|
||||||
}
|
|
||||||
if got.Ui != tc.wantUser {
|
|
||||||
t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestConcurrentServeHTTP_SharedInstance drives the full request path
|
|
||||||
// (bootstraps, auth-bypass evaluation, session lookup, redirect) across many
|
|
||||||
// goroutines against ONE shared plugin instance. Traefik runs the middleware
|
|
||||||
// concurrently for every request on a router, so this is the production
|
|
||||||
// concurrency shape. Holds under -race only if the request path touches
|
|
||||||
// shared mutable state via atomics/locks, never plain field writes.
|
|
||||||
func TestConcurrentServeHTTP_SharedInstance(t *testing.T) {
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
|
||||||
next: next,
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
initComplete: make(chan struct{}),
|
|
||||||
sessionManager: createTestSessionManager(t),
|
|
||||||
issuerURL: "https://provider.example.com",
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
logoutURLPath: "/logout",
|
|
||||||
clientID: "test-client",
|
|
||||||
audience: "test-client",
|
|
||||||
authURL: "https://provider.example.com/auth",
|
|
||||||
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
|
|
||||||
metadataRefreshStartedAtomic: 0,
|
|
||||||
}
|
|
||||||
close(oidc.initComplete)
|
|
||||||
|
|
||||||
const goroutines = 40
|
|
||||||
const perGoroutine = 5
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(goroutines)
|
|
||||||
for g := 0; g < goroutines; g++ {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; i < perGoroutine; i++ {
|
|
||||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/g%d/", g), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
oidc.ServeHTTP(w, req)
|
|
||||||
// Unauthenticated request must bounce to the IdP (3xx), not
|
|
||||||
// forward to the backend (200) or error.
|
|
||||||
if w.Code < 300 || w.Code >= 400 {
|
|
||||||
t.Errorf("expected auth redirect (3xx), got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConcurrentServeHTTP_ExcludedBypass drives the auth-bypass path
|
|
||||||
// concurrently (shouldBypassAuth read-path) on a shared instance.
|
|
||||||
func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) {
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
|
||||||
next: next,
|
|
||||||
logger: NewLogger("debug"),
|
|
||||||
initComplete: make(chan struct{}),
|
|
||||||
sessionManager: createTestSessionManager(t),
|
|
||||||
firstRequestStarted: 1,
|
|
||||||
metadataRefreshStartedAtomic: 1,
|
|
||||||
authURL: "https://provider.example.com/auth",
|
|
||||||
}
|
|
||||||
close(oidc.initComplete)
|
|
||||||
|
|
||||||
const goroutines = 40
|
|
||||||
const perGoroutine = 5
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(goroutines)
|
|
||||||
for g := 0; g < goroutines; g++ {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; i < perGoroutine; i++ {
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
oidc.ServeHTTP(w, req)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
+42
-101
@@ -7,32 +7,10 @@ import (
|
|||||||
// REDACTED is the placeholder value for sensitive information
|
// REDACTED is the placeholder value for sensitive information
|
||||||
const REDACTED = "[REDACTED]"
|
const REDACTED = "[REDACTED]"
|
||||||
|
|
||||||
// marshalDCR builds the JSON/YAML-safe representation of the dynamic
|
// MarshalJSON implements custom JSON marshaling to redact sensitive fields
|
||||||
// client registration config, redacting the initial access token.
|
// Rewritten without type aliases for yaegi compatibility
|
||||||
func marshalDCR(d *DynamicClientRegistrationConfig) map[string]interface{} {
|
func (c Config) MarshalJSON() ([]byte, error) {
|
||||||
if d == nil {
|
// Build a map manually to avoid type alias issues with yaegi
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m := make(map[string]interface{})
|
|
||||||
m["enabled"] = d.Enabled
|
|
||||||
m["persistCredentials"] = d.PersistCredentials
|
|
||||||
m["storageBackend"] = d.StorageBackend
|
|
||||||
m["initialAccessToken"] = REDACTED
|
|
||||||
m["registrationEndpoint"] = d.RegistrationEndpoint
|
|
||||||
m["credentialsFile"] = d.CredentialsFile
|
|
||||||
m["redisKeyPrefix"] = d.RedisKeyPrefix
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// configToMap builds the JSON/YAML-safe map representation of Config. Both
|
|
||||||
// MarshalJSON and MarshalYAML share it so the (hand-maintained for yaegi
|
|
||||||
// compatibility) field/redaction list cannot drift between the two
|
|
||||||
// encoders — previously the list silently dropped entire config areas
|
|
||||||
// (dynamicClientRegistration, securityHeaders, audience, overrideScopes,
|
|
||||||
// enablePKCE, allowOpaqueTokens, requireTokenIntrospection,
|
|
||||||
// strictAudienceValidation, extraAuthParams, client assertion settings)
|
|
||||||
// and never redacted their embedded secrets (R150).
|
|
||||||
func (c Config) configToMap() map[string]interface{} {
|
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
// Copy public fields
|
// Copy public fields
|
||||||
@@ -50,66 +28,11 @@ func (c Config) configToMap() map[string]interface{} {
|
|||||||
result["allowedUsers"] = c.AllowedUsers
|
result["allowedUsers"] = c.AllowedUsers
|
||||||
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
||||||
|
|
||||||
// Fields that the legacy hand-written list dropped (R150): important
|
// Redact sensitive fields
|
||||||
// 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["clientSecret"] = REDACTED
|
||||||
result["sessionEncryptionKey"] = REDACTED
|
result["sessionEncryptionKey"] = REDACTED
|
||||||
result["clientAssertionPrivateKey"] = REDACTED
|
|
||||||
|
|
||||||
// Handle Redis config (redacts password)
|
// Handle Redis config
|
||||||
if c.Redis != nil {
|
if c.Redis != nil {
|
||||||
redisMap := make(map[string]interface{})
|
redisMap := make(map[string]interface{})
|
||||||
redisMap["enabled"] = c.Redis.Enabled
|
redisMap["enabled"] = c.Redis.Enabled
|
||||||
@@ -121,29 +44,47 @@ func (c Config) configToMap() map[string]interface{} {
|
|||||||
result["redis"] = redisMap
|
result["redis"] = redisMap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dynamic client registration (redacts the initial access token)
|
return json.Marshal(result)
|
||||||
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
|
// MarshalYAML implements custom YAML marshaling to redact sensitive fields
|
||||||
// Rewritten without type aliases for yaegi compatibility
|
// Rewritten without type aliases for yaegi compatibility
|
||||||
func (c Config) MarshalYAML() (interface{}, error) {
|
func (c Config) MarshalYAML() (interface{}, error) {
|
||||||
return c.configToMap(), nil
|
// Build a map manually to avoid type alias issues with yaegi
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Copy public fields
|
||||||
|
result["providerURL"] = c.ProviderURL
|
||||||
|
result["clientID"] = c.ClientID
|
||||||
|
result["callbackURL"] = c.CallbackURL
|
||||||
|
result["logoutURL"] = c.LogoutURL
|
||||||
|
result["postLogoutRedirectURI"] = c.PostLogoutRedirectURI
|
||||||
|
result["scopes"] = c.Scopes
|
||||||
|
result["forceHTTPS"] = c.ForceHTTPS
|
||||||
|
result["logLevel"] = c.LogLevel
|
||||||
|
result["rateLimit"] = c.RateLimit
|
||||||
|
result["excludedURLs"] = c.ExcludedURLs
|
||||||
|
result["allowedUserDomains"] = c.AllowedUserDomains
|
||||||
|
result["allowedUsers"] = c.AllowedUsers
|
||||||
|
result["allowedRolesAndGroups"] = c.AllowedRolesAndGroups
|
||||||
|
|
||||||
|
// Redact sensitive fields
|
||||||
|
result["clientSecret"] = REDACTED
|
||||||
|
result["sessionEncryptionKey"] = REDACTED
|
||||||
|
|
||||||
|
// Handle Redis config
|
||||||
|
if c.Redis != nil {
|
||||||
|
redisMap := make(map[string]interface{})
|
||||||
|
redisMap["enabled"] = c.Redis.Enabled
|
||||||
|
redisMap["address"] = c.Redis.Address
|
||||||
|
redisMap["password"] = REDACTED
|
||||||
|
redisMap["db"] = c.Redis.DB
|
||||||
|
redisMap["poolSize"] = c.Redis.PoolSize
|
||||||
|
redisMap["cacheMode"] = c.Redis.CacheMode
|
||||||
|
result["redis"] = redisMap
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON for RedisConfig to redact sensitive fields
|
// MarshalJSON for RedisConfig to redact sensitive fields
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestConfigToMap_IncludesAllJSONFields pins FIX-40: configToMap is the
|
|
||||||
// single field/redaction list shared by MarshalJSON and MarshalYAML (R150),
|
|
||||||
// so every json-tagged Config field must appear as a configToMap key or the
|
|
||||||
// marshaled/logged config silently drops it again. Fields tagged
|
|
||||||
// `json:"-"` are intentionally excluded from marshaling and are skipped.
|
|
||||||
func TestConfigToMap_IncludesAllJSONFields(t *testing.T) {
|
|
||||||
// Non-nil pointer fields so their conditional branches in configToMap
|
|
||||||
// run and populate a map key, matching how a real, non-empty Config is
|
|
||||||
// built.
|
|
||||||
cfg := Config{
|
|
||||||
Redis: &RedisConfig{},
|
|
||||||
DynamicClientRegistration: &DynamicClientRegistrationConfig{},
|
|
||||||
SecurityHeaders: &SecurityHeadersConfig{},
|
|
||||||
}
|
|
||||||
result := cfg.configToMap()
|
|
||||||
|
|
||||||
typ := reflect.TypeOf(Config{})
|
|
||||||
var missing []string
|
|
||||||
for i := 0; i < typ.NumField(); i++ {
|
|
||||||
tag := typ.Field(i).Tag.Get("json")
|
|
||||||
if tag == "" || tag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.Split(tag, ",")[0]
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := result[name]; !ok {
|
|
||||||
missing = append(missing, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missing) > 0 {
|
|
||||||
t.Fatalf("configToMap is missing json-tagged Config fields: %v", missing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Shared Config-fixture helpers for round-regression tests.
|
|
||||||
//
|
|
||||||
// Moved here for FIX-41: r134ValidConfig was declared inside review_r134_test.go
|
|
||||||
// but consumed by review_r135_test.go, so deleting or renaming the declaring
|
|
||||||
// file silently broke the consumer. Every review_rNN*_test.go file must
|
|
||||||
// depend only on shared helpers like this one, never on another round file.
|
|
||||||
|
|
||||||
// r134ValidConfig returns a minimal Config that passes Validate(), for tests
|
|
||||||
// that mutate one field and assert the resulting accept/reject outcome.
|
|
||||||
func r134ValidConfig() *Config {
|
|
||||||
return &Config{
|
|
||||||
ProviderURL: "https://provider.example.com",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
ClientID: "test-client",
|
|
||||||
ClientSecret: "test-secret",
|
|
||||||
SessionEncryptionKey: "0123456789abcdef0123456789abcdef",
|
|
||||||
RateLimit: CreateConfig().RateLimit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestContentValidCache(t *testing.T) {
|
|
||||||
cm := NewChunkManager(NewLogger(""))
|
|
||||||
t.Cleanup(cm.Shutdown)
|
|
||||||
now := time.Now()
|
|
||||||
valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
|
|
||||||
futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())})
|
|
||||||
badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl")
|
|
||||||
|
|
||||||
t.Run("valid token passes twice and is cached", func(t *testing.T) {
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil {
|
|
||||||
t.Fatalf("pass %d: %v", i, res.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok {
|
|
||||||
t.Fatal("valid token was not cached")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("cache is per token type", func(t *testing.T) {
|
|
||||||
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok {
|
|
||||||
t.Fatal("access-token entry leaked into the ID-token key")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("time checks still run on a cache hit", func(t *testing.T) {
|
|
||||||
cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat))
|
|
||||||
res := cm.validateToken(futureIat, AccessTokenConfig)
|
|
||||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
|
|
||||||
t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("cache hit uses the stored claims", func(t *testing.T) {
|
|
||||||
other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
|
|
||||||
future := now.Add(time.Hour)
|
|
||||||
cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future})
|
|
||||||
res := cm.validateToken(other, AccessTokenConfig)
|
|
||||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
|
|
||||||
t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("invalid content never cached", func(t *testing.T) {
|
|
||||||
if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil {
|
|
||||||
t.Fatal("control character accepted")
|
|
||||||
}
|
|
||||||
if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok {
|
|
||||||
t.Fatal("invalid token was cached")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("cache stays bounded", func(t *testing.T) {
|
|
||||||
for i := 0; i < contentValidCacheMax+10; i++ {
|
|
||||||
cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{})
|
|
||||||
}
|
|
||||||
cm.contentMu.Lock()
|
|
||||||
n := len(cm.contentValid)
|
|
||||||
cm.contentMu.Unlock()
|
|
||||||
if n > contentValidCacheMax {
|
|
||||||
t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCleanupOldCookies_UsesConfiguredCookiePath pins the fix for cookie
|
|
||||||
// cleanup under cookiePath. Session cookies are written at the configured
|
|
||||||
// cookiePath (EnhanceSessionSecurity), but CleanupOldCookies wrote its
|
|
||||||
// deletion cookies at a hardcoded "/". A browser deletes a cookie only when
|
|
||||||
// name, domain and path all match, so the cleanup removed nothing when
|
|
||||||
// cookiePath was set.
|
|
||||||
func TestCleanupOldCookies_UsesConfiguredCookiePath(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cookiePath string
|
|
||||||
wantPath string
|
|
||||||
}{
|
|
||||||
{name: "configured path", cookiePath: "/app", wantPath: "/app"},
|
|
||||||
{name: "default path", cookiePath: "", wantPath: "/"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(
|
|
||||||
"test-encryption-key-32-bytes-long!!",
|
|
||||||
false, "app.example.com", "testpref", 0,
|
|
||||||
newNoOpLogger(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("session manager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
sm.cookiePath = tc.cookiePath
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/app/page", nil)
|
|
||||||
req.Host = "app.example.com"
|
|
||||||
req.AddCookie(&http.Cookie{Name: "testpref_sess", Value: "x"})
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
sm.CleanupOldCookies(rw, req)
|
|
||||||
|
|
||||||
deleted := rw.Result().Cookies()
|
|
||||||
if len(deleted) == 0 {
|
|
||||||
t.Fatal("expected deletion cookies for the other domains")
|
|
||||||
}
|
|
||||||
for _, c := range deleted {
|
|
||||||
if c.Path != tc.wantPath {
|
|
||||||
t.Errorf("deletion cookie %s for domain %s has Path %q, want %q", c.Name, c.Domain, c.Path, tc.wantPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+173
-44
@@ -41,6 +41,24 @@ func TestLRUStrategy_OnRemove_CoverageBoost(t *testing.T) {
|
|||||||
strategy.OnRemove("")
|
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
|
// PROFILING MANAGER TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -260,6 +278,82 @@ 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
|
// SESSION MANAGER TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -732,53 +826,11 @@ func TestValidateTemplateSecure_CoverageBoost(t *testing.T) {
|
|||||||
template: "{{.Unknown}}",
|
template: "{{.Unknown}}",
|
||||||
shouldError: true,
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateTemplateSecure(tt.template, nil)
|
err := validateTemplateSecure(tt.template)
|
||||||
if tt.shouldError && err == nil {
|
if tt.shouldError && err == nil {
|
||||||
t.Errorf("Expected error for template: %s", tt.template)
|
t.Errorf("Expected error for template: %s", tt.template)
|
||||||
}
|
}
|
||||||
@@ -921,6 +973,82 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
|
|||||||
// CHUNK MANAGER TESTS
|
// CHUNK MANAGER TESTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test GetSessionCount
|
||||||
|
count := cm.GetSessionCount()
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Expected 0 sessions, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test GetMemoryStats
|
||||||
|
stats := cm.GetMemoryStats()
|
||||||
|
if stats == nil {
|
||||||
|
t.Fatal("Expected non-nil stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify expected keys exist
|
||||||
|
if _, ok := stats["active_sessions"]; !ok {
|
||||||
|
t.Error("Expected active_sessions key in stats")
|
||||||
|
}
|
||||||
|
if _, ok := stats["max_sessions"]; !ok {
|
||||||
|
t.Error("Expected max_sessions key in stats")
|
||||||
|
}
|
||||||
|
if _, ok := stats["bytes_allocated"]; !ok {
|
||||||
|
t.Error("Expected bytes_allocated key in stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test CanCreateSession - should be true initially
|
||||||
|
canCreate, err := cm.CanCreateSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !canCreate {
|
||||||
|
t.Error("Expected CanCreateSession to return true when empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
|
||||||
|
logger := NewLogger("debug")
|
||||||
|
cm := NewChunkManager(logger)
|
||||||
|
if cm == nil {
|
||||||
|
t.Fatal("Expected non-nil ChunkManager")
|
||||||
|
}
|
||||||
|
defer cm.Shutdown()
|
||||||
|
|
||||||
|
// Test EmergencyCleanup - should not panic on empty session map
|
||||||
|
cm.EmergencyCleanup()
|
||||||
|
|
||||||
|
// Verify no sessions exist
|
||||||
|
if cm.GetSessionCount() != 0 {
|
||||||
|
t.Error("Expected 0 sessions after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
||||||
logger := NewLogger("debug")
|
logger := NewLogger("debug")
|
||||||
cm := NewChunkManager(logger)
|
cm := NewChunkManager(logger)
|
||||||
@@ -932,7 +1060,8 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
|
|||||||
// Test CleanupExpiredSessions - should not panic on empty session map
|
// Test CleanupExpiredSessions - should not panic on empty session map
|
||||||
cm.CleanupExpiredSessions()
|
cm.CleanupExpiredSessions()
|
||||||
|
|
||||||
if len(cm.sessionMap) != 0 {
|
// Verify no sessions exist
|
||||||
|
if cm.GetSessionCount() != 0 {
|
||||||
t.Error("Expected 0 sessions after cleanup")
|
t.Error("Expected 0 sessions after cleanup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-22
@@ -151,11 +151,9 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
|
|||||||
// Configure custom claim names
|
// Configure custom claim names
|
||||||
ts.tOidc.roleClaimName = "custom_roles"
|
ts.tOidc.roleClaimName = "custom_roles"
|
||||||
|
|
||||||
// Create token with malformed role claim (non-string, non-array scalar).
|
// Create token with malformed role claim (not an array)
|
||||||
// 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{}{
|
claims := map[string]interface{}{
|
||||||
"custom_roles": 12345,
|
"custom_roles": "this-should-be-an-array",
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
|
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims)
|
||||||
@@ -163,12 +161,15 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create test token: %v", err)
|
t.Fatalf("Failed to create test token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("Expected no error for malformed role claim, got %v", err)
|
t.Error("Expected error for malformed role claim, got nil")
|
||||||
}
|
}
|
||||||
if len(groups) != 0 || len(roles) != 0 {
|
|
||||||
t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles)
|
// Check error message contains the custom claim name
|
||||||
|
expectedError := "custom_roles claim is not an array"
|
||||||
|
if err.Error() != expectedError {
|
||||||
|
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +191,15 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create test token: %v", err)
|
t.Fatalf("Failed to create test token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("Expected no error for malformed group claim, got %v", err)
|
t.Error("Expected error for malformed group claim, got nil")
|
||||||
}
|
}
|
||||||
if len(groups) != 0 || len(roles) != 0 {
|
|
||||||
t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles)
|
// Check error message contains the custom claim name
|
||||||
|
expectedError := "custom_groups claim is not an array"
|
||||||
|
if err.Error() != expectedError {
|
||||||
|
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,10 +328,9 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
|
|||||||
t.Errorf("Unexpected error: %v", err)
|
t.Errorf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// String elements kept, numeric scalars stringified (R105, matching R102
|
// Should only extract string elements
|
||||||
// numeric-tolerance), only non-scalars (here: bool) dropped.
|
if !stringSliceEqual(roles, []string{"role1", "role2"}) {
|
||||||
if !stringSliceEqual(roles, []string{"role1", "12345", "role2"}) {
|
t.Errorf("Expected roles [role1 role2], got %v", roles)
|
||||||
t.Errorf("Expected roles [role1 12345 role2], got %v", roles)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,9 +357,8 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
|
|||||||
t.Errorf("Unexpected error: %v", err)
|
t.Errorf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// String elements kept, numeric scalars stringified (R105); only
|
// Should only extract string elements
|
||||||
// non-scalars (here: nil) dropped.
|
if !stringSliceEqual(groups, []string{"group1", "group2"}) {
|
||||||
if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) {
|
t.Errorf("Expected groups [group1 group2], got %v", groups)
|
||||||
t.Errorf("Expected groups [group1 group2 3.14], got %v", groups)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,20 +298,13 @@ strictAudienceValidation: true
|
|||||||
```
|
```
|
||||||
Look for `introspection_endpoint`
|
Look for `introspection_endpoint`
|
||||||
|
|
||||||
2. **Configure the endpoint manually:**
|
2. **Disable required introspection (less secure):**
|
||||||
```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
|
```yaml
|
||||||
allowOpaqueTokens: true
|
allowOpaqueTokens: true
|
||||||
requireTokenIntrospection: false # Falls back to ID token
|
requireTokenIntrospection: false # Falls back to ID token
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Use JWT access tokens instead** (recommended)
|
3. **Use JWT access tokens instead** (recommended)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-20
@@ -133,10 +133,7 @@ treats every one of these as a first-class concern.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Default state | `enableBearerAuth=false` | Bearer is opt-in; existing deployments observe no change. |
|
| 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. |
|
| 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. |
|
| Token format | JWT only (3 segments, JOSE-encoded). Opaque tokens are not accepted on the bearer path. | Matches the validation pipeline; opaque tokens require introspection only and bypass JWT-specific defences. |
|
||||||
| 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. |
|
| `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. |
|
| `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. |
|
| Token type | ID tokens are explicitly rejected (`nonce` claim, `typ: at+jwt`, `token_use=id`, scope/aud heuristics — reuses the existing `detectTokenType` helper). | ID tokens are not API credentials; treating them as such is classic token confusion. |
|
||||||
@@ -144,12 +141,12 @@ treats every one of these as a first-class concern.
|
|||||||
| `iat` upper-age | Rejects tokens older than `maxTokenAgeSeconds` (default 24h). | Bounds clock-manipulation / forever-token abuse, even if `exp` is far in the future. |
|
| `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 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. |
|
| Identifier sanitisation | Length cap (`maxIdentifierLength`, default 256). Rejects control chars, Unicode bidi-overrides (U+202A–U+202E, U+2066–U+2069), and the delimiters `, ; =`. | Defence in depth against downstream header injection / log injection / admin-UI spoofing. |
|
||||||
| JTI replay marking | No request path writes an access-token JTI to a replay cache (the only JTI replay check left is for backchannel logout tokens). Revocation still works without it: `RevokeToken` (called from logout) blacklists the raw token and, for a JWT, its `jti`; both the JWT and the opaque bearer path check that blacklist — raw token always, plus `jti` when the introspection response carries one — before accepting the token. | Revocation without replay-detection overhead: a legitimately reused, non-revoked bearer token is never falsely flagged as a replay. |
|
| JTI replay marking | Bearer path skips the JTI **Set** (so the same token can be reused until `exp`) but the **Get** stays active. | Allows legitimate bearer reuse without false-positive replay detection; revoked tokens (added to the blacklist by `RevokeToken`) still fail immediately. |
|
||||||
| Mixed bearer + cookie | **Cookie wins by default.** Flip to bearer-wins with `bearerOverridesCookie=true`. | Safer against browser/extension/proxy bearer injection scenarios. The cookie is the authoritative authenticator when present. |
|
| 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. |
|
| `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. |
|
| 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. |
|
| Per-IP throttle | After `bearerFailureThreshold` consecutive 401s from one source IP within `bearerFailureWindowSeconds`, further bearer requests from that IP return `429 Too Many Requests` + `Retry-After` for `bearerFailurePenaltySeconds`. | Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. |
|
||||||
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Only an HTTP 200 response with `active=false` returns `401`; any other endpoint failure (network error, or any other status including 401/403/408/429) returns `503`. | RFC 7662 §2.3 defines a 401/403 from the introspection endpoint as the resource's own client credentials being rejected, not a statement about the presented token, so it must never be read as "token revoked". |
|
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Endpoint failure returns `503` (distinguishes infra outage from credential rejection). | Real-time revocation for high-assurance environments. Adds per-request IdP latency. |
|
||||||
| Response shape | `401 Unauthorized` with generic body. `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3 (toggleable via `bearerEmitWWWAuthenticate`). `403` for roles/groups denial. `429` for throttle. `503` for introspection-endpoint outage. | Auditable from spec to code; reason categories never leak into the response body. |
|
| 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. |
|
| Logging | Failure reason + identifier hash (SHA-256 truncated to 8 hex chars) logged at debug. Raw tokens are never logged. | Audit trail without secrets-in-logs. |
|
||||||
|
|
||||||
@@ -163,13 +160,12 @@ treats every one of these as a first-class concern.
|
|||||||
| `stripAuthorizationHeader` | `true` | Remove the `Authorization` header before forwarding to the backend. Disable only when a downstream needs to re-verify the bearer. |
|
| `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. |
|
| `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. |
|
| `bearerOverridesCookie` | `false` | Cookie wins when both are present (default). Set `true` for the AWS/GCP/Kubernetes bearer-wins convention. |
|
||||||
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age. `0` and unset both map to the 24h default — there is no value that disables the check. A JWT or opaque-introspection response with no `iat` is rejected once this bound is in effect, which it always is. |
|
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age (24h). Set `0` to disable the check (not recommended). |
|
||||||
| `maxIdentifierLength` | `256` | Length cap for the post-sanitisation identifier. |
|
| `maxIdentifierLength` | `256` | Length cap for the post-sanitisation identifier. |
|
||||||
| `bearerFailureThreshold` | `20` | Consecutive 401s from one IP that trip the throttle. |
|
| `bearerFailureThreshold` | `20` | Consecutive 401s from one IP that trip the throttle. |
|
||||||
| `bearerFailureWindowSeconds` | `60` | Rolling window over which 401s are counted. |
|
| `bearerFailureWindowSeconds` | `60` | Rolling window over which 401s are counted. |
|
||||||
| `bearerFailurePenaltySeconds` | `60` | Duration of the 429 penalty box after the threshold trips. |
|
| `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. |
|
| `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
|
## What the bearer path does NOT do
|
||||||
|
|
||||||
@@ -177,11 +173,8 @@ treats every one of these as a first-class concern.
|
|||||||
iteration. Browser SPAs that want to attach a bearer to fetch calls work
|
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
|
if your backend treats them as machine clients, but the spec defaults are
|
||||||
tuned for service-to-service traffic.
|
tuned for service-to-service traffic.
|
||||||
- **Opaque access tokens by default.** Tokens must be JWTs unless both
|
- **Opaque access tokens.** Tokens must be JWTs. Introspection is a
|
||||||
`requireTokenIntrospection=true` and `allowOpaqueTokens=true` are set. For a
|
revocation overlay on top of JWT verification, not a substitute for it.
|
||||||
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
|
- **`email_verified` enforcement.** The bearer path rejects `email` as the
|
||||||
identifier claim at startup precisely because `email_verified` is not
|
identifier claim at startup precisely because `email_verified` is not
|
||||||
enforced in this iteration. Adding human-user bearer support is a
|
enforced in this iteration. Adding human-user bearer support is a
|
||||||
@@ -203,11 +196,6 @@ treats every one of these as a first-class concern.
|
|||||||
- **Enable `requireTokenIntrospection`** if your IdP supports it and
|
- **Enable `requireTokenIntrospection`** if your IdP supports it and
|
||||||
revocation latency matters. Bearer-path introspection caches results for
|
revocation latency matters. Bearer-path introspection caches results for
|
||||||
a short window per token.
|
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
|
- **Monitor 429s.** Sustained 429 traffic indicates either a buggy client
|
||||||
loop or an active credential-stuffing attempt. The throttle is your
|
loop or an active credential-stuffing attempt. The throttle is your
|
||||||
primary signal for both.
|
primary signal for both.
|
||||||
@@ -234,8 +222,8 @@ treats every one of these as a first-class concern.
|
|||||||
| Multi-audience token without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
| Multi-audience token without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||||
| Detected as ID token | 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"` |
|
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||||
| Introspection returns HTTP 200 with `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
| Introspection reports `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||||
| Introspection endpoint HTTP failure (network error, or any non-200 status including 401/403/408/429) | 503 | `Service Unavailable` | (none) |
|
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) |
|
||||||
| Identifier claim missing / empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
| Identifier claim missing / empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||||
| Identifier fails sanitisation | 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>` |
|
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <bearerFailurePenaltySeconds>` |
|
||||||
|
|||||||
+29
-171
@@ -14,6 +14,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
|
|||||||
- [Security Headers](#security-headers)
|
- [Security Headers](#security-headers)
|
||||||
- [Scope Configuration](#scope-configuration)
|
- [Scope Configuration](#scope-configuration)
|
||||||
- [Advanced Options](#advanced-options)
|
- [Advanced Options](#advanced-options)
|
||||||
|
- [Standalone binary (oidcgate)](#standalone-binary-oidcgate)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,11 +97,6 @@ For each token / revocation request the plugin builds a JWS with:
|
|||||||
|
|
||||||
When `clientAuthMethod: private_key_jwt`, `clientSecret` is optional.
|
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:**
|
**Example — inline PEM:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -183,10 +179,9 @@ clientSecret: your-client-secret
|
|||||||
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
|
| `logLevel` | string | `info` | Logging verbosity (`debug`, `info`, `error`) |
|
||||||
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
|
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
|
||||||
| `rateLimit` | int | `100` | Maximum requests per second |
|
| `rateLimit` | int | `100` | Maximum requests per second |
|
||||||
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
|
| `excludedURLs` | []string | none | Paths that bypass authentication |
|
||||||
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. See [Logout Behavior](#logout-behavior) for what happens at logout when this is set. |
|
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
|
||||||
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
|
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
|
||||||
| `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 |
|
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
|
||||||
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
|
| `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). |
|
| `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). |
|
||||||
@@ -194,41 +189,6 @@ clientSecret: your-client-secret
|
|||||||
| `clientAssertionKeyPath` | string | none | Path to PEM private key on disk for `private_key_jwt`. Mutually exclusive with `clientAssertionPrivateKey`. |
|
| `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`. |
|
| `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`. |
|
| `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
|
### TLS Termination at Load Balancer
|
||||||
|
|
||||||
@@ -275,8 +235,7 @@ SSE / WebSocket connection.
|
|||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `audience` | string | `clientID`, or `resource` if set | Expected audience for access token validation |
|
| `audience` | string | `clientID` | 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 |
|
| `strictAudienceValidation` | bool | `false` | Reject sessions with audience mismatch |
|
||||||
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
| `allowOpaqueTokens` | bool | `false` | Enable opaque token support via RFC 7662 |
|
||||||
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
| `requireTokenIntrospection` | bool | `false` | Require introspection for opaque tokens |
|
||||||
@@ -288,16 +247,6 @@ audience: "https://my-api.example.com"
|
|||||||
strictAudienceValidation: true
|
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
|
#### Opaque Token Support
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -312,57 +261,6 @@ strictAudienceValidation: true
|
|||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
||||||
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
| `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
|
### Bearer-token (M2M) authentication
|
||||||
|
|
||||||
@@ -378,7 +276,7 @@ guidance.
|
|||||||
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
|
| `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. |
|
| `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. |
|
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
|
||||||
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). `0` and unset both map to the 24h default; no value disables the check, and a bearer token or opaque-introspection response without `iat` is rejected. |
|
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). 0 disables the check. |
|
||||||
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
||||||
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
||||||
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
||||||
@@ -395,7 +293,6 @@ guidance.
|
|||||||
| `maxRefreshTokenAgeSeconds` | int | `21600` | Heuristic max age (in seconds) of a stored refresh token. Once exceeded, requests treat the RT as expired up front (returns 401 to AJAX, triggers full re-auth on navigations) instead of grant-spamming the IdP with `invalid_grant` retries. IdPs do not advertise RT TTL on the wire, so this is intentionally a conservative heuristic — tune to match your provider. Set `0` to disable. Default `21600` (6h). |
|
| `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 |
|
| `cookieDomain` | string | auto-detected | Domain for session cookies |
|
||||||
| `cookiePrefix` | string | `_oidc_raczylo_` | Prefix for cookie names |
|
| `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
|
### Multi-Subdomain Setup
|
||||||
|
|
||||||
@@ -535,77 +432,22 @@ minimalHeaders: true # Only forwards X-Forwarded-User
|
|||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
- name: "X-User-Email"
|
- name: "X-User-Email"
|
||||||
value: "{{.Claims.email}}"
|
value: "{{{{.Claims.email}}}}"
|
||||||
- name: "X-User-ID"
|
- name: "X-User-ID"
|
||||||
value: "{{.Claims.sub}}"
|
value: "{{{{.Claims.sub}}}}"
|
||||||
- name: "Authorization"
|
- name: "Authorization"
|
||||||
value: "Bearer {{.AccessToken}}"
|
value: "Bearer {{{{.AccessToken}}}}"
|
||||||
- name: "X-User-Roles"
|
- 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:**
|
**Template Variables:**
|
||||||
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
|
- `{{.Claims.field}}` - ID token claims
|
||||||
- `{{.AccessToken}}` - Raw access token
|
- `{{.AccessToken}}` - Raw access token
|
||||||
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
|
- `{{.IdToken}}` - Raw ID token
|
||||||
- `{{.RefreshToken}}` - Raw refresh token
|
- `{{.RefreshToken}}` - Raw refresh token
|
||||||
|
|
||||||
**Important — file provider only:** Traefik's file provider runs every dynamic
|
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -823,3 +665,19 @@ sessionEncryptionKey: ${OIDC_SECRET_API}
|
|||||||
# Good
|
# Good
|
||||||
sessionEncryptionKey: ${OIDC_SECRET_SVC}
|
sessionEncryptionKey: ${OIDC_SECRET_SVC}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standalone binary (oidcgate)
|
||||||
|
|
||||||
|
If you don't run Traefik, the same configuration shape documented above
|
||||||
|
works for the [`oidcgate`](OIDCGATE.md) standalone forward-auth daemon
|
||||||
|
under `cmd/oidcgate`. Three extra top-level keys (`listen`, `authPath`,
|
||||||
|
`startPath`) configure the daemon itself; everything else maps 1:1 onto
|
||||||
|
the `traefikoidc.Config` fields documented in this reference.
|
||||||
|
|
||||||
|
See [`docs/OIDCGATE.md`](OIDCGATE.md) for the full daemon guide including
|
||||||
|
nginx, Caddy, Traefik ForwardAuth, HAProxy and Envoy wiring snippets,
|
||||||
|
the `OIDCGATE_*` environment-variable inventory, the security posture
|
||||||
|
(X-Forwarded-Uri sanitisation, excludedURLs guardrail), and how to layer
|
||||||
|
M2M [bearer-token auth](BEARER_AUTH.md) on the same daemon.
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# oidcgate — standalone OIDC forward-auth daemon
|
||||||
|
|
||||||
|
`oidcgate` is a single binary that exposes the same OIDC middleware that
|
||||||
|
powers the Traefik plugin as a forward-auth daemon for nginx, Caddy,
|
||||||
|
Traefik ForwardAuth, HAProxy, and Envoy `ext_authz_http`.
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Build](#build)
|
||||||
|
- [Run](#run)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [YAML file](#yaml-file)
|
||||||
|
- [Environment-variable overrides](#environment-variable-overrides)
|
||||||
|
- [Endpoints](#endpoints)
|
||||||
|
- [Reverse-proxy snippets](#reverse-proxy-snippets)
|
||||||
|
- [nginx (`auth_request`)](#nginx-auth_request)
|
||||||
|
- [Caddy (`forward_auth`)](#caddy-forward_auth)
|
||||||
|
- [Traefik (`ForwardAuth`)](#traefik-forwardauth)
|
||||||
|
- [HAProxy](#haproxy)
|
||||||
|
- [Envoy (`ext_authz_http`)](#envoy-ext_authz_http)
|
||||||
|
- [Security posture](#security-posture)
|
||||||
|
- [Bearer-token (M2M) auth on the same daemon](#bearer-token-m2m-auth-on-the-same-daemon)
|
||||||
|
- [Operational guidance](#operational-guidance)
|
||||||
|
- [Debugging](#debugging)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o oidcgate ./cmd/oidcgate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./oidcgate --config /etc/oidcgate/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon parses `--config`, loads YAML, applies any `OIDCGATE_*` env-var
|
||||||
|
overrides, validates the result, and binds to `listen`. On SIGINT/SIGTERM it
|
||||||
|
calls `http.Server.Shutdown` with a 15s deadline, draining in-flight requests.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### YAML file
|
||||||
|
|
||||||
|
The OIDC subtree of the config maps 1:1 onto the [`traefikoidc.Config`](CONFIGURATION.md)
|
||||||
|
struct — every field documented under "Configuration Reference" works here
|
||||||
|
verbatim. Three extra top-level keys configure the daemon itself:
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | _required_ | TCP address (e.g. `:8080`, `127.0.0.1:8080`). |
|
||||||
|
| `authPath` | `/oauth2/auth` | Silent-probe endpoint (used by nginx `auth_request`). |
|
||||||
|
| `startPath` | `/oauth2/start` | Visible sign-in endpoint. |
|
||||||
|
|
||||||
|
Minimal example (see [`examples/oidcgate.yaml`](../examples/oidcgate.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen: ":8080"
|
||||||
|
providerURL: "https://accounts.google.com"
|
||||||
|
clientID: "your-client-id"
|
||||||
|
clientSecret: "your-client-secret"
|
||||||
|
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested structs (`redis:`, `securityHeaders:`, `dynamicClientRegistration:`)
|
||||||
|
round-trip cleanly through YAML — same shape as in `.traefik.yml`.
|
||||||
|
|
||||||
|
### Environment-variable overrides
|
||||||
|
|
||||||
|
Any of the following scalar fields can be overridden at runtime by an
|
||||||
|
`OIDCGATE_<UPPER_SNAKE_CASE>` environment variable. The env var wins over
|
||||||
|
the YAML value when set and non-empty. Intended for secret injection
|
||||||
|
(K8s `valueFrom.secretKeyRef`, systemd `EnvironmentFile=`, etc.).
|
||||||
|
|
||||||
|
| YAML key | Env var |
|
||||||
|
|---|---|
|
||||||
|
| `listen` | `OIDCGATE_LISTEN` |
|
||||||
|
| `authPath` | `OIDCGATE_AUTH_PATH` |
|
||||||
|
| `startPath` | `OIDCGATE_START_PATH` |
|
||||||
|
| `providerURL` | `OIDCGATE_PROVIDER_URL` |
|
||||||
|
| `clientID` | `OIDCGATE_CLIENT_ID` |
|
||||||
|
| `clientSecret` | `OIDCGATE_CLIENT_SECRET` |
|
||||||
|
| `audience` | `OIDCGATE_AUDIENCE` |
|
||||||
|
| `callbackURL` | `OIDCGATE_CALLBACK_URL` |
|
||||||
|
| `logoutURL` | `OIDCGATE_LOGOUT_URL` |
|
||||||
|
| `postLogoutRedirectURI` | `OIDCGATE_POST_LOGOUT_REDIRECT_URI` |
|
||||||
|
| `sessionEncryptionKey` | `OIDCGATE_SESSION_ENCRYPTION_KEY` |
|
||||||
|
| `cookiePrefix` | `OIDCGATE_COOKIE_PREFIX` |
|
||||||
|
| `cookieDomain` | `OIDCGATE_COOKIE_DOMAIN` |
|
||||||
|
| `logLevel` | `OIDCGATE_LOG_LEVEL` |
|
||||||
|
| `revocationURL` | `OIDCGATE_REVOCATION_URL` |
|
||||||
|
| `oidcEndSessionURL` | `OIDCGATE_OIDC_END_SESSION_URL` |
|
||||||
|
| `userIdentifierClaim` | `OIDCGATE_USER_IDENTIFIER_CLAIM` |
|
||||||
|
| `groupClaimName` | `OIDCGATE_GROUP_CLAIM_NAME` |
|
||||||
|
| `roleClaimName` | `OIDCGATE_ROLE_CLAIM_NAME` |
|
||||||
|
| `clientAuthMethod` | `OIDCGATE_CLIENT_AUTH_METHOD` |
|
||||||
|
| `clientAssertionPrivateKey` | `OIDCGATE_CLIENT_ASSERTION_PRIVATE_KEY` |
|
||||||
|
| `clientAssertionKeyPath` | `OIDCGATE_CLIENT_ASSERTION_KEY_PATH` |
|
||||||
|
| `clientAssertionKeyID` | `OIDCGATE_CLIENT_ASSERTION_KEY_ID` |
|
||||||
|
| `clientAssertionAlg` | `OIDCGATE_CLIENT_ASSERTION_ALG` |
|
||||||
|
| `caCertPath` | `OIDCGATE_CA_CERT_PATH` |
|
||||||
|
| `caCertPEM` | `OIDCGATE_CA_CERT_PEM` |
|
||||||
|
|
||||||
|
Nested-struct fields (Redis, security headers, DCR) are YAML-only — set
|
||||||
|
them in the config file, not via env.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/oauth2/auth` | GET | Silent probe — `200` if authenticated, `401` if not. Never returns `302`; the middleware's redirect-to-IdP is rewritten in-flight to `401` with the original `Location` carried as `X-Auth-Redirect`. |
|
||||||
|
| `/oauth2/start` | GET | Visible sign-in — `302` to the IdP authorize URL. Accepts `?rd=<safe-path>` (or honours `X-Forwarded-Uri`) for the post-login redirect target. |
|
||||||
|
| `/oauth2/callback` | GET | IdP `code`+`state` exchange. Path is configurable via `callbackURL`. |
|
||||||
|
| `/oauth2/logout` | GET/POST | Terminates the session. Path is configurable via `logoutURL`. Honours `oidcEndSessionURL` for RP-initiated logout. |
|
||||||
|
| `/healthz` | GET | Liveness — `200` while the process is alive. |
|
||||||
|
| `/readyz` | GET | Readiness — `200` once the OIDC discovery document has been fetched, otherwise `503`. |
|
||||||
|
|
||||||
|
## Reverse-proxy snippets
|
||||||
|
|
||||||
|
### nginx (`auth_request`)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location = /oauth2/auth {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://oidcgate:8080;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location @oidc_signin {
|
||||||
|
return 302 /oauth2/start?rd=$scheme://$host$request_uri;
|
||||||
|
}
|
||||||
|
location /oauth2/ {
|
||||||
|
proxy_pass http://oidcgate:8080;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
auth_request /oauth2/auth;
|
||||||
|
error_page 401 = @oidc_signin;
|
||||||
|
auth_request_set $user $upstream_http_x_forwarded_user;
|
||||||
|
auth_request_set $email $upstream_http_x_forwarded_email;
|
||||||
|
proxy_set_header X-Forwarded-User $user;
|
||||||
|
proxy_set_header X-Forwarded-Email $email;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy (`forward_auth`)
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
example.com {
|
||||||
|
forward_auth oidcgate:8080 {
|
||||||
|
uri /oauth2/auth
|
||||||
|
copy_headers X-Forwarded-User X-Forwarded-Email
|
||||||
|
@denied status 401
|
||||||
|
handle_response @denied {
|
||||||
|
redir /oauth2/start?rd={http.request.uri} 302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle /oauth2/* {
|
||||||
|
reverse_proxy oidcgate:8080
|
||||||
|
}
|
||||||
|
reverse_proxy backend:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traefik (`ForwardAuth`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
oidcgate:
|
||||||
|
forwardAuth:
|
||||||
|
address: "http://oidcgate:8080/oauth2/auth"
|
||||||
|
authResponseHeaders:
|
||||||
|
- X-Forwarded-User
|
||||||
|
- X-Forwarded-Email
|
||||||
|
```
|
||||||
|
|
||||||
|
Traefik can follow the `X-Auth-Redirect` value via a chained `redirectScheme`
|
||||||
|
middleware, or you can configure the upstream router to redirect `401` →
|
||||||
|
`/oauth2/start` directly.
|
||||||
|
|
||||||
|
### HAProxy
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend fe_https
|
||||||
|
bind *:443 ssl crt /etc/haproxy/certs/site.pem
|
||||||
|
http-request set-var(req.orig_uri) path
|
||||||
|
http-request send-spoe-group oidc auth-check # or use lua/SPOE; simplest is the lua snippet below
|
||||||
|
|
||||||
|
# The simpler pattern: dispatch /oauth2/* to oidcgate, everything else
|
||||||
|
# goes through a Lua filter that issues a sub-request to /oauth2/auth.
|
||||||
|
acl is_oidc_endpoint path_beg /oauth2/
|
||||||
|
use_backend be_oidcgate if is_oidc_endpoint
|
||||||
|
default_backend be_app
|
||||||
|
|
||||||
|
backend be_oidcgate
|
||||||
|
server oidcgate1 oidcgate:8080
|
||||||
|
|
||||||
|
backend be_app
|
||||||
|
server app1 backend:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
HAProxy does not have a first-class `auth_request` equivalent in pure
|
||||||
|
config — the canonical patterns are SPOE (Stream Processing Offload Engine),
|
||||||
|
a Lua filter that issues `/oauth2/auth` and reads the response, or a
|
||||||
|
sidecar that does the dance. Reach for SPOE for high-throughput
|
||||||
|
production; Lua is simpler for low-volume.
|
||||||
|
|
||||||
|
### Envoy (`ext_authz_http`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http_filters:
|
||||||
|
- name: envoy.filters.http.ext_authz
|
||||||
|
typed_config:
|
||||||
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
|
||||||
|
transport_api_version: V3
|
||||||
|
http_service:
|
||||||
|
server_uri:
|
||||||
|
uri: http://oidcgate:8080
|
||||||
|
cluster: oidcgate
|
||||||
|
timeout: 2s
|
||||||
|
path_prefix: /oauth2/auth
|
||||||
|
authorization_request:
|
||||||
|
allowed_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: cookie
|
||||||
|
- exact: authorization
|
||||||
|
- prefix: x-forwarded-
|
||||||
|
authorization_response:
|
||||||
|
allowed_upstream_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: x-forwarded-user
|
||||||
|
- exact: x-forwarded-email
|
||||||
|
allowed_client_headers:
|
||||||
|
patterns:
|
||||||
|
- exact: x-auth-redirect
|
||||||
|
- exact: set-cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
On `401`, the `X-Auth-Redirect` header is surfaced to the downstream client
|
||||||
|
via `allowed_client_headers`. A small Envoy `router` filter or
|
||||||
|
`local_reply_config` rule can convert that into a browser-facing `302`
|
||||||
|
redirect to `/oauth2/start`.
|
||||||
|
|
||||||
|
## Security posture
|
||||||
|
|
||||||
|
- **`X-Forwarded-Uri` is sanitised.** The daemon forces
|
||||||
|
`TrustForwardedURI=true` so the middleware honours `X-Forwarded-Uri` for
|
||||||
|
the post-login redirect target. To prevent open redirects (CWE-601),
|
||||||
|
the value is rejected unless it is a safe same-origin path: must start
|
||||||
|
with `/`, must NOT start with `//` (protocol-relative), and must have
|
||||||
|
no scheme or host after parsing. Absolute URLs or anything that could
|
||||||
|
redirect off-origin falls through to `req.URL.RequestURI()`.
|
||||||
|
|
||||||
|
- **`excludedURLs` cannot bypass the daemon's own paths.** At config
|
||||||
|
load, the loader rejects any `excludedURLs` entry that is a prefix of
|
||||||
|
`authPath`, `startPath`, `callbackURL`, `logoutURL`, or the internal
|
||||||
|
sentinel path. A misconfiguration like `excludedURLs: ["/"]` (common
|
||||||
|
"allow all then add auth selectively" mistake) is rejected at startup
|
||||||
|
with a descriptive error.
|
||||||
|
|
||||||
|
- **`callbackURL` and `logoutURL` must be paths.** Absolute URLs are
|
||||||
|
rejected at config load — both because `http.ServeMux.Handle` panics
|
||||||
|
on non-`/` patterns and because the middleware's path-match would
|
||||||
|
silently fail.
|
||||||
|
|
||||||
|
- **`listen` is required.** Empty or missing `listen` is rejected at
|
||||||
|
startup rather than failing later at `net.Listen`.
|
||||||
|
|
||||||
|
- **Secrets via env vars.** `clientSecret` and `sessionEncryptionKey`
|
||||||
|
can be supplied via env vars instead of YAML so they don't end up on
|
||||||
|
disk if you use a secret manager.
|
||||||
|
|
||||||
|
## Bearer-token (M2M) auth on the same daemon
|
||||||
|
|
||||||
|
oidcgate uses the full `traefikoidc.Config` shape, so the bearer-token
|
||||||
|
M2M auth path documented in [`BEARER_AUTH.md`](BEARER_AUTH.md) works
|
||||||
|
out of the box. Add to your YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
enableBearerAuth: true
|
||||||
|
audience: "https://api.example.com"
|
||||||
|
bearerIdentifierClaim: "sub"
|
||||||
|
# stripAuthorizationHeader: true # default
|
||||||
|
# bearerOverridesCookie: false # default — cookie wins on collision
|
||||||
|
```
|
||||||
|
|
||||||
|
With this set, the daemon accepts both:
|
||||||
|
- Browser users hitting `/oauth2/auth` → cookie session flow.
|
||||||
|
- API clients calling the protected backend with `Authorization: Bearer <jwt>`
|
||||||
|
→ bearer validation, principal headers, no session.
|
||||||
|
|
||||||
|
The bearer path doesn't go through `/oauth2/auth` separately — it's
|
||||||
|
applied by the middleware on every request the daemon sees, before the
|
||||||
|
cookie session check. See [BEARER_AUTH.md](BEARER_AUTH.md) for the full
|
||||||
|
threat model, identifier sanitisation rules, and failure-response
|
||||||
|
matrix.
|
||||||
|
|
||||||
|
## Operational guidance
|
||||||
|
|
||||||
|
- **Run behind a fronting proxy on a private network.** The daemon does
|
||||||
|
not terminate TLS. Put it on a localhost socket or a private subnet
|
||||||
|
reachable only from your nginx/Caddy/Traefik/HAProxy/Envoy.
|
||||||
|
- **`/healthz` and `/readyz` are unauthenticated** — correct for
|
||||||
|
Kubernetes liveness/readiness probes, but **do not expose them past a
|
||||||
|
load balancer**. Restrict via an ACL: nginx `allow 10.0.0.0/8; deny
|
||||||
|
all;`, Caddy `@health remote_ip 10.0.0.0/8`, k8s NetworkPolicy, or
|
||||||
|
your CNI of choice.
|
||||||
|
- **Multi-replica deployments** need a shared session store. Enable the
|
||||||
|
`redis:` block in the config (see [`docs/REDIS.md`](REDIS.md)) so
|
||||||
|
sessions survive a hop between replicas.
|
||||||
|
- **No built-in Prometheus metrics yet.** If you need request-level
|
||||||
|
visibility, take it from your fronting proxy's access logs — both
|
||||||
|
nginx and Envoy can tag `auth_request` / `ext_authz` outcomes.
|
||||||
|
- **Logs are minimal by default.** Set `logLevel: debug` while
|
||||||
|
bringing up a new deployment; raise to `info` (default) or higher
|
||||||
|
once stable. Debug logs include path-match decisions and metadata
|
||||||
|
refresh outcomes.
|
||||||
|
- **Graceful shutdown is 15s.** SIGINT or SIGTERM triggers
|
||||||
|
`http.Server.Shutdown(ctx)` with a 15-second deadline; in-flight
|
||||||
|
requests are allowed to complete. If your orchestrator's grace
|
||||||
|
period is shorter, requests can be cut mid-flight.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
- **Requests appear as `/__oidcgate_protected__` in middleware debug
|
||||||
|
logs.** This is the internal sentinel path used when `/oauth2/auth`
|
||||||
|
and `/oauth2/start` delegate into the traefikoidc middleware. The
|
||||||
|
upstream client never sees it; it only shows up in the middleware's
|
||||||
|
own `Debugf` output when `logLevel: debug` is set.
|
||||||
|
|
||||||
|
- **`/oauth2/auth` returns `401` with `X-Auth-Redirect` header on
|
||||||
|
unauthenticated requests.** This is the deliberate translation of the
|
||||||
|
middleware's `302` to make nginx `auth_request` work. The browser is
|
||||||
|
redirected via the fronting proxy's `error_page 401 = @oidc_signin;`
|
||||||
|
pattern, not by following the daemon's response directly.
|
||||||
|
|
||||||
|
- **`/readyz` stays `503` after startup.** The middleware fetches the
|
||||||
|
OIDC discovery document lazily on first request, so `/readyz` returns
|
||||||
|
`503` until at least one request has triggered metadata discovery.
|
||||||
|
Hit `/oauth2/auth` once after startup to warm it up — many K8s
|
||||||
|
setups achieve the same effect because the liveness probe already
|
||||||
|
goes through the proxy chain.
|
||||||
|
|
||||||
|
- **Cookie/session diagnostics.** With `logLevel: debug` the middleware
|
||||||
|
logs which session manager was selected (in-memory vs Redis), whether
|
||||||
|
cookies decrypted successfully, and the JWT validation outcome.
|
||||||
|
|
||||||
|
- **Open-redirect rejections are silent.** When the daemon ignores an
|
||||||
|
unsafe `X-Forwarded-Uri` value, it falls back to `req.URL.RequestURI()`
|
||||||
|
without logging. This is intentional (no recon signal) — if a user
|
||||||
|
reports "I keep landing on the wrong page after login", inspect
|
||||||
|
whether the upstream proxy is forwarding a non-canonical
|
||||||
|
`X-Forwarded-Uri` value.
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 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. |
|
|
||||||
+1
-6
@@ -735,14 +735,9 @@ spec:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
<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"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">audience</code></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">clientID</td>
|
||||||
<td class="py-2 px-3">Custom audience for access token validation</td>
|
<td class="py-2 px-3">Custom audience for access token validation</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">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">
|
<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"><code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">strictAudienceValidation</code></td>
|
||||||
<td class="py-2 px-3">false</td>
|
<td class="py-2 px-3">false</td>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
|||||||
|
# oidcgate — Standalone OIDC Forward-Auth Daemon (Tier 1)
|
||||||
|
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
**Status:** Design approved, pending implementation plan
|
||||||
|
**Scope:** Tier 1 only — forward-auth daemon. No reverse-proxy mode, no TLS termination, no metrics, no multi-tenancy.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Provide a standalone Go binary that exposes the existing `traefikoidc` OIDC middleware as a forward-auth daemon callable from nginx (`auth_request`), Caddy (`forward_auth`), Traefik (`ForwardAuth`), HAProxy, and Envoy (`ext_authz_http`). The library's public surface is **additive only**: no existing exported function signature changes; one optional `Config` field (`TrustForwardedURI`) and one new read-only accessor on `*TraefikOidc` are added. Existing Traefik plugin users see no behavior change unless they opt in to the new field.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Reverse-proxy mode (Tier 2 — separate effort if requested).
|
||||||
|
- TLS termination inside the daemon.
|
||||||
|
- Built-in Prometheus metrics.
|
||||||
|
- Multi-tenant routing (one binary serves one OIDC client config).
|
||||||
|
- oauth2-proxy CLI/flag compatibility.
|
||||||
|
- Docker image or goreleaser publishing as part of Tier 1.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
github.com/lukaszraczylo/traefikoidc (library, unchanged)
|
||||||
|
├── main.go (existing New/NewWithContext)
|
||||||
|
├── middleware.go (existing ServeHTTP + ~10 LoC patch)
|
||||||
|
└── cmd/
|
||||||
|
└── oidcgate/
|
||||||
|
├── main.go (entrypoint, flags, signal handling)
|
||||||
|
├── config.go (YAML loader + env-var override walker)
|
||||||
|
├── server.go (http.ServeMux wiring, listen loop)
|
||||||
|
├── endpoints.go (auth/start/callback/logout handlers)
|
||||||
|
├── success.go (synthetic success handler used as `next`)
|
||||||
|
├── interceptor.go (302→401 response-writer for /oauth2/auth)
|
||||||
|
├── health.go (/healthz, /readyz)
|
||||||
|
├── config_test.go
|
||||||
|
├── endpoints_test.go
|
||||||
|
└── interceptor_test.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process model
|
||||||
|
|
||||||
|
Single binary, single `*TraefikOidc` instance built at boot from YAML config, served on one listener port. Health endpoints on the same listener. Graceful shutdown on `SIGINT`/`SIGTERM` via `srv.Shutdown(ctx)` with a 15s deadline, after which context cancellation propagates into the existing goroutine manager.
|
||||||
|
|
||||||
|
## Endpoint Contract
|
||||||
|
|
||||||
|
All four endpoints share the listener. Paths are configurable; defaults shown.
|
||||||
|
|
||||||
|
| Endpoint | Default path | Method | Contract |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Auth probe** | `/oauth2/auth` | GET | Silent. `200 OK` + injected headers on success. `401` on failure (never `302`). Consumed by nginx `auth_request`, Traefik ForwardAuth, Caddy `forward_auth`, Envoy ext_authz_http. |
|
||||||
|
| **Sign-in** | `/oauth2/start` | GET | Always `302` to IdP `authorize` URL with `state`+`nonce`+PKCE. Reads target URL from `?rd=` query or `X-Forwarded-Uri` header. Hit by the browser after a `401` from `/oauth2/auth`. |
|
||||||
|
| **Callback** | `config.callbackURL` | GET | IdP `code`+`state` exchange. Existing `auth_flow.go` logic runs unchanged. On success → `302` to original URL. |
|
||||||
|
| **Logout** | `config.logoutURL` | GET/POST | Existing `logout.go` handler. Terminates session. Honors `oidcEndSessionURL` if configured. |
|
||||||
|
|
||||||
|
### Wiring (how each endpoint delegates)
|
||||||
|
|
||||||
|
All four handlers feed `(*TraefikOidc).ServeHTTP` after rewriting `req.URL.Path`:
|
||||||
|
|
||||||
|
- `/oauth2/callback` → rewrite to `config.callbackURL`, delegate. Middleware path-match at `middleware.go` triggers callback flow.
|
||||||
|
- `/oauth2/logout` → rewrite to `config.logoutURL`, delegate. Middleware logout path-match at the top of `ServeHTTP` triggers.
|
||||||
|
- `/oauth2/start` → rewrite `req.URL.Path` to the sentinel `/__oidcgate_protected__`, delegate. Middleware sees an unauthenticated GET on a protected path, emits the `302` to IdP. The redirect flows through naturally.
|
||||||
|
- `/oauth2/auth` → rewrite `req.URL.Path` to `/__oidcgate_protected__`, wrap `ResponseWriter` with an interceptor (see below), delegate.
|
||||||
|
|
||||||
|
The sentinel path `/__oidcgate_protected__` is chosen because it cannot collide with `callbackURL` / `logoutURL` / `/health*` path matches inside `ServeHTTP` and is not a likely user-configured `excludedURLs` entry. It is internal-only: clients never see it.
|
||||||
|
|
||||||
|
### Synthetic `next` handler
|
||||||
|
|
||||||
|
The middleware calls `t.next.ServeHTTP(rw, req)` at four sites (`middleware.go:174,185,187,592`) when the request is authenticated and should be forwarded. The daemon supplies a `next` that:
|
||||||
|
|
||||||
|
1. Writes `200 OK`.
|
||||||
|
2. Mirrors any `X-Forwarded-*` and templated headers that the middleware set on `req.Header` (e.g. `X-Forwarded-User` at `middleware.go:101,512`) onto the **response** headers, so proxies can capture them via `auth_request_set` / `authResponseHeaders`.
|
||||||
|
3. Writes empty body.
|
||||||
|
|
||||||
|
### 302 → 401 interceptor (`/oauth2/auth` only)
|
||||||
|
|
||||||
|
nginx `auth_request` cannot follow `302`s. For the silent endpoint, the daemon wraps the `ResponseWriter` such that:
|
||||||
|
|
||||||
|
- If the middleware writes status `302` (the IdP-redirect branch), the interceptor rewrites it to `401`.
|
||||||
|
- `Location` header from the swallowed `302` is preserved as `X-Auth-Redirect` on the `401` response (advisory; some proxies may surface it).
|
||||||
|
- `Set-Cookie` headers (state, PKCE, nonce) are preserved verbatim so the browser carries them into the subsequent `/oauth2/start` request.
|
||||||
|
- For any non-`302` status, the interceptor is a passthrough.
|
||||||
|
|
||||||
|
`/oauth2/start` does **not** wrap; the middleware's natural `302` flows through to the browser.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Source
|
||||||
|
|
||||||
|
- **File:** `--config /etc/oidcgate/config.yaml` (path overridable via flag).
|
||||||
|
- **Format:** YAML, unmarshalled into the existing `traefikoidc.Config` struct (`settings.go:39`) via `yaml.v3` (already a dependency in `go.mod`).
|
||||||
|
- **Migration from Traefik:** copy the `plugin.traefikoidc:` subtree out of `.traefik.yml` and add the daemon-specific top-level keys below.
|
||||||
|
- **Env-var overrides** (secrets in particular): after YAML unmarshal, walk the config struct. Any scalar string/int/bool field with a non-empty `OIDCGATE_<UPPER_SNAKE_CASE_FIELD>` env-var replaces the YAML value. Nested structs (`Redis`, `SecurityHeaders`, `DynamicClientRegistration`) stay YAML-only.
|
||||||
|
|
||||||
|
### Top-level oidcgate-specific keys
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen: ":8080" # required, listener address
|
||||||
|
authPath: "/oauth2/auth" # optional, default shown
|
||||||
|
startPath: "/oauth2/start" # optional, default shown
|
||||||
|
# all other keys = existing traefikoidc.Config fields
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
The existing validation inside `traefikoidc.NewWithContext` (`main.go:97`) runs unchanged. On any returned error, the daemon logs the error and exits non-zero.
|
||||||
|
|
||||||
|
## Library-Side Patches
|
||||||
|
|
||||||
|
Two additive changes, both default-off / read-only:
|
||||||
|
|
||||||
|
1. **`settings.go` + `middleware.go` (~10 LoC):** add `Config.TrustForwardedURI bool` (default `false`). When `true`, the post-login-redirect target captured during the "unauthenticated GET → 302 to IdP" branch is sourced from `req.Header.Get("X-Forwarded-Uri")` if non-empty, instead of from `req.URL`. The daemon sets `TrustForwardedURI = true` at config build time. Default-off preserves current Traefik plugin behavior exactly.
|
||||||
|
|
||||||
|
2. **`main.go` (~5 LoC):** add `func (t *TraefikOidc) Ready() bool` returning `true` once at least one successful OIDC metadata discovery fetch has populated the metadata cache. Read-only; no behavior change for existing consumers.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
parse flags
|
||||||
|
→ load YAML
|
||||||
|
→ apply env-var overrides
|
||||||
|
→ build synthetic success handler
|
||||||
|
→ call traefikoidc.New(ctx, success, cfg, "oidcgate") (validation happens here)
|
||||||
|
→ build mux (auth, start, callback, logout, healthz, readyz)
|
||||||
|
→ http.Server.ListenAndServe on cfg.listen
|
||||||
|
→ wait for SIGINT/SIGTERM
|
||||||
|
→ srv.Shutdown(15s ctx)
|
||||||
|
→ ctx cancel propagates to goroutine manager
|
||||||
|
→ exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`/readyz` returns `200` only once `traefikoidc.New` has returned without error **and** the first OIDC metadata discovery fetch has succeeded. Implementation: add a read-only accessor `func (t *TraefikOidc) Ready() bool` that returns `true` once the metadata cache has at least one successful discovery fetch. The daemon's `/readyz` handler calls this and returns `200` / `503` accordingly.
|
||||||
|
|
||||||
|
`/healthz` returns `200` as long as the process is alive.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `config_test.go` — YAML round-trip; env-var override precedence; validation pass-through.
|
||||||
|
- `endpoints_test.go` — `httptest.NewServer`-based scenarios:
|
||||||
|
- `/oauth2/auth` with no session → `401`.
|
||||||
|
- `/oauth2/auth` with valid session → `200` + `X-Forwarded-User` mirrored on response.
|
||||||
|
- `/oauth2/start` → `302` with valid IdP `authorize` URL incl. `state` and PKCE.
|
||||||
|
- `/oauth2/callback` → completes exchange, sets session, redirects to original URL.
|
||||||
|
- `/oauth2/logout` → clears session cookie.
|
||||||
|
- `interceptor_test.go` — middleware-emitted `302` becomes `401`; `Location` → `X-Auth-Redirect`; `Set-Cookie` preserved.
|
||||||
|
- Reuse existing mock IdP from `enhanced_mocks_test.go`. No new mock infra.
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Interceptor swallows a legitimate `302` from the middleware that isn't the IdP redirect. | Inspect: only intercept when `Location` matches `t.providerURL`'s authorize endpoint or when it points off-host. Test coverage in `interceptor_test.go`. |
|
||||||
|
| Library-side patch breaks current Traefik users. | New `TrustForwardedURI` defaults to `false`; existing path untouched when unset. |
|
||||||
|
| Env-var walker overreaches into nested structs. | Restrict to top-level scalar fields; document explicitly; nested structs stay YAML-only. |
|
||||||
|
| Path-rewrite trick hits a middleware path-comparison we didn't anticipate. | All four `t.next.ServeHTTP` sites verified at `middleware.go:174,185,187,592`. Endpoint tests exercise each path. |
|
||||||
|
|
||||||
|
## Out of Scope (Tier 2 candidates)
|
||||||
|
|
||||||
|
- Reverse-proxy mode (`httputil.ReverseProxy` as the configured `next`).
|
||||||
|
- TLS termination (`tls.Config`, ACME).
|
||||||
|
- Prometheus metrics endpoint.
|
||||||
|
- Multi-tenant routing.
|
||||||
|
- oauth2-proxy flag/env compatibility.
|
||||||
|
- Goreleaser binaries, Docker image, systemd unit.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `go build ./cmd/oidcgate` produces a working binary.
|
||||||
|
2. Existing test suite (`go test ./...`) still passes — zero regressions in the library.
|
||||||
|
3. New endpoint tests pass for all four endpoints against the mock IdP.
|
||||||
|
4. `oidcgate --config example.yaml` boots, serves `/healthz`, performs end-to-end OIDC flow against a real IdP (manual smoke test against e.g. a local Keycloak).
|
||||||
|
5. README section documents nginx, Caddy, and Traefik wiring examples.
|
||||||
+199
-101
@@ -7,9 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -54,7 +52,6 @@ type DynamicClientRegistrar struct {
|
|||||||
registrationResponse *ClientRegistrationResponse
|
registrationResponse *ClientRegistrationResponse
|
||||||
store DCRCredentialsStore // Storage backend for credentials
|
store DCRCredentialsStore // Storage backend for credentials
|
||||||
providerURL string
|
providerURL string
|
||||||
scopes []string // Runtime auth/scopes from the operator config (merged)
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,19 +103,6 @@ func (r *DynamicClientRegistrar) SetStore(store DCRCredentialsStore) {
|
|||||||
r.store = store
|
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
|
// RegisterClient performs dynamic client registration with the OIDC provider
|
||||||
// It first attempts to load existing credentials from storage 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.
|
// then registers a new client if no valid credentials exist.
|
||||||
@@ -157,10 +141,8 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
|||||||
|
|
||||||
// Validate the endpoint URL
|
// Validate the endpoint URL
|
||||||
if !strings.HasPrefix(endpoint, "https://") {
|
if !strings.HasPrefix(endpoint, "https://") {
|
||||||
// Allow http only for loopback/development endpoints. Use a
|
// Allow http only for localhost/development
|
||||||
// host-boundary check (not a prefix) so e.g.
|
if !strings.HasPrefix(endpoint, "http://localhost") && !strings.HasPrefix(endpoint, "http://127.0.0.1") {
|
||||||
// http://localhost.evil.com isn't treated as localhost.
|
|
||||||
if !isLoopbackRegistrationEndpoint(endpoint) {
|
|
||||||
return nil, fmt.Errorf("registration endpoint must use HTTPS for security")
|
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)
|
r.logger.Infof("Warning: using insecure HTTP for registration endpoint (development only): %s", endpoint)
|
||||||
@@ -221,19 +203,6 @@ func (r *DynamicClientRegistrar) RegisterClient(ctx context.Context, registratio
|
|||||||
return nil, fmt.Errorf("registration response missing client_id")
|
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)
|
r.logger.Infof("Successfully registered client with ID: %s", regResp.ClientID)
|
||||||
|
|
||||||
// Cache the response
|
// Cache the response
|
||||||
@@ -264,41 +233,11 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
|||||||
|
|
||||||
// Required: redirect_uris
|
// Required: redirect_uris
|
||||||
if len(metadata.RedirectURIs) > 0 {
|
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
|
reqData["redirect_uris"] = metadata.RedirectURIs
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("redirect_uris is required for client registration")
|
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
|
// Optional fields - only include if set
|
||||||
if len(metadata.ResponseTypes) > 0 {
|
if len(metadata.ResponseTypes) > 0 {
|
||||||
reqData["response_types"] = metadata.ResponseTypes
|
reqData["response_types"] = metadata.ResponseTypes
|
||||||
@@ -353,10 +292,8 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
|||||||
if metadata.TokenEndpointAuthMethod != "" {
|
if metadata.TokenEndpointAuthMethod != "" {
|
||||||
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
|
reqData["token_endpoint_auth_method"] = metadata.TokenEndpointAuthMethod
|
||||||
} else {
|
} else {
|
||||||
// Default must match the plugin's runtime client-auth default
|
// Default to client_secret_basic for confidential clients
|
||||||
// (client_secret_post, settings.go Config.Validate) so an IdP that
|
reqData["token_endpoint_auth_method"] = "client_secret_basic"
|
||||||
// enforces the registered method does not reject the token exchange.
|
|
||||||
reqData["token_endpoint_auth_method"] = "client_secret_post"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DefaultMaxAge > 0 {
|
if metadata.DefaultMaxAge > 0 {
|
||||||
@@ -373,13 +310,6 @@ func (r *DynamicClientRegistrar) buildRegistrationRequest() ([]byte, error) {
|
|||||||
|
|
||||||
if metadata.Scope != "" {
|
if metadata.Scope != "" {
|
||||||
reqData["scope"] = 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)
|
return json.Marshal(reqData)
|
||||||
@@ -398,19 +328,6 @@ func (r *DynamicClientRegistrar) areCredentialsValid(resp *ClientRegistrationRes
|
|||||||
return false
|
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
|
// Check if secret has expired
|
||||||
if resp.ClientSecretExpiresAt > 0 {
|
if resp.ClientSecretExpiresAt > 0 {
|
||||||
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
|
expiresAt := time.Unix(resp.ClientSecretExpiresAt, 0)
|
||||||
@@ -453,11 +370,26 @@ func (r *DynamicClientRegistrar) saveCredentialsToStore(ctx context.Context, res
|
|||||||
return r.saveCredentials(resp)
|
return r.saveCredentials(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteCredentialsFromStore removes credentials from the configured storage backend
|
||||||
|
// Falls back to legacy file-based deletion if no store is configured
|
||||||
|
func (r *DynamicClientRegistrar) deleteCredentialsFromStore(ctx context.Context) error {
|
||||||
|
// Use store if available
|
||||||
|
if r.store != nil {
|
||||||
|
return r.store.Delete(ctx, r.providerURL)
|
||||||
|
}
|
||||||
|
// Fallback to legacy file-based deletion
|
||||||
|
filePath := r.credentialsFilePath()
|
||||||
|
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// saveCredentials persists client credentials to a file (legacy method)
|
// saveCredentials persists client credentials to a file (legacy method)
|
||||||
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
|
func (r *DynamicClientRegistrar) saveCredentials(resp *ClientRegistrationResponse) error {
|
||||||
filePath := r.credentialsFilePath()
|
filePath := r.credentialsFilePath()
|
||||||
|
|
||||||
data, err := json.MarshalIndent(resp, "", " ") // #nosec G117 -- the registration response, including client_secret, is the credential this function persists
|
data, err := json.MarshalIndent(resp, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||||
}
|
}
|
||||||
@@ -492,20 +424,186 @@ func (r *DynamicClientRegistrar) loadCredentials() (*ClientRegistrationResponse,
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isLoopbackRegistrationEndpoint reports whether the (plaintext) registration
|
// UpdateClientRegistration updates an existing client registration using RFC 7592
|
||||||
// endpoint targets a loopback host, matching the hostname boundary (not a
|
// This requires the registration_client_uri and registration_access_token from the original registration
|
||||||
// string prefix) so http://localhost.evil.com is not treated as localhost.
|
func (r *DynamicClientRegistrar) UpdateClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||||
func isLoopbackRegistrationEndpoint(endpoint string) bool {
|
r.mu.RLock()
|
||||||
u, err := url.Parse(endpoint)
|
cachedResp := r.registrationResponse
|
||||||
if err != nil || !u.IsAbs() {
|
r.mu.RUnlock()
|
||||||
return false
|
|
||||||
|
if cachedResp == nil {
|
||||||
|
return nil, fmt.Errorf("no existing registration to update")
|
||||||
}
|
}
|
||||||
host := u.Hostname()
|
|
||||||
if host == "localhost" {
|
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||||
return true
|
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||||
}
|
}
|
||||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
||||||
return true
|
// Build update request
|
||||||
|
reqBody, err := r.buildRegistrationRequest()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build update request: %w", err)
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, cachedResp.RegistrationClientURI, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create update request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read update response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error responses
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var regError ClientRegistrationError
|
||||||
|
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||||
|
return nil, fmt.Errorf("update failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse successful response
|
||||||
|
var regResp ClientRegistrationResponse
|
||||||
|
if err := json.Unmarshal(body, ®Resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse update response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
r.mu.Lock()
|
||||||
|
r.registrationResponse = ®Resp
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Persist updated credentials if enabled
|
||||||
|
if r.config.PersistCredentials {
|
||||||
|
if err := r.saveCredentialsToStore(ctx, ®Resp); err != nil {
|
||||||
|
r.logger.Errorf("Failed to persist updated credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Infof("Successfully updated client registration for client ID: %s", regResp.ClientID)
|
||||||
|
return ®Resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadClientRegistration reads the current client registration using RFC 7592
|
||||||
|
func (r *DynamicClientRegistrar) ReadClientRegistration(ctx context.Context) (*ClientRegistrationResponse, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
cachedResp := r.registrationResponse
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if cachedResp == nil {
|
||||||
|
return nil, fmt.Errorf("no existing registration to read")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cachedResp.RegistrationClientURI, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create read request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error responses
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var regError ClientRegistrationError
|
||||||
|
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||||
|
return nil, fmt.Errorf("read failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse successful response
|
||||||
|
var regResp ClientRegistrationResponse
|
||||||
|
if err := json.Unmarshal(body, ®Resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ®Resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClientRegistration deletes the client registration using RFC 7592
|
||||||
|
func (r *DynamicClientRegistrar) DeleteClientRegistration(ctx context.Context) error {
|
||||||
|
r.mu.RLock()
|
||||||
|
cachedResp := r.registrationResponse
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if cachedResp == nil {
|
||||||
|
return fmt.Errorf("no existing registration to delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cachedResp.RegistrationClientURI == "" || cachedResp.RegistrationAccessToken == "" {
|
||||||
|
return fmt.Errorf("registration management not supported: missing registration_client_uri or registration_access_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cachedResp.RegistrationClientURI, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create delete request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cachedResp.RegistrationAccessToken)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle error responses (204 No Content is success)
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
var regError ClientRegistrationError
|
||||||
|
if jsonErr := json.Unmarshal(body, ®Error); jsonErr == nil && regError.Error != "" {
|
||||||
|
return fmt.Errorf("delete failed: %s - %s", regError.Error, regError.ErrorDescription)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
r.mu.Lock()
|
||||||
|
r.registrationResponse = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove credentials from storage if persistence is enabled
|
||||||
|
if r.config.PersistCredentials {
|
||||||
|
if err := r.deleteCredentialsFromStore(ctx); err != nil {
|
||||||
|
r.logger.Errorf("Failed to remove credentials from storage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Successfully deleted client registration")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -541,7 +541,6 @@ func TestCredentialsValidation(t *testing.T) {
|
|||||||
name: "valid non-expiring credentials",
|
name: "valid non-expiring credentials",
|
||||||
response: &ClientRegistrationResponse{
|
response: &ClientRegistrationResponse{
|
||||||
ClientID: "test-client-id",
|
ClientID: "test-client-id",
|
||||||
ClientSecret: "test-client-secret",
|
|
||||||
ClientSecretExpiresAt: 0, // Never expires
|
ClientSecretExpiresAt: 0, // Never expires
|
||||||
},
|
},
|
||||||
expected: true,
|
expected: true,
|
||||||
@@ -550,7 +549,6 @@ func TestCredentialsValidation(t *testing.T) {
|
|||||||
name: "valid future-expiring credentials",
|
name: "valid future-expiring credentials",
|
||||||
response: &ClientRegistrationResponse{
|
response: &ClientRegistrationResponse{
|
||||||
ClientID: "test-client-id",
|
ClientID: "test-client-id",
|
||||||
ClientSecret: "test-client-secret",
|
|
||||||
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||||
},
|
},
|
||||||
expected: true,
|
expected: true,
|
||||||
@@ -600,7 +598,7 @@ func TestBuildRegistrationRequest(t *testing.T) {
|
|||||||
"redirect_uris": []interface{}{"https://example.com/callback"},
|
"redirect_uris": []interface{}{"https://example.com/callback"},
|
||||||
"response_types": []interface{}{"code"},
|
"response_types": []interface{}{"code"},
|
||||||
"grant_types": []interface{}{"authorization_code", "refresh_token"},
|
"grant_types": []interface{}{"authorization_code", "refresh_token"},
|
||||||
"token_endpoint_auth_method": "client_secret_post",
|
"token_endpoint_auth_method": "client_secret_basic",
|
||||||
},
|
},
|
||||||
expectError: false,
|
expectError: false,
|
||||||
},
|
},
|
||||||
@@ -737,6 +735,258 @@ 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
|
// stringContains is a helper function to check if a string contains a substring
|
||||||
func stringContains(s, substr string) bool {
|
func stringContains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr))
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr))
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid st
|
|||||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
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() {
|
func (m *EnhancedMockJWKCache) Cleanup() {
|
||||||
atomic.AddInt32(&m.CleanupCalls, 1)
|
atomic.AddInt32(&m.CleanupCalls, 1)
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
+28
-438
@@ -191,23 +191,12 @@ type CircuitBreaker struct {
|
|||||||
maxFailures int
|
maxFailures int
|
||||||
// timeout is how long to wait before allowing requests in half-open state
|
// timeout is how long to wait before allowing requests in half-open state
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
// resetTimeout is how long to stay in half-open (after the open timeout)
|
// resetTimeout is how long to wait before transitioning from open to half-open
|
||||||
// before a successful response fully closes the circuit
|
|
||||||
resetTimeout time.Duration
|
resetTimeout time.Duration
|
||||||
// state tracks the current circuit breaker state
|
// state tracks the current circuit breaker state
|
||||||
state CircuitBreakerState
|
state CircuitBreakerState
|
||||||
// failures counts consecutive failures
|
// failures counts consecutive failures
|
||||||
failures int64
|
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.
|
// CircuitBreakerConfig holds configuration parameters for circuit breakers.
|
||||||
@@ -250,32 +239,12 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
|||||||
cb.RecordRequest()
|
cb.RecordRequest()
|
||||||
|
|
||||||
if !cb.allowRequest() {
|
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")
|
return fmt.Errorf("circuit breaker is open")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := fn()
|
err := fn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A terminal client error (HTTPError 4xx other than 429) reflects a
|
cb.recordFailure()
|
||||||
// per-user problem such as invalid_grant, not a downstream-service
|
|
||||||
// failure. It must not trip the state machine: in particular it
|
|
||||||
// must not reopen a half-open circuit, or the 15s/30s half-open
|
|
||||||
// hold (resetTimeout) extends an outage on any single per-user
|
|
||||||
// error (FIX-09). It is still recorded in the base metrics below,
|
|
||||||
// since it is a real error returned to the caller.
|
|
||||||
if !isTerminalClientError(err) {
|
|
||||||
cb.recordFailure()
|
|
||||||
}
|
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -285,37 +254,6 @@ func (cb *CircuitBreaker) ExecuteWithContext(ctx context.Context, fn func() erro
|
|||||||
return nil
|
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.
|
// Execute executes a function through the circuit breaker without context.
|
||||||
// This is provided for backward compatibility with existing code.
|
// This is provided for backward compatibility with existing code.
|
||||||
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
func (cb *CircuitBreaker) Execute(fn func() error) error {
|
||||||
@@ -335,9 +273,8 @@ func (cb *CircuitBreaker) allowRequest() bool {
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
case CircuitBreakerOpen:
|
case CircuitBreakerOpen:
|
||||||
if now.Sub(cb.openedAt) > cb.timeout {
|
if now.Sub(cb.lastFailureTime) > cb.timeout {
|
||||||
cb.state = CircuitBreakerHalfOpen
|
cb.state = CircuitBreakerHalfOpen
|
||||||
cb.halfOpenSince = now
|
|
||||||
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
cb.logger.Infof("Circuit breaker transitioning to half-open state")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -363,13 +300,11 @@ func (cb *CircuitBreaker) recordFailure() {
|
|||||||
case CircuitBreakerClosed:
|
case CircuitBreakerClosed:
|
||||||
if cb.failures >= int64(cb.maxFailures) {
|
if cb.failures >= int64(cb.maxFailures) {
|
||||||
cb.state = CircuitBreakerOpen
|
cb.state = CircuitBreakerOpen
|
||||||
cb.openedAt = time.Now()
|
|
||||||
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
|
cb.LogError("Circuit breaker opened after %d failures", cb.failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
case CircuitBreakerHalfOpen:
|
case CircuitBreakerHalfOpen:
|
||||||
cb.state = CircuitBreakerOpen
|
cb.state = CircuitBreakerOpen
|
||||||
cb.openedAt = time.Now()
|
|
||||||
cb.LogError("Circuit breaker returned to open state after failure in half-open")
|
cb.LogError("Circuit breaker returned to open state after failure in half-open")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,15 +317,6 @@ func (cb *CircuitBreaker) recordSuccess() {
|
|||||||
|
|
||||||
switch cb.state {
|
switch cb.state {
|
||||||
case CircuitBreakerHalfOpen:
|
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.failures = 0
|
||||||
cb.state = CircuitBreakerClosed
|
cb.state = CircuitBreakerClosed
|
||||||
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
|
cb.LogInfo("Circuit breaker closed after successful request in half-open state")
|
||||||
@@ -422,24 +348,7 @@ func (cb *CircuitBreaker) Reset() {
|
|||||||
// IsAvailable returns whether the circuit breaker is currently allowing requests.
|
// IsAvailable returns whether the circuit breaker is currently allowing requests.
|
||||||
// This provides a quick way to check if the service is available.
|
// This provides a quick way to check if the service is available.
|
||||||
func (cb *CircuitBreaker) IsAvailable() bool {
|
func (cb *CircuitBreaker) IsAvailable() bool {
|
||||||
cb.mutex.Lock()
|
return cb.allowRequest()
|
||||||
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.
|
// GetMetrics returns comprehensive metrics about the circuit breaker.
|
||||||
@@ -598,157 +507,12 @@ func (re *RetryExecutor) ExecuteWithContext(ctx context.Context, fn func() error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the given function with retry logic (for backward compatibility)
|
// Execute runs the given function with retry logic (for backward compatibility)
|
||||||
// Execute runs a function with retry logic (backward compatibility).
|
// Execute executes a function with retry logic (backward compatibility).
|
||||||
// This method provides the same functionality as ExecuteWithContext.
|
// This method provides the same functionality as ExecuteWithContext.
|
||||||
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
|
func (re *RetryExecutor) Execute(ctx context.Context, fn func() error) error {
|
||||||
return re.ExecuteWithContext(ctx, fn)
|
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 checks if an error should trigger a retry
|
||||||
// isRetryableError determines if an error should trigger a retry attempt.
|
// isRetryableError determines if an error should trigger a retry attempt.
|
||||||
// Checks error message against configured retryable error patterns.
|
// Checks error message against configured retryable error patterns.
|
||||||
@@ -759,17 +523,6 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
|||||||
return false
|
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)
|
// Check for Traefik default certificate error (startup race condition)
|
||||||
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
|
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
|
||||||
if isTraefikDefaultCertError(err) {
|
if isTraefikDefaultCertError(err) {
|
||||||
@@ -786,20 +539,19 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := err.Error()
|
||||||
|
|
||||||
for _, retryableErr := range re.config.RetryableErrors {
|
for _, retryableErr := range re.config.RetryableErrors {
|
||||||
if contains(errStr, strings.ToLower(retryableErr)) {
|
if contains(errStr, retryableErr) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var netErr net.Error
|
if netErr, ok := err.(net.Error); ok {
|
||||||
if errors.As(err, &netErr) {
|
|
||||||
if netErr.Timeout() {
|
if netErr.Timeout() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
errStr := strings.ToLower(netErr.Error())
|
errStr := netErr.Error()
|
||||||
temporaryPatterns := []string{
|
temporaryPatterns := []string{
|
||||||
"connection refused",
|
"connection refused",
|
||||||
"connection reset",
|
"connection reset",
|
||||||
@@ -816,19 +568,8 @@ func (re *RetryExecutor) isRetryableError(err error) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// asHTTPError (not errors.As) so retry classification survives error
|
if httpErr, ok := err.(*HTTPError); ok {
|
||||||
// wrapping: a transient or 5xx/429 error returned as fmt.Errorf("...:
|
return httpErr.StatusCode >= 500 || httpErr.StatusCode == 429
|
||||||
// %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
|
return false
|
||||||
@@ -1021,10 +762,6 @@ type GracefulDegradation struct {
|
|||||||
config GracefulDegradationConfig
|
config GracefulDegradationConfig
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
shutdownOnce sync.Once
|
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.
|
// GracefulDegradationConfig holds configuration for graceful degradation behavior.
|
||||||
@@ -1051,53 +788,6 @@ func DefaultGracefulDegradationConfig() GracefulDegradationConfig {
|
|||||||
// NewGracefulDegradation creates a new graceful degradation manager
|
// NewGracefulDegradation creates a new graceful degradation manager
|
||||||
// NewGracefulDegradation creates a new graceful degradation mechanism.
|
// NewGracefulDegradation creates a new graceful degradation mechanism.
|
||||||
// Initializes fallback and health check maps and starts background health monitoring.
|
// 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 {
|
func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *GracefulDegradation {
|
||||||
gd := &GracefulDegradation{
|
gd := &GracefulDegradation{
|
||||||
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
|
BaseRecoveryMechanism: NewBaseRecoveryMechanism("graceful-degradation", logger),
|
||||||
@@ -1108,22 +798,6 @@ func NewGracefulDegradation(config GracefulDegradationConfig, logger *Logger) *G
|
|||||||
}
|
}
|
||||||
|
|
||||||
gd.stopChan = make(chan struct{})
|
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()
|
go gd.startHealthCheckRoutine()
|
||||||
|
|
||||||
return gd
|
return gd
|
||||||
@@ -1185,9 +859,8 @@ func (gd *GracefulDegradation) ExecuteWithFallback(serviceName string, primary f
|
|||||||
|
|
||||||
// isServiceDegraded checks if a service is currently degraded
|
// isServiceDegraded checks if a service is currently degraded
|
||||||
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
|
func (gd *GracefulDegradation) isServiceDegraded(serviceName string) bool {
|
||||||
// Uses a write lock because the recovery-timeout branch deletes from the map.
|
gd.mutex.RLock()
|
||||||
gd.mutex.Lock()
|
defer gd.mutex.RUnlock()
|
||||||
defer gd.mutex.Unlock()
|
|
||||||
|
|
||||||
degradedTime, exists := gd.degradedServices[serviceName]
|
degradedTime, exists := gd.degradedServices[serviceName]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -1228,54 +901,25 @@ func (gd *GracefulDegradation) executeFallback(serviceName string) (interface{},
|
|||||||
return fallback()
|
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() {
|
func (gd *GracefulDegradation) startHealthCheckRoutine() {
|
||||||
gd.mutex.Lock()
|
// Use singleton task registry to prevent multiple instances
|
||||||
|
|
||||||
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()
|
registry := GetGlobalTaskRegistry()
|
||||||
|
|
||||||
task, err := registry.CreateSingletonTask(
|
task, err := registry.CreateSingletonTask(
|
||||||
"graceful-degradation-health-check",
|
"graceful-degradation-health-check",
|
||||||
gd.config.HealthCheckInterval,
|
gd.config.HealthCheckInterval,
|
||||||
globalPerformHealthChecks,
|
gd.performHealthChecks,
|
||||||
gd.BaseRecoveryMechanism.logger,
|
gd.BaseRecoveryMechanism.logger,
|
||||||
nil, // No specific wait group
|
nil, // No specific wait group
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
gd.mutex.Unlock()
|
|
||||||
gd.BaseRecoveryMechanism.logger.Errorf("Failed to create health check task: %v", err)
|
gd.BaseRecoveryMechanism.logger.Errorf("Failed to create health check task: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gd.mutex.Lock()
|
||||||
gd.healthCheckTask = task
|
gd.healthCheckTask = task
|
||||||
gd.mutex.Unlock()
|
gd.mutex.Unlock()
|
||||||
|
|
||||||
@@ -1327,58 +971,25 @@ func (gd *GracefulDegradation) Reset() {
|
|||||||
gd.LogInfo("Graceful degradation state has been 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() {
|
func (gd *GracefulDegradation) Close() {
|
||||||
gd.shutdownOnce.Do(func() {
|
gd.shutdownOnce.Do(func() {
|
||||||
// Signal shutdown under gd.mutex. startHealthCheckRoutine checks
|
// Signal shutdown
|
||||||
// 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 {
|
select {
|
||||||
case <-gd.stopChan:
|
case <-gd.stopChan:
|
||||||
// Already closed
|
// Already closed
|
||||||
default:
|
default:
|
||||||
close(gd.stopChan)
|
close(gd.stopChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop health check task
|
||||||
|
gd.mutex.Lock()
|
||||||
|
task := gd.healthCheckTask
|
||||||
gd.mutex.Unlock()
|
gd.mutex.Unlock()
|
||||||
|
|
||||||
// Stop the shared health-check task only when this was the last live
|
if task != nil {
|
||||||
// instance. The task is process-global and shared by every
|
task.Stop()
|
||||||
// GracefulDegradation instance; stopping it on any one instance's
|
// Don't set to nil to avoid race conditions
|
||||||
// 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")
|
gd.logger.Debug("GracefulDegradation shut down successfully")
|
||||||
@@ -1438,21 +1049,6 @@ func NewErrorRecoveryManager(logger *Logger) *ErrorRecoveryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close shuts down the resources owned by this ErrorRecoveryManager,
|
|
||||||
// currently its GracefulDegradation instance (which unregisters from
|
|
||||||
// gdInstances and, if this was the last live instance, stops the shared
|
|
||||||
// health-check task). Safe to call multiple times; safe on a nil receiver.
|
|
||||||
// FIX-18: every caller that constructs an ErrorRecoveryManager must call
|
|
||||||
// Close, or its GracefulDegradation leaks in gdInstances forever.
|
|
||||||
func (erm *ErrorRecoveryManager) Close() {
|
|
||||||
if erm == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if erm.gracefulDegradation != nil {
|
|
||||||
erm.gracefulDegradation.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCircuitBreaker gets or creates a circuit breaker for a service
|
// GetCircuitBreaker gets or creates a circuit breaker for a service
|
||||||
// GetCircuitBreaker returns the circuit breaker for a specific service.
|
// GetCircuitBreaker returns the circuit breaker for a specific service.
|
||||||
// Creates a new circuit breaker if one doesn't exist for the service.
|
// Creates a new circuit breaker if one doesn't exist for the service.
|
||||||
@@ -1475,14 +1071,8 @@ func (erm *ErrorRecoveryManager) GetCircuitBreaker(serviceName string) *CircuitB
|
|||||||
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
|
func (erm *ErrorRecoveryManager) ExecuteWithRecovery(ctx context.Context, serviceName string, fn func() error) error {
|
||||||
cb := erm.GetCircuitBreaker(serviceName)
|
cb := erm.GetCircuitBreaker(serviceName)
|
||||||
|
|
||||||
// Retry INNER, circuit OUTER (matching TokenResilienceManager and the
|
return erm.retryExecutor.Execute(ctx, func() error {
|
||||||
// documented R106 compositing rule). Wrapping cb around the whole retry
|
return cb.Execute(fn)
|
||||||
// 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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIsTerminalClientError408IsTransient pins that HTTP 408 Request Timeout
|
|
||||||
// is treated as a transient, service-health signal by isTerminalClientError,
|
|
||||||
// not as a terminal per-request client error. A token endpoint (or a proxy
|
|
||||||
// in front of it) returns 408 when the upstream is slow, which is the same
|
|
||||||
// reasoning FIX-13 already applies in token_validation_rs.go's classifier
|
|
||||||
// and bearer_auth.go, and that internal/recovery/base.go and metrics.go
|
|
||||||
// apply via their RetryableStatusCodes/isRetryable checks. isTerminalClientError
|
|
||||||
// must classify 408 the same way, or a burst of token-endpoint 408s never
|
|
||||||
// opens the refresh circuit breaker.
|
|
||||||
//
|
|
||||||
// Fail-on-old: isTerminalClientError excluded only 429, so it reported 408
|
|
||||||
// as terminal.
|
|
||||||
func TestIsTerminalClientError408IsTransient(t *testing.T) {
|
|
||||||
err := &HTTPError{StatusCode: 408, Message: "request timeout"}
|
|
||||||
if isTerminalClientError(err) {
|
|
||||||
t.Fatal("408 Request Timeout must be treated as transient (like 429), not a terminal client error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerHalfOpenRequestTimeoutReopens pins the same contract at
|
|
||||||
// the circuit-breaker level: a 408 probe result must still reopen a
|
|
||||||
// half-open breaker, exactly like the existing 429 case
|
|
||||||
// (TestCircuitBreakerHalfOpenRateLimitReopens in error_recovery_fix09_test.go).
|
|
||||||
//
|
|
||||||
// Fail-on-old: with 408 misclassified as terminal, recordFailure's HalfOpen
|
|
||||||
// exemption for terminal client errors (FIX-09) swallowed it and the breaker
|
|
||||||
// stayed HalfOpen instead of reopening.
|
|
||||||
func TestCircuitBreakerHalfOpenRequestTimeoutReopens(t *testing.T) {
|
|
||||||
cb := newFix09HalfOpenBreaker(t)
|
|
||||||
|
|
||||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 408, Message: "request timeout"}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("a 408 must still reopen a half-open circuit like 429, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow is a regression test
|
|
||||||
// for FIX-01.
|
|
||||||
//
|
|
||||||
// Before the fix, ExecuteWithContext called the promoted
|
|
||||||
// BaseRecoveryMechanism.RecordFailure() on every request rejected while the
|
|
||||||
// circuit is open. RecordFailure sets lastFailureTime, the same field
|
|
||||||
// allowRequest reads as the Open -> HalfOpen timer. Every rejection then
|
|
||||||
// re-armed the timer, so under steady traffic shorter than Timeout the
|
|
||||||
// breaker never left Open (see error_recovery.go:253,288 pre-fix).
|
|
||||||
//
|
|
||||||
// Fail-on-old: with a request every 10ms and Timeout 50ms, no request is
|
|
||||||
// ever admitted within the 500ms budget, because each rejection pushes the
|
|
||||||
// open window forward by another Timeout.
|
|
||||||
func TestCircuitBreakerOpenRejectionsDoNotExtendOpenWindow(t *testing.T) {
|
|
||||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
|
||||||
MaxFailures: 1,
|
|
||||||
Timeout: 50 * time.Millisecond,
|
|
||||||
ResetTimeout: 30 * time.Second,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
// Trip the breaker open with a single real failure.
|
|
||||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return errors.New("boom")
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected the tripping call to return its own error")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("expected circuit to be open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
admitted := false
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
callErr := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if callErr == nil {
|
|
||||||
admitted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !admitted {
|
|
||||||
t.Fatal("expected a probe to be admitted once Timeout elapsed with no real failure in between; open-circuit rejections must not re-arm the open timer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerOpenRejectionStillCountedInMetrics pins the R180 intent
|
|
||||||
// alongside FIX-01: an open-circuit rejection is still an admission outcome
|
|
||||||
// and must still increment total_failures, even though it may no longer
|
|
||||||
// touch the open timer.
|
|
||||||
func TestCircuitBreakerOpenRejectionStillCountedInMetrics(t *testing.T) {
|
|
||||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
|
||||||
MaxFailures: 1,
|
|
||||||
Timeout: time.Hour,
|
|
||||||
ResetTimeout: time.Hour,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return errors.New("boom")
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected the tripping call to return its own error")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
t.Fatal("fn must not be called while the circuit is open")
|
|
||||||
return nil
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected the rejected call to return an error")
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := cb.GetBaseMetrics()
|
|
||||||
totalFailures := metrics["total_failures"].(int64)
|
|
||||||
if totalFailures != 2 {
|
|
||||||
t.Errorf("total_failures = %d, want 2 (1 real failure + 1 open rejection)", totalFailures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newFix09HalfOpenBreaker builds a breaker, trips it with one real (non
|
|
||||||
// client) failure, and waits past Timeout so the next ExecuteWithContext
|
|
||||||
// call performs the Open -> HalfOpen probe transition itself.
|
|
||||||
func newFix09HalfOpenBreaker(t *testing.T) *CircuitBreaker {
|
|
||||||
t.Helper()
|
|
||||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
|
||||||
MaxFailures: 1,
|
|
||||||
Timeout: 100 * time.Millisecond,
|
|
||||||
ResetTimeout: 50 * time.Millisecond,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
if err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected the tripping call to return its own error")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("setup: expected circuit open after the tripping failure, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(150 * time.Millisecond) // past Timeout: next call probes half-open
|
|
||||||
return cb
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen is a regression
|
|
||||||
// test for FIX-09. A terminal client error (HTTPError 4xx other than 429,
|
|
||||||
// e.g. invalid_grant from a replayed code) reflects a per-user problem, not
|
|
||||||
// a downstream-service failure, so it must not reopen a half-open breaker.
|
|
||||||
//
|
|
||||||
// Fail-on-old: recordFailure's HalfOpen case reopened unconditionally on
|
|
||||||
// any fn() error, so the circuit is Open (not HalfOpen) right after this
|
|
||||||
// probe.
|
|
||||||
func TestCircuitBreakerHalfOpenTerminalClientErrorStaysHalfOpen(t *testing.T) {
|
|
||||||
cb := newFix09HalfOpenBreaker(t)
|
|
||||||
|
|
||||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
|
||||||
t.Fatalf("a terminal client 4xx must not reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Past resetTimeout relative to the half-open entry: a subsequent
|
|
||||||
// success must still be able to fully close the circuit.
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
|
|
||||||
if err := cb.ExecuteWithContext(context.Background(), func() error { return nil }); err != nil {
|
|
||||||
t.Fatalf("expected the closing probe to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerClosed {
|
|
||||||
t.Fatalf("expected circuit to close after a half-open success following the ignored client error, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerHalfOpenRateLimitReopens pins that HTTP 429 (the
|
|
||||||
// service itself signaling it is overloaded) still reopens a half-open
|
|
||||||
// circuit, unlike an ordinary terminal client 4xx.
|
|
||||||
func TestCircuitBreakerHalfOpenRateLimitReopens(t *testing.T) {
|
|
||||||
cb := newFix09HalfOpenBreaker(t)
|
|
||||||
|
|
||||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 429, Message: "rate limited"}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("a 429 must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerHalfOpenNetworkErrorReopens pins that a genuine network
|
|
||||||
// error still reopens a half-open circuit.
|
|
||||||
func TestCircuitBreakerHalfOpenNetworkErrorReopens(t *testing.T) {
|
|
||||||
cb := newFix09HalfOpenBreaker(t)
|
|
||||||
|
|
||||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &net.OpError{Op: "dial", Err: errors.New("connection refused")}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("a network error must still reopen a half-open circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCircuitBreakerClosedTerminalClientErrorStaysClosed pins FIX-09's
|
|
||||||
// scope choice: the terminal-client-error exemption applies to the Closed
|
|
||||||
// state too, not only half-open, so a per-user invalid_grant never trips
|
|
||||||
// the breaker at all. A following genuine 5xx still opens it normally.
|
|
||||||
func TestCircuitBreakerClosedTerminalClientErrorStaysClosed(t *testing.T) {
|
|
||||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
|
||||||
MaxFailures: 1,
|
|
||||||
Timeout: 100 * time.Millisecond,
|
|
||||||
ResetTimeout: 50 * time.Millisecond,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
err := cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 400, Message: "invalid_grant"}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the probe's own error to be returned to the caller")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerClosed {
|
|
||||||
t.Fatalf("a terminal client 4xx must not trip a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cb.ExecuteWithContext(context.Background(), func() error {
|
|
||||||
return &HTTPError{StatusCode: 500, Message: "downstream unavailable"}
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the tripping call to return its own error")
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerOpen {
|
|
||||||
t.Fatalf("a genuine 5xx must still open a closed circuit, got %v", circuitBreakerStateToString(cb.GetState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse is a
|
|
||||||
// regression test for FIX-14.
|
|
||||||
//
|
|
||||||
// helpers.go's token-endpoint exchange returns a *HTTPError whose Message
|
|
||||||
// carries up to 10 KiB of the raw response body once a non-200 status is
|
|
||||||
// received (helpers.go:192-195). Before the fix, ExecuteSingleUseWithContext
|
|
||||||
// matched singleUseRetryableErrors as a plain substring of err.Error(), so
|
|
||||||
// an IdP 500 whose body happens to mention "connection refused" (e.g. the
|
|
||||||
// IdP's own downstream error) was retried up to MaxAttempts times even
|
|
||||||
// though the request had definitely already reached the server. For a
|
|
||||||
// single-use operation (authorization-code exchange, refresh) re-sending
|
|
||||||
// consumed input causes invalid_grant or refresh-token-family revocation.
|
|
||||||
//
|
|
||||||
// Fail-on-old: fn is called 3 times (MaxAttempts) instead of once.
|
|
||||||
func TestExecuteSingleUseWithContextNeverRetriesAfterHTTPResponse(t *testing.T) {
|
|
||||||
re := NewRetryExecutor(RetryConfig{
|
|
||||||
MaxAttempts: 3,
|
|
||||||
InitialDelay: 1,
|
|
||||||
MaxDelay: 1,
|
|
||||||
BackoffFactor: 1,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
|
||||||
calls++
|
|
||||||
return &HTTPError{
|
|
||||||
StatusCode: 500,
|
|
||||||
Message: "token endpoint returned status 500: {\"error\":\"server_error\",\"detail\":\"upstream: connection refused\"}",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the HTTPError to be returned")
|
|
||||||
}
|
|
||||||
if calls != 1 {
|
|
||||||
t.Fatalf("fn called %d times, want 1: an HTTP response proves the request was already sent and must never be retried for a single-use operation", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExecuteSingleUseWithContextStillRetriesRealDialError guards against
|
|
||||||
// over-broadening the FIX-14 fix: a genuine dial/connect failure -- proof
|
|
||||||
// the request never reached the server -- must still be retried.
|
|
||||||
func TestExecuteSingleUseWithContextStillRetriesRealDialError(t *testing.T) {
|
|
||||||
re := NewRetryExecutor(RetryConfig{
|
|
||||||
MaxAttempts: 3,
|
|
||||||
InitialDelay: 1,
|
|
||||||
MaxDelay: 1,
|
|
||||||
BackoffFactor: 1,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
|
||||||
calls++
|
|
||||||
if calls == 1 {
|
|
||||||
return &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected the retried call to succeed, got: %v", err)
|
|
||||||
}
|
|
||||||
if calls != 2 {
|
|
||||||
t.Fatalf("fn called %d times, want 2: a real dial error must still be retried", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExecuteSingleUseWithContextNeverRetriesPostSendOpError pins FIX-14's
|
|
||||||
// minor finding: isNeverSentError must match a *net.OpError only for Op
|
|
||||||
// "dial" or "proxyconnect" (proof the request never reached the far end).
|
|
||||||
// A "read"/"write" OpError happens after the connection is already
|
|
||||||
// established, when the single-use request may already have reached the
|
|
||||||
// IdP, and must not be retried.
|
|
||||||
func TestExecuteSingleUseWithContextNeverRetriesPostSendOpError(t *testing.T) {
|
|
||||||
re := NewRetryExecutor(RetryConfig{
|
|
||||||
MaxAttempts: 3,
|
|
||||||
InitialDelay: 1,
|
|
||||||
MaxDelay: 1,
|
|
||||||
BackoffFactor: 1,
|
|
||||||
}, NewLogger("error"))
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
err := re.ExecuteSingleUseWithContext(context.Background(), func() error {
|
|
||||||
calls++
|
|
||||||
return &net.OpError{Op: "read", Net: "tcp", Err: syscall.EHOSTUNREACH}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected the OpError to be returned")
|
|
||||||
}
|
|
||||||
if calls != 1 {
|
|
||||||
t.Fatalf("fn called %d times, want 1: a post-send (read/write) OpError must not be retried for a single-use operation", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-75
@@ -89,50 +89,18 @@ func TestCircuitBreakerHalfOpenTransition(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
// First request after the open timeout probes half-open. A single success
|
allowed := false
|
||||||
// does NOT immediately close: the circuit stays half-open until
|
_ = cb.Execute(func() error {
|
||||||
// resetTimeout has elapsed (reset-cooldown hysteresis).
|
allowed = true
|
||||||
if err := cb.Execute(func() error { return nil }); err != nil {
|
return nil
|
||||||
t.Errorf("Request should be allowed in half-open state, got: %v", err)
|
})
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerHalfOpen {
|
if !allowed {
|
||||||
t.Errorf("Circuit should stay half-open during reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
|
t.Error("Request should be allowed in half-open state")
|
||||||
}
|
}
|
||||||
|
|
||||||
// After resetTimeout elapses, a successful request fully closes it.
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
if err := cb.Execute(func() error { return nil }); err != nil {
|
|
||||||
t.Errorf("Request should be allowed, got: %v", err)
|
|
||||||
}
|
|
||||||
if cb.GetState() != CircuitBreakerClosed {
|
if cb.GetState() != CircuitBreakerClosed {
|
||||||
t.Errorf("Circuit should be closed after successful request following reset cooldown, got %v", circuitBreakerStateToString(cb.GetState()))
|
t.Errorf("Circuit should be closed after successful half-open request, got %v", 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()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,13 +243,7 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
|
|||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
if !cb.IsAvailable() {
|
if !cb.IsAvailable() {
|
||||||
t.Error("Circuit should report availability once the open timeout has elapsed")
|
t.Error("Circuit should be available in half-open state")
|
||||||
}
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,7 +1097,6 @@ func TestBaseRecoveryMechanism_LogMethods(t *testing.T) {
|
|||||||
|
|
||||||
func TestErrorRecoveryManagerCreation(t *testing.T) {
|
func TestErrorRecoveryManagerCreation(t *testing.T) {
|
||||||
erm := NewErrorRecoveryManager(nil)
|
erm := NewErrorRecoveryManager(nil)
|
||||||
t.Cleanup(erm.Close)
|
|
||||||
|
|
||||||
if erm == nil {
|
if erm == nil {
|
||||||
t.Fatal("Expected non-nil error recovery manager")
|
t.Fatal("Expected non-nil error recovery manager")
|
||||||
@@ -1152,7 +1113,6 @@ func TestErrorRecoveryManagerCreation(t *testing.T) {
|
|||||||
|
|
||||||
func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
|
func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
|
||||||
erm := NewErrorRecoveryManager(nil)
|
erm := NewErrorRecoveryManager(nil)
|
||||||
t.Cleanup(erm.Close)
|
|
||||||
|
|
||||||
cb1 := erm.GetCircuitBreaker("service1")
|
cb1 := erm.GetCircuitBreaker("service1")
|
||||||
cb2 := erm.GetCircuitBreaker("service1")
|
cb2 := erm.GetCircuitBreaker("service1")
|
||||||
@@ -1173,7 +1133,6 @@ func TestErrorRecoveryManagerGetCircuitBreaker(t *testing.T) {
|
|||||||
|
|
||||||
func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
|
func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
|
||||||
erm := NewErrorRecoveryManager(nil)
|
erm := NewErrorRecoveryManager(nil)
|
||||||
t.Cleanup(erm.Close)
|
|
||||||
|
|
||||||
success := false
|
success := false
|
||||||
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
|
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
|
||||||
@@ -1192,7 +1151,6 @@ func TestErrorRecoveryManagerExecuteWithRecovery(t *testing.T) {
|
|||||||
|
|
||||||
func TestErrorRecoveryManagerMetrics(t *testing.T) {
|
func TestErrorRecoveryManagerMetrics(t *testing.T) {
|
||||||
erm := NewErrorRecoveryManager(nil)
|
erm := NewErrorRecoveryManager(nil)
|
||||||
t.Cleanup(erm.Close)
|
|
||||||
|
|
||||||
_ = erm.GetCircuitBreaker("service1")
|
_ = erm.GetCircuitBreaker("service1")
|
||||||
_ = erm.GetCircuitBreaker("service2")
|
_ = erm.GetCircuitBreaker("service2")
|
||||||
@@ -1212,7 +1170,6 @@ func TestErrorRecoveryManagerMetrics(t *testing.T) {
|
|||||||
func TestErrorRecoveryManagerIntegration(t *testing.T) {
|
func TestErrorRecoveryManagerIntegration(t *testing.T) {
|
||||||
logger := GetSingletonNoOpLogger()
|
logger := GetSingletonNoOpLogger()
|
||||||
erm := NewErrorRecoveryManager(logger)
|
erm := NewErrorRecoveryManager(logger)
|
||||||
t.Cleanup(erm.Close)
|
|
||||||
|
|
||||||
t.Run("circuit breaker and retry integration", func(t *testing.T) {
|
t.Run("circuit breaker and retry integration", func(t *testing.T) {
|
||||||
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
cb := NewCircuitBreaker(CircuitBreakerConfig{
|
||||||
@@ -1314,12 +1271,9 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
|
|||||||
defer gd.Close()
|
defer gd.Close()
|
||||||
|
|
||||||
t.Run("register health check", func(t *testing.T) {
|
t.Run("register health check", func(t *testing.T) {
|
||||||
var healthy atomic.Bool
|
healthy := true
|
||||||
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 {
|
healthCheck := func() bool {
|
||||||
return healthy.Load()
|
return healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
gd.RegisterHealthCheck("service1", healthCheck)
|
gd.RegisterHealthCheck("service1", healthCheck)
|
||||||
@@ -1327,7 +1281,7 @@ func TestGracefulDegradationRegisterHealthCheck(t *testing.T) {
|
|||||||
gd.markServiceDegraded("service1")
|
gd.markServiceDegraded("service1")
|
||||||
assert.True(t, gd.isServiceDegraded("service1"))
|
assert.True(t, gd.isServiceDegraded("service1"))
|
||||||
|
|
||||||
healthy.Store(true)
|
healthy = true
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1553,13 +1507,6 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
|
|||||||
config := DefaultGracefulDegradationConfig()
|
config := DefaultGracefulDegradationConfig()
|
||||||
gd := NewGracefulDegradation(config, logger)
|
gd := NewGracefulDegradation(config, logger)
|
||||||
defer gd.Close()
|
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
|
healthCheckCalled := false
|
||||||
gd.RegisterHealthCheck("test-service", func() bool {
|
gd.RegisterHealthCheck("test-service", func() bool {
|
||||||
@@ -1582,9 +1529,6 @@ func TestGracefulDegradationHealthChecks(t *testing.T) {
|
|||||||
config := DefaultGracefulDegradationConfig()
|
config := DefaultGracefulDegradationConfig()
|
||||||
gd := NewGracefulDegradation(config, logger)
|
gd := NewGracefulDegradation(config, logger)
|
||||||
defer gd.Close()
|
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 {
|
gd.RegisterHealthCheck("failing-service", func() bool {
|
||||||
return false
|
return false
|
||||||
@@ -1665,12 +1609,9 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
|
|||||||
return "fallback data", nil
|
return "fallback data", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
var serviceHealthy atomic.Bool
|
serviceHealthy := false
|
||||||
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 {
|
gd.RegisterHealthCheck("critical-service", func() bool {
|
||||||
return serviceHealthy.Load()
|
return serviceHealthy
|
||||||
})
|
})
|
||||||
|
|
||||||
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
|
result1, err1 := gd.ExecuteWithFallback("critical-service", func() (interface{}, error) {
|
||||||
@@ -1693,7 +1634,7 @@ func TestGracefulDegradationFullScenario(t *testing.T) {
|
|||||||
assert.NoError(t, err3)
|
assert.NoError(t, err3)
|
||||||
assert.Equal(t, "fallback data", result3)
|
assert.Equal(t, "fallback data", result3)
|
||||||
|
|
||||||
serviceHealthy.Store(true)
|
serviceHealthy = true
|
||||||
time.Sleep(250 * time.Millisecond)
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
|
||||||
metrics := gd.GetMetrics()
|
metrics := gd.GetMetrics()
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
moduleName: github.com/lukaszraczylo/traefikoidc
|
||||||
version: v1.0.29
|
version: v0.8.0
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: INFO
|
level: INFO
|
||||||
@@ -263,7 +263,7 @@ services:
|
|||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
- "--entrypoints.websecure.address=:443"
|
- "--entrypoints.websecure.address=:443"
|
||||||
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
|
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
|
||||||
- "--experimental.plugins.traefikoidc.version=v1.0.29"
|
- "--experimental.plugins.traefikoidc.version=v0.8.0"
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -445,22 +445,22 @@ spec:
|
|||||||
# you can set these. NOTE: Plugin configuration takes precedence!
|
# you can set these. NOTE: Plugin configuration takes precedence!
|
||||||
|
|
||||||
# Docker Compose env file (.env)
|
# Docker Compose env file (.env)
|
||||||
# ----- (below is an example .env file, NOT YAML — shown for reference) -----
|
---
|
||||||
# OIDC Configuration
|
# OIDC Configuration
|
||||||
# OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
# OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
# OIDC_PROVIDER_URL=https://auth.example.com
|
OIDC_PROVIDER_URL=https://auth.example.com
|
||||||
|
|
||||||
# Redis Configuration (fallback)
|
# Redis Configuration (fallback)
|
||||||
# REDIS_ENABLED=true
|
REDIS_ENABLED=true
|
||||||
# REDIS_ADDRESS=redis:6379
|
REDIS_ADDRESS=redis:6379
|
||||||
# REDIS_PASSWORD=yourredispassword
|
REDIS_PASSWORD=yourredispassword
|
||||||
# REDIS_DB=0
|
REDIS_DB=0
|
||||||
# REDIS_KEY_PREFIX=traefikoidc:
|
REDIS_KEY_PREFIX=traefikoidc:
|
||||||
# REDIS_CACHE_MODE=hybrid
|
REDIS_CACHE_MODE=hybrid
|
||||||
# REDIS_POOL_SIZE=20
|
REDIS_POOL_SIZE=20
|
||||||
# REDIS_ENABLE_CIRCUIT_BREAKER=true
|
REDIS_ENABLE_CIRCUIT_BREAKER=true
|
||||||
# REDIS_ENABLE_HEALTH_CHECK=true
|
REDIS_ENABLE_HEALTH_CHECK=true
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Minimal oidcgate config. See docs/OIDCGATE.md for full reference.
|
||||||
|
listen: ":8080"
|
||||||
|
authPath: "/oauth2/auth"
|
||||||
|
startPath: "/oauth2/start"
|
||||||
|
|
||||||
|
providerURL: "https://accounts.google.com"
|
||||||
|
clientID: "REPLACE_ME.apps.googleusercontent.com"
|
||||||
|
clientSecret: "REPLACE_ME" # OR set OIDCGATE_CLIENT_SECRET
|
||||||
|
sessionEncryptionKey: "REPLACE_WITH_64_HEX_BYTES" # OR OIDCGATE_SESSION_ENCRYPTION_KEY
|
||||||
|
callbackURL: "/oauth2/callback"
|
||||||
|
logoutURL: "/oauth2/logout"
|
||||||
|
postLogoutRedirectURI: "/"
|
||||||
|
|
||||||
|
# allowedUserDomains: [company.com]
|
||||||
|
# excludedURLs: [/health, /metrics]
|
||||||
@@ -6,7 +6,7 @@ experimental:
|
|||||||
plugins:
|
plugins:
|
||||||
traefikoidc:
|
traefikoidc:
|
||||||
moduleName: github.com/lukaszraczylo/traefikoidc
|
moduleName: github.com/lukaszraczylo/traefikoidc
|
||||||
version: v1.0.29
|
version: v0.8.0
|
||||||
|
|
||||||
# Dynamic configuration (dynamic.yml or labels)
|
# Dynamic configuration (dynamic.yml or labels)
|
||||||
http:
|
http:
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression tests for FIX-02 (review-2026-09-10 finding at
|
|
||||||
// middleware.go:105): shouldBypassAuth bypassed OIDC auth for ANY request
|
|
||||||
// whose method is OPTIONS, not only genuine CORS preflights. A real
|
|
||||||
// preflight always carries both an Origin header and an
|
|
||||||
// Access-Control-Request-Method header; the R124 fix checked only the
|
|
||||||
// method, so an unauthenticated `OPTIONS <protected-path>` with neither
|
|
||||||
// header reached the backend unauthenticated (reproduced against
|
|
||||||
// http.FileServer: 200 with the protected file's body).
|
|
||||||
//
|
|
||||||
// TestServeHTTP_ForgedPreflightDoesNotLeakBody hardens the residual gap a
|
|
||||||
// follow-up review found in that fix: the Origin +
|
|
||||||
// Access-Control-Request-Method check only rules out an accidental bare
|
|
||||||
// OPTIONS request, not a deliberate attacker, since any non-browser client
|
|
||||||
// can set both headers itself. bypassReasonOptions still forwarded to next
|
|
||||||
// with no session check, so a forged preflight against http.FileServer
|
|
||||||
// still returned the protected file's body.
|
|
||||||
//
|
|
||||||
// A later maintainer decision (the AllowUnauthenticatedPreflight config
|
|
||||||
// option, settings.go) restored the released default: every OPTIONS request
|
|
||||||
// -- genuine preflight or not -- needs auth unless the operator opts in.
|
|
||||||
// Every test below that exercises the bypass path (positive or negative)
|
|
||||||
// sets allowUnauthenticatedPreflight: true so it keeps testing the
|
|
||||||
// Origin+Access-Control-Request-Method gate itself rather than the flag
|
|
||||||
// gate placed in front of it. The default-off case is pinned separately by
|
|
||||||
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth in
|
|
||||||
// fix02b_allow_unauthenticated_preflight_test.go.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestShouldBypassAuth_BareOptionsDoesNotBypass pins the negative case: a
|
|
||||||
// bare OPTIONS request carrying neither Origin nor
|
|
||||||
// Access-Control-Request-Method is not a CORS preflight and must go
|
|
||||||
// through the normal auth pipeline like any other method.
|
|
||||||
func TestShouldBypassAuth_BareOptionsDoesNotBypass(t *testing.T) {
|
|
||||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
|
||||||
|
|
||||||
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
|
|
||||||
t.Fatalf("bare OPTIONS (no Origin, no Access-Control-Request-Method) must not bypass auth, got bypass reason %q", reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass pins a partial
|
|
||||||
// case: Origin alone (e.g. a plain cross-origin GET, not a preflight) must
|
|
||||||
// not bypass either -- both headers are required.
|
|
||||||
func TestShouldBypassAuth_OptionsWithOnlyOriginDoesNotBypass(t *testing.T) {
|
|
||||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
|
|
||||||
if bypass, reason := tObj.shouldBypassAuth(req); bypass {
|
|
||||||
t.Fatalf("OPTIONS with only Origin set must not bypass auth, got bypass reason %q", reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShouldBypassAuth_GenuinePreflightBypasses pins the positive case: a
|
|
||||||
// real CORS preflight (Origin + Access-Control-Request-Method) must still
|
|
||||||
// bypass auth, so a browser's actual preflight keeps working.
|
|
||||||
func TestShouldBypassAuth_GenuinePreflightBypasses(t *testing.T) {
|
|
||||||
tObj := &TraefikOidc{logger: GetSingletonNoOpLogger(), allowUnauthenticatedPreflight: true}
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
||||||
|
|
||||||
bypass, reason := tObj.shouldBypassAuth(req)
|
|
||||||
if !bypass {
|
|
||||||
t.Fatal("a genuine CORS preflight (Origin + Access-Control-Request-Method) must bypass auth")
|
|
||||||
}
|
|
||||||
if reason != bypassReasonOptions {
|
|
||||||
t.Fatalf("preflight bypass reason = %q, want %q", reason, bypassReasonOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend is the
|
|
||||||
// end-to-end reproduction from the finding: an unauthenticated OPTIONS
|
|
||||||
// request against a protected path, with no session cookie and no CORS
|
|
||||||
// headers, must not reach the next handler. Mirrors the reviewer's
|
|
||||||
// http.FileServer repro that returned the protected file's body.
|
|
||||||
func TestServeHTTP_UnauthenticatedBareOptionsDoesNotReachBackend(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
nextCalled := false
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
nextCalled = true
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
|
|
||||||
})
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
allowUnauthenticatedPreflight: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if nextCalled {
|
|
||||||
t.Fatalf("unauthenticated bare OPTIONS must not reach the backend, but next handler ran (status=%d body=%q)", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
if rw.Code == http.StatusOK && strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
|
||||||
t.Fatalf("protected content leaked through an unauthenticated OPTIONS request: status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_GenuinePreflightReachesBackend confirms the fix does not
|
|
||||||
// regress the R124 behavior it builds on: a real CORS preflight for the
|
|
||||||
// same protected path must still reach the backend unconditionally.
|
|
||||||
func TestServeHTTP_GenuinePreflightReachesBackend(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
nextCalled := false
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
nextCalled = true
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
allowUnauthenticatedPreflight: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if !nextCalled {
|
|
||||||
t.Fatalf("a genuine CORS preflight must still reach the backend, got status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_ForgedPreflightDoesNotLeakBody reproduces the finding's own
|
|
||||||
// exploit: a non-browser client sends an OPTIONS request carrying BOTH
|
|
||||||
// Origin and Access-Control-Request-Method (indistinguishable from a real
|
|
||||||
// preflight to shouldBypassAuth) against a protected path, with no session
|
|
||||||
// cookie. It must not receive the backend's response body, even though the
|
|
||||||
// request still forwards unauthenticated so a real browser preflight keeps
|
|
||||||
// getting its CORS headers answered.
|
|
||||||
func TestServeHTTP_ForgedPreflightDoesNotLeakBody(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
next := http.FileServer(http.Dir(dir))
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
allowUnauthenticatedPreflight: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
req.Header.Set("Origin", "https://attacker.example")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
|
||||||
t.Fatalf("forged preflight leaked protected content: status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression tests for the FIX-02 maintainer decision: the released
|
|
||||||
// behavior required authentication for every OPTIONS request by default.
|
|
||||||
// FIX-02 (fix02_options_bypass_test.go) narrowed an accidental
|
|
||||||
// unconditional OPTIONS bypass down to genuine CORS preflights (Origin +
|
|
||||||
// Access-Control-Request-Method), but that still left every preflight
|
|
||||||
// unauthenticated by default -- a behavior change from the release. This
|
|
||||||
// file pins the maintainer decision to restore released behavior: the
|
|
||||||
// preflight bypass now only applies when the operator opts in via
|
|
||||||
// AllowUnauthenticatedPreflight (default false). With the flag off, OPTIONS
|
|
||||||
// goes through the normal auth pipeline like any other method.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth pins the default
|
|
||||||
// (AllowUnauthenticatedPreflight=false) behavior: even a genuine CORS
|
|
||||||
// preflight (Origin + Access-Control-Request-Method) must NOT reach the
|
|
||||||
// backend without a session -- it goes through the normal auth pipeline
|
|
||||||
// (redirect or 401) exactly like any other unauthenticated request. This is
|
|
||||||
// the released behavior FIX-02 must restore by default.
|
|
||||||
func TestServeHTTP_DefaultConfigGenuinePreflightRequiresAuth(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
nextCalled := false
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
nextCalled = true
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
// AllowUnauthenticatedPreflight left at its zero value (false):
|
|
||||||
// default config must not bypass auth for OPTIONS.
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if nextCalled {
|
|
||||||
t.Fatalf("default config (allowUnauthenticatedPreflight=false) must send a genuine OPTIONS preflight through the normal auth pipeline, but the backend was called unconditionally: status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
if rw.Code == http.StatusNoContent {
|
|
||||||
t.Fatalf("default config must not answer the preflight bypass response (204 from next); got status=%d", rw.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded
|
|
||||||
// pins the opt-in behavior: with AllowUnauthenticatedPreflight=true, a
|
|
||||||
// genuine CORS preflight still bypasses auth (so real browser preflights
|
|
||||||
// keep working) and the optionsPreflightWriter still discards the body
|
|
||||||
// (FIX-02's forged-preflight hardening keeps applying when the flag is on).
|
|
||||||
func TestServeHTTP_AllowUnauthenticatedPreflight_GenuinePreflightBypassesBodyDiscarded(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP-SECRET-PAYROLL"), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
next := http.FileServer(http.Dir(dir))
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
allowUnauthenticatedPreflight: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if strings.Contains(rw.Body.String(), "TOP-SECRET-PAYROLL") {
|
|
||||||
t.Fatalf("allowUnauthenticatedPreflight=true must still discard the preflight response body: status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth
|
|
||||||
// pins that the flag only widens the genuine-preflight gate -- a bare
|
|
||||||
// OPTIONS request (no Origin, no Access-Control-Request-Method) still needs
|
|
||||||
// auth even with AllowUnauthenticatedPreflight=true.
|
|
||||||
func TestServeHTTP_AllowUnauthenticatedPreflight_BareOptionsStillRequiresAuth(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
nextCalled := false
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
nextCalled = true
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("TOP-SECRET-PAYROLL"))
|
|
||||||
})
|
|
||||||
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: next,
|
|
||||||
sessionManager: sm,
|
|
||||||
redirURLPath: "/callback",
|
|
||||||
authURL: "https://idp.example.com/authorize",
|
|
||||||
issuerURL: "https://idp.example.com",
|
|
||||||
clientID: "test-client-id",
|
|
||||||
scopes: []string{"openid"},
|
|
||||||
initComplete: initComplete,
|
|
||||||
allowUnauthenticatedPreflight: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "https://app.example.com/secret.txt", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if nextCalled {
|
|
||||||
t.Fatalf("allowUnauthenticatedPreflight=true must not bypass auth for a bare OPTIONS request (no Origin/Access-Control-Request-Method), but the backend was called: status=%d body=%q", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression tests for FIX-05 (review-2026-09-10 finding at session.go:2409,
|
|
||||||
// session.go:3099): SetRefreshToken/SetIDToken re-Get the gorilla-registry-
|
|
||||||
// cached chunk *sessions.Session objects for the NEW token, fill them, and
|
|
||||||
// only THEN call the expire helper for the OLD chunks. Because gorilla's
|
|
||||||
// Registry.Get returns the SAME cached object per cookie name for a given
|
|
||||||
// request, the "expire old" pass re-Gets those identical objects and wipes
|
|
||||||
// session.Values on every non-new one -- erasing the NEW chunk data this
|
|
||||||
// call just wrote. A session loaded from existing (legacy) chunk cookies
|
|
||||||
// whose replacement token also needs chunking loses the new token entirely.
|
|
||||||
//
|
|
||||||
// Adapted from the throwaway repro at
|
|
||||||
// .claude/review-2026-09-10/repro/zz_g1_chunkwipe_test.go and
|
|
||||||
// zz_g1_chunkwipe_id_test.go (TestG1ChunkWipeRefresh / TestG1ChunkWipeAccessControl
|
|
||||||
// / TestG1ChunkWipeIDToken), renamed to describe the behavior they pin.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// randChunkTestToken returns a random URL-safe token of n raw bytes, large
|
|
||||||
// enough (with maxCookieSize=1400) to force the chunked storage path.
|
|
||||||
func randChunkTestToken(t *testing.T, n int) string {
|
|
||||||
t.Helper()
|
|
||||||
b := make([]byte, n)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// randChunkTestJWT returns a structurally valid (3-dot-separated) JWT whose
|
|
||||||
// payload is padded past maxCookieSize, so SetIDToken takes the chunked path.
|
|
||||||
func randChunkTestJWT(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
enc := base64.RawURLEncoding.EncodeToString
|
|
||||||
header := enc([]byte(`{"alg":"RS256","typ":"JWT","kid":"k"}`))
|
|
||||||
now := time.Now().Unix()
|
|
||||||
payload := enc([]byte(fmt.Sprintf(
|
|
||||||
`{"iss":"https://idp","sub":"u","aud":"c","iat":%d,"exp":%d,"pad":"%s"}`,
|
|
||||||
now, now+3600, randChunkTestToken(t, 3000),
|
|
||||||
)))
|
|
||||||
return header + "." + payload + "." + randChunkTestToken(t, 256)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeSetCookiesIntoJar copies a response's Set-Cookie headers into jar,
|
|
||||||
// dropping any cookie the response expired (MaxAge < 0), so the next request
|
|
||||||
// carries forward only what a real browser would still hold.
|
|
||||||
func mergeSetCookiesIntoJar(jar map[string]*http.Cookie, rr *httptest.ResponseRecorder) {
|
|
||||||
for _, c := range rr.Result().Cookies() {
|
|
||||||
if c.MaxAge < 0 {
|
|
||||||
delete(jar, c.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
jar[c.Name] = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRequestWithJarCookies builds a request carrying every cookie currently
|
|
||||||
// held in jar, simulating a browser replaying its cookie jar on the next
|
|
||||||
// request to the same session.
|
|
||||||
func newRequestWithJarCookies(jar map[string]*http.Cookie) *http.Request {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "https://app.example.com/", nil)
|
|
||||||
for _, c := range jar {
|
|
||||||
r.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// runChunkedTokenReplaceRegression drives three requests against one
|
|
||||||
// SessionManager: request 1 stores a large (chunked) token A and saves it,
|
|
||||||
// request 2 loads the cookies A produced and replaces the token with a
|
|
||||||
// different large (chunked) token B, request 3 loads the cookies B produced
|
|
||||||
// and must read back B, not A and not empty.
|
|
||||||
func runChunkedTokenReplaceRegression(
|
|
||||||
t *testing.T,
|
|
||||||
set func(*SessionData, string),
|
|
||||||
get func(*SessionData) string,
|
|
||||||
tokA, tokB string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
jar := map[string]*http.Cookie{}
|
|
||||||
|
|
||||||
r1 := newRequestWithJarCookies(jar)
|
|
||||||
s1, err := sm.GetSession(r1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession (request 1): %v", err)
|
|
||||||
}
|
|
||||||
set(s1, tokA)
|
|
||||||
rr1 := httptest.NewRecorder()
|
|
||||||
if err := s1.Save(r1, rr1); err != nil {
|
|
||||||
t.Fatalf("Save (request 1): %v", err)
|
|
||||||
}
|
|
||||||
s1.returnToPoolSafely()
|
|
||||||
mergeSetCookiesIntoJar(jar, rr1)
|
|
||||||
|
|
||||||
r2 := newRequestWithJarCookies(jar)
|
|
||||||
s2, err := sm.GetSession(r2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession (request 2): %v", err)
|
|
||||||
}
|
|
||||||
if got := get(s2); got != tokA {
|
|
||||||
t.Fatalf("request 2 must load token A written by request 1 before replacing it: got len=%d, want tokA", len(got))
|
|
||||||
}
|
|
||||||
set(s2, tokB)
|
|
||||||
if got := get(s2); got != tokB {
|
|
||||||
t.Fatalf("in-memory read immediately after replacing the chunked token must return the NEW token: got len=%d (isA=%v), want tokB", len(got), got == tokA)
|
|
||||||
}
|
|
||||||
rr2 := httptest.NewRecorder()
|
|
||||||
if err := s2.Save(r2, rr2); err != nil {
|
|
||||||
t.Fatalf("Save (request 2): %v", err)
|
|
||||||
}
|
|
||||||
s2.returnToPoolSafely()
|
|
||||||
mergeSetCookiesIntoJar(jar, rr2)
|
|
||||||
|
|
||||||
r3 := newRequestWithJarCookies(jar)
|
|
||||||
s3, err := sm.GetSession(r3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession (request 3): %v", err)
|
|
||||||
}
|
|
||||||
defer s3.returnToPoolSafely()
|
|
||||||
got := get(s3)
|
|
||||||
if got != tokB {
|
|
||||||
t.Fatalf("after replacing a chunked token loaded from cookies: got len=%d (isTokA=%v), want tokB (the user would be silently signed out)", len(got), got == tokA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies pins
|
|
||||||
// FIX-05 (session.go:2409): replacing a chunked refresh token that was
|
|
||||||
// loaded from existing chunk cookies must not wipe the newly written chunks.
|
|
||||||
func TestSessionData_SetRefreshToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
|
||||||
tokA := randChunkTestToken(t, 4500)
|
|
||||||
tokB := randChunkTestToken(t, 4500)
|
|
||||||
runChunkedTokenReplaceRegression(
|
|
||||||
t,
|
|
||||||
func(s *SessionData, v string) { s.SetRefreshToken(v) },
|
|
||||||
func(s *SessionData) string { return s.GetRefreshToken() },
|
|
||||||
tokA, tokB,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies pins
|
|
||||||
// FIX-05 (session.go:3099): the same bug in SetIDToken, using structurally
|
|
||||||
// valid JWTs since SetIDToken rejects non-JWT-shaped input.
|
|
||||||
func TestSessionData_SetIDToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
|
||||||
tokA := randChunkTestJWT(t)
|
|
||||||
tokB := randChunkTestJWT(t)
|
|
||||||
runChunkedTokenReplaceRegression(
|
|
||||||
t,
|
|
||||||
func(s *SessionData, v string) { s.SetIDToken(v) },
|
|
||||||
func(s *SessionData) string { return s.GetIDToken() },
|
|
||||||
tokA, tokB,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies is the
|
|
||||||
// control: SetAccessToken already uses the expire-then-fill order (it was
|
|
||||||
// unaffected by the R154 regression) and must stay green both before and
|
|
||||||
// after FIX-05, proving the fix does not depend on a shared helper bug.
|
|
||||||
func TestSessionData_SetAccessToken_ReplacesChunkedTokenLoadedFromCookies(t *testing.T) {
|
|
||||||
tokA := randChunkTestToken(t, 4500)
|
|
||||||
tokB := randChunkTestToken(t, 4500)
|
|
||||||
runChunkedTokenReplaceRegression(
|
|
||||||
t,
|
|
||||||
func(s *SessionData, v string) { s.SetAccessToken(v) },
|
|
||||||
func(s *SessionData) string { return s.GetAccessToken() },
|
|
||||||
tokA, tokB,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression tests for FIX-06 (review-2026-09-10 finding at
|
|
||||||
// middleware.go:309, high severity): ServeHTTP wraps the response writer
|
|
||||||
// in *trackingWriter for panic-commit tracking (rw = tw) and then hands
|
|
||||||
// that SAME wrapped writer to every t.next.ServeHTTP call, including the
|
|
||||||
// SSE/WebSocket bypass and the authenticated forwarding path. Traefik runs
|
|
||||||
// this plugin interpreted under yaegi v0.16.1, and yaegi's stdlib
|
|
||||||
// interop layer composes a boundary wrapper only for ResponseWriter+
|
|
||||||
// Hijacker crossing into compiled code -- not for Flusher or Unwrap. So
|
|
||||||
// Traefik's compiled ReverseProxy calling
|
|
||||||
// http.NewResponseController(rw).Flush() on the interpreted trackingWriter
|
|
||||||
// gets "feature not supported" in production, even though trackingWriter
|
|
||||||
// declares Flush/Unwrap and native tests (which never cross an
|
|
||||||
// interpreter boundary) pass. See checkSSEFlushReachesNext in cmd/yaegicheck/main.go (run by
|
|
||||||
// `make yaegi-validate`) for the interop reproduction.
|
|
||||||
//
|
|
||||||
// The fix hands t.next.ServeHTTP the ORIGINAL writer ServeHTTP received
|
|
||||||
// (trackingWriter.Unwrap()), never the wrapper, on every forwarding path.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// nextWriterSpy is a minimal next handler that records whether the
|
|
||||||
// http.ResponseWriter it was handed is (or, transitively, still wraps) a
|
|
||||||
// *trackingWriter, so a test can assert the downstream writer is the real
|
|
||||||
// one Traefik would give a compiled ReverseProxy -- not the interpreted
|
|
||||||
// wrapper.
|
|
||||||
func nextWriterSpy(called *bool, sawTrackingWriter *bool) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
*called = true
|
|
||||||
if _, ok := w.(*trackingWriter); ok {
|
|
||||||
*sawTrackingWriter = true
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// authenticatedRequestWithCookie builds a request carrying a saved,
|
|
||||||
// authenticated session cookie for user, so the SSE/WebSocket bypass's
|
|
||||||
// cookie-only auth check (applyBypassUserHeaders) admits it.
|
|
||||||
func authenticatedRequestWithCookie(t *testing.T, sm *SessionManager, user string) *http.Request {
|
|
||||||
t.Helper()
|
|
||||||
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
|
|
||||||
session, err := sm.GetSession(setupReq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession: %v", err)
|
|
||||||
}
|
|
||||||
session.SetAuthenticated(true)
|
|
||||||
session.SetUserIdentifier(user)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
if err := session.Save(setupReq, rr); err != nil {
|
|
||||||
t.Fatalf("Save: %v", err)
|
|
||||||
}
|
|
||||||
session.returnToPoolSafely()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/stream", nil)
|
|
||||||
for _, c := range rr.Result().Cookies() {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter pins FIX-06 on the
|
|
||||||
// SSE bypass path.
|
|
||||||
func TestServeHTTP_SSEBypass_NextReceivesUnwrappedWriter(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
var called, sawTrackingWriter bool
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
|
||||||
sessionManager: sm,
|
|
||||||
initComplete: initComplete,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
|
|
||||||
req.Header.Set("Accept", "text/event-stream")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if !called {
|
|
||||||
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated SSE bypass must forward", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
if sawTrackingWriter {
|
|
||||||
t.Fatal("next handler received the interpreted trackingWriter on the SSE bypass path, not the original ResponseWriter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter pins FIX-06 on
|
|
||||||
// the WebSocket upgrade bypass path.
|
|
||||||
func TestServeHTTP_WebSocketBypass_NextReceivesUnwrappedWriter(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
var called, sawTrackingWriter bool
|
|
||||||
initComplete := make(chan struct{})
|
|
||||||
close(initComplete)
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
name: "test",
|
|
||||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
|
||||||
sessionManager: sm,
|
|
||||||
initComplete: initComplete,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := authenticatedRequestWithCookie(t, sm, "[email protected]")
|
|
||||||
req.Header.Set("Upgrade", "websocket")
|
|
||||||
req.Header.Set("Connection", "Upgrade")
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
|
|
||||||
tObj.ServeHTTP(rw, req)
|
|
||||||
|
|
||||||
if !called {
|
|
||||||
t.Fatalf("next handler was not invoked (status=%d body=%q); the authenticated WebSocket bypass must forward", rw.Code, rw.Body.String())
|
|
||||||
}
|
|
||||||
if sawTrackingWriter {
|
|
||||||
t.Fatal("next handler received the interpreted trackingWriter on the WebSocket bypass path, not the original ResponseWriter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForwardAuthorized_NextReceivesUnwrappedWriter pins FIX-06 on the
|
|
||||||
// authenticated forwarding path (forwardAuthorized is ServeHTTP's and
|
|
||||||
// bearer_auth.go's shared tail call to t.next.ServeHTTP). ServeHTTP always
|
|
||||||
// calls forwardAuthorized with rw already wrapped in *trackingWriter (see
|
|
||||||
// middleware.go:309); reproduce that shape directly here.
|
|
||||||
func TestForwardAuthorized_NextReceivesUnwrappedWriter(t *testing.T) {
|
|
||||||
var called, sawTrackingWriter bool
|
|
||||||
oidc := &TraefikOidc{
|
|
||||||
logger: GetSingletonNoOpLogger(),
|
|
||||||
next: nextWriterSpy(&called, &sawTrackingWriter),
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
tw := &trackingWriter{ResponseWriter: rec}
|
|
||||||
p := &principal{Identifier: "[email protected]"}
|
|
||||||
|
|
||||||
oidc.forwardAuthorized(tw, req, p)
|
|
||||||
|
|
||||||
if !called {
|
|
||||||
t.Fatal("next handler was not invoked")
|
|
||||||
}
|
|
||||||
if sawTrackingWriter {
|
|
||||||
t.Fatal("next handler received the interpreted trackingWriter on the authenticated path, not the original ResponseWriter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression test for FIX-10 (review-2026-09-10 finding at session.go:1784,
|
|
||||||
// middleware.go:566/789/883): a request-scoped deferred pool-return has no
|
|
||||||
// way to tell a stale holder from the current owner (ABA on the pool-return
|
|
||||||
// CAS). ServeHTTP acquires a session and defers its return; deep in the call
|
|
||||||
// chain, session.Clear() returns the SAME object to the pool and the
|
|
||||||
// handler re-acquires it under a fresh GetSession/newSession call. If a
|
|
||||||
// concurrent request pops that pooled object before the ORIGINAL deferred
|
|
||||||
// return fires, the stale return's CompareAndSwap(true, false) still
|
|
||||||
// succeeds against the new owner, incorrectly releasing (and Reset()ting) a
|
|
||||||
// session another request is actively using.
|
|
||||||
//
|
|
||||||
// Adapted from the throwaway repro at
|
|
||||||
// .claude/review-2026-09-10/repro/zz_g1_aba_test.go (TestG1StaleDeferABA),
|
|
||||||
// renamed to describe the behavior it pins and rewritten against the
|
|
||||||
// generation-gated return the fix introduces (ownerGeneration /
|
|
||||||
// returnToPoolIfOwner) instead of the raw returnToPoolSafely the bug lived
|
|
||||||
// in -- a caller must adopt the new API for the fix to apply, so the test
|
|
||||||
// exercises the same call the fixed middleware.go ServeHTTP now makes.
|
|
||||||
//
|
|
||||||
// The ABA reuse step (does the pool hand the SAME object back to a second
|
|
||||||
// GetSession after Clear) is itself an unspecified sync.Pool implementation
|
|
||||||
// detail, not part of the contract this fix establishes. Rather than depend
|
|
||||||
// on that timing (flaky: it did not reproduce on every run), this test
|
|
||||||
// drives the two field mutations GetSession/newSession make on every real
|
|
||||||
// handout -- inUse.Store(true) then generation.Add(1) -- directly on the
|
|
||||||
// SAME object Clear() just returned, deterministically recreating "a new
|
|
||||||
// owner acquired this exact object" regardless of pool internals.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner reproduces the
|
|
||||||
// ABA sequence: request A acquires session O and captures its ownership
|
|
||||||
// generation (mirrors ServeHTTP's defer registration at middleware.go:566).
|
|
||||||
// O is cleared and returned to the pool (mirrors session.Clear at
|
|
||||||
// middleware.go:774/868); a new owner then claims the SAME object the way
|
|
||||||
// GetSession/newSession do (mirrors the re-acquire at
|
|
||||||
// middleware.go:781/875) and starts using it. A's stale deferred return
|
|
||||||
// then fires (mirrors ServeHTTP's defer running at function exit) and must
|
|
||||||
// be a no-op against the new owner's live ownership.
|
|
||||||
func TestSessionPoolReturn_StaleGenerationDoesNotReleaseNewOwner(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
r1 := httptest.NewRequest("GET", "https://app.example.com/", nil)
|
|
||||||
o, err := sm.GetSession(r1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession (request A): %v", err)
|
|
||||||
}
|
|
||||||
// ServeHTTP captures the generation right after acquiring, then defers
|
|
||||||
// a generation-gated return instead of an unconditional one.
|
|
||||||
staleGen := o.ownerGeneration()
|
|
||||||
|
|
||||||
if err := o.Clear(r1, httptest.NewRecorder()); err != nil {
|
|
||||||
t.Fatalf("Clear: %v", err)
|
|
||||||
}
|
|
||||||
if o.inUse.Load() {
|
|
||||||
t.Fatalf("Clear must return the object to the pool (inUse still true)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new owner claims the exact same object: this is the sequence
|
|
||||||
// GetSession/newSession run on every real handout -- generation,
|
|
||||||
// sessionOwner, then inUse (session.go:1076-1078, 1108-1110) --
|
|
||||||
// reproduced directly so the test does not depend on sync.Pool's
|
|
||||||
// unspecified reuse timing. sessionOwner must be set to the new
|
|
||||||
// generation, not left at 0: a stale returnToPoolIfOwner call gates on
|
|
||||||
// sessionOwner, so leaving it 0 would make the object look already-free
|
|
||||||
// rather than owned by someone else, which is what this test needs to
|
|
||||||
// tell apart.
|
|
||||||
o.sessionOwner.Store(o.generation.Add(1))
|
|
||||||
o.inUse.Store(true)
|
|
||||||
o.SetUserIdentifier("bob")
|
|
||||||
|
|
||||||
if staleGen == o.ownerGeneration() {
|
|
||||||
t.Fatalf("test setup bug: new owner's generation (%d) must differ from the captured stale generation (%d)", o.ownerGeneration(), staleGen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A's stale deferred return fires at ServeHTTP exit, after the new
|
|
||||||
// owner has already claimed and started using the same object.
|
|
||||||
o.returnToPoolIfOwner(staleGen)
|
|
||||||
|
|
||||||
if !o.inUse.Load() {
|
|
||||||
t.Fatalf("stale deferred return (captured generation %d) released a session owned by another holder (current generation %d)", staleGen, o.ownerGeneration())
|
|
||||||
}
|
|
||||||
if o.GetUserIdentifier() != "bob" {
|
|
||||||
t.Fatalf("stale deferred return corrupted the new owner's session data: got user %q, want \"bob\"", o.GetUserIdentifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
o.returnToPoolSafely()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSessionPoolReturn_CurrentGenerationStillReturns is the control: a
|
|
||||||
// non-stale generation-gated return (the common case -- no Clear+reacquire
|
|
||||||
// happened) must still return the object to the pool, so the fix does not
|
|
||||||
// leak sessions on the ordinary path.
|
|
||||||
func TestSessionPoolReturn_CurrentGenerationStillReturns(t *testing.T) {
|
|
||||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSessionManager: %v", err)
|
|
||||||
}
|
|
||||||
defer sm.Shutdown()
|
|
||||||
|
|
||||||
r := httptest.NewRequest("GET", "https://app.example.com/", nil)
|
|
||||||
s, err := sm.GetSession(r)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSession: %v", err)
|
|
||||||
}
|
|
||||||
gen := s.ownerGeneration()
|
|
||||||
|
|
||||||
s.returnToPoolIfOwner(gen)
|
|
||||||
|
|
||||||
if s.inUse.Load() {
|
|
||||||
t.Fatalf("returnToPoolIfOwner with the current generation must return the object to the pool (inUse still true)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-18: main.go wires up TWO GracefulDegradation instances per plugin
|
|
||||||
// instance — one directly via NewErrorRecoveryManager (t.errorRecoveryManager)
|
|
||||||
// and one nested inside NewTokenResilienceManager's own ErrorRecoveryManager
|
|
||||||
// (t.tokenResilienceManager). Production Close() only ever closed the first
|
|
||||||
// one (t.errorRecoveryManager.gracefulDegradation.Close()), so the second gd
|
|
||||||
// leaked forever in the package-level gdInstances registry and the shared
|
|
||||||
// "graceful-degradation-health-check" task's last-instance stop gate
|
|
||||||
// (len(gdInstances.set)==0) never fired.
|
|
||||||
//
|
|
||||||
// gdInstancesLen (below) reads the size of the package-level gdInstances set.
|
|
||||||
// The test asserts on the DELTA around creating and closing both managers, so
|
|
||||||
// it stays correct regardless of what other tests in this package leave
|
|
||||||
// registered.
|
|
||||||
func TestFix18_ClosingBothManagersEmptiesGDInstances(t *testing.T) {
|
|
||||||
logger := GetSingletonNoOpLogger()
|
|
||||||
|
|
||||||
before := gdInstancesLen()
|
|
||||||
|
|
||||||
erm := NewErrorRecoveryManager(logger)
|
|
||||||
trm := NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
|
|
||||||
|
|
||||||
afterNew := gdInstancesLen()
|
|
||||||
if afterNew != before+2 {
|
|
||||||
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances (one direct, one nested in TokenResilienceManager); got delta %d", afterNew-before)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let each instance's async health-check goroutine (started by
|
|
||||||
// NewGracefulDegradation) settle — either it assigns the shared task, or
|
|
||||||
// it observes an already-closed instance and bails — before closing.
|
|
||||||
// This makes the Close() calls below exercise the real "task already
|
|
||||||
// assigned, stop it by name" path deterministically on every run instead
|
|
||||||
// of sometimes racing the goroutine (FIX-18 review, item 4).
|
|
||||||
waitForHealthRoutineSettled(t, erm.gracefulDegradation)
|
|
||||||
waitForHealthRoutineSettled(t, trm.errorRecoveryManager.gracefulDegradation)
|
|
||||||
|
|
||||||
// Close every gd this wiring created. Before FIX-18, only erm's gd had a
|
|
||||||
// Close path reachable from TraefikOidc.Close (via
|
|
||||||
// t.errorRecoveryManager.gracefulDegradation.Close()); trm's own gd had
|
|
||||||
// no Close method to call at all.
|
|
||||||
erm.Close()
|
|
||||||
trm.Close()
|
|
||||||
|
|
||||||
afterClose := gdInstancesLen()
|
|
||||||
if afterClose != before {
|
|
||||||
t.Fatalf("gdInstances leaked %d entries after closing every gd this wiring created (afterClose delta=%d, want 0) — TokenResilienceManager's own GracefulDegradation was never closed", afterClose-before, afterClose-before)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only assert the shared health-check task itself stopped when this test
|
|
||||||
// is known to be the sole registrant (before==0): the task is a
|
|
||||||
// process-global singleton shared by every GracefulDegradation created
|
|
||||||
// anywhere in the suite, so this assertion would be flaky under a
|
|
||||||
// nonzero baseline left by another test.
|
|
||||||
if before == 0 && GetResourceManager().IsTaskRunning("graceful-degradation-health-check") {
|
|
||||||
t.Fatal("graceful-degradation-health-check task must stop once gdInstances is empty (last-instance stop gate)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFix18_TraefikOidcCloseEmptiesGDInstances pins the FIX-18 major-finding
|
|
||||||
// gap left by the test above: calling erm.Close()/trm.Close() directly stays
|
|
||||||
// green even if the real (*TraefikOidc).Close() is reverted to skip one of
|
|
||||||
// them (utilities.go's t.errorRecoveryManager.Close() /
|
|
||||||
// t.tokenResilienceManager.Close() calls). This test wires both managers on
|
|
||||||
// a TraefikOidc exactly as NewWithContext does (main.go's
|
|
||||||
// t.errorRecoveryManager = NewErrorRecoveryManager(...) followed by
|
|
||||||
// t.tokenResilienceManager = NewTokenResilienceManager(...)) and drives the
|
|
||||||
// real Close(), so it only passes when Close() actually reaches both.
|
|
||||||
func TestFix18_TraefikOidcCloseEmptiesGDInstances(t *testing.T) {
|
|
||||||
logger := GetSingletonNoOpLogger()
|
|
||||||
before := gdInstancesLen()
|
|
||||||
|
|
||||||
oidc := &TraefikOidc{logger: logger}
|
|
||||||
oidc.errorRecoveryManager = NewErrorRecoveryManager(logger)
|
|
||||||
oidc.tokenResilienceManager = NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
|
|
||||||
|
|
||||||
afterNew := gdInstancesLen()
|
|
||||||
if afterNew != before+2 {
|
|
||||||
t.Fatalf("precondition: wiring one plugin instance must create exactly 2 GracefulDegradation instances; got delta %d", afterNew-before)
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForHealthRoutineSettled(t, oidc.errorRecoveryManager.gracefulDegradation)
|
|
||||||
waitForHealthRoutineSettled(t, oidc.tokenResilienceManager.errorRecoveryManager.gracefulDegradation)
|
|
||||||
|
|
||||||
// Keep a sentinel instance alive across Close() so this test's Close()
|
|
||||||
// call is never the last live instance: this test only asserts on
|
|
||||||
// gdInstances, but Close()'s "am I last" decision (stopIfLastInstance)
|
|
||||||
// also stops process-global singleton tasks — singleton-token-cleanup
|
|
||||||
// and, via GetGlobalTaskRegistry().StopAllTasks(), every other
|
|
||||||
// registered singleton (e.g. memory-monitor) — that this test does not
|
|
||||||
// own and must not stop out from under the rest of the suite.
|
|
||||||
registerLiveInstance()
|
|
||||||
t.Cleanup(func() { unregisterLiveInstance() })
|
|
||||||
|
|
||||||
// Balance Close()'s own unregisterLiveInstance() call: New() always
|
|
||||||
// registers before it returns an instance for Close() to eventually
|
|
||||||
// unregister, and this test's synthetic instance must too, or it skews
|
|
||||||
// the process-global liveInstanceCount permanently negative for every
|
|
||||||
// other test that runs afterward in the same binary (FIX-35 territory,
|
|
||||||
// but liveInstanceCount is shared package state regardless of which
|
|
||||||
// finding a given test targets).
|
|
||||||
registerLiveInstance()
|
|
||||||
|
|
||||||
if err := oidc.Close(); err != nil {
|
|
||||||
t.Fatalf("Close returned an error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
after := gdInstancesLen()
|
|
||||||
if after != before {
|
|
||||||
t.Fatalf("gdInstances leaked %d entries after TraefikOidc.Close (delta=%d, want 0) — Close must close both t.errorRecoveryManager and t.tokenResilienceManager", after-before, after-before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFix18_CloseImmediatelyAfterNewDoesNotReRegister pins the exact race the
|
|
||||||
// FIX-18 review's scratch probe found: NewGracefulDegradation followed
|
|
||||||
// immediately by Close(), before the async health-check goroutine has had a
|
|
||||||
// chance to run. The old startHealthCheckRoutine re-added gd to gdInstances
|
|
||||||
// from inside that goroutine (after Close had already deleted it), permanently
|
|
||||||
// re-registering an already-closed instance and restarting the shared
|
|
||||||
// health-check task for it with nothing left able to ever stop it again.
|
|
||||||
func TestFix18_CloseImmediatelyAfterNewDoesNotReRegister(t *testing.T) {
|
|
||||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
|
||||||
|
|
||||||
cfg := DefaultGracefulDegradationConfig()
|
|
||||||
gd := NewGracefulDegradation(cfg, GetSingletonNoOpLogger())
|
|
||||||
gd.Close()
|
|
||||||
|
|
||||||
// Give the async health-check goroutine every opportunity to run and,
|
|
||||||
// under the bug, re-register the closed instance.
|
|
||||||
deadline := time.Now().Add(200 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
gdInstances.RLock()
|
|
||||||
n := len(gdInstances.set)
|
|
||||||
gdInstances.RUnlock()
|
|
||||||
if n != 0 {
|
|
||||||
t.Fatalf("closed instance was re-registered: gdInstances has %d entries, want 0", n)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFix18_NewWithContextClosesManagersOnEarlyError pins the FIX-18 minor
|
|
||||||
// finding: NewWithContext creates t.errorRecoveryManager and
|
|
||||||
// t.tokenResilienceManager, then returns an error from a later validation
|
|
||||||
// step (here, an invalid private_key_jwt key) without ever closing either
|
|
||||||
// one — leaking their GracefulDegradation instances in gdInstances forever.
|
|
||||||
func TestFix18_NewWithContextClosesManagersOnEarlyError(t *testing.T) {
|
|
||||||
before := gdInstancesLen()
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
ProviderURL: "https://accounts.google.com",
|
|
||||||
ClientID: "test-client",
|
|
||||||
ClientSecret: "test-secret",
|
|
||||||
CallbackURL: "/callback",
|
|
||||||
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
|
|
||||||
RateLimit: 100,
|
|
||||||
ClientAuthMethod: "private_key_jwt",
|
|
||||||
ClientAssertionKeyID: "test-kid",
|
|
||||||
ClientAssertionPrivateKey: "-----BEGIN PRIVATE KEY-----\nnot-valid-base64-content!!!\n-----END PRIVATE KEY-----\n",
|
|
||||||
}
|
|
||||||
|
|
||||||
plugin, err := NewWithContext(context.Background(), cfg, nil, "fix18-bad-key")
|
|
||||||
if err == nil {
|
|
||||||
if plugin != nil {
|
|
||||||
plugin.Close()
|
|
||||||
}
|
|
||||||
t.Fatal("expected NewWithContext to fail on an invalid private_key_jwt key")
|
|
||||||
}
|
|
||||||
if plugin != nil {
|
|
||||||
t.Fatal("NewWithContext must return a nil instance on error")
|
|
||||||
}
|
|
||||||
|
|
||||||
after := gdInstancesLen()
|
|
||||||
if after != before {
|
|
||||||
t.Fatalf("NewWithContext's error path leaked %d GracefulDegradation instance(s) in gdInstances (before=%d after=%d) — the errorRecoveryManager and tokenResilienceManager created just before the failure were never closed", after-before, before, after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthRoutineSettled reports whether startHealthCheckRoutine has finished
|
|
||||||
// making its one-time decision for this instance: either it assigned a
|
|
||||||
// health-check task, or it observed stopChan already closed and returned
|
|
||||||
// without assigning one (in which case it never will, since it runs at most
|
|
||||||
// once). Tests use this to wait for the async goroutine NewGracefulDegradation
|
|
||||||
// starts to settle before asserting on shared state, instead of depending on
|
|
||||||
// goroutine-scheduling timing.
|
|
||||||
//
|
|
||||||
// Test-only: moved out of error_recovery.go (production code) because its
|
|
||||||
// only caller is waitForHealthRoutineSettled below, following the pattern
|
|
||||||
// FIX-39 established for resetGlobalTransportPoolForTest
|
|
||||||
// (internal/pool/export_test.go) — a test-only helper has no business being
|
|
||||||
// compiled into the production binary.
|
|
||||||
func (gd *GracefulDegradation) healthRoutineSettled() bool {
|
|
||||||
gd.mutex.RLock()
|
|
||||||
defer gd.mutex.RUnlock()
|
|
||||||
|
|
||||||
if gd.healthCheckTask != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-gd.stopChan:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForHealthRoutineSettled polls until gd's async startHealthCheckRoutine
|
|
||||||
// goroutine has settled (see GracefulDegradation.healthRoutineSettled).
|
|
||||||
func waitForHealthRoutineSettled(t *testing.T, gd *GracefulDegradation) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if gd.healthRoutineSettled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatal("health-check goroutine did not settle (task assigned or instance already closed) in time")
|
|
||||||
}
|
|
||||||
|
|
||||||
// gdInstancesLen reads the current size of the package-level gdInstances set
|
|
||||||
// under its own lock.
|
|
||||||
func gdInstancesLen() int {
|
|
||||||
gdInstances.RLock()
|
|
||||||
defer gdInstances.RUnlock()
|
|
||||||
return len(gdInstances.set)
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshotAndClearGdInstancesForTest captures the current contents of the
|
|
||||||
// package-level gdInstances registry and replaces it with a fresh empty set,
|
|
||||||
// mirroring resetGdInstancesForTest (review_r34_regression_test.go). Unlike
|
|
||||||
// that helper, it returns a restore func the caller registers with
|
|
||||||
// t.Cleanup: a bare reset with no restore permanently discards whatever
|
|
||||||
// GracefulDegradation instances other tests in the same binary had
|
|
||||||
// registered before this test ran, instead of only clearing for the
|
|
||||||
// duration of this test.
|
|
||||||
func snapshotAndClearGdInstancesForTest() func() {
|
|
||||||
gdInstances.Lock()
|
|
||||||
saved := gdInstances.set
|
|
||||||
gdInstances.set = make(map[*GracefulDegradation]struct{})
|
|
||||||
gdInstances.Unlock()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
gdInstances.Lock()
|
|
||||||
gdInstances.set = saved
|
|
||||||
gdInstances.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-19: RegisterBackgroundTask replaced a registered-but-not-yet-started
|
|
||||||
// task (started==0, stopped==0) unconditionally. CreateSingletonTask runs
|
|
||||||
// Register -> IsTaskRunning -> StartBackgroundTask without holding tasksMu
|
|
||||||
// across the three calls (autocleanup.go:612-632), so a second concurrent
|
|
||||||
// caller can land in RegisterBackgroundTask in the window before the first
|
|
||||||
// caller's own Start() call runs.
|
|
||||||
//
|
|
||||||
// This test reproduces that exact interleaving deterministically, without
|
|
||||||
// real goroutines, by issuing the calls in the documented race order:
|
|
||||||
//
|
|
||||||
// 1. Caller A registers the task (T1).
|
|
||||||
// 2. Caller A reads its handle from the registry (mirrors
|
|
||||||
// CreateSingletonTask's internal StartBackgroundTask lookup) but has not
|
|
||||||
// called Start() on it yet.
|
|
||||||
// 3. Caller B registers the SAME name before A calls Start() — at head this
|
|
||||||
// replaces the map entry with a fresh task (T2).
|
|
||||||
// 4. Caller A calls Start() on the handle it read in step 2.
|
|
||||||
// 5. Caller B starts whatever is now registered under the name.
|
|
||||||
//
|
|
||||||
// At head this starts two independent tickers (T1 wrapping count1, T2
|
|
||||||
// wrapping count2). Only the object actually stored in the registry (T2) is
|
|
||||||
// reachable from StopAllTasks, so T1 becomes an orphan that keeps running
|
|
||||||
// forever past Close/StopAllTasks. After the fix, step 3's register call
|
|
||||||
// keeps the existing (still-pending) T1 instead of replacing it, so there is
|
|
||||||
// only ever one task, and count2 (which would wrap a second, orphaned task)
|
|
||||||
// never starts at all.
|
|
||||||
func TestFix19_RegisterBackgroundTaskKeepsPendingTaskNoOrphan(t *testing.T) {
|
|
||||||
rm := GetResourceManager()
|
|
||||||
name := "fix19-" + t.Name()
|
|
||||||
|
|
||||||
var count1, count2 int32
|
|
||||||
|
|
||||||
// Step 1: caller A registers T1.
|
|
||||||
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
|
|
||||||
atomic.AddInt32(&count1, 1)
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("first RegisterBackgroundTask: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: caller A reads its handle, but has not started it yet.
|
|
||||||
rm.tasksMu.RLock()
|
|
||||||
aTask := rm.tasks[name]
|
|
||||||
rm.tasksMu.RUnlock()
|
|
||||||
if aTask == nil {
|
|
||||||
t.Fatal("task not present in registry after first RegisterBackgroundTask")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: caller B registers the same name before A calls Start().
|
|
||||||
if err := rm.RegisterBackgroundTask(name, 5*time.Millisecond, func() {
|
|
||||||
atomic.AddInt32(&count2, 1)
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("second RegisterBackgroundTask: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: caller A starts the handle it read in step 2.
|
|
||||||
aTask.Start()
|
|
||||||
|
|
||||||
// Step 5: caller B starts whatever is now registered under the name.
|
|
||||||
if err := rm.StartBackgroundTask(name); err != nil {
|
|
||||||
t.Fatalf("StartBackgroundTask: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let both tickers (or, after the fix, the single ticker) tick a few
|
|
||||||
// times before stopping.
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
|
|
||||||
// Stop only the named task this test created, not rm.StopAllTasks(): the
|
|
||||||
// ResourceManager is a process-global singleton, and StopAllTasks stops
|
|
||||||
// every task registered on it, including unrelated singletons (e.g.
|
|
||||||
// memory-monitor, singleton-token-cleanup) that other tests in the same
|
|
||||||
// binary may have started and still expect running.
|
|
||||||
if err := rm.StopBackgroundTask(name); err != nil {
|
|
||||||
t.Fatalf("StopBackgroundTask(%q): %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot count1 right after the stop, then again after another window
|
|
||||||
// long enough for several more ticks. A well-stopped task's counter must
|
|
||||||
// not move between the two snapshots.
|
|
||||||
afterStop1 := atomic.LoadInt32(&count1)
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
final1 := atomic.LoadInt32(&count1)
|
|
||||||
|
|
||||||
if final1 != afterStop1 {
|
|
||||||
t.Fatalf("task wrapping count1 kept running after StopBackgroundTask (orphan escaped registry): %d -> %d", afterStop1, final1)
|
|
||||||
}
|
|
||||||
if afterStop1 == 0 {
|
|
||||||
t.Fatal("precondition: task wrapping count1 never ran at all")
|
|
||||||
}
|
|
||||||
|
|
||||||
// count2 must never have run: after the fix, the second
|
|
||||||
// RegisterBackgroundTask call keeps the existing pending task instead of
|
|
||||||
// constructing and starting a second one.
|
|
||||||
if got := atomic.LoadInt32(&count2); got != 0 {
|
|
||||||
t.Fatalf("second RegisterBackgroundTask call must not create and start a second task while the first is still pending; count2=%d", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: make sure the task this test created cannot keep running past
|
|
||||||
// the test even if an assertion above already failed it.
|
|
||||||
aTask.Stop()
|
|
||||||
rm.tasksMu.RLock()
|
|
||||||
bTask := rm.tasks[name]
|
|
||||||
rm.tasksMu.RUnlock()
|
|
||||||
if bTask != nil {
|
|
||||||
bTask.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fix26CapturingLogger builds a *Logger whose Errorf output lands in buf
|
|
||||||
// while Info/Debug are discarded, so a test can assert on exactly what
|
|
||||||
// reaches the error stream without touching the process-wide os.Stderr.
|
|
||||||
func fix26CapturingLogger(buf *bytes.Buffer) *Logger {
|
|
||||||
return &Logger{
|
|
||||||
logError: log.New(buf, "", 0),
|
|
||||||
logInfo: log.New(io.Discard, "", 0),
|
|
||||||
logDebug: log.New(io.Discard, "", 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly
|
|
||||||
// pins FIX-26. validateDiscoveredEndpoint already drops (R146) a discovered
|
|
||||||
// http endpoint when providerURL is https, and the pre-existing "Ignoring
|
|
||||||
// discovered ..." ERROR line already names the endpoint, the URL, and the
|
|
||||||
// scheme-downgrade reason (it wraps ErrDiscoveredEndpointSchemeDowngrade).
|
|
||||||
// What was missing was a way to tell that rejection apart, at a glance, from
|
|
||||||
// every other validation rejection (SSRF block, malformed URL, path
|
|
||||||
// traversal, ...) for the three endpoints login cannot function without. The
|
|
||||||
// fix adds one additional, distinctly-tagged SECURITY line — naming the
|
|
||||||
// endpoint and stating there is no config override — for exactly those
|
|
||||||
// three (jwks_uri, authorization, token); the other four discovered
|
|
||||||
// endpoints (revocation, end_session, introspection, registration) keep
|
|
||||||
// only the generic line, since three of them (revocationURL,
|
|
||||||
// oidcEndSessionURL, introspectionURL) have an operator override that
|
|
||||||
// replaces them right after sanitize runs.
|
|
||||||
func TestUpdateMetadataEndpoints_HTTPTokenEndpointUnderHTTPSProviderFailsLoudly(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: fix26CapturingLogger(&buf),
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
|
||||||
TokenURL: "http://provider.example.com/token",
|
|
||||||
})
|
|
||||||
|
|
||||||
if tObj.tokenURL != "" {
|
|
||||||
t.Fatalf("an http token endpoint under an https provider must still be dropped, got %q", tObj.tokenURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
logged := buf.String()
|
|
||||||
if !strings.Contains(logged, "SECURITY:") {
|
|
||||||
t.Fatalf("dropping an endpoint for the https-pin reason must log a SECURITY-tagged line, got:\n%s", logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, "token") {
|
|
||||||
t.Fatalf("the SECURITY line must name the dropped endpoint (token), got:\n%s", logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, "http://provider.example.com/token") {
|
|
||||||
t.Fatalf("the SECURITY line must name the dropped URL, got:\n%s", logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, "no override") {
|
|
||||||
t.Fatalf("the SECURITY line must state that this check has no override, got:\n%s", logged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog is the
|
|
||||||
// negative case: well-formed https endpoints (jwks_uri, authorization,
|
|
||||||
// token) must not trip the new SECURITY line.
|
|
||||||
func TestUpdateMetadataEndpoints_AllHTTPSCriticalEndpointsNoSecurityLog(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: fix26CapturingLogger(&buf),
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
|
||||||
JWKSURL: "https://provider.example.com/jwks",
|
|
||||||
AuthURL: "https://provider.example.com/auth",
|
|
||||||
TokenURL: "https://provider.example.com/token",
|
|
||||||
})
|
|
||||||
|
|
||||||
if tObj.tokenURL != "https://provider.example.com/token" {
|
|
||||||
t.Fatalf("an https token endpoint must be accepted, got %q", tObj.tokenURL)
|
|
||||||
}
|
|
||||||
if strings.Contains(buf.String(), "SECURITY:") {
|
|
||||||
t.Fatalf("accepted https endpoints must not log a SECURITY line, got:\n%s", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly extends the
|
|
||||||
// pin to the other two endpoints login cannot function without: jwks_uri
|
|
||||||
// (signature verification) and authorization (the redirect target). Each
|
|
||||||
// must independently trigger its own named SECURITY line.
|
|
||||||
func TestUpdateMetadataEndpoints_HTTPJWKSAndAuthEndpointsFailLoudly(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
field string
|
|
||||||
endpoint string
|
|
||||||
}{
|
|
||||||
{field: "jwks_uri", endpoint: "http://provider.example.com/jwks"},
|
|
||||||
{field: "authorization", endpoint: "http://provider.example.com/auth"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.field, func(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: fix26CapturingLogger(&buf),
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
}
|
|
||||||
md := &ProviderMetadata{}
|
|
||||||
switch tc.field {
|
|
||||||
case "jwks_uri":
|
|
||||||
md.JWKSURL = tc.endpoint
|
|
||||||
case "authorization":
|
|
||||||
md.AuthURL = tc.endpoint
|
|
||||||
}
|
|
||||||
tObj.updateMetadataEndpoints(md)
|
|
||||||
|
|
||||||
logged := buf.String()
|
|
||||||
if !strings.Contains(logged, "SECURITY:") {
|
|
||||||
t.Fatalf("%s: dropping the endpoint must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, tc.field) {
|
|
||||||
t.Fatalf("%s: the SECURITY line must name the endpoint, got:\n%s", tc.field, logged)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine pins that
|
|
||||||
// the registration endpoint never trips the SECURITY-tagged line. It is the
|
|
||||||
// one discovered endpoint of the four (end_session, revocation,
|
|
||||||
// introspection, registration) with no config*URL field checked inside
|
|
||||||
// updateMetadataEndpoints at all: RegisterClient reads dcrConfig.
|
|
||||||
// RegistrationEndpoint directly (main.go, outside this function) whenever
|
|
||||||
// it is set, so a dropped discovered value here is orthogonal to whether
|
|
||||||
// that separate override exists.
|
|
||||||
func TestUpdateMetadataEndpoints_RegistrationNeverLogsSecurityLine(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: fix26CapturingLogger(&buf),
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
tObj.updateMetadataEndpoints(&ProviderMetadata{
|
|
||||||
RegistrationURL: "http://provider.example.com/register",
|
|
||||||
})
|
|
||||||
|
|
||||||
logged := buf.String()
|
|
||||||
if !strings.Contains(logged, "Ignoring discovered registration") {
|
|
||||||
t.Fatalf("registration must still be dropped with the generic line, got:\n%s", logged)
|
|
||||||
}
|
|
||||||
if strings.Contains(logged, "SECURITY:") {
|
|
||||||
t.Fatalf("registration must never log a SECURITY line, got:\n%s", logged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine
|
|
||||||
// pins the re-review fix at main.go:658 (low severity): a scheme-downgrade
|
|
||||||
// drop of end_session, revocation or introspection is exactly as terminal
|
|
||||||
// as a drop of jwks_uri/authorization/token WHEN the operator has not set
|
|
||||||
// the matching override (oidcEndSessionURL / revocationURL /
|
|
||||||
// introspectionURL) -- logout falls back to a local-only redirect, provider
|
|
||||||
// revocation is skipped, or requireTokenIntrospection breaks, all silently
|
|
||||||
// except for the generic "Ignoring discovered" line. The old
|
|
||||||
// discoveredEndpointHasNoOverride was static and always answered false for
|
|
||||||
// these three regardless of whether an override was actually configured, so
|
|
||||||
// the SECURITY line never fired for them even with no override at all. It
|
|
||||||
// must now fire exactly when the corresponding override is empty.
|
|
||||||
func TestUpdateMetadataEndpoints_NonCriticalEndpointsWithoutOverrideLogSecurityLine(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
field string
|
|
||||||
endpoint string
|
|
||||||
}{
|
|
||||||
{field: "end_session", endpoint: "http://provider.example.com/logout"},
|
|
||||||
{field: "revocation", endpoint: "http://provider.example.com/revoke"},
|
|
||||||
{field: "introspection", endpoint: "http://provider.example.com/introspect"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.field, func(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
// Deliberately no configEndSessionURL/configRevocationURL/
|
|
||||||
// configIntrospectionURL set: the operator configured no
|
|
||||||
// override for any of these three.
|
|
||||||
tObj := &TraefikOidc{
|
|
||||||
logger: fix26CapturingLogger(&buf),
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
}
|
|
||||||
md := &ProviderMetadata{}
|
|
||||||
switch tc.field {
|
|
||||||
case "end_session":
|
|
||||||
md.EndSessionURL = tc.endpoint
|
|
||||||
case "revocation":
|
|
||||||
md.RevokeURL = tc.endpoint
|
|
||||||
case "introspection":
|
|
||||||
md.IntrospectionURL = tc.endpoint
|
|
||||||
}
|
|
||||||
tObj.updateMetadataEndpoints(md)
|
|
||||||
|
|
||||||
logged := buf.String()
|
|
||||||
if !strings.Contains(logged, "Ignoring discovered "+tc.field) {
|
|
||||||
t.Fatalf("%s: the endpoint must still be dropped with the generic line, got:\n%s", tc.field, logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, "SECURITY:") {
|
|
||||||
t.Fatalf("%s: dropping it with no configured override must log a SECURITY-tagged line, got:\n%s", tc.field, logged)
|
|
||||||
}
|
|
||||||
if !strings.Contains(logged, "no override") {
|
|
||||||
t.Fatalf("%s: the SECURITY line must state that this check has no override, got:\n%s", tc.field, logged)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint pins the
|
|
||||||
// second half of the same finding, table-driven over all three endpoints
|
|
||||||
// that carry an override: setting the operator override for an endpoint
|
|
||||||
// discovery drops for the scheme-downgrade reason means the endpoint does
|
|
||||||
// not stay empty — the override runs right after sanitize and replaces it,
|
|
||||||
// so a "requests needing this endpoint will fail" style claim would be
|
|
||||||
// false for these three. Only end_session was pinned before; a regression
|
|
||||||
// that stopped applying configRevocationURL or configIntrospectionURL
|
|
||||||
// specifically would have left every test green (re-review finding at
|
|
||||||
// fix26_scheme_downgrade_test.go:218).
|
|
||||||
func TestUpdateMetadataEndpoints_ConfigOverrideReplacesDroppedEndpoint(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
field string
|
|
||||||
droppedURL string
|
|
||||||
overrideURL string
|
|
||||||
buildMD func(dropped string) *ProviderMetadata
|
|
||||||
buildTObj func(logger *Logger, override string) *TraefikOidc
|
|
||||||
resultingURL func(tObj *TraefikOidc) string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
field: "end_session",
|
|
||||||
droppedURL: "http://provider.example.com/logout",
|
|
||||||
overrideURL: "https://provider.example.com/logout-override",
|
|
||||||
buildMD: func(dropped string) *ProviderMetadata {
|
|
||||||
return &ProviderMetadata{EndSessionURL: dropped}
|
|
||||||
},
|
|
||||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
|
||||||
return &TraefikOidc{
|
|
||||||
logger: logger,
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
configEndSessionURL: override,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.endSessionURL },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "revocation",
|
|
||||||
droppedURL: "http://provider.example.com/revoke",
|
|
||||||
overrideURL: "https://provider.example.com/revoke-override",
|
|
||||||
buildMD: func(dropped string) *ProviderMetadata {
|
|
||||||
return &ProviderMetadata{RevokeURL: dropped}
|
|
||||||
},
|
|
||||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
|
||||||
return &TraefikOidc{
|
|
||||||
logger: logger,
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
configRevocationURL: override,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.revocationURL },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "introspection",
|
|
||||||
droppedURL: "http://provider.example.com/introspect",
|
|
||||||
overrideURL: "https://provider.example.com/introspect-override",
|
|
||||||
buildMD: func(dropped string) *ProviderMetadata {
|
|
||||||
return &ProviderMetadata{IntrospectionURL: dropped}
|
|
||||||
},
|
|
||||||
buildTObj: func(logger *Logger, override string) *TraefikOidc {
|
|
||||||
return &TraefikOidc{
|
|
||||||
logger: logger,
|
|
||||||
providerURL: "https://provider.example.com",
|
|
||||||
configIntrospectionURL: override,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resultingURL: func(tObj *TraefikOidc) string { return tObj.introspectionURL },
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.field, func(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tObj := tc.buildTObj(fix26CapturingLogger(&buf), tc.overrideURL)
|
|
||||||
|
|
||||||
tObj.updateMetadataEndpoints(tc.buildMD(tc.droppedURL))
|
|
||||||
|
|
||||||
if got := tc.resultingURL(tObj); got != tc.overrideURL {
|
|
||||||
t.Fatalf("%s: the configured override must replace the dropped discovered endpoint, got %q, want %q", tc.field, got, tc.overrideURL)
|
|
||||||
}
|
|
||||||
if strings.Contains(buf.String(), "SECURITY:") {
|
|
||||||
t.Fatalf("%s: has a config override, so no SECURITY line applies, got:\n%s", tc.field, buf.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Regression test for FIX-34 (review-2026-09-10 finding at
|
|
||||||
// middleware.go:1169, low severity): forwardAuthorized truncated an
|
|
||||||
// oversized ID token to headerTemplateMaxLen (8192) bytes and still
|
|
||||||
// forwarded it in X-Auth-Request-Token. A truncated JWT is structurally
|
|
||||||
// invalid; a downstream that verifies the signature rejects it (fail
|
|
||||||
// closed, fine), but one that merely base64-decodes claims without
|
|
||||||
// verifying reads a corrupted, silently-wrong value. The header must be
|
|
||||||
// dropped instead, matching the fail-closed pattern already used elsewhere
|
|
||||||
// in forwardAuthorized (X-Forwarded-User / X-Auth-Request-User on a failed
|
|
||||||
// sanitization).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating pins
|
|
||||||
// FIX-34: an ID token longer than headerTemplateMaxLen must not appear in
|
|
||||||
// X-Auth-Request-Token at all, truncated or otherwise.
|
|
||||||
func TestForwardAuthorized_OversizedIDTokenDropsHeaderInsteadOfTruncating(t *testing.T) {
|
|
||||||
oidc := &TraefikOidc{
|
|
||||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
||||||
logger: NewLogger("error"),
|
|
||||||
// minimalHeaders defaults false: the X-Auth-Request-* block (where
|
|
||||||
// X-Auth-Request-Token is set) only runs when it is false.
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
oversizedIDToken := strings.Repeat("a", headerTemplateMaxLen+1)
|
|
||||||
p := &principal{
|
|
||||||
Identifier: "user",
|
|
||||||
IDToken: oversizedIDToken,
|
|
||||||
}
|
|
||||||
oidc.forwardAuthorized(rw, req, p)
|
|
||||||
|
|
||||||
if got := req.Header.Get("X-Auth-Request-Token"); got != "" {
|
|
||||||
t.Fatalf("X-Auth-Request-Token must be dropped (not truncated) for an oversized ID token; got %d bytes, want header absent", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestForwardAuthorized_IDTokenWithinLimitStillForwarded is the control: an
|
|
||||||
// ID token at or under the header budget must still be forwarded
|
|
||||||
// unmodified, so the fix does not regress the common case.
|
|
||||||
func TestForwardAuthorized_IDTokenWithinLimitStillForwarded(t *testing.T) {
|
|
||||||
oidc := &TraefikOidc{
|
|
||||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
|
||||||
logger: NewLogger("error"),
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
rw := httptest.NewRecorder()
|
|
||||||
idToken := "header.payload.signature"
|
|
||||||
p := &principal{
|
|
||||||
Identifier: "user",
|
|
||||||
IDToken: idToken,
|
|
||||||
}
|
|
||||||
oidc.forwardAuthorized(rw, req, p)
|
|
||||||
|
|
||||||
if got := req.Header.Get("X-Auth-Request-Token"); got != idToken {
|
|
||||||
t.Fatalf("X-Auth-Request-Token = %q, want the unmodified ID token %q", got, idToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FIX-35: Close() decided "am I the last live instance" once, early in its
|
|
||||||
// shutdown sequence (unregisterLiveInstance()'s return value), then reused
|
|
||||||
// that stale boolean much later to decide whether to stop process-global
|
|
||||||
// singleton tasks (memory-monitor, singleton-token-cleanup,
|
|
||||||
// singleton-metadata-refresh-*). Session/cache teardown happens in between,
|
|
||||||
// taking real time. A concurrently-created new instance (an overlapping
|
|
||||||
// Traefik reload) can register and adopt those same singletons in that
|
|
||||||
// window; the old instance's stale decision then stops them anyway, and the
|
|
||||||
// new instance runs with no memory monitor, token cleanup, or health checks
|
|
||||||
// until the next reload.
|
|
||||||
//
|
|
||||||
// TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose drives the REAL
|
|
||||||
// (*TraefikOidc).Close() through this exact window via closeTestHook, rather
|
|
||||||
// than re-implementing Close's stop decision inline: a test that never calls
|
|
||||||
// Close itself stays green even if the production fix (utilities.go's fresh,
|
|
||||||
// lock-guarded re-check; main.go's registerLiveInstance-before-adoption
|
|
||||||
// ordering) is reverted, which is a much weaker regression pin.
|
|
||||||
//
|
|
||||||
// It establishes its own liveInstanceCount baseline (save, zero, restore via
|
|
||||||
// t.Cleanup) instead of assuming the package-global counter is already 0.
|
|
||||||
// Without that, a live instance left registered by an earlier test in the
|
|
||||||
// same run (any order under -shuffle) makes the pre-FIX-35 stale "was last"
|
|
||||||
// decision already false on its own, so this test cannot tell a reverted fix
|
|
||||||
// from a working one — it passes either way.
|
|
||||||
func TestFix35_ConcurrentNewKeepsSingletonAliveAcrossOldClose(t *testing.T) {
|
|
||||||
// Save whether memory-monitor was already running before ResetGlobalMemoryMonitor
|
|
||||||
// below stops it, and restore that state afterward (registered first, so
|
|
||||||
// it runs LAST among this function's cleanups, after ResetGlobalMemoryMonitor
|
|
||||||
// has already torn the singleton down) — otherwise this test permanently
|
|
||||||
// destroys memory-monitor for every other test that runs afterward in the
|
|
||||||
// same binary, regardless of what state it found the process in.
|
|
||||||
wasMemoryMonitorRunning := GetResourceManager().IsTaskRunning("memory-monitor")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if wasMemoryMonitorRunning {
|
|
||||||
GetGlobalMemoryMonitor().StartMonitoring(context.Background(), time.Second)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ResetGlobalMemoryMonitor()
|
|
||||||
t.Cleanup(ResetGlobalMemoryMonitor)
|
|
||||||
t.Cleanup(func() { closeTestHook = nil })
|
|
||||||
|
|
||||||
baseline := resetLiveInstanceCountForTest()
|
|
||||||
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
|
|
||||||
|
|
||||||
// --- Old instance: already live, has adopted the memory-monitor singleton
|
|
||||||
// (mirrors main.go's registerLiveInstance() followed by
|
|
||||||
// memoryMonitor.StartMonitoring()).
|
|
||||||
registerLiveInstance()
|
|
||||||
mm := GetGlobalMemoryMonitor()
|
|
||||||
oldCtx, oldCancel := context.WithCancel(context.Background())
|
|
||||||
defer oldCancel()
|
|
||||||
mm.StartMonitoring(oldCtx, time.Second)
|
|
||||||
if !GetResourceManager().IsTaskRunning("memory-monitor") {
|
|
||||||
t.Fatal("precondition: memory-monitor must be running after StartMonitoring")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Simulate the concurrent New(): a new instance registers and adopts
|
|
||||||
// the memory-monitor singleton in the window between the old instance's
|
|
||||||
// unregisterLiveInstance() and its gated singleton stops. closeTestHook
|
|
||||||
// fires from inside the real Close(), at exactly that point.
|
|
||||||
newCtx, newCancel := context.WithCancel(context.Background())
|
|
||||||
defer newCancel()
|
|
||||||
closeTestHook = func() {
|
|
||||||
registerLiveInstance()
|
|
||||||
mm.StartMonitoring(newCtx, time.Second) // adopts: already running, so this is a no-op start.
|
|
||||||
}
|
|
||||||
// Undo the hook's registerLiveInstance for the new instance and actually
|
|
||||||
// stop the task so it does not outlive this test. Runs via t.Cleanup
|
|
||||||
// (LIFO, before the baseline restore above) so it still executes even if
|
|
||||||
// an assertion below fails the test early.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
unregisterLiveInstance()
|
|
||||||
_ = GetResourceManager().StopBackgroundTask("memory-monitor")
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Drive the real Close() on an instance built the same way New()
|
|
||||||
// builds one for this purpose: registered live, nothing else set (every
|
|
||||||
// other field Close touches is nil-guarded).
|
|
||||||
oldInstance := &TraefikOidc{logger: GetSingletonNoOpLogger()}
|
|
||||||
if err := oldInstance.Close(); err != nil {
|
|
||||||
t.Fatalf("Close returned an error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !GetResourceManager().IsTaskRunning("memory-monitor") {
|
|
||||||
t.Fatal("memory-monitor must keep running: a new instance registered and adopted it before Close's last-instance decision")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop pins the FIX-35
|
|
||||||
// review's major finding: a separate fresh-read check only wraps the counter
|
|
||||||
// read. Used as `if check() { stop() }`, liveInstanceMu is
|
|
||||||
// released as soon as the check returns, before stop() (which can block for
|
|
||||||
// real time — BackgroundTask.Stop waits up to 5s per task) ever runs. A
|
|
||||||
// concurrent registerLiveInstance() can land in that gap and have its
|
|
||||||
// just-adopted singleton killed by a stop decision made before it
|
|
||||||
// registered.
|
|
||||||
//
|
|
||||||
// stopIfLastInstance closes that window by holding liveInstanceMu across
|
|
||||||
// both the check and the stop call. This test proves the lock is genuinely
|
|
||||||
// held for the stop's full duration: while a slow stop callback is running,
|
|
||||||
// a concurrent registerLiveInstance() must not be able to complete.
|
|
||||||
func TestFix35_StopIfLastInstanceHoldsLockAcrossCheckAndStop(t *testing.T) {
|
|
||||||
baseline := resetLiveInstanceCountForTest()
|
|
||||||
t.Cleanup(func() { restoreLiveInstanceCountForTest(baseline) })
|
|
||||||
|
|
||||||
stopStarted := make(chan struct{})
|
|
||||||
releaseStop := make(chan struct{})
|
|
||||||
stopDone := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
stopIfLastInstance(func() {
|
|
||||||
close(stopStarted)
|
|
||||||
<-releaseStop // hold the "stop" open, simulating BackgroundTask.Stop's wait.
|
|
||||||
})
|
|
||||||
close(stopDone)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-stopStarted:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("stopIfLastInstance never invoked its stop callback at baseline (count 0)")
|
|
||||||
}
|
|
||||||
|
|
||||||
registerDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
registerLiveInstance()
|
|
||||||
close(registerDone)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-registerDone:
|
|
||||||
t.Fatal("registerLiveInstance completed while the stop callback was still running — liveInstanceMu was not held across the stop, reopening the check-then-stop window")
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
// Expected: still blocked on liveInstanceMu.
|
|
||||||
}
|
|
||||||
|
|
||||||
close(releaseStop)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-registerDone:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("registerLiveInstance did not complete after the stop callback released liveInstanceMu")
|
|
||||||
}
|
|
||||||
<-stopDone
|
|
||||||
|
|
||||||
unregisterLiveInstance() // balance the registerLiveInstance above
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetLiveInstanceCountForTest establishes a clean baseline (0) for the
|
|
||||||
// package-level liveInstanceCount so a test's last-instance decisions are not
|
|
||||||
// skewed by instances registered-and-never-unregistered by earlier tests in
|
|
||||||
// the same process. Test-only; mirrors resetGdInstancesForTest. Returns the
|
|
||||||
// value liveInstanceCount held before the reset so the caller can restore it
|
|
||||||
// via t.Cleanup (restoreLiveInstanceCountForTest) instead of permanently
|
|
||||||
// clobbering a nonzero baseline left by another test in the same run.
|
|
||||||
func resetLiveInstanceCountForTest() int32 {
|
|
||||||
liveInstanceMu.Lock()
|
|
||||||
defer liveInstanceMu.Unlock()
|
|
||||||
previous := liveInstanceCount
|
|
||||||
liveInstanceCount = 0
|
|
||||||
return previous
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreLiveInstanceCountForTest sets the package-level liveInstanceCount
|
|
||||||
// back to a value captured by resetLiveInstanceCountForTest. Test-only.
|
|
||||||
func restoreLiveInstanceCountForTest(previous int32) {
|
|
||||||
liveInstanceMu.Lock()
|
|
||||||
defer liveInstanceMu.Unlock()
|
|
||||||
liveInstanceCount = previous
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits pins FIX-42: the
|
|
||||||
// RFC 7518 §3.3 minimum key-size rejection that 8640451 added to
|
|
||||||
// validateAlgKeyMatch (client_assertion.go:107) for private_key_jwt RS*/PS*
|
|
||||||
// signing keys, undocumented in that commit. FIX-25's sibling decision keeps
|
|
||||||
// the rejection as-is and records it in CHANGELOG.md; this test pins the
|
|
||||||
// contract with a named, matchable sentinel error so the rejection survives
|
|
||||||
// refactors and callers can detect it with errors.Is instead of matching a
|
|
||||||
// log string.
|
|
||||||
func TestValidateAlgKeyMatch_RejectsRSAKeyUnder2048Bits(t *testing.T) {
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate 1024-bit test RSA key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validateAlgKeyMatch("RS256", key)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a 1024-bit RSA key must be rejected for RS256, got nil error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrRSAKeyTooSmall) {
|
|
||||||
t.Fatalf("error must wrap ErrRSAKeyTooSmall so callers can match it with errors.Is, got: %v", err)
|
|
||||||
}
|
|
||||||
const wantSubstring = "requires an RSA key of at least 2048 bits"
|
|
||||||
if !strings.Contains(err.Error(), wantSubstring) {
|
|
||||||
t.Fatalf("error message must state the requirement (%q), got: %v", wantSubstring, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateAlgKeyMatch_Accepts2048BitRSAKey is the boundary/positive
|
|
||||||
// case: a key at exactly the 2048-bit floor must be accepted.
|
|
||||||
func TestValidateAlgKeyMatch_Accepts2048BitRSAKey(t *testing.T) {
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate 2048-bit test RSA key: %v", err)
|
|
||||||
}
|
|
||||||
if err := validateAlgKeyMatch("RS256", key); err != nil {
|
|
||||||
t.Fatalf("a 2048-bit RSA key must be accepted for RS256, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// originalRequestURI returns the request URI that should be used as the
|
||||||
|
// post-login redirect target. When TrustForwardedURI is enabled and the
|
||||||
|
// X-Forwarded-Uri header carries a safe same-origin path, that header
|
||||||
|
// wins. Otherwise (or if the header is missing/unsafe), falls back to
|
||||||
|
// req.URL.RequestURI() — the path the request reached the proxy with.
|
||||||
|
//
|
||||||
|
// "Safe" means: starts with "/", does NOT start with "//" (protocol-relative
|
||||||
|
// URLs can change host), and has no scheme or host after parsing. This
|
||||||
|
// prevents an attacker-controllable header from triggering an open redirect
|
||||||
|
// via http.Redirect later in the auth flow.
|
||||||
|
func (t *TraefikOidc) originalRequestURI(req *http.Request) string {
|
||||||
|
if t.trustForwardedURI {
|
||||||
|
if v := req.Header.Get("X-Forwarded-Uri"); v != "" && isSafeRedirectTarget(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.URL.RequestURI()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeRedirectTarget(v string) bool {
|
||||||
|
if !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.Host == "" && u.Scheme == ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package traefikoidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_DefaultOff(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: false}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/spoofed")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected?x=1" {
|
||||||
|
t.Fatalf("default-off: want /protected?x=1, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_TrustEnabled(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/real?y=2")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/real?y=2" {
|
||||||
|
t.Fatalf("trust-on with header: want /real?y=2, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_TrustEnabledNoHeader(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("trust-on no header: want /protected, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestOriginalRequestURI_RejectsAbsoluteURL(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "https://evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("absolute URL must be rejected, want /protected fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_RejectsProtocolRelative(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "//evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("protocol-relative URL must be rejected, want /protected fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_AcceptsSafePathWithQuery(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "/safe?x=1&y=2")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/safe?x=1&y=2" {
|
||||||
|
t.Fatalf("safe path with query must be accepted, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginalRequestURI_RejectsBareHostnameNoSlash(t *testing.T) {
|
||||||
|
tr := &TraefikOidc{trustForwardedURI: true}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Uri", "evil.example/phish")
|
||||||
|
if got := tr.originalRequestURI(req); got != "/protected" {
|
||||||
|
t.Fatalf("non-/ prefix must be rejected, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSharedHealthTaskSurvivesCreateDuringLastClose pins the create-versus-
|
|
||||||
// last-close race on the shared graceful-degradation health-check task.
|
|
||||||
//
|
|
||||||
// Close() decides it is the last live instance and then stops the shared task.
|
|
||||||
// A GracefulDegradation created between that decision and the stop used to
|
|
||||||
// register at once, and its health routine adopted the still-running task.
|
|
||||||
// The stop then killed the task that the new, live instance relies on, and
|
|
||||||
// nothing restarted it.
|
|
||||||
//
|
|
||||||
// The test pauses the last instance's Close() between the decision and the
|
|
||||||
// stop (beforeSharedTaskStop), creates a new instance, and then lets the stop
|
|
||||||
// run. The new instance is live, so the shared task must be running at the end.
|
|
||||||
func TestSharedHealthTaskSurvivesCreateDuringLastClose(t *testing.T) {
|
|
||||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
|
||||||
|
|
||||||
cfg := DefaultGracefulDegradationConfig()
|
|
||||||
cfg.HealthCheckInterval = time.Hour
|
|
||||||
logger := GetSingletonNoOpLogger()
|
|
||||||
|
|
||||||
gdA := NewGracefulDegradation(cfg, logger)
|
|
||||||
waitForHealthRoutineSettled(t, gdA)
|
|
||||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
|
||||||
t.Fatal("precondition: shared health-check task must run while gdA is live")
|
|
||||||
}
|
|
||||||
|
|
||||||
parked := make(chan struct{})
|
|
||||||
release := make(chan struct{})
|
|
||||||
gdA.beforeSharedTaskStop = func() {
|
|
||||||
close(parked)
|
|
||||||
<-release
|
|
||||||
}
|
|
||||||
|
|
||||||
closed := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
gdA.Close()
|
|
||||||
close(closed)
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-parked:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("gdA.Close did not reach the shared-task stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
created := make(chan *GracefulDegradation, 1)
|
|
||||||
go func() { created <- NewGracefulDegradation(cfg, logger) }()
|
|
||||||
|
|
||||||
// Without serialization, gdB registers at once and its routine adopts the
|
|
||||||
// still-running task before gdA stops it. With serialization, gdB waits
|
|
||||||
// behind gdA's stop; the bound only limits how long the test waits then.
|
|
||||||
var gdB *GracefulDegradation
|
|
||||||
select {
|
|
||||||
case gdB = <-created:
|
|
||||||
waitForHealthRoutineSettled(t, gdB)
|
|
||||||
case <-time.After(200 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
select {
|
|
||||||
case <-closed:
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("gdA.Close did not return")
|
|
||||||
}
|
|
||||||
if gdB == nil {
|
|
||||||
select {
|
|
||||||
case gdB = <-created:
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("NewGracefulDegradation did not return after gdA.Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer gdB.Close()
|
|
||||||
waitForHealthRoutineSettled(t, gdB)
|
|
||||||
|
|
||||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
|
||||||
t.Fatal("shared health-check task is stopped although gdB is still live")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const gdHealthTaskName = "graceful-degradation-health-check"
|
|
||||||
|
|
||||||
// TestSharedHealthTaskNotRestartedAfterLastClose pins the stop-versus-start
|
|
||||||
// race behind the flaky TestSharedHealthTaskSurvivesOtherInstanceClose.
|
|
||||||
//
|
|
||||||
// startHealthCheckRoutine checks gd.stopChan under gd.mutex and then creates
|
|
||||||
// or adopts the shared health-check task. Close() used to close stopChan
|
|
||||||
// without gd.mutex, so a routine that had already passed its check could
|
|
||||||
// reach the task registry AFTER the last Close() stopped the task.
|
|
||||||
// RegisterBackgroundTask replaces a stopped task with a fresh one, so the
|
|
||||||
// routine restarted the shared task with no live instance left to stop it.
|
|
||||||
//
|
|
||||||
// The test parks gd2's routine right after its stopChan check by holding the
|
|
||||||
// task-registry mutex (Close never takes it), closes both instances, and then
|
|
||||||
// releases the routine. The shared task must stay stopped.
|
|
||||||
func TestSharedHealthTaskNotRestartedAfterLastClose(t *testing.T) {
|
|
||||||
t.Cleanup(snapshotAndClearGdInstancesForTest())
|
|
||||||
|
|
||||||
cfg := DefaultGracefulDegradationConfig()
|
|
||||||
cfg.HealthCheckInterval = time.Hour
|
|
||||||
logger := GetSingletonNoOpLogger()
|
|
||||||
|
|
||||||
gd1 := NewGracefulDegradation(cfg, logger)
|
|
||||||
defer gd1.Close()
|
|
||||||
waitForHealthRoutineSettled(t, gd1)
|
|
||||||
if !GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
|
||||||
t.Fatal("precondition: shared health-check task must run while gd1 is live")
|
|
||||||
}
|
|
||||||
|
|
||||||
globalTaskRegistryMutex.Lock()
|
|
||||||
registryLocked := true
|
|
||||||
releaseRegistry := func() {
|
|
||||||
if registryLocked {
|
|
||||||
registryLocked = false
|
|
||||||
globalTaskRegistryMutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer releaseRegistry()
|
|
||||||
|
|
||||||
gd2 := NewGracefulDegradation(cfg, logger)
|
|
||||||
defer gd2.Close()
|
|
||||||
waitForGoroutineParkedIn(t, "(*GracefulDegradation).startHealthCheckRoutine", "traefikoidc.GetGlobalTaskRegistry")
|
|
||||||
|
|
||||||
gd1.Close()
|
|
||||||
|
|
||||||
closed := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
gd2.Close()
|
|
||||||
close(closed)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Release the parked routine once the last Close() has stopped the task.
|
|
||||||
// A Close() that waits for the in-flight routine keeps the task running,
|
|
||||||
// so the bound only limits how long this test waits in that case.
|
|
||||||
deadline := time.Now().Add(200 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) && GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
releaseRegistry()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-closed:
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("gd2.Close did not return")
|
|
||||||
}
|
|
||||||
|
|
||||||
// gd2's routine holds gd2.mutex until it has created or adopted a task,
|
|
||||||
// so taking the lock waits for the routine to finish.
|
|
||||||
gd2.mutex.RLock()
|
|
||||||
routineTask := gd2.healthCheckTask
|
|
||||||
gd2.mutex.RUnlock()
|
|
||||||
|
|
||||||
if routineTask != nil && atomic.LoadInt32(&routineTask.stopped) == 0 {
|
|
||||||
routineTask.Stop()
|
|
||||||
t.Error("gd2's health routine holds a running task after the last GracefulDegradation closed")
|
|
||||||
}
|
|
||||||
if GetResourceManager().IsTaskRunning(gdHealthTaskName) {
|
|
||||||
_ = GetResourceManager().StopBackgroundTask(gdHealthTaskName)
|
|
||||||
t.Error("shared health-check task was restarted after the last GracefulDegradation closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForGoroutineParkedIn polls all goroutine stacks until one stack contains
|
|
||||||
// every given frame substring.
|
|
||||||
func waitForGoroutineParkedIn(t *testing.T, frames ...string) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if goroutineStackContains(frames...) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("no goroutine parked in %v", frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
func goroutineStackContains(frames ...string) bool {
|
|
||||||
buf := make([]byte, 1<<20)
|
|
||||||
for {
|
|
||||||
n := runtime.Stack(buf, true)
|
|
||||||
if n < len(buf) {
|
|
||||||
buf = buf[:n]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
buf = make([]byte, 2*len(buf))
|
|
||||||
}
|
|
||||||
for _, stack := range strings.Split(string(buf), "\n\n") {
|
|
||||||
if stackHasAllFrames(stack, frames) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func stackHasAllFrames(stack string, frames []string) bool {
|
|
||||||
for _, f := range frames {
|
|
||||||
if !strings.Contains(stack, f) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
// Shared GracefulDegradation-instance-registry test helper for
|
|
||||||
// round-regression and FIX-round tests.
|
|
||||||
//
|
|
||||||
// Moved here for the R4 re-review (closing the rest of FIX-41): resetGdInstancesForTest
|
|
||||||
// was declared inside review_r34_regression_test.go but consumed by
|
|
||||||
// fix18_gd_lifecycle_test.go, a FIX-round test file — the dependency was not
|
|
||||||
// even confined to other round files. Every review_rNN*_test.go and
|
|
||||||
// fixNN*_test.go file must depend only on shared helpers like this one,
|
|
||||||
// never on another round file's private declarations.
|
|
||||||
|
|
||||||
// resetGdInstancesForTest establishes a clean baseline for the process-global
|
|
||||||
// gdInstances registry so a test's "last instance" and iteration assertions
|
|
||||||
// are not skewed by instances created by earlier tests in the same process.
|
|
||||||
func resetGdInstancesForTest() {
|
|
||||||
gdInstances.Lock()
|
|
||||||
gdInstances.set = make(map[*GracefulDegradation]struct{})
|
|
||||||
gdInstances.Unlock()
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,9 @@ module github.com/lukaszraczylo/traefikoidc
|
|||||||
|
|
||||||
go 1.24.0
|
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 (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0
|
github.com/alicebob/miniredis/v2 v2.35.0
|
||||||
github.com/gorilla/sessions v1.3.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/redis/go-redis/v9 v9.17.2
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
|||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
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 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||||
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
|
|||||||
@@ -63,14 +63,6 @@ func (m *GoroutineManager) StartGoroutine(name string, fn func(context.Context))
|
|||||||
m.wg.Done()
|
m.wg.Done()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
managedGoroutine.running = false
|
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()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// Recover from panics
|
// Recover from panics
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ func TestStartGoroutine(t *testing.T) {
|
|||||||
|
|
||||||
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
|
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
|
||||||
executed.Store(true)
|
executed.Store(true)
|
||||||
<-ctx.Done() // keep alive so it is tracked while running
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Give goroutine time to execute
|
// Give goroutine time to execute
|
||||||
@@ -55,7 +54,6 @@ func TestStartGoroutine(t *testing.T) {
|
|||||||
t.Error("Expected goroutine to execute")
|
t.Error("Expected goroutine to execute")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A live goroutine is tracked (R157: finished goroutines are pruned).
|
|
||||||
status := gm.GetStatus()
|
status := gm.GetStatus()
|
||||||
if len(status) != 1 {
|
if len(status) != 1 {
|
||||||
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
|
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
|
||||||
@@ -372,9 +370,8 @@ func TestGetStatus(t *testing.T) {
|
|||||||
|
|
||||||
status := gm.GetStatus()
|
status := gm.GetStatus()
|
||||||
|
|
||||||
// R157: finished goroutines are pruned, so only the live one remains.
|
if len(status) != 2 {
|
||||||
if len(status) != 1 {
|
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
|
||||||
t.Errorf("Expected 1 live goroutine in status, got %d", len(status))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningStatus, exists := status["running"]; exists {
|
if runningStatus, exists := status["running"]; exists {
|
||||||
@@ -397,8 +394,12 @@ func TestGetStatus(t *testing.T) {
|
|||||||
t.Error("Expected 'running' goroutine in status")
|
t.Error("Expected 'running' goroutine in status")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, exists := status["quick"]; exists {
|
if quickStatus, exists := status["quick"]; exists {
|
||||||
t.Error("Expected finished 'quick' goroutine to be pruned from status")
|
if quickStatus.Running {
|
||||||
|
t.Error("Expected 'quick' goroutine to be marked as not running")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("Expected 'quick' goroutine in status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +435,7 @@ func TestConcurrentStartGoroutine(t *testing.T) {
|
|||||||
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
|
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
|
||||||
gm.StartGoroutine(name, func(ctx context.Context) {
|
gm.StartGoroutine(name, func(ctx context.Context) {
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
<-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
|
time.Sleep(50 * time.Millisecond)
|
||||||
counter.Add(-1)
|
counter.Add(-1)
|
||||||
})
|
})
|
||||||
}(i)
|
}(i)
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ownedHeaderOIDC(t *testing.T, names ...string) *TraefikOidc {
|
|
||||||
t.Helper()
|
|
||||||
m := map[string]*template.Template{}
|
|
||||||
for _, n := range names {
|
|
||||||
m[n] = template.Must(template.New(n).Option("missingkey=zero").Parse("{{.Claims.tenant}}"))
|
|
||||||
}
|
|
||||||
return &TraefikOidc{logger: NewLogger("error"), headerTemplates: m}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A templated header the middleware owns must never carry a client value:
|
|
||||||
// an optional claim that renders empty must leave the header absent.
|
|
||||||
func TestTemplatedHeaderDropsClientSpoof(t *testing.T) {
|
|
||||||
o := ownedHeaderOIDC(t, "X-Tenant")
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("X-Tenant", "admin-corp")
|
|
||||||
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
|
|
||||||
if got := req.Header.Get("X-Tenant"); got != "" {
|
|
||||||
t.Fatalf("client-supplied X-Tenant reached the backend: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTemplatedHeaderRenderedValueWins(t *testing.T) {
|
|
||||||
o := ownedHeaderOIDC(t, "X-Tenant")
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("X-Tenant", "evil")
|
|
||||||
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"tenant": "acme"}})
|
|
||||||
if got := req.Header.Get("X-Tenant"); got != "acme" {
|
|
||||||
t.Fatalf("X-Tenant = %q, want acme", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity headers are injected before templates run; an empty render must
|
|
||||||
// not clobber them, and Authorization stays as received.
|
|
||||||
func TestTemplatedHeaderKeepsIdentityAndAuthorization(t *testing.T) {
|
|
||||||
o := ownedHeaderOIDC(t, "X-Forwarded-User", "Authorization")
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("X-Forwarded-User", "alice")
|
|
||||||
req.Header.Set("Authorization", "Bearer abc")
|
|
||||||
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"sub": "u"}})
|
|
||||||
if got := req.Header.Get("X-Forwarded-User"); got != "alice" {
|
|
||||||
t.Fatalf("identity header clobbered: %q", got)
|
|
||||||
}
|
|
||||||
if got := req.Header.Get("Authorization"); got != "Bearer abc" {
|
|
||||||
t.Fatalf("Authorization changed: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-183
@@ -6,15 +6,15 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
|
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
|
||||||
@@ -109,13 +109,10 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
data := url.Values{
|
data := url.Values{
|
||||||
"grant_type": {grantType},
|
"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,
|
// 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.
|
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
|
||||||
if clientAuthMethod != "client_secret_basic" || clientAssertion != nil {
|
if t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
|
||||||
data.Set("client_id", clientID)
|
data.Set("client_id", t.clientID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if grantType == "authorization_code" {
|
if grantType == "authorization_code" {
|
||||||
@@ -129,13 +126,6 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
data.Set("refresh_token", codeOrToken)
|
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
|
client := t.tokenHTTPClient
|
||||||
if client == nil {
|
if client == nil {
|
||||||
// Use shared transport pool to prevent memory leaks
|
// Use shared transport pool to prevent memory leaks
|
||||||
@@ -160,29 +150,29 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
t.metadataMu.RUnlock()
|
t.metadataMu.RUnlock()
|
||||||
|
|
||||||
useBasicAuth := false
|
useBasicAuth := false
|
||||||
if clientAssertion != nil {
|
if t.clientAssertion != nil {
|
||||||
assertion, err := clientAssertion.Sign(tokenURL, clientID)
|
assertion, err := t.clientAssertion.Sign(tokenURL, t.clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
|
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_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
||||||
data.Set("client_assertion", assertion)
|
data.Set("client_assertion", assertion)
|
||||||
} else if clientAuthMethod == "client_secret_basic" {
|
} else if t.clientAuthMethod == "client_secret_basic" {
|
||||||
useBasicAuth = true
|
useBasicAuth = true
|
||||||
} else {
|
} else {
|
||||||
data.Set("client_secret", clientSecret)
|
data.Set("client_secret", t.clientSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
|
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
if useBasicAuth {
|
if useBasicAuth {
|
||||||
setOAuthBasicAuth(req, clientID, clientSecret)
|
setOAuthBasicAuth(req, t.clientID, t.clientSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
|
||||||
}
|
}
|
||||||
@@ -194,33 +184,14 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
limitReader := io.LimitReader(resp.Body, 1024*10)
|
limitReader := io.LimitReader(resp.Body, 1024*10)
|
||||||
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
|
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
|
||||||
// Return a *HTTPError so the retry executor's isRetryableError can
|
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
// 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
|
var tokenResponse TokenResponse
|
||||||
// Cap the decoded body so a corrupted or malicious oversized 200-body cannot
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
||||||
// 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)
|
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
|
return &tokenResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,17 +204,6 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - *TokenResponse: New token set from the authorization server
|
// - *TokenResponse: New token set from the authorization server
|
||||||
// - An error if the refresh operation fails
|
// - 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) {
|
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -258,8 +218,7 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
|
|||||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
|
t.logger.Debugf("Token response: %+v", tokenResponse)
|
||||||
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
|
|
||||||
return tokenResponse, nil
|
return tokenResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,37 +253,10 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
|
|||||||
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
|
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
|
||||||
// It wraps the UniversalCache with token-specific operations.
|
// It wraps the UniversalCache with token-specific operations.
|
||||||
type TokenCache struct {
|
type TokenCache struct {
|
||||||
// ns scopes entries to one middleware instance (issuer|client|audience)
|
|
||||||
// so a verdict cached by one instance cannot satisfy another.
|
|
||||||
ns atomic.Value
|
|
||||||
// cache is the underlying universal cache implementation
|
// cache is the underlying universal cache implementation
|
||||||
cache *UniversalCache
|
cache *UniversalCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNamespace sets the verification context that scopes this cache's keys.
|
|
||||||
func (tc *TokenCache) SetNamespace(ns string) {
|
|
||||||
if tc == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tc.ns.Store(ns)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Namespace returns the scope set by SetNamespace, or "" when none is set.
|
|
||||||
func (tc *TokenCache) Namespace() string {
|
|
||||||
if tc == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
ns, _ := tc.ns.Load().(string)
|
|
||||||
return ns
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tc *TokenCache) key(token string) string {
|
|
||||||
if ns, _ := tc.ns.Load().(string); ns != "" {
|
|
||||||
return "t-" + ns + "|" + token
|
|
||||||
}
|
|
||||||
return "t-" + token
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTokenCache creates and initializes a new TokenCache.
|
// NewTokenCache creates and initializes a new TokenCache.
|
||||||
// It uses the global cache manager to ensure singleton behavior.
|
// It uses the global cache manager to ensure singleton behavior.
|
||||||
func NewTokenCache() *TokenCache {
|
func NewTokenCache() *TokenCache {
|
||||||
@@ -341,7 +273,7 @@ func NewTokenCache() *TokenCache {
|
|||||||
// - claims: Parsed claims from the token
|
// - claims: Parsed claims from the token
|
||||||
// - expiration: The duration for which the cache entry should be valid
|
// - expiration: The duration for which the cache entry should be valid
|
||||||
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
|
||||||
token = tc.key(token)
|
token = "t-" + token
|
||||||
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
|
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +285,7 @@ func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiratio
|
|||||||
// - map[string]interface{}: The cached claims if found
|
// - map[string]interface{}: The cached claims if found
|
||||||
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
|
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
|
||||||
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
||||||
token = tc.key(token)
|
token = "t-" + token
|
||||||
value, found := tc.cache.Get(token)
|
value, found := tc.cache.Get(token)
|
||||||
if !found {
|
if !found {
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -366,7 +298,7 @@ func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
|
|||||||
// Parameters:
|
// Parameters:
|
||||||
// - token: The raw token string to remove from the cache
|
// - token: The raw token string to remove from the cache
|
||||||
func (tc *TokenCache) Delete(token string) {
|
func (tc *TokenCache) Delete(token string) {
|
||||||
token = tc.key(token)
|
token = "t-" + token
|
||||||
tc.cache.Delete(token)
|
tc.cache.Delete(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,14 +380,6 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idToken := session.GetIDToken()
|
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 {
|
if err := session.Clear(req, rw); err != nil {
|
||||||
t.logger.Errorf("Error clearing session: %v", err)
|
t.logger.Errorf("Error clearing session: %v", err)
|
||||||
@@ -463,74 +387,15 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist the session's tokens so a token captured before logout (e.g. a
|
host := utils.DetermineHost(req)
|
||||||
// bearer mode access token) cannot be reused until its natural expiry.
|
scheme := utils.DetermineScheme(req, t.forceHTTPS)
|
||||||
// RevokeToken is a no-op-safe local blacklist (guards nil) and skips
|
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
||||||
// 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
|
postLogoutRedirectURI := t.postLogoutRedirectURI
|
||||||
// localRedirect is used when there is no provider end-session endpoint and
|
if postLogoutRedirectURI == "" {
|
||||||
// the plugin redirects the browser itself. It must never be an absolute URL
|
postLogoutRedirectURI = fmt.Sprintf("%s/", baseURL)
|
||||||
// derived from the request host (X-Forwarded-Host is client-controllable and
|
} else if !strings.HasPrefix(postLogoutRedirectURI, "http") {
|
||||||
// would be an open redirect); use a host-relative path, or the operator's
|
postLogoutRedirectURI = fmt.Sprintf("%s%s", baseURL, postLogoutRedirectURI)
|
||||||
// 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
|
// Read endSessionURL with RLock
|
||||||
@@ -545,34 +410,11 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
|||||||
http.Error(rw, "Logout error", http.StatusInternalServerError)
|
http.Error(rw, "Logout error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
http.Redirect(rw, req, logoutURL, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rw.Header().Set("Cache-Control", "no-store")
|
http.Redirect(rw, req, postLogoutRedirectURI, http.StatusFound)
|
||||||
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.
|
// BuildLogoutURL constructs a logout URL for the OIDC provider's end session endpoint.
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package traefikoidc
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateHostCanonicalForms(t *testing.T) {
|
|
||||||
o := &TraefikOidc{}
|
|
||||||
blocked := []string{
|
|
||||||
"localhost.", "LOCALHOST.", "metadata.google.internal.", "metadata.google.internal",
|
|
||||||
"169.254.169.254.", "fe80::1%eth0", "[fe80::1]", "::1",
|
|
||||||
}
|
|
||||||
for _, h := range blocked {
|
|
||||||
if err := o.validateHost(h); err == nil {
|
|
||||||
t.Errorf("validateHost(%q) = nil, want blocked", h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allowed := []string{"idp.example.com", "idp.example.com.", "2606:4700::1111"}
|
|
||||||
for _, h := range allowed {
|
|
||||||
if err := o.validateHost(h); err != nil {
|
|
||||||
t.Errorf("validateHost(%q) = %v, want allowed", h, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isLoopbackHost("localhost.") {
|
|
||||||
t.Error("isLoopbackHost(localhost.) = false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-30
@@ -61,23 +61,16 @@ func DefaultHTTPClientConfig() HTTPClientConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenHTTPClientConfig returns configuration optimized for token operations.
|
// TokenHTTPClientConfig returns configuration optimized for token operations
|
||||||
func TokenHTTPClientConfig() HTTPClientConfig {
|
func TokenHTTPClientConfig() HTTPClientConfig {
|
||||||
config := DefaultHTTPClientConfig()
|
config := DefaultHTTPClientConfig()
|
||||||
config.Timeout = 10 * time.Second // Shorter timeout for token operations
|
config.Timeout = 10 * time.Second // Shorter timeout for token operations
|
||||||
config.MaxRedirects = 50 // Token endpoints may redirect more
|
config.MaxRedirects = 50 // Token endpoints may redirect more
|
||||||
// UseCookieJar stays false (DefaultHTTPClientConfig's default). This
|
config.UseCookieJar = true // Enable cookie jar for token operations
|
||||||
// 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
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls.
|
// OIDCProviderHTTPClientConfig returns configuration optimized for OIDC provider calls
|
||||||
func OIDCProviderHTTPClientConfig() HTTPClientConfig {
|
func OIDCProviderHTTPClientConfig() HTTPClientConfig {
|
||||||
config := DefaultHTTPClientConfig()
|
config := DefaultHTTPClientConfig()
|
||||||
config.Timeout = 15 * time.Second // Slightly longer for OIDC operations
|
config.Timeout = 15 * time.Second // Slightly longer for OIDC operations
|
||||||
@@ -85,10 +78,7 @@ func OIDCProviderHTTPClientConfig() HTTPClientConfig {
|
|||||||
config.MaxIdleConnsPerHost = 25 // More connections per OIDC provider
|
config.MaxIdleConnsPerHost = 25 // More connections per OIDC provider
|
||||||
config.MaxConnsPerHost = 50 // Allow more concurrent requests to OIDC provider
|
config.MaxConnsPerHost = 50 // Allow more concurrent requests to OIDC provider
|
||||||
config.IdleConnTimeout = 90 * time.Second // Keep connections alive longer for reuse
|
config.IdleConnTimeout = 90 * time.Second // Keep connections alive longer for reuse
|
||||||
// UseCookieJar stays false, for the same cross-user reason as
|
config.UseCookieJar = true // Enable cookie jar for session management
|
||||||
// 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
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +138,10 @@ func (f *HTTPClientFactory) ValidateHTTPClientConfig(config *HTTPClientConfig) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyHTTPClientDefaults fills zero-valued fields in cfg with the standard
|
// CreateHTTPClient creates an HTTP client with the given configuration
|
||||||
// conservative defaults so that a partially-populated config never yields an
|
// Validates configuration parameters before creating the client
|
||||||
// unbounded HTTP client (zero overall Timeout or ResponseHeaderTimeout would make
|
func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Client {
|
||||||
// a stalled upstream hang forever). All construction paths MUST converge on this
|
// Set defaults for zero values before validation
|
||||||
// helper so behavior is identical: CreateHTTPClient and the shared transport
|
|
||||||
// pool (CreatePooledHTTPClient / GetOrCreateTransport).
|
|
||||||
func applyHTTPClientDefaults(config *HTTPClientConfig) {
|
|
||||||
if config.Timeout == 0 {
|
if config.Timeout == 0 {
|
||||||
config.Timeout = 30 * time.Second
|
config.Timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
@@ -191,13 +178,6 @@ func applyHTTPClientDefaults(config *HTTPClientConfig) {
|
|||||||
if config.ReadBufferSize == 0 {
|
if config.ReadBufferSize == 0 {
|
||||||
config.ReadBufferSize = 4096
|
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
|
// Validate configuration - only fail on critical errors
|
||||||
if err := f.ValidateHTTPClientConfig(&config); err != nil {
|
if err := f.ValidateHTTPClientConfig(&config); err != nil {
|
||||||
@@ -229,8 +209,9 @@ func (f *HTTPClientFactory) CreateHTTPClient(config HTTPClientConfig) *http.Clie
|
|||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
},
|
},
|
||||||
RootCAs: config.RootCAs,
|
PreferServerCipherSuites: true,
|
||||||
InsecureSkipVerify: config.InsecureSkipVerify, // #nosec G402 -- operator opt-in (insecureSkipVerify, default false); New logs a SECURITY WARNING
|
RootCAs: config.RootCAs,
|
||||||
|
InsecureSkipVerify: config.InsecureSkipVerify, //nolint:gosec // opt-in, loud warning emitted at plugin startup
|
||||||
},
|
},
|
||||||
ForceAttemptHTTP2: config.ForceHTTP2,
|
ForceAttemptHTTP2: config.ForceHTTP2,
|
||||||
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
|
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ func TestOIDCProviderHTTPClientConfigUnit(t *testing.T) {
|
|||||||
assert.Equal(t, 25, config.MaxIdleConnsPerHost, "OIDC provider should have 25 max idle conns per host")
|
assert.Equal(t, 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, 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.Equal(t, 90*time.Second, config.IdleConnTimeout, "OIDC provider should have 90s idle conn timeout")
|
||||||
// UseCookieJar must stay false (FIX-12): this config feeds the shared
|
assert.True(t, config.UseCookieJar, "OIDC provider should have cookie jar enabled")
|
||||||
// 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
|
// TestCreateDefaultClientUnit tests CreateDefaultClient function
|
||||||
@@ -41,9 +38,7 @@ func TestCreateTokenClientUnit(t *testing.T) {
|
|||||||
|
|
||||||
require.NotNil(t, client)
|
require.NotNil(t, client)
|
||||||
assert.NotNil(t, client.Transport, "client should have transport")
|
assert.NotNil(t, client.Transport, "client should have transport")
|
||||||
// No jar (FIX-12): TokenHTTPClientConfig's UseCookieJar stays false so
|
assert.NotNil(t, client.Jar, "token client should have cookie jar")
|
||||||
// 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")
|
assert.Equal(t, 10*time.Second, client.Timeout, "token client should have 10s timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user