Compare commits

..

5 Commits

Author SHA1 Message Date
lukaszraczylo abbfdb02a7 fix(jwk): replace JWKCache.mutex with singleflight pattern
JWKCache.GetJWKS previously held a sync.RWMutex.Lock() across the entire
HTTP round-trip to the IdP's JWKS endpoint (jwk.go:93). On a cold cache
(cold pod, JWK rotation, transient network blip) every concurrent
request piled up on this single global write-lock. Under Yaegi each
Lock() acquisition costs 10-50ms of interpreter dispatch — same
architectural shape as the bugs v1.0.14 and v1.0.15 already fixed,
just one that hadn't surfaced as the dominant bottleneck yet.

Code-review post-spike #2 flagged this at confidence 9/10 as the next
likely death-spiral on pod cold-start.

Change replaces the lock with a sync.Map-based singleflight: the first
caller for a given JWKS URL performs the fetch; concurrent callers
attach to the same *jwksFetch and wait on its done channel for the
result. Cold-cache cost is now O(1) HTTP fetch regardless of how many
goroutines are waiting, and no Yaegi-dispatched lock is held during the
fetch itself.

Correctness:
- LoadOrStore winner does the work; losers wait on a done channel.
- Done channel close is in a defer, so panics in fetchJWKS still
  unblock waiters.
- Map entry is removed in the same defer, so a fresh failed fetch can
  be retried by the next request without waiting for any stale entry.
- ctx.Done() unblocks waiters independently of the leader's progress.
- Re-checks the cache after winning LoadOrStore, since another fetch
  may have populated the cache between the initial miss and the win.

Cleanup: also removes a stray yaegi-extract output file
(github_com-lukaszraczylo-traefikoidc.go) that was accidentally
committed during local yaegi compatibility testing.

All tests pass with -race; golangci-lint clean.
2026-05-23 11:05:24 +01:00
lukaszraczylo 72e2b682bb fix: eliminate per-request global mutexes in Yaegi hot paths
The v1.0.14 fix replaced one contended sync.RWMutex (RefreshCoordinator.
refreshMutex) with sync.Map. Production showed the same death-spiral
signature recurring ~2 hours later — same shape, different mutex:
65 goroutines stuck on a sync.(*RWMutex).Lock at one address, pod
pinned at 1000m CPU, identical Yaegi runCfg/reflect.Value.Call stack
pattern. The mutex was RefreshCoordinator.attemptsMutex.

Generalising: under Yaegi (interpreted Go for traefik plugins), any
per-request global mutex acquisition is a latent serialization point.
reflect.Value.Call dispatch on a held lock turns a microsecond
critical section into a multi-millisecond one, and on a GOMAXPROCS=1
pod the queue is unbounded.

This commit removes every per-request global mutex on the hot path:

1. RefreshCoordinator.attemptsMutex (sync.RWMutex)
   sessionRefreshAttempts: map -> sync.Map.
   refreshAttemptTracker: all fields atomic (int32, int64 UnixNano,
   cooldownEndNano == 0 as the not-in-cooldown sentinel, replacing
   the inCooldown bool).
   isInCooldown / recordRefreshAttempt / recordRefreshSuccess /
   recordRefreshFailure all become lock-free. Cooldown entry uses
   CompareAndSwapInt64 so only one goroutine logs the transition.

2. RefreshCircuitBreaker.mutex (sync.RWMutex)
   lastFailureTime / lastSuccessTime -> atomic.Int64 UnixNano.
   state and failures already atomic.
   AllowRequest / RecordSuccess / RecordFailure now pure atomic ops.

3. TraefikOidc.firstRequestMutex (sync.Mutex)
   firstRequestReceived bool -> firstRequestStarted int32.
   metadataRefreshStarted bool -> metadataRefreshStartedAtomic int32.
   ServeHTTP bootstrap path uses CompareAndSwapInt32 — fires once,
   zero steady-state cost. Previously the mutex was acquired on
   every non-health request forever.

4. TraefikOidc.metadataRetryMutex (sync.Mutex)
   lastMetadataRetryTime time.Time -> lastMetadataRetryNano int64.
   The 30-second retry throttle is now a CAS on lastMetadataRetryNano.

cleanupStaleEntries iterates via sync.Map.Range; eviction is a
CompareAndDelete by pointer identity so a tracker freshly re-used by
a concurrent caller is not lost.

Empirical evidence (3 specialist-agent analysis of the v1.0.14 spike,
profiles in /tmp/traefik-spike-1779511683/):
  * mutex profile: 97% delay in sync.(*Mutex).Unlock via
    HTTPHandlerSwitcher -> accesslog -> metrics -> backoff.RetryNotify
  * 65 stuck goroutines at one RWMutex address (0x40022eb648),
    identical Yaegi CFG pointer, all on rc.attemptsMutex via
    recordRefreshAttempt + isInCooldown
  * traffic driver: long-lived in-cluster Go-http-client doing
    ~5.4 req/s POST embeddings via OIDC cookie session → same
    sessionID → contention all funnels to one tracker entry

Yaegi support for sync/atomic confirmed at
github.com/traefik/yaegi@v0.16.1/stdlib/go1_22_sync_atomic.go:
AddInt32/Int64, LoadInt32/Int64, StoreInt32/Int64,
CompareAndSwapInt32/Int64 all exposed via reflect.ValueOf. Yaegi
dispatches each call through reflect.Value.Call to the COMPILED
atomic.* function, which executes a single hardware CAS/LOCK-XADD
instruction. Each atomic op still pays Yaegi dispatch cost but
cannot block — no queueing, no death spiral.

Trade-off acknowledged: v1.0.15 issues ~6-8 atomic/sync.Map ops per
leader-path request vs the 4 mutex ops of v1.0.14. Under low
contention this is a modest CPU bump. Under high contention it's
an unbounded → bounded transformation. Net win.

All tests pass with -race; golangci-lint clean.
2026-05-23 10:47:21 +01:00
lukaszraczylo ae4ccaa89d fix(refresh-coordinator): replace global RWMutex with sync.Map
Under Yaegi, the RefreshCoordinator.refreshMutex was held for tens of
milliseconds per request because every operation inside the critical
section (map access, isInCooldown, recordRefreshAttempt,
isUnderMemoryPressure, atomic ops, struct allocation) is dispatched
through reflect.Value.Call with full arg boxing/unboxing.

Concurrent refreshes on the same coordinator serialized into a queue
that grew without bound. Live capture in production (3 Grafana
dashboards left open) showed:
  * 63 goroutines stuck on rc.refreshMutex.Lock() for 1-11 minutes
  * pod pinned at 1000m CPU (GOMAXPROCS=1)
  * 5.15M allocs/sec, 0.45 RPS effective throughput
  * yaegi.call.func9 accounting for 92.66% of cumulative allocs
  * mutex profile dominated by sync.(*Mutex).Unlock via the request chain

Change inFlightRefreshes from map[string]*refreshOperation+RWMutex to
sync.Map and rewrite getOrCreateOperation to:
  1. Speculatively allocate the candidate operation.
  2. Atomically LoadOrStore by tokenHash. Joiners take the existing
     operation; leader takes the new one. No global lock acquired.
  3. Leader runs rate-limit / cooldown / memory-pressure gates AFTER
     the atomic store. Joiners share the leader's outcome via op.done.
  4. Reserve the concurrent-refresh slot via CompareAndSwap so the
     count cannot overshoot in absence of the old serializing lock.
  5. On any gate failure the leader calls failCandidate, which deletes
     the entry from sync.Map, records the error on op.result and closes
     op.done so any joiner that snuck in returns the same error.

performCleanup becomes a single sync.Map.LoadAndDelete, eliminating
the lock entirely on the cleanup path.

Net effect: critical section is no longer Yaegi-interpreted; it
collapses to atomic instructions on a sharded sync.Map. Refresh
contention disappears even under Yaegi.

All tests pass with -race; golangci-lint clean.
2026-05-23 02:34:49 +01:00
lukaszraczylo 984fd1c08f docs: add Telemetry section linking to oss-telemetry opt-out docs
Discloses the single anonymous adoption ping sent on first plugin
instantiation. Points users to the upstream README section for the
disclosure pattern and to the local telemetry.go for the inline
implementation.
2026-05-21 04:07:19 +01:00
lukaszraczylo 99bdd23986 feat: anonymous usage telemetry via inline oss-telemetry
Adds a yaegi-safe inline telemetry helper that fires a single
fire-and-forget ping at plugin load. Helps track adoption and version
spread. No persistent identifiers are collected.

Implementation notes:
- inline (no external dep) so Traefik plugin loader does not need to
  resolve a new vendored module
- stdlib-only, no generics, no range-over-int — verified to load under
  yaegi 0.16.x (full plugin import + CreateConfig/New symbol lookup OK)
- avoids `switch{case A,B,C:}` blocks where some yaegi releases
  mis-evaluate comma-separated case lists
- sync.Once guards against amplified pings on Traefik dynamic config
  reloads (which re-instantiate the middleware)

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  TRAEFIKOIDC_DISABLE_TELEMETRY=1
2026-05-21 03:20:36 +01:00
44 changed files with 720 additions and 4495 deletions
+5 -130
View File
@@ -1,41 +1,13 @@
version: 2
# Two release artefacts:
#
# 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.
# Traefik plugins are source-only - no binary builds
# Traefik loads plugins via Yaegi interpreter at runtime
builds:
- 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 }}"
- skip: true
# Create source archive for GitHub releases
archives:
# 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]
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
files:
- "*.go"
@@ -53,93 +25,6 @@ archives:
- "!regression/**"
- "!examples/**"
- "!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:
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
@@ -173,13 +58,3 @@ signs:
- "--yes"
artifacts: checksum
output: true
# Sign the Docker images and manifests with cosign keyless.
docker_signs:
- cmd: cosign
artifacts: all
args:
- sign
- "${artifact}@${digest}"
- "--yes"
output: true
+13 -22
View File
@@ -64,28 +64,6 @@ cosign verify-blob \
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
```yaml
@@ -433,6 +411,19 @@ namespaced claims, Cognito regions, GitLab self-hosted) live in
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
See [LICENSE](LICENSE).
+2 -2
View File
@@ -69,7 +69,7 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
// - session: The session data to prepare for authentication.
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
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", t.originalRequestURI(req))
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
// Check and handle redirect limits
if err := t.validateRedirectCount(session, rw, req); err != nil {
@@ -98,7 +98,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
}
// Clear existing session data and set new authentication state
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, t.originalRequestURI(req))
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
session.MarkDirty()
+2 -2
View File
@@ -71,8 +71,8 @@ func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: sm,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://issuer.example.com",
audience: "https://api.example.com",
clientID: "https://api.example.com",
-28
View File
@@ -1,28 +0,0 @@
# 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"]
-222
View File
@@ -1,222 +0,0 @@
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' }
-303
View File
@@ -1,303 +0,0 @@
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
}
-69
View File
@@ -1,69 +0,0 @@
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
}
-167
View File
@@ -1,167 +0,0 @@
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)
}
}
-24
View File
@@ -1,24 +0,0 @@
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)
})
}
-38
View File
@@ -1,38 +0,0 @@
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)
}
}
-15
View File
@@ -1,15 +0,0 @@
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,
}
}
-195
View File
@@ -1,195 +0,0 @@
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)
}
}
-83
View File
@@ -1,83 +0,0 @@
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)
}
}
}
-108
View File
@@ -1,108 +0,0 @@
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)
}
}
-54
View File
@@ -1,54 +0,0 @@
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")
}
-43
View File
@@ -1,43 +0,0 @@
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)
}
-42
View File
@@ -1,42 +0,0 @@
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)
}
}
}
-43
View File
@@ -1,43 +0,0 @@
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
}
-70
View File
@@ -1,70 +0,0 @@
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", "alice@example.com")
req.Header.Set("X-Forwarded-Email", "alice@example.com")
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 != "alice@example.com" {
t.Errorf("X-Forwarded-User: want mirrored, got %q", got)
}
if got := rec.Header().Get("X-Forwarded-Email"); got != "alice@example.com" {
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)
}
}
-17
View File
@@ -14,7 +14,6 @@ Complete reference for all Traefik OIDC middleware configuration options.
- [Security Headers](#security-headers)
- [Scope Configuration](#scope-configuration)
- [Advanced Options](#advanced-options)
- [Standalone binary (oidcgate)](#standalone-binary-oidcgate)
---
@@ -665,19 +664,3 @@ sessionEncryptionKey: ${OIDC_SECRET_API}
# Good
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.
-362
View File
@@ -1,362 +0,0 @@
# 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.
File diff suppressed because it is too large Load Diff
@@ -1,173 +0,0 @@
# 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.
-15
View File
@@ -1,15 +0,0 @@
# 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]
-37
View File
@@ -1,37 +0,0 @@
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 == ""
}
-69
View File
@@ -1,69 +0,0 @@
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)
}
}
+7 -6
View File
@@ -478,11 +478,10 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
// Test 3: Rate limiting
t.Run("RateLimiting", func(t *testing.T) {
// Reset circuit breaker to closed state for this test
coordinator.circuitBreaker.mutex.Lock()
// Reset circuit breaker to closed state for this test. All fields are
// atomic so we don't need any mutex.
atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed
atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0)
coordinator.circuitBreaker.mutex.Unlock()
// Temporarily increase circuit breaker threshold to not interfere
oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures
@@ -525,9 +524,11 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
time.Sleep(config.CleanupInterval * 3)
// Old sessions should be cleaned up
coordinator.attemptsMutex.RLock()
count := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
count := 0
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
count++
return true
})
// Should have fewer sessions after cleanup
if count > 10 {
+49 -11
View File
@@ -53,10 +53,26 @@ type JWKSet struct {
Keys []JWK `json:"keys"`
}
// JWKCache provides thread-safe caching of JWKS using UniversalCache
// JWKCache provides thread-safe caching of JWKS using UniversalCache.
//
// inflightFetches deduplicates concurrent fetches for the same JWKS URL.
// It replaces a global sync.RWMutex that was previously held for the entire
// HTTP round-trip in GetJWKS: on a cold cache (cold pod, JWK rotation, brief
// network blip) every concurrent request piled up on that single Lock(), and
// under Yaegi each Lock acquisition costs 10-50ms of interpreter-dispatch
// overhead. The singleflight pattern keeps the cold-cache cost O(1) HTTP
// fetch regardless of how many requests are waiting.
type JWKCache struct {
cache *UniversalCache
mutex sync.RWMutex
cache *UniversalCache
inflightFetches sync.Map // map[jwksURL string]*jwksFetch
}
// jwksFetch represents an in-flight JWKS fetch. Done is closed when the fetch
// completes; jwks and err carry the result (one of them is set, never both).
type jwksFetch struct {
done chan struct{}
jwks *JWKSet
err error
}
// JWKCacheInterface defines the contract for JWK caching implementations.
@@ -83,36 +99,58 @@ func NewJWKCache() *JWKCache {
// request refetches from the upstream. JWK rotation is rare and a per-replica
// HTTP fetch on cold cache is cheap, so cross-replica coherence buys nothing.
func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
// Check cache first
// Fast path: cache hit.
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
if jwks, ok := cachedValue.(*JWKSet); ok {
return jwks, nil
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
// Singleflight: dedupe concurrent fetches per URL key. The first arrival
// performs the HTTP fetch; any later arrival for the same URL waits on
// its done channel and shares the result. No global lock is held during
// the fetch.
candidate := &jwksFetch{done: make(chan struct{})}
if existing, loaded := c.inflightFetches.LoadOrStore(jwksURL, candidate); loaded {
f, _ := existing.(*jwksFetch)
select {
case <-f.done:
return f.jwks, f.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Double-check after acquiring lock
// We're the leader. Make absolutely sure the result fields and the
// in-flight map entry are cleaned up before any waiter unblocks.
defer func() {
c.inflightFetches.Delete(jwksURL)
close(candidate.done)
}()
// Re-check the cache in case a concurrent fetch completed between our
// initial miss and our LoadOrStore win.
if cachedValue, found := c.cache.GetLocal(jwksURL); found {
if jwks, ok := cachedValue.(*JWKSet); ok {
candidate.jwks = jwks
return jwks, nil
}
}
// Fetch from URL
jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
if err != nil {
candidate.err = err
return nil, err
}
if len(jwks.Keys) == 0 {
return nil, fmt.Errorf("JWKS response contains no keys")
candidate.err = fmt.Errorf("JWKS response contains no keys")
return nil, candidate.err
}
// Cache for 1 hour
// Cache for 1 hour.
_ = c.cache.SetLocal(jwksURL, jwks, 1*time.Hour) // Safe to ignore: cache failures are non-critical
candidate.jwks = jwks
return jwks, nil
}
+4 -4
View File
@@ -415,8 +415,8 @@ func TestMiddlewareBackchannelLogoutRouting(t *testing.T) {
clientID: "test-client",
issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout",
}
close(oidc.initComplete)
@@ -457,8 +457,8 @@ func TestMiddlewareFrontchannelLogoutRouting(t *testing.T) {
clientID: "test-client",
issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout",
}
close(oidc.initComplete)
+1 -1
View File
@@ -89,6 +89,7 @@ var defaultExcludedURLs = map[string]struct{}{
// - The configured TraefikOidc handler ready to process requests.
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
sendTelemetry(pluginVersion)
return NewWithContext(ctx, config, next, name)
}
@@ -201,7 +202,6 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
}(),
forceHTTPS: config.ForceHTTPS,
enablePKCE: config.EnablePKCE,
trustForwardedURI: config.TrustForwardedURI,
overrideScopes: config.OverrideScopes,
strictAudienceValidation: config.StrictAudienceValidation,
allowOpaqueTokens: config.AllowOpaqueTokens,
+14 -30
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -484,9 +485,8 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close()
oidc := &TraefikOidc{
providerURL: server.URL,
firstRequestReceived: false,
firstRequestMutex: sync.Mutex{},
providerURL: server.URL,
firstRequestStarted: 0,
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
@@ -508,19 +508,13 @@ func TestFirstRequestHandling(t *testing.T) {
},
}
// Simulate first request processing
oidc.firstRequestMutex.Lock()
if !oidc.firstRequestReceived {
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
// Simulate first request processing — single-firing via CAS.
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
// This would normally be called asynchronously
go func() {
oidc.initializeMetadata(server.URL)
// initComplete is closed internally by initializeMetadata
}()
} else {
oidc.firstRequestMutex.Unlock()
}
// Wait for initialization
@@ -556,9 +550,8 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close()
oidc := &TraefikOidc{
providerURL: server.URL,
firstRequestReceived: false,
firstRequestMutex: sync.Mutex{},
providerURL: server.URL,
firstRequestStarted: 0,
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
@@ -580,31 +573,22 @@ func TestFirstRequestHandling(t *testing.T) {
},
}
// Simulate multiple concurrent "first" requests
// Simulate multiple concurrent "first" requests — only one CAS winner
// fires the bootstrap path.
const numRequests = 10
var wg sync.WaitGroup
wg.Add(numRequests)
initStarted := 0
var initMu sync.Mutex
var initStarted int32
for i := 0; i < numRequests; i++ {
go func() {
defer wg.Done()
oidc.firstRequestMutex.Lock()
if !oidc.firstRequestReceived {
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
initMu.Lock()
initStarted++
initMu.Unlock()
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) {
atomic.AddInt32(&initStarted, 1)
// Only one should actually start initialization
oidc.initializeMetadata(server.URL)
} else {
oidc.firstRequestMutex.Unlock()
}
}()
}
@@ -612,8 +596,8 @@ func TestFirstRequestHandling(t *testing.T) {
wg.Wait()
// Verify only one initialization was started
if initStarted != 1 {
t.Errorf("expected exactly 1 initialization, got %d", initStarted)
if atomic.LoadInt32(&initStarted) != 1 {
t.Errorf("expected exactly 1 initialization, got %d", atomic.LoadInt32(&initStarted))
}
// The metadata endpoint might be called once or not at all depending on timing
+28 -28
View File
@@ -61,8 +61,8 @@ func TestServeHTTP_ExcludedURLs(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", // Required for initialization check
}
close(oidc.initComplete)
@@ -92,8 +92,8 @@ func TestServeHTTP_EventStream(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
@@ -175,8 +175,8 @@ func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
@@ -272,8 +272,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close this to simulate timeout
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
req := httptest.NewRequest("GET", "/protected", nil)
@@ -307,8 +307,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -337,8 +337,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -367,8 +367,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -740,8 +740,8 @@ func TestMinimalHeaders(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -817,8 +817,8 @@ func TestMinimalHeaders_TokenHeaderNotSet(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
minimalHeaders: true, // Enable minimal headers
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -903,8 +903,8 @@ func TestStripAuthCookies(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: tt.stripAuthCookies,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -987,8 +987,8 @@ func TestStripAuthCookies_NoCookies(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1034,8 +1034,8 @@ func TestStripAuthCookies_OnlyOIDCCookies(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1085,8 +1085,8 @@ func TestStripAuthCookies_OnlyAppCookies(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1148,8 +1148,8 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sm,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
+4 -4
View File
@@ -16,6 +16,7 @@ import (
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -2685,10 +2686,9 @@ func TestMetadataRecoveryOnProviderFailure(t *testing.T) {
providerAvailable = true
mu.Unlock()
// Reset the retry timer to allow immediate retry
m.metadataRetryMutex.Lock()
m.lastMetadataRetryTime = time.Time{} // Reset to zero time
m.metadataRetryMutex.Unlock()
// Reset the retry timer to allow immediate retry. The field is atomic
// now, so no lock is needed.
atomic.StoreInt64(&m.lastMetadataRetryNano, 0)
// Second request should trigger recovery attempt
req2 := httptest.NewRequest("GET", "/protected", nil)
+17 -15
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
@@ -145,19 +146,20 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
if !strings.HasPrefix(req.URL.Path, "/health") {
t.firstRequestMutex.Lock()
if !t.firstRequestReceived {
t.firstRequestReceived = true
// Lock-free one-shot bootstrap. The previous firstRequestMutex.Lock()
// fired on EVERY non-health request forever (even after the boolean
// flipped true), which under Yaegi added a per-request serialization
// point. CAS gives single-firing semantics with zero steady-state cost.
if atomic.CompareAndSwapInt32(&t.firstRequestStarted, 0, 1) {
t.logger.Debug("Starting background tasks on first request")
t.startTokenCleanup()
if !t.metadataRefreshStarted && t.providerURL != "" {
t.metadataRefreshStarted = true
if t.providerURL != "" &&
atomic.CompareAndSwapInt32(&t.metadataRefreshStartedAtomic, 0, 1) {
// Metadata refresh is handled by singleton resource manager
t.startMetadataRefresh(t.providerURL)
}
}
t.firstRequestMutex.Unlock()
}
// Evaluate auth-bypass once, before waiting for initialization. Excluded
@@ -213,14 +215,14 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
t.metadataMu.RUnlock()
if issuerURL == "" {
// Provider metadata initialization failed - try to recover
// Retry every 30 seconds to allow automatic recovery when provider comes back online
t.metadataRetryMutex.Lock()
shouldRetry := time.Since(t.lastMetadataRetryTime) >= 30*time.Second
if shouldRetry {
t.lastMetadataRetryTime = time.Now()
}
t.metadataRetryMutex.Unlock()
// Provider metadata initialization failed - try to recover.
// Retry every 30 seconds to allow automatic recovery. Lock-free
// throttle via CAS on lastMetadataRetryNano: one goroutine wins
// the window, others see shouldRetry=false.
nowNano := time.Now().UnixNano()
last := atomic.LoadInt64(&t.lastMetadataRetryNano)
shouldRetry := time.Duration(nowNano-last) >= 30*time.Second &&
atomic.CompareAndSwapInt64(&t.lastMetadataRetryNano, last, nowNano)
if shouldRetry && t.providerURL != "" {
t.logger.Info("Attempting to recover OIDC provider metadata...")
@@ -603,7 +605,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-Redirect", t.originalRequestURI(req))
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
req.Header.Set("X-Auth-Request-User", p.Identifier)
if p.IDToken != "" {
req.Header.Set("X-Auth-Request-Token", p.IDToken)
+14 -14
View File
@@ -13,8 +13,8 @@ func TestMiddlewareContextCancellation(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate waiting
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
}
// Create request with canceled context
@@ -39,8 +39,8 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -73,8 +73,8 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -102,8 +102,8 @@ func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
sessionManager: createTestSessionManager(t),
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout",
postLogoutRedirectURI: "/",
forceHTTPS: false,
@@ -142,8 +142,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -187,8 +187,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
@@ -236,8 +236,8 @@ func TestMiddlewareOpaqueTokenHandling(t *testing.T) {
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
-12
View File
@@ -1,12 +0,0 @@
package traefikoidc
// Ready reports whether the middleware has completed at least one successful
// OIDC provider metadata discovery. Used by external supervisors (e.g. the
// oidcgate /readyz endpoint) to gate traffic until the IdP discovery doc
// has been fetched and the authorization endpoint is known.
func (t *TraefikOidc) Ready() bool {
t.metadataMu.RLock()
u := t.authURL
t.metadataMu.RUnlock()
return u != ""
}
-20
View File
@@ -1,20 +0,0 @@
package traefikoidc
import "testing"
func TestReady_FalseBeforeMetadata(t *testing.T) {
tr := &TraefikOidc{}
if tr.Ready() {
t.Fatal("Ready() should be false before metadata discovery")
}
}
func TestReady_TrueAfterAuthURLSet(t *testing.T) {
tr := &TraefikOidc{}
tr.metadataMu.Lock()
tr.authURL = "https://idp.example/authorize"
tr.metadataMu.Unlock()
if !tr.Ready() {
t.Fatal("Ready() should be true once authURL is populated")
}
}
+222 -166
View File
@@ -15,17 +15,29 @@ import (
// It implements request coalescing, rate limiting, and circuit breaking
// specifically for token refresh operations.
type RefreshCoordinator struct {
inFlightRefreshes map[string]*refreshOperation
// inFlightRefreshes maps tokenHash -> *refreshOperation. sync.Map is used
// instead of a plain map + RWMutex so concurrent refreshes do not
// serialize on a single global lock. Under Yaegi the previous
// refreshMutex.Lock() was held for tens of milliseconds per request due
// to interpreter overhead on the work inside the critical section,
// causing dozens of goroutines to stack up on it and pin one CPU core.
inFlightRefreshes sync.Map
// sessionRefreshAttempts maps sessionID -> *refreshAttemptTracker.
// sync.Map + atomic tracker fields means isInCooldown/recordRefreshAttempt/
// recordRefreshSuccess/recordRefreshFailure are lock-free. Previously
// these used attemptsMutex sync.RWMutex; under Yaegi every Lock() acquisition
// adds 10-50ms of dispatch overhead, and they were called twice per leader
// request (once for recordRefreshAttempt, once for isInCooldown). That
// serializing pattern caused the v1.0.15 death spiral after v1.0.14
// removed the refreshMutex (same architectural shape, different mutex).
sessionRefreshAttempts sync.Map
cleanupTimers map[string]*time.Timer
sessionRefreshAttempts map[string]*refreshAttemptTracker
circuitBreaker *RefreshCircuitBreaker
metrics *RefreshMetrics
logger *Logger
stopChan chan struct{}
config RefreshCoordinatorConfig
wg sync.WaitGroup
attemptsMutex sync.RWMutex
refreshMutex sync.RWMutex
cleanupTimerMu sync.Mutex
}
@@ -84,14 +96,22 @@ type refreshResult struct {
fromCache bool
}
// refreshAttemptTracker tracks refresh attempts for a session
// refreshAttemptTracker tracks refresh attempts for a session. All fields are
// accessed via sync/atomic so isInCooldown/recordRefreshAttempt/Success/Failure
// can run without holding any per-coordinator lock. Times are UnixNano so they
// fit in an int64 and can be read with a single atomic.LoadInt64.
//
// cooldownEndNano == 0 means "not in cooldown". This sentinel replaces the
// inCooldown bool that the previous implementation kept under attemptsMutex —
// under Yaegi any per-request global mutex turns into a serializing bottleneck
// (the v1.0.14 refreshMutex -> sync.Map fix removed only one such bottleneck;
// attemptsMutex was the next one in the queue).
type refreshAttemptTracker struct {
lastAttemptTime time.Time
windowStartTime time.Time
cooldownEndTime time.Time
attempts int32
consecutiveFailures int32
inCooldown bool
lastAttemptNano int64 // atomic, UnixNano of last attempt
windowStartNano int64 // atomic, UnixNano of attempt-window start
cooldownEndNano int64 // atomic, UnixNano; 0 = not in cooldown
attempts int32 // atomic
consecutiveFailures int32 // atomic
}
// RefreshMetrics tracks coordinator performance metrics
@@ -106,14 +126,18 @@ type RefreshMetrics struct {
currentInFlightRefreshes int32
}
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh operations
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh
// operations. All mutable fields are atomic so AllowRequest/RecordSuccess/
// RecordFailure run without any mutex. The previous sync.RWMutex.RLock() was
// taken on every CoordinateRefresh — under Yaegi this added 10-50ms of
// interpreter dispatch per call, which compounded with attemptsMutex to keep
// the pod's single CPU core saturated.
type RefreshCircuitBreaker struct {
lastFailureTime time.Time
lastSuccessTime time.Time
lastFailureNano int64 // atomic, UnixNano of most recent failure
lastSuccessNano int64 // atomic, UnixNano of most recent success
config RefreshCircuitBreakerConfig
mutex sync.RWMutex
state int32
failures int32
state int32 // atomic: 0=closed, 1=open, 2=half-open
failures int32 // atomic
}
// RefreshCircuitBreakerConfig configures the refresh circuit breaker
@@ -130,13 +154,13 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
}
rc := &RefreshCoordinator{
inFlightRefreshes: make(map[string]*refreshOperation),
sessionRefreshAttempts: make(map[string]*refreshAttemptTracker),
config: config,
metrics: &RefreshMetrics{},
logger: logger,
stopChan: make(chan struct{}),
cleanupTimers: make(map[string]*time.Timer),
// inFlightRefreshes and sessionRefreshAttempts are both sync.Map;
// their zero values are ready to use.
config: config,
metrics: &RefreshMetrics{},
logger: logger,
stopChan: make(chan struct{}),
cleanupTimers: make(map[string]*time.Timer),
circuitBreaker: &RefreshCircuitBreaker{
config: RefreshCircuitBreakerConfig{
MaxFailures: 3,
@@ -227,13 +251,28 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
tokenHash string,
refreshToken string,
) (*refreshOperation, bool, error) {
rc.refreshMutex.Lock()
defer rc.refreshMutex.Unlock()
// Speculatively construct the operation we WOULD register if we win the
// race. Allocating here keeps the LoadOrStore call below atomic and
// avoids any global lock — under Yaegi the previous map+RWMutex design
// held the write lock long enough (tens of ms per call) that concurrent
// refreshes on the same coordinator serialized into a queue that grew
// without bound. See struct comment on inFlightRefreshes.
candidate := &refreshOperation{
refreshToken: refreshToken,
done: make(chan struct{}),
startTime: time.Now(),
waiterCount: 1,
}
// Check for existing operation while holding the lock
if existingOp, exists := rc.inFlightRefreshes[tokenHash]; exists {
if existing, loaded := rc.inFlightRefreshes.LoadOrStore(tokenHash, candidate); loaded {
existingOp, ok := existing.(*refreshOperation)
if !ok {
// Defensive: anything stored here is always *refreshOperation, but
// keep the typed assert so a programming error elsewhere doesn't
// surface as a confusing panic in an interpreter frame.
return nil, false, fmt.Errorf("inFlightRefreshes corrupt: unexpected type %T", existing)
}
if existingOp.refreshToken == refreshToken {
// Join existing operation
atomic.AddInt32(&existingOp.waiterCount, 1)
return existingOp, false, nil
}
@@ -241,41 +280,60 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
return nil, false, fmt.Errorf("refresh token mismatch")
}
// No existing operation - check if we can create a new one
// All checks happen while holding the lock to prevent races
// We won the race and registered `candidate`. Apply gates now. If any
// gate fails we must remove our entry from the map and signal failure
// to any joiners that snuck in between LoadOrStore and now.
if err := rc.applyLeaderGates(sessionID); err != nil {
rc.failCandidate(tokenHash, candidate, err)
return nil, false, err
}
// Check and record refresh attempt for rate limiting
// Reserve concurrent slot via CAS — without the old global lock we can
// no longer rely on mutex-mediated check-then-increment. If we lose the
// CAS race we retry; if the limit has since been reached we back out.
for {
current := atomic.LoadInt32(&rc.metrics.currentInFlightRefreshes)
if int(current) >= rc.config.MaxConcurrentRefreshes {
err := fmt.Errorf("maximum concurrent refresh operations reached")
rc.failCandidate(tokenHash, candidate, err)
return nil, false, err
}
if atomic.CompareAndSwapInt32(&rc.metrics.currentInFlightRefreshes, current, current+1) {
break
}
}
return candidate, true, nil
}
// applyLeaderGates runs the rate-limit, cooldown, and memory-pressure checks
// that previously ran under the global refreshMutex. Only the leader (the
// goroutine that just registered the operation) runs them; joiners share the
// leader's outcome via operation.done.
func (rc *RefreshCoordinator) applyLeaderGates(sessionID string) error {
rc.recordRefreshAttempt(sessionID)
if rc.isInCooldown(sessionID) {
atomic.AddInt64(&rc.metrics.cooldownsTriggered, 1)
return nil, false, fmt.Errorf("refresh attempts exceeded for session, in cooldown period")
return fmt.Errorf("refresh attempts exceeded for session, in cooldown period")
}
// Check memory pressure
if rc.config.EnableMemoryPressureDetection && rc.isUnderMemoryPressure() {
atomic.AddInt64(&rc.metrics.memoryPressureEvents, 1)
return nil, false, fmt.Errorf("system under memory pressure, refresh denied")
return fmt.Errorf("system under memory pressure, refresh denied")
}
return nil
}
// Check and reserve concurrent refresh slot atomically
current := atomic.LoadInt32(&rc.metrics.currentInFlightRefreshes)
if int(current) >= rc.config.MaxConcurrentRefreshes {
return nil, false, fmt.Errorf("maximum concurrent refresh operations reached")
}
// Reserve the slot - we're still holding the lock so this is safe
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, 1)
// Create and register new operation
operation := &refreshOperation{
refreshToken: refreshToken,
done: make(chan struct{}),
startTime: time.Now(),
waiterCount: 1,
}
rc.inFlightRefreshes[tokenHash] = operation
return operation, true, nil
// failCandidate removes the leader's just-registered operation from the
// in-flight map and signals the error to any joiners by recording the result
// and closing the done channel. This keeps the (nil, false, err) return path
// equivalent to the pre-sync.Map version: callers see the error directly,
// joiners see it via operation.done.
func (rc *RefreshCoordinator) failCandidate(tokenHash string, op *refreshOperation, err error) {
rc.inFlightRefreshes.Delete(tokenHash)
op.mutex.Lock()
op.result = &refreshResult{err: err}
op.mutex.Unlock()
close(op.done)
}
// executeRefreshAsync performs the actual refresh operation asynchronously
@@ -367,100 +425,108 @@ func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) {
// performCleanup removes the operation from the in-flight map.
// Idempotent: only decrements the in-flight counter if an entry was actually
// removed. This guards against any future path accidentally calling cleanup
// twice for the same tokenHash (which would corrupt the refresh budget).
// removed. LoadAndDelete is atomic so any concurrent failCandidate or repeat
// cleanup call will see exactly one removal — the budget cannot be corrupted
// by double-decrement.
func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
rc.refreshMutex.Lock()
_, existed := rc.inFlightRefreshes[tokenHash]
if existed {
delete(rc.inFlightRefreshes, tokenHash)
}
rc.refreshMutex.Unlock()
if existed {
if _, existed := rc.inFlightRefreshes.LoadAndDelete(tokenHash); existed {
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
}
}
// isInCooldown checks if a session is in cooldown after recording an attempt
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
// getOrCreateTracker fetches the tracker for sessionID or atomically creates a
// fresh one. The sync.Map.LoadOrStore semantics make this lock-free even under
// concurrent first-touch races: at most one tracker per sessionID survives.
//
// trackerFromMapValue centralizes the type assertion so the lint-mandated
// two-value form lives in one place; the stored type is always
// *refreshAttemptTracker by construction.
func trackerFromMapValue(v interface{}) *refreshAttemptTracker {
t, _ := v.(*refreshAttemptTracker)
return t
}
tracker, exists := rc.sessionRefreshAttempts[sessionID]
if !exists {
func (rc *RefreshCoordinator) getOrCreateTracker(sessionID string) *refreshAttemptTracker {
if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
return trackerFromMapValue(v)
}
fresh := &refreshAttemptTracker{
windowStartNano: time.Now().UnixNano(),
}
actual, _ := rc.sessionRefreshAttempts.LoadOrStore(sessionID, fresh)
return trackerFromMapValue(actual)
}
// isInCooldown checks if a session is in cooldown. Lock-free read with a
// best-effort cooldown-reset CAS on the cooldownEndNano sentinel. If the
// reset races with another goroutine we accept the loser's view (the winner's
// reset still happens). The attempt-window expiry and limit-exceeded paths
// are write-mostly but use atomic.StoreInt64/AddInt32 — never a held lock.
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
v, ok := rc.sessionRefreshAttempts.Load(sessionID)
if !ok {
return false // No tracker means first attempt, not in cooldown
}
tracker := trackerFromMapValue(v)
now := time.Now()
nowNano := now.UnixNano()
// Check if already in cooldown
if tracker.inCooldown {
if now.After(tracker.cooldownEndTime) {
// Cooldown expired, reset tracker
tracker.inCooldown = false
tracker.attempts = 1 // Already recorded one attempt
tracker.consecutiveFailures = 0
tracker.windowStartTime = now
return false
// Already in cooldown?
if cooldownEnd := atomic.LoadInt64(&tracker.cooldownEndNano); cooldownEnd != 0 {
if nowNano <= cooldownEnd {
return true // still in cooldown
}
// Cooldown expired. Best-effort reset (a concurrent caller may also
// reset; the result is equivalent — fresh window + one recorded
// attempt — so the CAS race is benign).
if atomic.CompareAndSwapInt64(&tracker.cooldownEndNano, cooldownEnd, 0) {
atomic.StoreInt32(&tracker.attempts, 1)
atomic.StoreInt32(&tracker.consecutiveFailures, 0)
atomic.StoreInt64(&tracker.windowStartNano, nowNano)
}
return true // Still in cooldown
}
// Check if window expired
if now.Sub(tracker.windowStartTime) > rc.config.RefreshAttemptWindow {
// Reset window
tracker.attempts = 1 // Already recorded one attempt
tracker.windowStartTime = now
return false
}
// Check if just exceeded attempt limit
if int(tracker.attempts) >= rc.config.MaxRefreshAttempts {
// Enter cooldown now
tracker.inCooldown = true
tracker.cooldownEndTime = now.Add(rc.config.RefreshCooldownPeriod)
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
sessionID, tracker.attempts)
// Window expired?
if windowStart := atomic.LoadInt64(&tracker.windowStartNano); time.Duration(nowNano-windowStart) > rc.config.RefreshAttemptWindow {
atomic.StoreInt32(&tracker.attempts, 1)
atomic.StoreInt64(&tracker.windowStartNano, nowNano)
return false
}
// Just exceeded attempt limit?
if int(atomic.LoadInt32(&tracker.attempts)) >= rc.config.MaxRefreshAttempts {
end := now.Add(rc.config.RefreshCooldownPeriod).UnixNano()
// Only one CAS winner publishes the cooldown end + logs.
if atomic.CompareAndSwapInt64(&tracker.cooldownEndNano, 0, end) {
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
sessionID, atomic.LoadInt32(&tracker.attempts))
}
return true
}
return false
}
// recordRefreshAttempt records a refresh attempt for rate limiting
// recordRefreshAttempt records a refresh attempt for rate limiting. Lock-free:
// LoadOrStore for the tracker, atomic counters/timestamps for fields.
func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) {
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
tracker, exists := rc.sessionRefreshAttempts[sessionID]
if !exists {
tracker = &refreshAttemptTracker{
windowStartTime: time.Now(),
}
rc.sessionRefreshAttempts[sessionID] = tracker
}
tracker := rc.getOrCreateTracker(sessionID)
atomic.AddInt32(&tracker.attempts, 1)
tracker.lastAttemptTime = time.Now()
atomic.StoreInt64(&tracker.lastAttemptNano, time.Now().UnixNano())
}
// recordRefreshSuccess records a successful refresh
// recordRefreshSuccess records a successful refresh. Lock-free.
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) {
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
tracker.consecutiveFailures = 0
if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
atomic.StoreInt32(&trackerFromMapValue(v).consecutiveFailures, 0)
}
}
// recordRefreshFailure records a failed refresh
// recordRefreshFailure records a failed refresh. Lock-free.
func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) {
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
atomic.AddInt32(&tracker.consecutiveFailures, 1)
if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
atomic.AddInt32(&trackerFromMapValue(v).consecutiveFailures, 1)
}
}
@@ -512,20 +578,22 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
}
}
// cleanupStaleEntries removes outdated tracking entries
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration
// via sync.Map.Range; safe to race with concurrent reads/writes.
func (rc *RefreshCoordinator) cleanupStaleEntries() {
now := time.Now()
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
// Clean up old session trackers
for sessionID, tracker := range rc.sessionRefreshAttempts {
// Remove trackers that haven't been used recently
if now.Sub(tracker.lastAttemptTime) > 2*rc.config.RefreshAttemptWindow {
delete(rc.sessionRefreshAttempts, sessionID)
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano()
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
tracker := trackerFromMapValue(value)
if tracker == nil {
return true
}
}
if atomic.LoadInt64(&tracker.lastAttemptNano) < cutoff {
// Compare-and-delete to avoid evicting a tracker that was just
// re-used by a concurrent caller. We compare by pointer identity.
rc.sessionRefreshAttempts.CompareAndDelete(key, value)
}
return true
})
}
// GetMetrics returns current coordinator metrics
@@ -558,63 +626,51 @@ func (rc *RefreshCoordinator) Shutdown() {
rc.wg.Wait()
}
// AllowRequest checks if the circuit breaker allows a request
// AllowRequest reports whether the circuit breaker allows a request. Lock-free.
func (cb *RefreshCircuitBreaker) AllowRequest() bool {
cb.mutex.RLock()
defer cb.mutex.RUnlock()
state := atomic.LoadInt32(&cb.state)
switch state {
case 0: // Closed
switch atomic.LoadInt32(&cb.state) {
case 0: // closed
return true
case 1: // Open
if time.Since(cb.lastFailureTime) > cb.config.OpenDuration {
// Try to transition to half-open
case 1: // open
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
// Transition to half-open; first CAS winner gets the probe.
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
return true
}
}
return false
case 2: // Half-open
case 2: // half-open
return true
default:
return false
}
}
// RecordSuccess records a successful operation
// RecordSuccess records a successful operation. Lock-free.
func (cb *RefreshCircuitBreaker) RecordSuccess() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
state := atomic.LoadInt32(&cb.state)
if state == 2 { // Half-open
// Close the circuit
switch atomic.LoadInt32(&cb.state) {
case 2: // half-open -> close
atomic.StoreInt32(&cb.state, 0)
atomic.StoreInt32(&cb.failures, 0)
} else if state == 0 { // Closed
// Reset failure count on success
case 0: // closed
atomic.StoreInt32(&cb.failures, 0)
}
cb.lastSuccessTime = time.Now()
atomic.StoreInt64(&cb.lastSuccessNano, time.Now().UnixNano())
}
// RecordFailure records a failed operation
// RecordFailure records a failed operation. Lock-free.
func (cb *RefreshCircuitBreaker) RecordFailure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
failures := atomic.AddInt32(&cb.failures, 1)
cb.lastFailureTime = time.Now()
atomic.StoreInt64(&cb.lastFailureNano, time.Now().UnixNano())
state := atomic.LoadInt32(&cb.state)
if state == 0 && int(failures) >= cb.config.MaxFailures {
// Open the circuit
atomic.StoreInt32(&cb.state, 1)
} else if state == 2 {
// Half-open failed, return to open
switch atomic.LoadInt32(&cb.state) {
case 0:
if int(failures) >= cb.config.MaxFailures {
atomic.StoreInt32(&cb.state, 1)
}
case 2:
// Half-open probe failed -> back to open.
atomic.StoreInt32(&cb.state, 1)
}
}
+16 -15
View File
@@ -365,10 +365,12 @@ func TestMemoryLeakPrevention(t *testing.T) {
}
}
// Verify cleanup is working
coordinator.attemptsMutex.RLock()
sessionCount := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
// Verify cleanup is working. sync.Map has no Len(); count via Range.
sessionCount := 0
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
sessionCount++
return true
})
// Should have cleaned up old sessions (only recent ones remain)
if sessionCount > numWorkers*2 {
@@ -650,24 +652,23 @@ func TestCleanupRoutine(t *testing.T) {
coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i))
}
// Verify sessions exist
coordinator.attemptsMutex.RLock()
initialCount := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
countSessions := func() int {
n := 0
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool {
n++
return true
})
return n
}
if initialCount != 5 {
if initialCount := countSessions(); initialCount != 5 {
t.Errorf("Expected 5 sessions, got %d", initialCount)
}
// Wait for cleanup to run (2x window + cleanup interval)
time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval)
// Verify sessions were cleaned up
coordinator.attemptsMutex.RLock()
finalCount := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
if finalCount != 0 {
if finalCount := countSessions(); finalCount != 0 {
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
}
}
-7
View File
@@ -126,13 +126,6 @@ type Config struct {
// Supported: RS256/384/512, PS256/384/512, ES256/384/512.
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
// TrustForwardedURI, when true, makes the middleware prefer the
// X-Forwarded-Uri request header (set by an upstream reverse proxy)
// over req.URL when capturing the "where was the user going" target
// stored for post-login redirect. Used by the oidcgate standalone
// daemon. Default false preserves the Traefik plugin behavior exactly.
TrustForwardedURI bool `json:"trustForwardedURI,omitempty"`
// --- Bearer-token auth (opt-in M2M path) ---
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
+142
View File
@@ -0,0 +1,142 @@
package traefikoidc
import (
"bytes"
"context"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
// pluginVersion is bumped manually on each release. Keep in sync with the
// most recent git tag (see `git tag --sort=-v:refname | head -1`).
const pluginVersion = "1.0.11"
const (
telemetryProject = "traefikoidc"
telemetryTimeout = 2 * time.Second
)
// telemetryEndpoint is intentionally a var rather than a const so the test
// suite in this package can retarget it at an httptest server. Production
// code never mutates it.
var telemetryEndpoint = "https://oss.raczylo.com/v1/ping"
// telemetryOnce guarantees a single anonymous "plugin loaded" ping per
// process lifetime. Traefik can instantiate a middleware many times per
// process (one per route using the plugin); the sync.Once gate keeps the
// fire-and-forget call from amplifying into many pings.
//
// Reset in tests via `telemetryOnce = sync.Once{}`.
var telemetryOnce sync.Once
// telemetryInflight tracks any background goroutine started by sendTelemetry.
// Tests Wait on it to drain in-flight goroutines before mutating package
// state. Production code never calls Wait — the goroutine is fire-and-forget.
var telemetryInflight sync.WaitGroup
// sendTelemetry fires one anonymous usage ping in the background. It is
// failproof by contract:
//
// - never blocks the caller
// - never panics (the goroutine recovers internally)
// - never returns errors
// - silently dropped on invalid input, env-driven opt-out, or network failure
//
// Opt-out is honored via any of:
//
// - DO_NOT_TRACK=1
// - OSS_TELEMETRY_DISABLED=1
// - TRAEFIKOIDC_DISABLE_TELEMETRY=1
//
// Yaegi note: this file deliberately avoids generics (atomic.Pointer[T]) and
// range-over-int (Go 1.22) so it interprets under any reasonably recent
// Traefik yaegi runtime.
func sendTelemetry(version string) {
telemetryOnce.Do(func() {
if telemetryDisabledByEnv() {
return
}
if !validTelemetryVersion(version) {
return
}
telemetryInflight.Add(1)
go func() {
defer telemetryInflight.Done()
defer func() { _ = recover() }()
doTelemetryPost(version)
}()
})
}
func telemetryDisabledByEnv() bool {
keys := []string{
"DO_NOT_TRACK",
"OSS_TELEMETRY_DISABLED",
"TRAEFIKOIDC_DISABLE_TELEMETRY",
}
for _, k := range keys {
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
if v == "1" || v == "true" || v == "yes" || v == "on" {
return true
}
}
return false
}
// validTelemetryVersion mirrors the server-side regex ^[A-Za-z0-9.+_-]{1,32}$
// using a byte loop. No allocation, no regexp dependency.
//
// Yaegi note: written as an `||` chain rather than `switch{case A,B,C:}` —
// some yaegi releases mis-evaluate comma-separated case expressions in
// switch-true blocks, returning false for all inputs.
func validTelemetryVersion(v string) bool {
if len(v) == 0 || len(v) > 32 {
return false
}
for i := 0; i < len(v); i++ {
c := v[i]
ok := (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '+' || c == '_' || c == '-'
if !ok {
return false
}
}
return true
}
// doTelemetryPost builds the JSON body manually. The project name is a
// constant and the version is pre-validated against an ASCII-only allowlist,
// so direct concatenation needs no JSON escaping.
func doTelemetryPost(version string) {
body := make([]byte, 0, 96)
body = append(body, `{"project":"`...)
body = append(body, telemetryProject...)
body = append(body, `","version":"`...)
body = append(body, version...)
body = append(body, `","ts":`...)
body = strconv.AppendInt(body, time.Now().Unix(), 10)
body = append(body, '}')
ctx, cancel := context.WithTimeout(context.Background(), telemetryTimeout)
defer cancel()
url := telemetryEndpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: telemetryTimeout}
resp, err := client.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}
+167
View File
@@ -0,0 +1,167 @@
package traefikoidc
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// resetTelemetryState restores package-level mutable state so tests do not
// contaminate one another. The cleanup waits for any in-flight ping goroutine
// to finish before restoring telemetryEndpoint — without that drain step the
// goroutine and the cleanup would race on the var.
func resetTelemetryState(t *testing.T) {
t.Helper()
telemetryOnce = sync.Once{}
prev := telemetryEndpoint
t.Cleanup(func() {
telemetryInflight.Wait()
telemetryEndpoint = prev
telemetryOnce = sync.Once{}
})
}
func newTelemetryServer(t *testing.T, status int) (hits *int32, lastBody func() string) {
t.Helper()
var counter int32
var mu sync.Mutex
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&counter, 1)
b, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
mu.Lock()
body = string(b)
mu.Unlock()
w.WriteHeader(status)
}))
telemetryEndpoint = srv.URL
t.Cleanup(srv.Close)
return &counter, func() string {
mu.Lock()
defer mu.Unlock()
return body
}
}
func TestValidTelemetryVersion(t *testing.T) {
good := []string{"1.2.3", "1.4.0-beta1", "2.0", "v1.0.0", "1.0.0+meta", "dev"}
for _, v := range good {
if !validTelemetryVersion(v) {
t.Errorf("validTelemetryVersion(%q) = false, want true", v)
}
}
bad := []string{"", "has space", "semi;colon", strings.Repeat("1", 33)}
for _, v := range bad {
if validTelemetryVersion(v) {
t.Errorf("validTelemetryVersion(%q) = true, want false", v)
}
}
}
func TestTelemetryDisabledByEnv(t *testing.T) {
for _, k := range []string{"DO_NOT_TRACK", "OSS_TELEMETRY_DISABLED", "TRAEFIKOIDC_DISABLE_TELEMETRY"} {
t.Run(k, func(t *testing.T) {
t.Setenv(k, "1")
if !telemetryDisabledByEnv() {
t.Fatalf("%s=1 should disable", k)
}
})
}
t.Run("falsy_values_do_not_disable", func(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "0")
t.Setenv("OSS_TELEMETRY_DISABLED", "false")
t.Setenv("TRAEFIKOIDC_DISABLE_TELEMETRY", "no")
if telemetryDisabledByEnv() {
t.Fatal("falsy env values should not disable")
}
})
}
func TestSendTelemetry_FiresOnceAcrossManyCalls(t *testing.T) {
resetTelemetryState(t)
hits, lastBody := newTelemetryServer(t, http.StatusNoContent)
for i := 0; i < 50; i++ {
sendTelemetry("1.2.3")
}
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("expected exactly 1 hit, got %d", got)
}
var payload struct {
Project string `json:"project"`
Version string `json:"version"`
Ts int64 `json:"ts"`
}
if err := json.Unmarshal([]byte(lastBody()), &payload); err != nil {
t.Fatalf("server received non-JSON body: %q (err: %v)", lastBody(), err)
}
if payload.Project != "traefikoidc" || payload.Version != "1.2.3" || payload.Ts <= 0 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestSendTelemetry_RespectsDisableEnv(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusNoContent)
t.Setenv("DO_NOT_TRACK", "1")
sendTelemetry("1.2.3")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 0 {
t.Fatalf("DO_NOT_TRACK should suppress; got %d hits", got)
}
}
func TestSendTelemetry_DropsInvalidVersion(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusNoContent)
sendTelemetry("has space")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 0 {
t.Fatalf("invalid version should suppress; got %d hits", got)
}
}
func TestSendTelemetry_DoesNotBlock(t *testing.T) {
resetTelemetryState(t)
// Hanging server proves the caller is never blocked. The 2s context
// timeout in doTelemetryPost ensures the goroutine eventually exits;
// resetTelemetryState's cleanup waits for that drain before restoring
// telemetryEndpoint so there is no race with this test's mutation.
hung := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
time.Sleep(5 * time.Second)
}))
t.Cleanup(hung.Close)
telemetryEndpoint = hung.URL
start := time.Now()
sendTelemetry("1.2.3")
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("sendTelemetry blocked for %v, expected near-instant return", elapsed)
}
}
func TestSendTelemetry_SurvivesServerError(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusInternalServerError)
sendTelemetry("1.2.3")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("request should still reach server even on 500; got %d hits", got)
}
}
+13 -6
View File
@@ -65,7 +65,19 @@ type ProviderMetadata struct {
// the complete authentication flow. It's designed to work seamlessly with Traefik's
// plugin system and provides flexible configuration options.
type TraefikOidc struct {
lastMetadataRetryTime time.Time
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64
// firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
// a write lock on every non-health request forever.
firstRequestStarted int32
// metadataRefreshStartedAtomic is the CAS-only variant of the old
// metadataRefreshStarted bool. Both flags live under the same atomic so
// concurrent first-request goroutines race exactly once.
metadataRefreshStartedAtomic int32
jwkCache JWKCacheInterface
jwtVerifier JWTVerifier
ctx context.Context
@@ -130,17 +142,13 @@ type TraefikOidc struct {
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
metadataRetryMutex sync.Mutex
firstRequestMutex sync.Mutex
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
firstRequestReceived bool
requireTokenIntrospection bool
metadataRefreshStarted bool
allowPrivateIPAddresses bool
disableReplayDetection bool
allowOpaqueTokens bool
@@ -149,7 +157,6 @@ type TraefikOidc struct {
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
trustForwardedURI bool
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string