Compare commits

..
28 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 4fa579ccf4 test(oidcgate): integration test with real middleware against mock IdP (#142) 2026-05-23 03:12:17 +01:00
lukaszraczylo 2af05701dc build(release): publish multi-arch oidcgate Docker image per release tag
- Add 'oidcgate' build entry (linux/darwin × amd64/arm64) to goreleaser.
- Add per-OS/arch tar.gz archives for the daemon binary.
- Add dockers + docker_manifests entries publishing
  ghcr.io/lukaszraczylo/oidcgate:vX.Y.Z (release tag), :vX.Y, :vX, :latest
  as multi-arch manifests (linux/amd64 + linux/arm64).
- Add cmd/oidcgate/Dockerfile (distroless static, nonroot user).
- Sign images with cosign keyless (docker_signs).
- Preserve existing source-only Traefik plugin archive via meta:true.
- Update README to advertise the published image.
2026-05-19 17:14:29 +01:00
lukaszraczylo 03a755cb53 docs(oidcgate): expand user guide and cross-link
- Add HAProxy and Envoy ext_authz_http wiring snippets.
- Add full OIDCGATE_* env-var inventory (26 fields).
- Add Security Posture section (X-Forwarded-Uri sanitisation, excludedURLs
  guardrail, callbackURL/logoutURL validation).
- Add Bearer-token (M2M) auth composition section with link to BEARER_AUTH.md.
- Add Operational Guidance section (healthz/readyz ACL, Redis for multi-replica,
  no built-in metrics, graceful shutdown deadline).
- Add Debugging section (sentinel path, silent open-redirect rejections,
  /readyz warm-up).
- Cross-link from docs/CONFIGURATION.md.
2026-05-19 16:59:15 +01:00
lukaszraczylo dc0e7e0238 fix(oidcgate): gosec G304 — clean config path + native #nosec directive
The //nolint:gosec directive only suppresses golangci-lint; the standalone
gosec GitHub Action uses its own '#nosec G304 -- reason' syntax. Use both
filepath.Clean as canonical mitigation and the native directive.
2026-05-19 16:41:57 +01:00
lukaszraczylo b2e79d8798 Merge remote-tracking branch 'origin/main' into conflict-resolve
# Conflicts:
#	docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md
#	middleware.go
#	settings.go
#	types.go
2026-05-19 16:41:34 +01:00
lukaszraczylo 52ef32ece7 fix(oidcgate): security hardening — sanitize XFU, guardrails, validations 2026-05-19 15:17:04 +01:00
lukaszraczylo 3bf7c60ef4 chore: gofmt 2026-05-19 15:00:42 +01:00
lukaszraczylo 775ca7afc3 docs(oidcgate): user-facing setup guide and nginx/Caddy/Traefik wiring 2026-05-19 14:25:38 +01:00
lukaszraczylo a1273e6883 feat(oidcgate): main entrypoint with graceful shutdown 2026-05-19 14:22:46 +01:00
lukaszraczylo 0bc0079a58 refactor(oidcgate): WriteTimeout for slowloris guard, nolint reason 2026-05-19 14:18:28 +01:00
lukaszraczylo 20294f1339 feat(oidcgate): mux wiring and http.Server with graceful shutdown 2026-05-19 14:13:13 +01:00
lukaszraczylo 43938ed8a8 feat(oidcgate): healthz and readyz endpoints 2026-05-19 14:08:53 +01:00
lukaszraczylo 46679c82eb refactor(oidcgate): simplify cloneAndRewrite, flip ?rd precedence, assert XFU passthrough 2026-05-19 14:07:44 +01:00
lukaszraczylo a46be72be5 feat(oidcgate): auth/start/callback/logout endpoint handlers 2026-05-19 13:59:20 +01:00
lukaszraczylo 91966c1bec refactor(oidcgate): idempotent Finalize; document and test 307/308 intercept 2026-05-19 13:57:15 +01:00
lukaszraczylo c465fc888b feat(oidcgate): response-writer interceptor converts 302->401 for /oauth2/auth 2026-05-19 13:50:03 +01:00
lukaszraczylo 047fea3c75 refactor(oidcgate): drop unreachable lowercase prefix; add multi-value mirror test 2026-05-19 13:48:13 +01:00
lukaszraczylo 0c092a5a22 feat(oidcgate): synthetic success handler mirrors X-* headers to response 2026-05-19 13:41:51 +01:00
lukaszraczylo 8f458b4f6e fix(oidcgate): quality fixes — rune-safe snake-upper, drop dead import, listen validation, nested-struct test 2026-05-19 13:40:24 +01:00
lukaszraczylo 17c28fd574 feat(oidcgate): YAML config loader with env-var overrides 2026-05-19 13:30:28 +01:00
lukaszraczylo 21cc2ed747 refactor(lib): match codebase metadataMu lock pattern in Ready() 2026-05-19 13:25:13 +01:00
lukaszraczylo ded90e5dc1 feat(lib): add (*TraefikOidc).Ready() metadata-discovery readiness accessor 2026-05-19 13:19:20 +01:00
lukaszraczylo 46777d0510 fix(lib): also route X-Auth-Request-Redirect through originalRequestURI helper 2026-05-19 13:14:16 +01:00
lukaszraczylo f990365cb8 feat(lib): add TrustForwardedURI to honor X-Forwarded-Uri for post-login redirect target 2026-05-19 13:07:35 +01:00
lukaszraczylo 85eb9ecd16 docs: add oidcgate implementation plan 2026-05-19 13:00:56 +01:00
lukaszraczylo 3495e70cbb docs: add oidcgate Tier 1 forward-auth daemon design 2026-05-19 12:51:41 +01:00
lukaszraczylo fcb21a36e6 docs: harden bearer-auth spec with security review findings 2026-05-18 16:24:52 +01:00
lukaszraczylo a6c38c0747 docs: bearer-token auth design spec 2026-05-18 15:35:12 +01:00
63 changed files with 5032 additions and 1964 deletions
+130 -5
View File
@@ -1,13 +1,41 @@
version: 2 version: 2
# Traefik plugins are source-only - no binary builds # Two release artefacts:
# Traefik loads plugins via Yaegi interpreter at runtime #
# 1. The Traefik plugin: source-only — Traefik loads it via the Yaegi
# interpreter from the source tarball published on GitHub releases.
# 2. oidcgate: a standalone forward-auth daemon built from cmd/oidcgate.
# Shipped as both per-OS/arch binary archives AND a multi-arch Docker
# image at ghcr.io/lukaszraczylo/oidcgate, tagged to match the release.
builds: builds:
- skip: true - id: oidcgate
main: ./cmd/oidcgate
binary: oidcgate
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
flags:
- -trimpath
- -buildvcs=false
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
mod_timestamp: "{{ .CommitTimestamp }}"
# Create source archive for GitHub releases
archives: archives:
- formats: [tar.gz] # Source archive for the Traefik plugin path. meta:true → no binary
# builds attached; everything comes from `files:` below.
- id: source-plugin
meta: true
formats: [tar.gz]
name_template: "{{ .ProjectName }}_v{{ .Version }}_source" name_template: "{{ .ProjectName }}_v{{ .Version }}_source"
files: files:
- "*.go" - "*.go"
@@ -25,6 +53,93 @@ archives:
- "!regression/**" - "!regression/**"
- "!examples/**" - "!examples/**"
- "!docs/**" - "!docs/**"
- "!cmd/**"
# Per-OS/arch binary archives for the oidcgate daemon.
- id: oidcgate
ids: [oidcgate]
formats: [tar.gz]
name_template: "oidcgate_v{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- LICENSE*
- README*
- src: docs/OIDCGATE.md
dst: docs/
- src: examples/oidcgate.yaml
dst: examples/
# Build a Docker image per (linux, arch) combo. Tag suffixes are
# combined into a single multi-arch manifest list below via
# docker_manifests, so end users pull a single tag.
dockers:
- id: oidcgate-amd64
ids: [oidcgate]
goos: linux
goarch: amd64
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
use: buildx
dockerfile: cmd/oidcgate/Dockerfile
build_flag_templates:
- "--pull"
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.title=oidcgate"
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
- "--label=org.opencontainers.image.licenses=MIT"
- id: oidcgate-arm64
ids: [oidcgate]
goos: linux
goarch: arm64
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
use: buildx
dockerfile: cmd/oidcgate/Dockerfile
build_flag_templates:
- "--pull"
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.title=oidcgate"
- "--label=org.opencontainers.image.description=Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/traefikoidc"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/traefikoidc"
- "--label=org.opencontainers.image.documentation=https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
- "--label=org.opencontainers.image.licenses=MIT"
# Multi-arch manifests — these are what users actually pull.
# Tags match the release tag (vX.Y.Z) exactly, plus a few convenience tags.
docker_manifests:
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Version }}"
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
- name_template: "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}"
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}.{{ .Minor }}"
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
skip_push: auto
- name_template: "ghcr.io/lukaszraczylo/oidcgate:v{{ .Major }}"
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
skip_push: auto
- name_template: "ghcr.io/lukaszraczylo/oidcgate:latest"
image_templates:
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-amd64"
- "ghcr.io/lukaszraczylo/oidcgate:{{ .Version }}-arm64"
skip_push: auto
checksum: checksum:
name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt" name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt"
@@ -58,3 +173,13 @@ signs:
- "--yes" - "--yes"
artifacts: checksum artifacts: checksum
output: true output: true
# Sign the Docker images and manifests with cosign keyless.
docker_signs:
- cmd: cosign
artifacts: all
args:
- sign
- "${artifact}@${digest}"
- "--yes"
output: true
+22 -14
View File
@@ -64,6 +64,28 @@ cosign verify-blob \
traefikoidc_v<version>_checksums.txt traefikoidc_v<version>_checksums.txt
``` ```
## Standalone binary (oidcgate)
If you don't run Traefik, `oidcgate` exposes the same middleware as a
forward-auth daemon for nginx, Caddy, Traefik ForwardAuth, HAProxy, and
Envoy. See [`docs/OIDCGATE.md`](docs/OIDCGATE.md).
```bash
# From source
go build -o oidcgate ./cmd/oidcgate
./oidcgate --config examples/oidcgate.yaml
# Or pull the released image (multi-arch: linux/amd64, linux/arm64)
docker run --rm \
-v /path/to/config.yaml:/etc/oidcgate/config.yaml:ro \
-p 8080:8080 \
ghcr.io/lukaszraczylo/oidcgate:latest
```
Each tagged release publishes a Docker image at
`ghcr.io/lukaszraczylo/oidcgate:vX.Y.Z` (matching the release tag), plus
floating `:vX.Y`, `:vX`, and `:latest` aliases.
## Quickstart ## Quickstart
```yaml ```yaml
@@ -111,7 +133,6 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. | | `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. |
| `postLogoutRedirectURI` | `/` | Where to send users after logout. | | `postLogoutRedirectURI` | `/` | Where to send users after logout. |
| `scopes` | appended to `openid profile email` | Extra OAuth scopes. Set `overrideScopes: true` to replace defaults. | | `scopes` | appended to `openid profile email` | Extra OAuth scopes. Set `overrideScopes: true` to replace defaults. |
| `extraAuthParams` | none | Map of extra query parameters appended to the authorization request (e.g. `screen_hint: signup`, `login_hint`, `ui_locales`, `prompt`). Plugin-managed params (`client_id`, `state`, `nonce`, `redirect_uri`, `code_challenge`, `scope`, `response_type`, …) cannot be overridden. |
| `excludedURLs` | none | Prefix-matched paths that bypass auth. | | `excludedURLs` | none | Prefix-matched paths that bypass auth. |
| `allowedUserDomains` | none | Restrict to email domains. | | `allowedUserDomains` | none | Restrict to email domains. |
| `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). | | `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). |
@@ -412,19 +433,6 @@ namespaced claims, Cognito regions, GitLab self-hosted) live in
Set `logLevel: debug` to surface detail. Set `logLevel: debug` to surface detail.
## Telemetry
On first plugin instantiation this middleware sends a single anonymous
adoption ping — project name, version, timestamp; no identifiers, no
request data, no token contents. Fire-and-forget with a 2-second timeout;
cannot block plugin load or panic.
Local source: [`telemetry.go`](./telemetry.go). Disclosure mirrors
**[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `TRAEFIKOIDC_DISABLE_TELEMETRY=1`.
## License ## License
See [LICENSE](LICENSE). See [LICENSE](LICENSE).
+2 -4
View File
@@ -484,8 +484,7 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
session.SetAccessToken(opaqueAccessToken) session.SetAccessToken(opaqueAccessToken)
session.SetIDToken(idToken) session.SetIDToken(idToken)
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated || needsRefresh || expired { if !authenticated || needsRefresh || expired {
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v", t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
authenticated, needsRefresh, expired) authenticated, needsRefresh, expired)
@@ -624,8 +623,7 @@ func TestAuth0Scenario2StrictMode(t *testing.T) {
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
// In strict mode, this should FAIL (no fallback to ID token) // In strict mode, this should FAIL (no fallback to ID token)
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if authenticated { if authenticated {
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true") t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
} }
+24 -2
View File
@@ -69,7 +69,7 @@ func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrf
// - session: The session data to prepare for authentication. // - session: The session data to prepare for authentication.
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance. // - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI()) t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", t.originalRequestURI(req))
// Check and handle redirect limits // Check and handle redirect limits
if err := t.validateRedirectCount(session, rw, req); err != nil { if err := t.validateRedirectCount(session, rw, req); err != nil {
@@ -98,7 +98,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
} }
// Clear existing session data and set new authentication state // Clear existing session data and set new authentication state
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI()) t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, t.originalRequestURI(req))
session.MarkDirty() session.MarkDirty()
@@ -305,6 +305,28 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, redirectURL)
} }
// isUserAuthenticated determines the authentication status and refresh requirements.
// It delegates to provider-specific validation methods that handle different token types
// and expiration behaviors.
// Parameters:
// - session: The session data containing authentication tokens.
//
// Returns:
// - authenticated (bool): True if the user has valid tokens.
// - needsRefresh (bool): True if tokens are valid but nearing expiration.
// - expired (bool): True if the session is unauthenticated, the token is missing,
// or the token verification failed for reasons other than nearing/actual expiration.
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) {
if t.isAzureProvider() {
return t.validateAzureTokens(session)
} else if t.isGoogleProvider() {
return t.validateGoogleTokens(session)
}
// Auth0 and other providers can now use standard validation
// which handles opaque tokens generically
return t.validateStandardTokens(session)
}
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect // isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool { func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
xhr := req.Header.Get("X-Requested-With") xhr := req.Header.Get("X-Requested-With")
+4 -8
View File
@@ -262,8 +262,7 @@ func TestAzureOIDCRegression(t *testing.T) {
defer func() { tOidc.tokenVerifier = originalTokenVerifier }() defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test that CSRF is preserved during Azure validation failures // Test that CSRF is preserved during Azure validation failures
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
// Should not be authenticated due to validation failure // Should not be authenticated due to validation failure
if authenticated { if authenticated {
@@ -454,8 +453,7 @@ func TestValidateGoogleTokens(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession() session := tt.setupSession()
rs := (&requestState{}).captureSession(session) auth, refresh, expired := ts.tOidc.validateGoogleTokens(session)
auth, refresh, expired := ts.tOidc.validateGoogleTokensRS(rs)
if auth != tt.expectedAuth { if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description) t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -639,8 +637,7 @@ func TestIsUserAuthenticated(t *testing.T) {
defer func() { ts.tOidc.issuerURL = originalIssuer }() defer func() { ts.tOidc.issuerURL = originalIssuer }()
session := tt.setupSession() session := tt.setupSession()
rs := (&requestState{}).captureSession(session) auth, refresh, expired := ts.tOidc.isUserAuthenticated(session)
auth, refresh, expired := ts.tOidc.isUserAuthenticatedRS(rs)
if auth != tt.expectedAuth { if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description) t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -765,8 +762,7 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession() session := tt.setupSession()
rs := (&requestState{}).captureSession(session) auth, refresh, expired := ts.tOidc.validateAzureTokens(session)
auth, refresh, expired := ts.tOidc.validateAzureTokensRS(rs)
if auth != tt.expectedAuth { if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description) t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
+2 -2
View File
@@ -71,8 +71,8 @@ func makeBearerOIDC(t *testing.T, next http.Handler) *TraefikOidc {
logger: NewLogger("error"), logger: NewLogger("error"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sm, sessionManager: sm,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://issuer.example.com", issuerURL: "https://issuer.example.com",
audience: "https://api.example.com", audience: "https://api.example.com",
clientID: "https://api.example.com", clientID: "https://api.example.com",
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
#
# This Dockerfile is consumed by GoReleaser. The binary is built outside
# the Docker context (by goreleaser's Go cross-compile) and placed in the
# build context as ./oidcgate before `docker buildx build` runs.
#
# To build locally without goreleaser:
# go build -o oidcgate ./cmd/oidcgate
# docker build -f cmd/oidcgate/Dockerfile -t oidcgate:dev .
FROM gcr.io/distroless/static-debian12:nonroot
ARG TARGETOS
ARG TARGETARCH
LABEL org.opencontainers.image.title="oidcgate"
LABEL org.opencontainers.image.description="Standalone OIDC forward-auth daemon for nginx/Caddy/Traefik/HAProxy/Envoy"
LABEL org.opencontainers.image.source="https://github.com/lukaszraczylo/traefikoidc"
LABEL org.opencontainers.image.documentation="https://github.com/lukaszraczylo/traefikoidc/blob/main/docs/OIDCGATE.md"
LABEL org.opencontainers.image.licenses="MIT"
COPY oidcgate /usr/local/bin/oidcgate
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/oidcgate"]
CMD ["--config", "/etc/oidcgate/config.yaml"]
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/lukaszraczylo/traefikoidc"
"gopkg.in/yaml.v3"
)
// Config is the top-level oidcgate configuration. The OIDC subtree maps 1:1
// onto traefikoidc.Config; the few extra fields configure the daemon itself.
type Config struct {
Listen string `json:"listen"`
AuthPath string `json:"authPath"`
StartPath string `json:"startPath"`
OIDC traefikoidc.Config `json:"-"`
}
// envScalarFields lists Config field names (within OIDC and top-level)
// that may be overridden via OIDCGATE_<UPPER_SNAKE_CASE> environment
// variables. Only scalar strings/ints/bools are supported; nested structs
// (Redis, SecurityHeaders, DynamicClientRegistration) stay YAML-only.
var envScalarFields = []string{
"Listen", "AuthPath", "StartPath",
"ProviderURL", "ClientID", "ClientSecret", "Audience",
"CallbackURL", "LogoutURL", "PostLogoutRedirectURI",
"SessionEncryptionKey", "CookiePrefix", "CookieDomain",
"LogLevel", "RevocationURL", "OIDCEndSessionURL",
"UserIdentifierClaim", "GroupClaimName", "RoleClaimName",
"ClientAuthMethod", "ClientAssertionPrivateKey",
"ClientAssertionKeyPath", "ClientAssertionKeyID", "ClientAssertionAlg",
"CACertPath", "CACertPEM",
}
// Load reads YAML from path, applies env-var overrides, fills defaults,
// and forces TrustForwardedURI=true so the library honors X-Forwarded-Uri.
func Load(path string) (*Config, error) {
// Clean the operator-supplied path to satisfy gosec G304 (file inclusion
// via variable). filepath.Clean strips traversal sequences and normalises
// the path; this is canonical mitigation for config files supplied via a
// CLI flag — the operator runs the daemon, so the input is trusted, but
// gosec's static analysis still flags variable paths without the cleanup.
clean := filepath.Clean(path)
data, err := os.ReadFile(clean) // #nosec G304 -- operator-supplied config path, cleaned above
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
// Pass 1: YAML → generic map.
var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("yaml parse: %w", err)
}
// Split the top-level oidcgate-specific keys away from the OIDC subtree.
listen, _ := raw["listen"].(string)
authPath, _ := raw["authPath"].(string)
startPath, _ := raw["startPath"].(string)
delete(raw, "listen")
delete(raw, "authPath")
delete(raw, "startPath")
// Pass 2: remaining map → JSON → traefikoidc.Config (uses existing json tags).
jsonBytes, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("yaml→json: %w", err)
}
var oidcCfg traefikoidc.Config
if err := json.Unmarshal(jsonBytes, &oidcCfg); err != nil {
return nil, fmt.Errorf("oidc config parse: %w", err)
}
cfg := &Config{
Listen: listen,
AuthPath: authPath,
StartPath: startPath,
OIDC: oidcCfg,
}
applyEnvOverrides(cfg)
applyDefaults(cfg)
if cfg.Listen == "" {
return nil, fmt.Errorf("config: missing required 'listen' (or OIDCGATE_LISTEN env var)")
}
if !strings.HasPrefix(cfg.OIDC.CallbackURL, "/") {
return nil, fmt.Errorf("config: callbackURL must be a path starting with '/', got %q", cfg.OIDC.CallbackURL)
}
if !strings.HasPrefix(cfg.OIDC.LogoutURL, "/") {
return nil, fmt.Errorf("config: logoutURL must be a path starting with '/', got %q", cfg.OIDC.LogoutURL)
}
reserved := []string{
sentinelPath,
cfg.AuthPath,
cfg.StartPath,
cfg.OIDC.CallbackURL,
cfg.OIDC.LogoutURL,
}
for _, ex := range cfg.OIDC.ExcludedURLs {
for _, r := range reserved {
if r != "" && strings.HasPrefix(r, ex) {
return nil, fmt.Errorf("config: excludedURL %q would bypass reserved oidcgate path %q", ex, r)
}
}
}
// Force standalone semantics: trust X-Forwarded-Uri.
cfg.OIDC.TrustForwardedURI = true
return cfg, nil
}
// applyEnvOverrides walks the allow-listed scalar fields and replaces any
// non-empty OIDCGATE_<UPPER_SNAKE_CASE> env var. Field name "ClientID"
// becomes "OIDCGATE_CLIENT_ID"; "SessionEncryptionKey" becomes
// "OIDCGATE_SESSION_ENCRYPTION_KEY".
func applyEnvOverrides(cfg *Config) {
for _, field := range envScalarFields {
env := os.Getenv("OIDCGATE_" + camelToSnakeUpper(field))
if env == "" {
continue
}
setScalarField(cfg, field, env)
}
}
func setScalarField(cfg *Config, field, value string) {
switch field {
case "Listen":
cfg.Listen = value
case "AuthPath":
cfg.AuthPath = value
case "StartPath":
cfg.StartPath = value
case "ProviderURL":
cfg.OIDC.ProviderURL = value
case "ClientID":
cfg.OIDC.ClientID = value
case "ClientSecret":
cfg.OIDC.ClientSecret = value
case "Audience":
cfg.OIDC.Audience = value
case "CallbackURL":
cfg.OIDC.CallbackURL = value
case "LogoutURL":
cfg.OIDC.LogoutURL = value
case "PostLogoutRedirectURI":
cfg.OIDC.PostLogoutRedirectURI = value
case "SessionEncryptionKey":
cfg.OIDC.SessionEncryptionKey = value
case "CookiePrefix":
cfg.OIDC.CookiePrefix = value
case "CookieDomain":
cfg.OIDC.CookieDomain = value
case "LogLevel":
cfg.OIDC.LogLevel = value
case "RevocationURL":
cfg.OIDC.RevocationURL = value
case "OIDCEndSessionURL":
cfg.OIDC.OIDCEndSessionURL = value
case "UserIdentifierClaim":
cfg.OIDC.UserIdentifierClaim = value
case "GroupClaimName":
cfg.OIDC.GroupClaimName = value
case "RoleClaimName":
cfg.OIDC.RoleClaimName = value
case "ClientAuthMethod":
cfg.OIDC.ClientAuthMethod = value
case "ClientAssertionPrivateKey":
cfg.OIDC.ClientAssertionPrivateKey = value
case "ClientAssertionKeyPath":
cfg.OIDC.ClientAssertionKeyPath = value
case "ClientAssertionKeyID":
cfg.OIDC.ClientAssertionKeyID = value
case "ClientAssertionAlg":
cfg.OIDC.ClientAssertionAlg = value
case "CACertPath":
cfg.OIDC.CACertPath = value
case "CACertPEM":
cfg.OIDC.CACertPEM = value
}
}
func applyDefaults(cfg *Config) {
if cfg.AuthPath == "" {
cfg.AuthPath = "/oauth2/auth"
}
if cfg.StartPath == "" {
cfg.StartPath = "/oauth2/start"
}
}
// camelToSnakeUpper turns "ClientSecret" into "CLIENT_SECRET",
// "SessionEncryptionKey" into "SESSION_ENCRYPTION_KEY", etc.
// Multi-letter acronyms keep their grouping: "OIDCEndSessionURL" →
// "OIDC_END_SESSION_URL", "CACertPEM" → "CA_CERT_PEM".
func camelToSnakeUpper(s string) string {
runes := []rune(s)
var b strings.Builder
for i, r := range runes {
if i > 0 && isUpper(r) {
prev := runes[i-1]
next := rune(0)
if i+1 < len(runes) {
next = runes[i+1]
}
if !isUpper(prev) || (next != 0 && !isUpper(next)) {
b.WriteByte('_')
}
}
b.WriteRune(unicode.ToUpper(r))
}
return b.String()
}
func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// minimalYAML is a base config accepted by Load with no surprises.
const minimalYAML = `
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
`
func writeConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoad_YAMLRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(`
listen: ":9090"
authPath: "/auth"
startPath: "/start"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Listen != ":9090" {
t.Errorf("listen: want :9090, got %q", cfg.Listen)
}
if cfg.AuthPath != "/auth" {
t.Errorf("authPath: want /auth, got %q", cfg.AuthPath)
}
if cfg.StartPath != "/start" {
t.Errorf("startPath: want /start, got %q", cfg.StartPath)
}
if cfg.OIDC.ClientID != "abc" {
t.Errorf("clientID: want abc, got %q", cfg.OIDC.ClientID)
}
if cfg.OIDC.ClientSecret != "secret" {
t.Errorf("clientSecret: want secret, got %q", cfg.OIDC.ClientSecret)
}
if !cfg.OIDC.TrustForwardedURI {
t.Errorf("TrustForwardedURI should be forced true by Load")
}
}
func TestLoad_EnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(`
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "from-file"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OIDCGATE_CLIENT_SECRET", "from-env")
t.Setenv("OIDCGATE_LISTEN", ":9999")
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.OIDC.ClientSecret != "from-env" {
t.Errorf("env override (clientSecret): want from-env, got %q", cfg.OIDC.ClientSecret)
}
if cfg.Listen != ":9999" {
t.Errorf("env override (listen): want :9999, got %q", cfg.Listen)
}
}
func TestLoad_Defaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(`
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.AuthPath != "/oauth2/auth" {
t.Errorf("AuthPath default: want /oauth2/auth, got %q", cfg.AuthPath)
}
if cfg.StartPath != "/oauth2/start" {
t.Errorf("StartPath default: want /oauth2/start, got %q", cfg.StartPath)
}
}
func TestLoad_MissingFile(t *testing.T) {
if _, err := Load("/nonexistent/config.yaml"); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoad_NestedStructRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(`
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
redis:
address: "redis:6379"
password: "redispw"
`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.OIDC.Redis == nil {
t.Fatal("redis block should populate cfg.OIDC.Redis")
}
if cfg.OIDC.Redis.Address != "redis:6379" {
t.Errorf("redis address: want redis:6379, got %q", cfg.OIDC.Redis.Address)
}
}
// Fix 5: callbackURL / logoutURL must start with "/"
func TestLoad_RejectsAbsoluteCallbackURL(t *testing.T) {
path := writeConfig(t, `
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "https://app.example.com/oauth2/callback"
logoutURL: "/oauth2/logout"
`)
if _, err := Load(path); err == nil {
t.Fatal("callbackURL with absolute URL must be rejected")
}
}
func TestLoad_RejectsAbsoluteLogoutURL(t *testing.T) {
path := writeConfig(t, `
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "https://app.example.com/oauth2/logout"
`)
if _, err := Load(path); err == nil {
t.Fatal("logoutURL with absolute URL must be rejected")
}
}
// Fix 2: excludedURLs must not prefix reserved paths
func TestLoad_RejectsExcludedURLPrefixingReservedPath(t *testing.T) {
path := writeConfig(t, `
listen: ":8080"
providerURL: "https://idp.example"
clientID: "abc"
clientSecret: "secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
excludedURLs: ["/"]
`)
if _, err := Load(path); err == nil {
t.Fatal("excludedURLs: ['/'] must be rejected (bypasses all reserved paths)")
}
}
func TestLoad_AllowsNonOverlappingExcludedURL(t *testing.T) {
path := writeConfig(t, minimalYAML+`excludedURLs: ["/public"]
`)
if _, err := Load(path); err != nil {
t.Fatalf("non-overlapping excludedURL must be accepted: %v", err)
}
}
// Fix 3: env override coverage — every envScalarFields entry must have a
// matching case in setScalarField. isAllZeroForField detects drift.
func TestEnvOverrideCoverage(t *testing.T) {
for _, field := range envScalarFields {
field := field
t.Run(field, func(t *testing.T) {
probe := "/safe/probe-" + field
if field == "SessionEncryptionKey" {
probe = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
}
if field == "LogLevel" {
probe = "debug"
}
if field == "ClientAuthMethod" {
probe = "client_secret_post"
}
if field == "ClientAssertionAlg" {
probe = "RS256"
}
var fresh Config
setScalarField(&fresh, field, probe)
if isAllZeroForField(&fresh, field, probe) {
t.Fatalf("envScalarFields includes %q but setScalarField has no matching case (drift)", field)
}
})
}
}
// isAllZeroForField returns true when setScalarField did NOT set the expected
// field — i.e., the switch is missing a case for `field`.
func isAllZeroForField(cfg *Config, field, probe string) bool {
switch field {
case "Listen":
return cfg.Listen != probe
case "AuthPath":
return cfg.AuthPath != probe
case "StartPath":
return cfg.StartPath != probe
case "ProviderURL":
return cfg.OIDC.ProviderURL != probe
case "ClientID":
return cfg.OIDC.ClientID != probe
case "ClientSecret":
return cfg.OIDC.ClientSecret != probe
case "Audience":
return cfg.OIDC.Audience != probe
case "CallbackURL":
return cfg.OIDC.CallbackURL != probe
case "LogoutURL":
return cfg.OIDC.LogoutURL != probe
case "PostLogoutRedirectURI":
return cfg.OIDC.PostLogoutRedirectURI != probe
case "SessionEncryptionKey":
return cfg.OIDC.SessionEncryptionKey != probe
case "CookiePrefix":
return cfg.OIDC.CookiePrefix != probe
case "CookieDomain":
return cfg.OIDC.CookieDomain != probe
case "LogLevel":
return cfg.OIDC.LogLevel != probe
case "RevocationURL":
return cfg.OIDC.RevocationURL != probe
case "OIDCEndSessionURL":
return cfg.OIDC.OIDCEndSessionURL != probe
case "UserIdentifierClaim":
return cfg.OIDC.UserIdentifierClaim != probe
case "GroupClaimName":
return cfg.OIDC.GroupClaimName != probe
case "RoleClaimName":
return cfg.OIDC.RoleClaimName != probe
case "ClientAuthMethod":
return cfg.OIDC.ClientAuthMethod != probe
case "ClientAssertionPrivateKey":
return cfg.OIDC.ClientAssertionPrivateKey != probe
case "ClientAssertionKeyPath":
return cfg.OIDC.ClientAssertionKeyPath != probe
case "ClientAssertionKeyID":
return cfg.OIDC.ClientAssertionKeyID != probe
case "ClientAssertionAlg":
return cfg.OIDC.ClientAssertionAlg != probe
case "CACertPath":
return cfg.OIDC.CACertPath != probe
case "CACertPEM":
return cfg.OIDC.CACertPEM != probe
}
return true // unknown field → drift
}
+69
View File
@@ -0,0 +1,69 @@
package main
import "net/http"
// sentinelPath is the synthetic request path used when delegating /oauth2/auth
// and /oauth2/start into the traefikoidc middleware. It must NOT collide with
// callbackURL, logoutURL, /health*, or any plausible excludedURLs entry —
// the underscores and double-prefixing make accidental matches near-impossible.
const sentinelPath = "/__oidcgate_protected__"
// newAuthHandler builds the /oauth2/auth (silent probe) handler.
// Rewrites the request path to sentinelPath, wraps the ResponseWriter to
// convert the middleware's 302→IdP into 401, and delegates.
func newAuthHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ic := newAuthInterceptor(rw)
defer ic.Finalize()
r2 := cloneAndRewrite(req, sentinelPath)
next.ServeHTTP(ic, r2)
})
}
// newStartHandler builds the /oauth2/start (visible sign-in) handler.
// Rewrites the path to sentinelPath, forwards any ?rd= query as
// X-Forwarded-Uri so the middleware (with TrustForwardedURI=true) captures
// the right post-login redirect target, then delegates. The middleware's
// natural 302→IdP flows through unchanged.
func newStartHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
r2 := cloneAndRewrite(req, sentinelPath)
// Precedence: explicit ?rd= wins over an ambient upstream
// X-Forwarded-Uri so /oauth2/start?rd=/dashboard does not get
// silently overridden by the proxy's current-URL forwarding.
if rd := req.URL.Query().Get("rd"); rd != "" {
r2.Header.Set("X-Forwarded-Uri", rd)
}
next.ServeHTTP(rw, r2)
})
}
// newCallbackHandler builds the IdP callback endpoint.
// Rewrites the request path to the configured callbackURL so the middleware's
// path-match at the top of ServeHTTP triggers the callback flow.
func newCallbackHandler(next http.Handler, callbackURL string) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
r2 := cloneAndRewrite(req, callbackURL)
next.ServeHTTP(rw, r2)
})
}
// newLogoutHandler builds the logout endpoint.
// Rewrites the request path to the configured logoutURL so the middleware's
// path-match at the top of ServeHTTP triggers the logout flow.
func newLogoutHandler(next http.Handler, logoutURL string) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
r2 := cloneAndRewrite(req, logoutURL)
next.ServeHTTP(rw, r2)
})
}
// cloneAndRewrite returns a clone of req with URL.Path set to newPath.
// req.Clone deep-copies URL via net/http's cloneURL, so mutating
// r2.URL.Path does not affect the original req. RawQuery, Host,
// Fragment, RawPath are preserved unchanged.
func cloneAndRewrite(req *http.Request, newPath string) *http.Request {
r2 := req.Clone(req.Context())
r2.URL.Path = newPath
return r2
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// stubMiddleware lets us test endpoint wiring without spinning up a full
// traefikoidc instance. Each test injects the behavior it wants.
type stubMiddleware struct {
calls []stubCall
fn func(rw http.ResponseWriter, req *http.Request)
}
type stubCall struct {
path string
header http.Header
}
func (s *stubMiddleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
s.calls = append(s.calls, stubCall{path: req.URL.Path, header: req.Header.Clone()})
if s.fn != nil {
s.fn(rw, req)
}
}
func TestAuth_RewritesToSentinel_AndConverts302To401(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Location", "https://idp.example/authorize?state=abc")
rw.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/")
rw.WriteHeader(http.StatusFound)
},
}
h := newAuthHandler(stub)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
req.Header.Set("X-Forwarded-Uri", "/protected/page")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401, got %d", rec.Code)
}
if len(stub.calls) != 1 || stub.calls[0].path != sentinelPath {
t.Fatalf("middleware path: want %q, got %v", sentinelPath, stub.calls)
}
if rec.Header().Get("X-Auth-Redirect") == "" {
t.Error("X-Auth-Redirect should carry Location")
}
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/protected/page" {
t.Errorf("X-Forwarded-Uri must pass through to middleware: want /protected/page, got %q", got)
}
}
func TestAuth_AuthenticatedReturnsHeadersAnd200(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) {
// Middleware would stamp X-Forwarded-User on req then call next.
req.Header.Set("X-Forwarded-User", "alice")
newSuccessHandler().ServeHTTP(rw, req)
},
}
h := newAuthHandler(stub)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
t.Errorf("X-Forwarded-User mirrored: want alice, got %q", got)
}
}
func TestStart_DelegatesWithSentinel_NoInterception(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Location", "https://idp.example/authorize")
rw.WriteHeader(http.StatusFound)
},
}
h := newStartHandler(stub)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("start: 302 must flow through, got %d", rec.Code)
}
if stub.calls[0].path != sentinelPath {
t.Fatalf("start path rewrite: want %q, got %q", sentinelPath, stub.calls[0].path)
}
}
func TestStart_ForwardsRdAsXForwardedURI(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusFound) },
}
h := newStartHandler(stub)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/back/here", nil)
h.ServeHTTP(rec, req)
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/back/here" {
t.Fatalf("?rd should become X-Forwarded-Uri: want /back/here, got %q", got)
}
}
func TestStart_RdQueryWinsOverUpstreamHeader(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusFound) },
}
h := newStartHandler(stub)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/explicit", nil)
req.Header.Set("X-Forwarded-Uri", "/ambient")
h.ServeHTTP(rec, req)
if got := stub.calls[0].header.Get("X-Forwarded-Uri"); got != "/explicit" {
t.Fatalf("?rd= must win over upstream X-Forwarded-Uri: want /explicit, got %q", got)
}
}
func TestCallback_RewritesToConfiguredCallbackURL(t *testing.T) {
var seenPath, seenQuery string
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) {
seenPath = req.URL.Path
seenQuery = req.URL.RawQuery
rw.WriteHeader(http.StatusOK)
},
}
h := newCallbackHandler(stub, "/oauth2/callback")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/callback?code=abc&state=xyz", nil)
h.ServeHTTP(rec, req)
if seenPath != "/oauth2/callback" {
t.Fatalf("callback path: want /oauth2/callback, got %q", seenPath)
}
if seenQuery != "code=abc&state=xyz" {
t.Fatalf("callback query must survive rewrite: want code=abc&state=xyz, got %q", seenQuery)
}
}
func TestLogout_RewritesToConfiguredLogoutURL(t *testing.T) {
var seenPath string
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, req *http.Request) {
seenPath = req.URL.Path
rw.WriteHeader(http.StatusOK)
},
}
h := newLogoutHandler(stub, "/oauth2/logout")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/oauth2/logout", nil)
h.ServeHTTP(rec, req)
if seenPath != "/oauth2/logout" {
t.Fatalf("logout path: want /oauth2/logout, got %q", seenPath)
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import "net/http"
// readyReporter is satisfied by *traefikoidc.TraefikOidc via its Ready() method.
type readyReporter interface {
Ready() bool
}
func newHealthzHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
})
}
func newReadyzHandler(r readyReporter) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
if r.Ready() {
rw.WriteHeader(http.StatusOK)
return
}
rw.WriteHeader(http.StatusServiceUnavailable)
})
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
type readyStub struct{ ready bool }
func (r *readyStub) Ready() bool { return r.ready }
func TestHealthz_Always200(t *testing.T) {
h := newHealthzHandler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("healthz: want 200, got %d", rec.Code)
}
}
func TestReadyz_503BeforeDiscovery(t *testing.T) {
h := newReadyzHandler(&readyStub{ready: false})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz pre-discovery: want 503, got %d", rec.Code)
}
}
func TestReadyz_200AfterDiscovery(t *testing.T) {
h := newReadyzHandler(&readyStub{ready: true})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code)
}
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "github.com/lukaszraczylo/traefikoidc"
type traefikoidcConfigStub struct {
callbackURL string
logoutURL string
}
func (s traefikoidcConfigStub) AsOIDC() traefikoidc.Config {
return traefikoidc.Config{
CallbackURL: s.callbackURL,
LogoutURL: s.logoutURL,
}
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/traefikoidc"
)
// fakeProviderHost is a synthetic hostname used in place of the httptest.Server's
// 127.0.0.1 address. traefikoidc's URL validator blocks loopback IPs
// unconditionally; a non-loopback hostname passes the check. The custom HTTP
// client returned by mockHTTPClient rewires all dials for this host to the
// actual test-server port, so the mock IdP still receives every request.
const fakeProviderHost = "test-oidc-provider.local"
// mockHTTPClient returns an *http.Client whose dialer transparently redirects
// connections to fakeProviderHost to the real httptest.Server address.
func mockHTTPClient(realAddr string) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return dialer.DialContext(ctx, network, addr)
}
if host == fakeProviderHost {
addr = realAddr
}
return dialer.DialContext(ctx, network, addr)
},
}
return &http.Client{Transport: transport}
}
// newMockIdP returns an httptest.Server that serves the minimal OIDC
// discovery surface required by traefikoidc.NewWithContext to bootstrap
// — discovery doc + an empty JWKS. All URLs in the discovery doc use
// fakeProviderHost so they pass the middleware's URL security validator.
func newMockIdP(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
fakeBase := "http://" + fakeProviderHost
mux.HandleFunc("/.well-known/openid-configuration", func(rw http.ResponseWriter, _ *http.Request) {
discovery := map[string]any{
"issuer": fakeBase,
"authorization_endpoint": fakeBase + "/authorize",
"token_endpoint": fakeBase + "/token",
"jwks_uri": fakeBase + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
}
rw.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(rw).Encode(discovery)
})
mux.HandleFunc("/jwks", func(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write([]byte(`{"keys":[]}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// buildTestConfig produces a Config that points at the fake provider hostname
// (which the custom HTTP client redirects to the real mock server) and uses
// a known-good SessionEncryptionKey + safe path defaults.
func buildTestConfig(srv *httptest.Server) *Config {
// realAddr is HOST:PORT of the httptest server (e.g. "127.0.0.1:56789").
realAddr := srv.Listener.Addr().String()
cfg := &Config{
Listen: "127.0.0.1:0", // unused — we drive the mux directly via httptest
AuthPath: "/oauth2/auth",
StartPath: "/oauth2/start",
OIDC: traefikoidc.Config{
ProviderURL: "http://" + fakeProviderHost,
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
CallbackURL: "/oauth2/callback",
LogoutURL: "/oauth2/logout",
TrustForwardedURI: true,
EnablePKCE: true,
HTTPClient: mockHTTPClient(realAddr),
},
}
return cfg
}
// buildIntegrationStack builds the same wiring main.go builds: real
// middleware constructed against the mock IdP, success handler as next,
// mux on top.
func buildIntegrationStack(t *testing.T, idp *httptest.Server) (http.Handler, *traefikoidc.TraefikOidc) {
t.Helper()
cfg := buildTestConfig(idp)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
mw, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, newSuccessHandler(), "oidcgate-test")
if err != nil {
t.Fatalf("NewWithContext: %v", err)
}
mux := buildMux(cfg, mw, mw)
return mux, mw
}
func TestIntegration_UnauthenticatedAuthReturns401WithRedirect(t *testing.T) {
idp := newMockIdP(t)
mux, _ := buildIntegrationStack(t, idp)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401, got %d (body=%q)", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("X-Auth-Redirect")
if loc == "" {
t.Fatal("X-Auth-Redirect should carry the IdP authorize URL")
}
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
t.Errorf("X-Auth-Redirect should point at the mock IdP authorize endpoint, got %q", loc)
}
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) == 0 {
t.Error("expected at least one Set-Cookie (state/PKCE/nonce) on 401")
}
}
func TestIntegration_StartRedirectsToIdPWithStateAndPKCE(t *testing.T) {
idp := newMockIdP(t)
mux, _ := buildIntegrationStack(t, idp)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/oauth2/start?rd=/dashboard", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status: want 302, got %d", rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "http://"+fakeProviderHost+"/authorize") {
t.Fatalf("Location: want prefix http://%s/authorize, got %q", fakeProviderHost, loc)
}
if !strings.Contains(loc, "state=") {
t.Errorf("Location should include state= param, got %q", loc)
}
if !strings.Contains(loc, "code_challenge=") {
t.Errorf("Location should include code_challenge= param (PKCE), got %q", loc)
}
}
func TestIntegration_HealthzAlways200(t *testing.T) {
idp := newMockIdP(t)
mux, _ := buildIntegrationStack(t, idp)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("healthz: want 200, got %d", rec.Code)
}
}
func TestIntegration_ReadyzBecomes200AfterDiscovery(t *testing.T) {
idp := newMockIdP(t)
mux, mw := buildIntegrationStack(t, idp)
// Hit /oauth2/auth once to trigger metadata discovery (the middleware
// performs discovery lazily on first request).
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/oauth2/auth", nil))
// Poll Ready() until true or timeout.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if mw.Ready() {
break
}
time.Sleep(50 * time.Millisecond)
}
if !mw.Ready() {
t.Fatal("middleware should be Ready() within 3s after first request triggered discovery")
}
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("readyz post-discovery: want 200, got %d", rec.Code)
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import "net/http"
// authInterceptor wraps a ResponseWriter for the /oauth2/auth endpoint.
// The traefikoidc middleware emits an HTTP 302 to the IdP authorize URL
// when a request is unauthenticated, but nginx auth_request and similar
// silent-probe contracts cannot follow redirects. authInterceptor buffers
// the header/body and, at Finalize() time:
//
// - if status was a redirect class (302, 303, 307, 308), rewrites it
// to 401, moves the original Location header to X-Auth-Redirect
// (advisory), strips Location, preserves Set-Cookie headers (state,
// PKCE, nonce — the browser will carry them into the next request),
// and writes an empty body.
// - otherwise: passes through verbatim.
type authInterceptor struct {
inner http.ResponseWriter
headers http.Header
status int
body []byte
wroteHeader bool
finalized bool
}
func newAuthInterceptor(inner http.ResponseWriter) *authInterceptor {
return &authInterceptor{
inner: inner,
headers: http.Header{},
status: http.StatusOK,
}
}
func (w *authInterceptor) Header() http.Header { return w.headers }
func (w *authInterceptor) WriteHeader(status int) {
if w.wroteHeader {
return
}
w.status = status
w.wroteHeader = true
}
func (w *authInterceptor) Write(b []byte) (int, error) { //nolint:unparam // signature mandated by http.ResponseWriter
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
w.body = append(w.body, b...)
return len(b), nil
}
// Finalize flushes the buffered response, applying the 302/303 → 401 rewrite.
// Must be called exactly once after the wrapped handler returns.
func (w *authInterceptor) Finalize() {
if w.finalized {
return
}
w.finalized = true
switch w.status {
case http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
// Move Location → X-Auth-Redirect, strip Location, force 401, drop body.
if loc := w.headers.Get("Location"); loc != "" {
w.headers.Set("X-Auth-Redirect", loc)
w.headers.Del("Location")
}
copyHeaders(w.inner.Header(), w.headers)
w.inner.WriteHeader(http.StatusUnauthorized)
return
}
copyHeaders(w.inner.Header(), w.headers)
w.inner.WriteHeader(w.status)
if len(w.body) > 0 {
_, _ = w.inner.Write(w.body)
}
}
func copyHeaders(dst, src http.Header) {
for k, vs := range src {
for _, v := range vs {
dst.Add(k, v)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestInterceptor_302BecomesNot401(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("Location", "https://idp.example/authorize?state=abc")
w.Header().Add("Set-Cookie", "_oidc_state=abc; Path=/; HttpOnly")
w.Header().Add("Set-Cookie", "_oidc_pkce=xyz; Path=/; HttpOnly")
w.WriteHeader(http.StatusFound)
_, _ = w.Write([]byte("ignored body"))
w.Finalize()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401, got %d", rec.Code)
}
if got := rec.Header().Get("X-Auth-Redirect"); got != "https://idp.example/authorize?state=abc" {
t.Errorf("X-Auth-Redirect: want preserved Location, got %q", got)
}
if got := rec.Header().Get("Location"); got != "" {
t.Errorf("Location must be stripped on 401, got %q", got)
}
cookies := rec.Header().Values("Set-Cookie")
if len(cookies) != 2 {
t.Fatalf("Set-Cookie count: want 2, got %d (%v)", len(cookies), cookies)
}
if body := strings.TrimSpace(rec.Body.String()); body != "" {
t.Errorf("body must be empty on 401, got %q", body)
}
}
func TestInterceptor_NonRedirectPassthrough(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("X-Forwarded-User", "alice")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
w.Finalize()
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
t.Errorf("X-Forwarded-User: want preserved, got %q", got)
}
if !strings.Contains(rec.Body.String(), "ok") {
t.Errorf("body: want 'ok' preserved, got %q", rec.Body.String())
}
}
func TestInterceptor_303SeeOtherAlsoIntercepted(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("Location", "/elsewhere")
w.WriteHeader(http.StatusSeeOther)
w.Finalize()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("303 should be intercepted to 401, got %d", rec.Code)
}
}
func TestInterceptor_307TemporaryRedirectIntercepted(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("Location", "/elsewhere")
w.WriteHeader(http.StatusTemporaryRedirect)
w.Finalize()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("307 should be intercepted to 401, got %d", rec.Code)
}
}
func TestInterceptor_308PermanentRedirectIntercepted(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("Location", "/elsewhere")
w.WriteHeader(http.StatusPermanentRedirect)
w.Finalize()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("308 should be intercepted to 401, got %d", rec.Code)
}
}
func TestInterceptor_DoubleFinalizeIsNoop(t *testing.T) {
rec := httptest.NewRecorder()
w := newAuthInterceptor(rec)
w.Header().Set("X-Forwarded-User", "alice")
w.WriteHeader(http.StatusOK)
w.Finalize()
// Second call must not panic, must not change anything observable.
w.Finalize()
if rec.Code != http.StatusOK {
t.Fatalf("double Finalize must not change status, got %d", rec.Code)
}
if got := rec.Header().Get("X-Forwarded-User"); got != "alice" {
t.Errorf("double Finalize must not duplicate headers, got %q", got)
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/lukaszraczylo/traefikoidc"
)
func main() {
configPath := flag.String("config", "/etc/oidcgate/config.yaml", "Path to YAML config file")
flag.Parse()
cfg, err := Load(*configPath)
if err != nil {
log.Fatalf("oidcgate: load config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
success := newSuccessHandler()
middleware, err := traefikoidc.NewWithContext(ctx, &cfg.OIDC, success, "oidcgate")
if err != nil {
cancel()
log.Fatalf("oidcgate: build middleware: %v", err)
}
mux := buildMux(cfg, middleware, middleware)
srv := buildServer(cfg, mux)
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
log.Println("oidcgate: shutdown signal received")
if err := shutdown(srv); err != nil {
log.Printf("oidcgate: shutdown error: %v", err)
}
}()
log.Printf("oidcgate: listening on %s", cfg.Listen)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
cancel()
log.Fatalf("oidcgate: serve: %v", err)
}
log.Println("oidcgate: stopped")
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"context"
"net/http"
"time"
)
// buildMux wires all six routes onto a single ServeMux:
//
// /healthz, /readyz, AuthPath, StartPath, OIDC.CallbackURL, OIDC.LogoutURL.
//
// The same `middleware` instance is delegated to by all four OIDC routes;
// the synthetic success handler is wired into the middleware at construction
// time (in main.go) so it doesn't appear here.
func buildMux(cfg *Config, middleware http.Handler, ready readyReporter) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/healthz", newHealthzHandler())
mux.Handle("/readyz", newReadyzHandler(ready))
mux.Handle(cfg.AuthPath, newAuthHandler(middleware))
mux.Handle(cfg.StartPath, newStartHandler(middleware))
mux.Handle(cfg.OIDC.CallbackURL, newCallbackHandler(middleware, cfg.OIDC.CallbackURL))
mux.Handle(cfg.OIDC.LogoutURL, newLogoutHandler(middleware, cfg.OIDC.LogoutURL))
return mux
}
// buildServer wraps the mux in an http.Server with sensible timeouts.
func buildServer(cfg *Config, mux http.Handler) *http.Server { //nolint:unused // consumed by main.go in Task 9
return &http.Server{
Addr: cfg.Listen,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
}
// shutdown gracefully stops the server with a 15s deadline.
func shutdown(srv *http.Server) error { //nolint:unused // consumed by main.go in Task 9
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(ctx)
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestMux_RoutesAllEndpoints(t *testing.T) {
stub := &stubMiddleware{
fn: func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusOK) },
}
mux := buildMux(&Config{
Listen: ":0",
AuthPath: "/oauth2/auth",
StartPath: "/oauth2/start",
OIDC: traefikoidcConfigStub{
callbackURL: "/oauth2/callback",
logoutURL: "/oauth2/logout",
}.AsOIDC(),
}, stub, &readyStub{ready: true})
cases := []struct {
path string
method string
want int
}{
{"/healthz", http.MethodGet, http.StatusOK},
{"/readyz", http.MethodGet, http.StatusOK},
{"/oauth2/auth", http.MethodGet, http.StatusOK},
{"/oauth2/start", http.MethodGet, http.StatusOK},
{"/oauth2/callback", http.MethodGet, http.StatusOK},
{"/oauth2/logout", http.MethodPost, http.StatusOK},
}
for _, c := range cases {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
if rec.Code != c.want {
t.Errorf("%s %s: want %d, got %d", c.method, c.path, c.want, rec.Code)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"net/http"
"strings"
)
// mirrorAllowedHeaders is the set of NON-X-prefixed request headers that the
// success handler copies onto the response. The traefikoidc middleware sets
// "Authorization: Bearer ..." via the templated-header feature when operators
// configure it, and proxies need that to flow upstream.
var mirrorAllowedHeaders = map[string]struct{}{
"Authorization": {},
}
// successHandler is the http.Handler installed as the middleware's `next`.
// When the middleware reaches this handler the request is authenticated; we
// mirror the X-* (and a small allow-list of non-X-*) headers the middleware
// stamped onto req.Header back onto the response so upstream proxies can
// capture them via auth_request_set / authResponseHeaders / copy_headers,
// then write 200 with an empty body.
func newSuccessHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
for name, values := range req.Header {
if !shouldMirror(name) {
continue
}
for _, v := range values {
rw.Header().Add(name, v)
}
}
rw.WriteHeader(http.StatusOK)
})
}
func shouldMirror(name string) bool {
if strings.HasPrefix(name, "X-") {
return true
}
canonical := http.CanonicalHeaderKey(name)
_, ok := mirrorAllowedHeaders[canonical]
return ok
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSuccessHandler_Writes200(t *testing.T) {
h := newSuccessHandler()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
}
func TestSuccessHandler_MirrorsForwardedHeaders(t *testing.T) {
h := newSuccessHandler()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("X-Forwarded-User", "[email protected]")
req.Header.Set("X-Forwarded-Email", "[email protected]")
req.Header.Set("X-Custom-Templated", "value")
req.Header.Set("Authorization", "Bearer token-from-template")
req.Header.Set("Cookie", "session=should-NOT-mirror")
h.ServeHTTP(rec, req)
if got := rec.Header().Get("X-Forwarded-User"); got != "[email protected]" {
t.Errorf("X-Forwarded-User: want mirrored, got %q", got)
}
if got := rec.Header().Get("X-Forwarded-Email"); got != "[email protected]" {
t.Errorf("X-Forwarded-Email: want mirrored, got %q", got)
}
if got := rec.Header().Get("X-Custom-Templated"); got != "value" {
t.Errorf("X-Custom-Templated: want mirrored (X- prefix), got %q", got)
}
if got := rec.Header().Get("Authorization"); got != "Bearer token-from-template" {
t.Errorf("Authorization: want mirrored (templated bearer), got %q", got)
}
if got := rec.Header().Get("Cookie"); got != "" {
t.Errorf("Cookie must NOT be mirrored, got %q", got)
}
}
func TestSuccessHandler_EmptyBody(t *testing.T) {
h := newSuccessHandler()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
h.ServeHTTP(rec, req)
if body := strings.TrimSpace(rec.Body.String()); body != "" {
t.Fatalf("body: want empty, got %q", body)
}
}
func TestSuccessHandler_MultiValueHeader(t *testing.T) {
h := newSuccessHandler()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Add("X-Role", "admin")
req.Header.Add("X-Role", "editor")
h.ServeHTTP(rec, req)
got := rec.Header()["X-Role"]
if len(got) != 2 || got[0] != "admin" || got[1] != "editor" {
t.Errorf("X-Role multi-value: want [admin editor], got %v", got)
}
}
+17
View File
@@ -14,6 +14,7 @@ Complete reference for all Traefik OIDC middleware configuration options.
- [Security Headers](#security-headers) - [Security Headers](#security-headers)
- [Scope Configuration](#scope-configuration) - [Scope Configuration](#scope-configuration)
- [Advanced Options](#advanced-options) - [Advanced Options](#advanced-options)
- [Standalone binary (oidcgate)](#standalone-binary-oidcgate)
--- ---
@@ -664,3 +665,19 @@ sessionEncryptionKey: ${OIDC_SECRET_API}
# Good # Good
sessionEncryptionKey: ${OIDC_SECRET_SVC} sessionEncryptionKey: ${OIDC_SECRET_SVC}
``` ```
---
## Standalone binary (oidcgate)
If you don't run Traefik, the same configuration shape documented above
works for the [`oidcgate`](OIDCGATE.md) standalone forward-auth daemon
under `cmd/oidcgate`. Three extra top-level keys (`listen`, `authPath`,
`startPath`) configure the daemon itself; everything else maps 1:1 onto
the `traefikoidc.Config` fields documented in this reference.
See [`docs/OIDCGATE.md`](OIDCGATE.md) for the full daemon guide including
nginx, Caddy, Traefik ForwardAuth, HAProxy and Envoy wiring snippets,
the `OIDCGATE_*` environment-variable inventory, the security posture
(X-Forwarded-Uri sanitisation, excludedURLs guardrail), and how to layer
M2M [bearer-token auth](BEARER_AUTH.md) on the same daemon.
+362
View File
@@ -0,0 +1,362 @@
# oidcgate — standalone OIDC forward-auth daemon
`oidcgate` is a single binary that exposes the same OIDC middleware that
powers the Traefik plugin as a forward-auth daemon for nginx, Caddy,
Traefik ForwardAuth, HAProxy, and Envoy `ext_authz_http`.
## Table of contents
- [Build](#build)
- [Run](#run)
- [Configuration](#configuration)
- [YAML file](#yaml-file)
- [Environment-variable overrides](#environment-variable-overrides)
- [Endpoints](#endpoints)
- [Reverse-proxy snippets](#reverse-proxy-snippets)
- [nginx (`auth_request`)](#nginx-auth_request)
- [Caddy (`forward_auth`)](#caddy-forward_auth)
- [Traefik (`ForwardAuth`)](#traefik-forwardauth)
- [HAProxy](#haproxy)
- [Envoy (`ext_authz_http`)](#envoy-ext_authz_http)
- [Security posture](#security-posture)
- [Bearer-token (M2M) auth on the same daemon](#bearer-token-m2m-auth-on-the-same-daemon)
- [Operational guidance](#operational-guidance)
- [Debugging](#debugging)
## Build
```bash
go build -o oidcgate ./cmd/oidcgate
```
## Run
```bash
./oidcgate --config /etc/oidcgate/config.yaml
```
The daemon parses `--config`, loads YAML, applies any `OIDCGATE_*` env-var
overrides, validates the result, and binds to `listen`. On SIGINT/SIGTERM it
calls `http.Server.Shutdown` with a 15s deadline, draining in-flight requests.
## Configuration
### YAML file
The OIDC subtree of the config maps 1:1 onto the [`traefikoidc.Config`](CONFIGURATION.md)
struct — every field documented under "Configuration Reference" works here
verbatim. Three extra top-level keys configure the daemon itself:
| Key | Default | Purpose |
|---|---|---|
| `listen` | _required_ | TCP address (e.g. `:8080`, `127.0.0.1:8080`). |
| `authPath` | `/oauth2/auth` | Silent-probe endpoint (used by nginx `auth_request`). |
| `startPath` | `/oauth2/start` | Visible sign-in endpoint. |
Minimal example (see [`examples/oidcgate.yaml`](../examples/oidcgate.yaml)):
```yaml
listen: ":8080"
providerURL: "https://accounts.google.com"
clientID: "your-client-id"
clientSecret: "your-client-secret"
sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
```
Nested structs (`redis:`, `securityHeaders:`, `dynamicClientRegistration:`)
round-trip cleanly through YAML — same shape as in `.traefik.yml`.
### Environment-variable overrides
Any of the following scalar fields can be overridden at runtime by an
`OIDCGATE_<UPPER_SNAKE_CASE>` environment variable. The env var wins over
the YAML value when set and non-empty. Intended for secret injection
(K8s `valueFrom.secretKeyRef`, systemd `EnvironmentFile=`, etc.).
| YAML key | Env var |
|---|---|
| `listen` | `OIDCGATE_LISTEN` |
| `authPath` | `OIDCGATE_AUTH_PATH` |
| `startPath` | `OIDCGATE_START_PATH` |
| `providerURL` | `OIDCGATE_PROVIDER_URL` |
| `clientID` | `OIDCGATE_CLIENT_ID` |
| `clientSecret` | `OIDCGATE_CLIENT_SECRET` |
| `audience` | `OIDCGATE_AUDIENCE` |
| `callbackURL` | `OIDCGATE_CALLBACK_URL` |
| `logoutURL` | `OIDCGATE_LOGOUT_URL` |
| `postLogoutRedirectURI` | `OIDCGATE_POST_LOGOUT_REDIRECT_URI` |
| `sessionEncryptionKey` | `OIDCGATE_SESSION_ENCRYPTION_KEY` |
| `cookiePrefix` | `OIDCGATE_COOKIE_PREFIX` |
| `cookieDomain` | `OIDCGATE_COOKIE_DOMAIN` |
| `logLevel` | `OIDCGATE_LOG_LEVEL` |
| `revocationURL` | `OIDCGATE_REVOCATION_URL` |
| `oidcEndSessionURL` | `OIDCGATE_OIDC_END_SESSION_URL` |
| `userIdentifierClaim` | `OIDCGATE_USER_IDENTIFIER_CLAIM` |
| `groupClaimName` | `OIDCGATE_GROUP_CLAIM_NAME` |
| `roleClaimName` | `OIDCGATE_ROLE_CLAIM_NAME` |
| `clientAuthMethod` | `OIDCGATE_CLIENT_AUTH_METHOD` |
| `clientAssertionPrivateKey` | `OIDCGATE_CLIENT_ASSERTION_PRIVATE_KEY` |
| `clientAssertionKeyPath` | `OIDCGATE_CLIENT_ASSERTION_KEY_PATH` |
| `clientAssertionKeyID` | `OIDCGATE_CLIENT_ASSERTION_KEY_ID` |
| `clientAssertionAlg` | `OIDCGATE_CLIENT_ASSERTION_ALG` |
| `caCertPath` | `OIDCGATE_CA_CERT_PATH` |
| `caCertPEM` | `OIDCGATE_CA_CERT_PEM` |
Nested-struct fields (Redis, security headers, DCR) are YAML-only — set
them in the config file, not via env.
## Endpoints
| Path | Method | Purpose |
|---|---|---|
| `/oauth2/auth` | GET | Silent probe — `200` if authenticated, `401` if not. Never returns `302`; the middleware's redirect-to-IdP is rewritten in-flight to `401` with the original `Location` carried as `X-Auth-Redirect`. |
| `/oauth2/start` | GET | Visible sign-in — `302` to the IdP authorize URL. Accepts `?rd=<safe-path>` (or honours `X-Forwarded-Uri`) for the post-login redirect target. |
| `/oauth2/callback` | GET | IdP `code`+`state` exchange. Path is configurable via `callbackURL`. |
| `/oauth2/logout` | GET/POST | Terminates the session. Path is configurable via `logoutURL`. Honours `oidcEndSessionURL` for RP-initiated logout. |
| `/healthz` | GET | Liveness — `200` while the process is alive. |
| `/readyz` | GET | Readiness — `200` once the OIDC discovery document has been fetched, otherwise `503`. |
## Reverse-proxy snippets
### nginx (`auth_request`)
```nginx
location = /oauth2/auth {
internal;
proxy_pass http://oidcgate:8080;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @oidc_signin {
return 302 /oauth2/start?rd=$scheme://$host$request_uri;
}
location /oauth2/ {
proxy_pass http://oidcgate:8080;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
auth_request /oauth2/auth;
error_page 401 = @oidc_signin;
auth_request_set $user $upstream_http_x_forwarded_user;
auth_request_set $email $upstream_http_x_forwarded_email;
proxy_set_header X-Forwarded-User $user;
proxy_set_header X-Forwarded-Email $email;
proxy_pass http://backend;
}
```
### Caddy (`forward_auth`)
```caddyfile
example.com {
forward_auth oidcgate:8080 {
uri /oauth2/auth
copy_headers X-Forwarded-User X-Forwarded-Email
@denied status 401
handle_response @denied {
redir /oauth2/start?rd={http.request.uri} 302
}
}
handle /oauth2/* {
reverse_proxy oidcgate:8080
}
reverse_proxy backend:3000
}
```
### Traefik (`ForwardAuth`)
```yaml
http:
middlewares:
oidcgate:
forwardAuth:
address: "http://oidcgate:8080/oauth2/auth"
authResponseHeaders:
- X-Forwarded-User
- X-Forwarded-Email
```
Traefik can follow the `X-Auth-Redirect` value via a chained `redirectScheme`
middleware, or you can configure the upstream router to redirect `401` →
`/oauth2/start` directly.
### HAProxy
```haproxy
frontend fe_https
bind *:443 ssl crt /etc/haproxy/certs/site.pem
http-request set-var(req.orig_uri) path
http-request send-spoe-group oidc auth-check # or use lua/SPOE; simplest is the lua snippet below
# The simpler pattern: dispatch /oauth2/* to oidcgate, everything else
# goes through a Lua filter that issues a sub-request to /oauth2/auth.
acl is_oidc_endpoint path_beg /oauth2/
use_backend be_oidcgate if is_oidc_endpoint
default_backend be_app
backend be_oidcgate
server oidcgate1 oidcgate:8080
backend be_app
server app1 backend:3000
```
HAProxy does not have a first-class `auth_request` equivalent in pure
config — the canonical patterns are SPOE (Stream Processing Offload Engine),
a Lua filter that issues `/oauth2/auth` and reads the response, or a
sidecar that does the dance. Reach for SPOE for high-throughput
production; Lua is simpler for low-volume.
### Envoy (`ext_authz_http`)
```yaml
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
http_service:
server_uri:
uri: http://oidcgate:8080
cluster: oidcgate
timeout: 2s
path_prefix: /oauth2/auth
authorization_request:
allowed_headers:
patterns:
- exact: cookie
- exact: authorization
- prefix: x-forwarded-
authorization_response:
allowed_upstream_headers:
patterns:
- exact: x-forwarded-user
- exact: x-forwarded-email
allowed_client_headers:
patterns:
- exact: x-auth-redirect
- exact: set-cookie
```
On `401`, the `X-Auth-Redirect` header is surfaced to the downstream client
via `allowed_client_headers`. A small Envoy `router` filter or
`local_reply_config` rule can convert that into a browser-facing `302`
redirect to `/oauth2/start`.
## Security posture
- **`X-Forwarded-Uri` is sanitised.** The daemon forces
`TrustForwardedURI=true` so the middleware honours `X-Forwarded-Uri` for
the post-login redirect target. To prevent open redirects (CWE-601),
the value is rejected unless it is a safe same-origin path: must start
with `/`, must NOT start with `//` (protocol-relative), and must have
no scheme or host after parsing. Absolute URLs or anything that could
redirect off-origin falls through to `req.URL.RequestURI()`.
- **`excludedURLs` cannot bypass the daemon's own paths.** At config
load, the loader rejects any `excludedURLs` entry that is a prefix of
`authPath`, `startPath`, `callbackURL`, `logoutURL`, or the internal
sentinel path. A misconfiguration like `excludedURLs: ["/"]` (common
"allow all then add auth selectively" mistake) is rejected at startup
with a descriptive error.
- **`callbackURL` and `logoutURL` must be paths.** Absolute URLs are
rejected at config load — both because `http.ServeMux.Handle` panics
on non-`/` patterns and because the middleware's path-match would
silently fail.
- **`listen` is required.** Empty or missing `listen` is rejected at
startup rather than failing later at `net.Listen`.
- **Secrets via env vars.** `clientSecret` and `sessionEncryptionKey`
can be supplied via env vars instead of YAML so they don't end up on
disk if you use a secret manager.
## Bearer-token (M2M) auth on the same daemon
oidcgate uses the full `traefikoidc.Config` shape, so the bearer-token
M2M auth path documented in [`BEARER_AUTH.md`](BEARER_AUTH.md) works
out of the box. Add to your YAML:
```yaml
enableBearerAuth: true
audience: "https://api.example.com"
bearerIdentifierClaim: "sub"
# stripAuthorizationHeader: true # default
# bearerOverridesCookie: false # default — cookie wins on collision
```
With this set, the daemon accepts both:
- Browser users hitting `/oauth2/auth` → cookie session flow.
- API clients calling the protected backend with `Authorization: Bearer <jwt>`
→ bearer validation, principal headers, no session.
The bearer path doesn't go through `/oauth2/auth` separately — it's
applied by the middleware on every request the daemon sees, before the
cookie session check. See [BEARER_AUTH.md](BEARER_AUTH.md) for the full
threat model, identifier sanitisation rules, and failure-response
matrix.
## Operational guidance
- **Run behind a fronting proxy on a private network.** The daemon does
not terminate TLS. Put it on a localhost socket or a private subnet
reachable only from your nginx/Caddy/Traefik/HAProxy/Envoy.
- **`/healthz` and `/readyz` are unauthenticated** — correct for
Kubernetes liveness/readiness probes, but **do not expose them past a
load balancer**. Restrict via an ACL: nginx `allow 10.0.0.0/8; deny
all;`, Caddy `@health remote_ip 10.0.0.0/8`, k8s NetworkPolicy, or
your CNI of choice.
- **Multi-replica deployments** need a shared session store. Enable the
`redis:` block in the config (see [`docs/REDIS.md`](REDIS.md)) so
sessions survive a hop between replicas.
- **No built-in Prometheus metrics yet.** If you need request-level
visibility, take it from your fronting proxy's access logs — both
nginx and Envoy can tag `auth_request` / `ext_authz` outcomes.
- **Logs are minimal by default.** Set `logLevel: debug` while
bringing up a new deployment; raise to `info` (default) or higher
once stable. Debug logs include path-match decisions and metadata
refresh outcomes.
- **Graceful shutdown is 15s.** SIGINT or SIGTERM triggers
`http.Server.Shutdown(ctx)` with a 15-second deadline; in-flight
requests are allowed to complete. If your orchestrator's grace
period is shorter, requests can be cut mid-flight.
## Debugging
- **Requests appear as `/__oidcgate_protected__` in middleware debug
logs.** This is the internal sentinel path used when `/oauth2/auth`
and `/oauth2/start` delegate into the traefikoidc middleware. The
upstream client never sees it; it only shows up in the middleware's
own `Debugf` output when `logLevel: debug` is set.
- **`/oauth2/auth` returns `401` with `X-Auth-Redirect` header on
unauthenticated requests.** This is the deliberate translation of the
middleware's `302` to make nginx `auth_request` work. The browser is
redirected via the fronting proxy's `error_page 401 = @oidc_signin;`
pattern, not by following the daemon's response directly.
- **`/readyz` stays `503` after startup.** The middleware fetches the
OIDC discovery document lazily on first request, so `/readyz` returns
`503` until at least one request has triggered metadata discovery.
Hit `/oauth2/auth` once after startup to warm it up — many K8s
setups achieve the same effect because the liveness probe already
goes through the proxy chain.
- **Cookie/session diagnostics.** With `logLevel: debug` the middleware
logs which session manager was selected (in-memory vs Redis), whether
cookies decrypted successfully, and the JWT validation outcome.
- **Open-redirect rejections are silent.** When the daemon ignores an
unsafe `X-Forwarded-Uri` value, it falls back to `req.URL.RequestURI()`
without logging. This is intentional (no recon signal) — if a user
reports "I keep landing on the wrong page after login", inspect
whether the upstream proxy is forwarding a non-canonical
`X-Forwarded-Uri` value.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
# oidcgate — Standalone OIDC Forward-Auth Daemon (Tier 1)
**Date:** 2026-05-19
**Status:** Design approved, pending implementation plan
**Scope:** Tier 1 only — forward-auth daemon. No reverse-proxy mode, no TLS termination, no metrics, no multi-tenancy.
## Goal
Provide a standalone Go binary that exposes the existing `traefikoidc` OIDC middleware as a forward-auth daemon callable from nginx (`auth_request`), Caddy (`forward_auth`), Traefik (`ForwardAuth`), HAProxy, and Envoy (`ext_authz_http`). The library's public surface is **additive only**: no existing exported function signature changes; one optional `Config` field (`TrustForwardedURI`) and one new read-only accessor on `*TraefikOidc` are added. Existing Traefik plugin users see no behavior change unless they opt in to the new field.
## Non-Goals
- Reverse-proxy mode (Tier 2 — separate effort if requested).
- TLS termination inside the daemon.
- Built-in Prometheus metrics.
- Multi-tenant routing (one binary serves one OIDC client config).
- oauth2-proxy CLI/flag compatibility.
- Docker image or goreleaser publishing as part of Tier 1.
## Architecture
### File layout
```
github.com/lukaszraczylo/traefikoidc (library, unchanged)
├── main.go (existing New/NewWithContext)
├── middleware.go (existing ServeHTTP + ~10 LoC patch)
└── cmd/
└── oidcgate/
├── main.go (entrypoint, flags, signal handling)
├── config.go (YAML loader + env-var override walker)
├── server.go (http.ServeMux wiring, listen loop)
├── endpoints.go (auth/start/callback/logout handlers)
├── success.go (synthetic success handler used as `next`)
├── interceptor.go (302→401 response-writer for /oauth2/auth)
├── health.go (/healthz, /readyz)
├── config_test.go
├── endpoints_test.go
└── interceptor_test.go
```
### Process model
Single binary, single `*TraefikOidc` instance built at boot from YAML config, served on one listener port. Health endpoints on the same listener. Graceful shutdown on `SIGINT`/`SIGTERM` via `srv.Shutdown(ctx)` with a 15s deadline, after which context cancellation propagates into the existing goroutine manager.
## Endpoint Contract
All four endpoints share the listener. Paths are configurable; defaults shown.
| Endpoint | Default path | Method | Contract |
|---|---|---|---|
| **Auth probe** | `/oauth2/auth` | GET | Silent. `200 OK` + injected headers on success. `401` on failure (never `302`). Consumed by nginx `auth_request`, Traefik ForwardAuth, Caddy `forward_auth`, Envoy ext_authz_http. |
| **Sign-in** | `/oauth2/start` | GET | Always `302` to IdP `authorize` URL with `state`+`nonce`+PKCE. Reads target URL from `?rd=` query or `X-Forwarded-Uri` header. Hit by the browser after a `401` from `/oauth2/auth`. |
| **Callback** | `config.callbackURL` | GET | IdP `code`+`state` exchange. Existing `auth_flow.go` logic runs unchanged. On success → `302` to original URL. |
| **Logout** | `config.logoutURL` | GET/POST | Existing `logout.go` handler. Terminates session. Honors `oidcEndSessionURL` if configured. |
### Wiring (how each endpoint delegates)
All four handlers feed `(*TraefikOidc).ServeHTTP` after rewriting `req.URL.Path`:
- `/oauth2/callback` → rewrite to `config.callbackURL`, delegate. Middleware path-match at `middleware.go` triggers callback flow.
- `/oauth2/logout` → rewrite to `config.logoutURL`, delegate. Middleware logout path-match at the top of `ServeHTTP` triggers.
- `/oauth2/start` → rewrite `req.URL.Path` to the sentinel `/__oidcgate_protected__`, delegate. Middleware sees an unauthenticated GET on a protected path, emits the `302` to IdP. The redirect flows through naturally.
- `/oauth2/auth` → rewrite `req.URL.Path` to `/__oidcgate_protected__`, wrap `ResponseWriter` with an interceptor (see below), delegate.
The sentinel path `/__oidcgate_protected__` is chosen because it cannot collide with `callbackURL` / `logoutURL` / `/health*` path matches inside `ServeHTTP` and is not a likely user-configured `excludedURLs` entry. It is internal-only: clients never see it.
### Synthetic `next` handler
The middleware calls `t.next.ServeHTTP(rw, req)` at four sites (`middleware.go:174,185,187,592`) when the request is authenticated and should be forwarded. The daemon supplies a `next` that:
1. Writes `200 OK`.
2. Mirrors any `X-Forwarded-*` and templated headers that the middleware set on `req.Header` (e.g. `X-Forwarded-User` at `middleware.go:101,512`) onto the **response** headers, so proxies can capture them via `auth_request_set` / `authResponseHeaders`.
3. Writes empty body.
### 302 → 401 interceptor (`/oauth2/auth` only)
nginx `auth_request` cannot follow `302`s. For the silent endpoint, the daemon wraps the `ResponseWriter` such that:
- If the middleware writes status `302` (the IdP-redirect branch), the interceptor rewrites it to `401`.
- `Location` header from the swallowed `302` is preserved as `X-Auth-Redirect` on the `401` response (advisory; some proxies may surface it).
- `Set-Cookie` headers (state, PKCE, nonce) are preserved verbatim so the browser carries them into the subsequent `/oauth2/start` request.
- For any non-`302` status, the interceptor is a passthrough.
`/oauth2/start` does **not** wrap; the middleware's natural `302` flows through to the browser.
## Configuration
### Source
- **File:** `--config /etc/oidcgate/config.yaml` (path overridable via flag).
- **Format:** YAML, unmarshalled into the existing `traefikoidc.Config` struct (`settings.go:39`) via `yaml.v3` (already a dependency in `go.mod`).
- **Migration from Traefik:** copy the `plugin.traefikoidc:` subtree out of `.traefik.yml` and add the daemon-specific top-level keys below.
- **Env-var overrides** (secrets in particular): after YAML unmarshal, walk the config struct. Any scalar string/int/bool field with a non-empty `OIDCGATE_<UPPER_SNAKE_CASE_FIELD>` env-var replaces the YAML value. Nested structs (`Redis`, `SecurityHeaders`, `DynamicClientRegistration`) stay YAML-only.
### Top-level oidcgate-specific keys
```yaml
listen: ":8080" # required, listener address
authPath: "/oauth2/auth" # optional, default shown
startPath: "/oauth2/start" # optional, default shown
# all other keys = existing traefikoidc.Config fields
```
### Validation
The existing validation inside `traefikoidc.NewWithContext` (`main.go:97`) runs unchanged. On any returned error, the daemon logs the error and exits non-zero.
## Library-Side Patches
Two additive changes, both default-off / read-only:
1. **`settings.go` + `middleware.go` (~10 LoC):** add `Config.TrustForwardedURI bool` (default `false`). When `true`, the post-login-redirect target captured during the "unauthenticated GET → 302 to IdP" branch is sourced from `req.Header.Get("X-Forwarded-Uri")` if non-empty, instead of from `req.URL`. The daemon sets `TrustForwardedURI = true` at config build time. Default-off preserves current Traefik plugin behavior exactly.
2. **`main.go` (~5 LoC):** add `func (t *TraefikOidc) Ready() bool` returning `true` once at least one successful OIDC metadata discovery fetch has populated the metadata cache. Read-only; no behavior change for existing consumers.
## Lifecycle
```
parse flags
→ load YAML
→ apply env-var overrides
→ build synthetic success handler
→ call traefikoidc.New(ctx, success, cfg, "oidcgate") (validation happens here)
→ build mux (auth, start, callback, logout, healthz, readyz)
→ http.Server.ListenAndServe on cfg.listen
→ wait for SIGINT/SIGTERM
→ srv.Shutdown(15s ctx)
→ ctx cancel propagates to goroutine manager
→ exit 0
```
`/readyz` returns `200` only once `traefikoidc.New` has returned without error **and** the first OIDC metadata discovery fetch has succeeded. Implementation: add a read-only accessor `func (t *TraefikOidc) Ready() bool` that returns `true` once the metadata cache has at least one successful discovery fetch. The daemon's `/readyz` handler calls this and returns `200` / `503` accordingly.
`/healthz` returns `200` as long as the process is alive.
## Testing
- `config_test.go` — YAML round-trip; env-var override precedence; validation pass-through.
- `endpoints_test.go` — `httptest.NewServer`-based scenarios:
- `/oauth2/auth` with no session → `401`.
- `/oauth2/auth` with valid session → `200` + `X-Forwarded-User` mirrored on response.
- `/oauth2/start` → `302` with valid IdP `authorize` URL incl. `state` and PKCE.
- `/oauth2/callback` → completes exchange, sets session, redirects to original URL.
- `/oauth2/logout` → clears session cookie.
- `interceptor_test.go` — middleware-emitted `302` becomes `401`; `Location` → `X-Auth-Redirect`; `Set-Cookie` preserved.
- Reuse existing mock IdP from `enhanced_mocks_test.go`. No new mock infra.
## Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Interceptor swallows a legitimate `302` from the middleware that isn't the IdP redirect. | Inspect: only intercept when `Location` matches `t.providerURL`'s authorize endpoint or when it points off-host. Test coverage in `interceptor_test.go`. |
| Library-side patch breaks current Traefik users. | New `TrustForwardedURI` defaults to `false`; existing path untouched when unset. |
| Env-var walker overreaches into nested structs. | Restrict to top-level scalar fields; document explicitly; nested structs stay YAML-only. |
| Path-rewrite trick hits a middleware path-comparison we didn't anticipate. | All four `t.next.ServeHTTP` sites verified at `middleware.go:174,185,187,592`. Endpoint tests exercise each path. |
## Out of Scope (Tier 2 candidates)
- Reverse-proxy mode (`httputil.ReverseProxy` as the configured `next`).
- TLS termination (`tls.Config`, ACME).
- Prometheus metrics endpoint.
- Multi-tenant routing.
- oauth2-proxy flag/env compatibility.
- Goreleaser binaries, Docker image, systemd unit.
## Acceptance Criteria
1. `go build ./cmd/oidcgate` produces a working binary.
2. Existing test suite (`go test ./...`) still passes — zero regressions in the library.
3. New endpoint tests pass for all four endpoints against the mock IdP.
4. `oidcgate --config example.yaml` boots, serves `/healthz`, performs end-to-end OIDC flow against a real IdP (manual smoke test against e.g. a local Keycloak).
5. README section documents nginx, Caddy, and Traefik wiring examples.
+15
View File
@@ -0,0 +1,15 @@
# Minimal oidcgate config. See docs/OIDCGATE.md for full reference.
listen: ":8080"
authPath: "/oauth2/auth"
startPath: "/oauth2/start"
providerURL: "https://accounts.google.com"
clientID: "REPLACE_ME.apps.googleusercontent.com"
clientSecret: "REPLACE_ME" # OR set OIDCGATE_CLIENT_SECRET
sessionEncryptionKey: "REPLACE_WITH_64_HEX_BYTES" # OR OIDCGATE_SESSION_ENCRYPTION_KEY
callbackURL: "/oauth2/callback"
logoutURL: "/oauth2/logout"
postLogoutRedirectURI: "/"
# allowedUserDomains: [company.com]
# excludedURLs: [/health, /metrics]
+37
View File
@@ -0,0 +1,37 @@
package traefikoidc
import (
"net/http"
"net/url"
"strings"
)
// originalRequestURI returns the request URI that should be used as the
// post-login redirect target. When TrustForwardedURI is enabled and the
// X-Forwarded-Uri header carries a safe same-origin path, that header
// wins. Otherwise (or if the header is missing/unsafe), falls back to
// req.URL.RequestURI() — the path the request reached the proxy with.
//
// "Safe" means: starts with "/", does NOT start with "//" (protocol-relative
// URLs can change host), and has no scheme or host after parsing. This
// prevents an attacker-controllable header from triggering an open redirect
// via http.Redirect later in the auth flow.
func (t *TraefikOidc) originalRequestURI(req *http.Request) string {
if t.trustForwardedURI {
if v := req.Header.Get("X-Forwarded-Uri"); v != "" && isSafeRedirectTarget(v) {
return v
}
}
return req.URL.RequestURI()
}
func isSafeRedirectTarget(v string) bool {
if !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") {
return false
}
u, err := url.Parse(v)
if err != nil {
return false
}
return u.Host == "" && u.Scheme == ""
}
+69
View File
@@ -0,0 +1,69 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestOriginalRequestURI_DefaultOff(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: false}
req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil)
req.Header.Set("X-Forwarded-Uri", "/spoofed")
if got := tr.originalRequestURI(req); got != "/protected?x=1" {
t.Fatalf("default-off: want /protected?x=1, got %q", got)
}
}
func TestOriginalRequestURI_TrustEnabled(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected?x=1", nil)
req.Header.Set("X-Forwarded-Uri", "/real?y=2")
if got := tr.originalRequestURI(req); got != "/real?y=2" {
t.Fatalf("trust-on with header: want /real?y=2, got %q", got)
}
}
func TestOriginalRequestURI_TrustEnabledNoHeader(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
if got := tr.originalRequestURI(req); got != "/protected" {
t.Fatalf("trust-on no header: want /protected, got %q", got)
}
}
func TestOriginalRequestURI_RejectsAbsoluteURL(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("X-Forwarded-Uri", "https://evil.example/phish")
if got := tr.originalRequestURI(req); got != "/protected" {
t.Fatalf("absolute URL must be rejected, want /protected fallback, got %q", got)
}
}
func TestOriginalRequestURI_RejectsProtocolRelative(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("X-Forwarded-Uri", "//evil.example/phish")
if got := tr.originalRequestURI(req); got != "/protected" {
t.Fatalf("protocol-relative URL must be rejected, want /protected fallback, got %q", got)
}
}
func TestOriginalRequestURI_AcceptsSafePathWithQuery(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("X-Forwarded-Uri", "/safe?x=1&y=2")
if got := tr.originalRequestURI(req); got != "/safe?x=1&y=2" {
t.Fatalf("safe path with query must be accepted, got %q", got)
}
}
func TestOriginalRequestURI_RejectsBareHostnameNoSlash(t *testing.T) {
tr := &TraefikOidc{trustForwardedURI: true}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("X-Forwarded-Uri", "evil.example/phish")
if got := tr.originalRequestURI(req); got != "/protected" {
t.Fatalf("non-/ prefix must be rejected, got %q", got)
}
}
-1
View File
@@ -5,7 +5,6 @@ go 1.24.0
require ( require (
github.com/alicebob/miniredis/v2 v2.35.0 github.com/alicebob/miniredis/v2 v2.35.0
github.com/gorilla/sessions v1.3.0 github.com/gorilla/sessions v1.3.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
-2
View File
@@ -16,8 +16,6 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
+4 -8
View File
@@ -234,8 +234,7 @@ func TestIssue134_Followup_ValidateAzureTokensSkipsGraphAccessToken(t *testing.T
oidc, errBuf := newAzureFollowupOIDC(t, jwks) oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, graphAccessToken, idToken) session := authedSessionWithTokens(t, graphAccessToken, idToken)
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs)
output := errBuf.String() output := errBuf.String()
assert.NotContains(t, output, "crypto/rsa: verification error", assert.NotContains(t, output, "crypto/rsa: verification error",
@@ -345,8 +344,7 @@ func TestIssue134_Followup_StandardAzureAccessTokenStillVerifies(t *testing.T) {
oidc, errBuf := newAzureFollowupOIDC(t, jwks) oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, accessToken, idToken) session := authedSessionWithTokens(t, accessToken, idToken)
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs)
assert.True(t, authenticated, "standard Azure access token must verify and authenticate") assert.True(t, authenticated, "standard Azure access token must verify and authenticate")
assert.False(t, needsRefresh) assert.False(t, needsRefresh)
@@ -383,8 +381,7 @@ func TestIssue134_Followup_GraphAccessTokenWithoutIDToken(t *testing.T) {
oidc, errBuf := newAzureFollowupOIDC(t, jwks) oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, graphAccessToken, "") session := authedSessionWithTokens(t, graphAccessToken, "")
rs := (&requestState{}).captureSession(session) authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs)
assert.True(t, authenticated, "Graph token without ID token must remain authenticated (matches existing opaque-token semantics)") assert.True(t, authenticated, "Graph token without ID token must remain authenticated (matches existing opaque-token semantics)")
assert.False(t, needsRefresh) assert.False(t, needsRefresh)
@@ -446,8 +443,7 @@ func TestIssue134_Followup_ConfusedDeputyAttackDoesNotBypassVerification(t *test
oidc, _ := newAzureFollowupOIDC(t, jwks) oidc, _ := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, forgedAccessToken, forgedIDToken) session := authedSessionWithTokens(t, forgedAccessToken, forgedIDToken)
rs := (&requestState{}).captureSession(session) authenticated, _, _ := oidc.validateAzureTokens(session)
authenticated, _, _ := oidc.validateAzureTokensRS(rs)
assert.False(t, authenticated, assert.False(t, authenticated,
"attacker's forged tokens must not authenticate even when the access token has a nonce header — ID token verification rejects the wrong-key signature") "attacker's forged tokens must not authenticate even when the access token has a nonce header — ID token verification rejects the wrong-key signature")
} }
+6 -7
View File
@@ -478,10 +478,11 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
// Test 3: Rate limiting // Test 3: Rate limiting
t.Run("RateLimiting", func(t *testing.T) { t.Run("RateLimiting", func(t *testing.T) {
// Reset circuit breaker to closed state for this test. All fields are // Reset circuit breaker to closed state for this test
// atomic so we don't need any mutex. coordinator.circuitBreaker.mutex.Lock()
atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed atomic.StoreInt32(&coordinator.circuitBreaker.state, 0) // closed
atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0) atomic.StoreInt32(&coordinator.circuitBreaker.failures, 0)
coordinator.circuitBreaker.mutex.Unlock()
// Temporarily increase circuit breaker threshold to not interfere // Temporarily increase circuit breaker threshold to not interfere
oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures oldMaxFailures := coordinator.circuitBreaker.config.MaxFailures
@@ -524,11 +525,9 @@ func TestRefreshCoordinatorIntegration(t *testing.T) {
time.Sleep(config.CleanupInterval * 3) time.Sleep(config.CleanupInterval * 3)
// Old sessions should be cleaned up // Old sessions should be cleaned up
count := 0 coordinator.attemptsMutex.RLock()
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool { count := len(coordinator.sessionRefreshAttempts)
count++ coordinator.attemptsMutex.RUnlock()
return true
})
// Should have fewer sessions after cleanup // Should have fewer sessions after cleanup
if count > 10 { if count > 10 {
+11 -49
View File
@@ -53,26 +53,10 @@ type JWKSet struct {
Keys []JWK `json:"keys"` 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 { type JWKCache struct {
cache *UniversalCache cache *UniversalCache
inflightFetches sync.Map // map[jwksURL string]*jwksFetch mutex sync.RWMutex
}
// 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. // JWKCacheInterface defines the contract for JWK caching implementations.
@@ -99,58 +83,36 @@ func NewJWKCache() *JWKCache {
// request refetches from the upstream. JWK rotation is rare and a per-replica // 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. // 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) { func (c *JWKCache) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
// Fast path: cache hit. // Check cache first
if cachedValue, found := c.cache.GetLocal(jwksURL); found { if cachedValue, found := c.cache.GetLocal(jwksURL); found {
if jwks, ok := cachedValue.(*JWKSet); ok { if jwks, ok := cachedValue.(*JWKSet); ok {
return jwks, nil return jwks, nil
} }
} }
// Singleflight: dedupe concurrent fetches per URL key. The first arrival c.mutex.Lock()
// performs the HTTP fetch; any later arrival for the same URL waits on defer c.mutex.Unlock()
// 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()
}
}
// We're the leader. Make absolutely sure the result fields and the // Double-check after acquiring lock
// 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 cachedValue, found := c.cache.GetLocal(jwksURL); found {
if jwks, ok := cachedValue.(*JWKSet); ok { if jwks, ok := cachedValue.(*JWKSet); ok {
candidate.jwks = jwks
return jwks, nil return jwks, nil
} }
} }
// Fetch from URL
jwks, err := fetchJWKS(ctx, jwksURL, httpClient) jwks, err := fetchJWKS(ctx, jwksURL, httpClient)
if err != nil { if err != nil {
candidate.err = err
return nil, err return nil, err
} }
if len(jwks.Keys) == 0 { if len(jwks.Keys) == 0 {
candidate.err = fmt.Errorf("JWKS response contains no keys") return nil, 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 _ = c.cache.SetLocal(jwksURL, jwks, 1*time.Hour) // Safe to ignore: cache failures are non-critical
candidate.jwks = jwks
return jwks, nil return jwks, nil
} }
+4 -4
View File
@@ -415,8 +415,8 @@ func TestMiddlewareBackchannelLogoutRouting(t *testing.T) {
clientID: "test-client", clientID: "test-client",
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
logoutURLPath: "/logout", logoutURLPath: "/logout",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -457,8 +457,8 @@ func TestMiddlewareFrontchannelLogoutRouting(t *testing.T) {
clientID: "test-client", clientID: "test-client",
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
logoutURLPath: "/logout", logoutURLPath: "/logout",
} }
close(oidc.initComplete) close(oidc.initComplete)
+1 -27
View File
@@ -16,7 +16,6 @@ import (
"text/template" "text/template"
"time" "time"
telemetry "github.com/lukaszraczylo/oss-telemetry"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -24,11 +23,6 @@ const (
ConstSessionTimeout = 86400 ConstSessionTimeout = 86400
) )
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
// process. Traefik calls New once per route that uses the plugin; oss-telemetry
// does not deduplicate client-side (the server does), so the gate stays here.
var telemetryStartupOnce sync.Once
// isTestMode detects if the code is running in a test environment. // isTestMode detects if the code is running in a test environment.
func isTestMode() bool { func isTestMode() bool {
if os.Getenv("SUPPRESS_DIAGNOSTIC_LOGS") == "1" { if os.Getenv("SUPPRESS_DIAGNOSTIC_LOGS") == "1" {
@@ -95,13 +89,6 @@ var defaultExcludedURLs = map[string]struct{}{
// - The configured TraefikOidc handler ready to process requests. // - The configured TraefikOidc handler ready to process requests.
// - An error if essential configuration is missing or invalid (e.g., short encryption key). // - 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) { func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
telemetryStartupOnce.Do(func() {
// Only stamped release builds phone home; dev/local/test builds keep the
// devPluginVersion sentinel (see version.go) and stay silent.
if traefikoidcPluginVersion != devPluginVersion {
telemetry.Send("traefikoidc", traefikoidcPluginVersion)
}
})
return NewWithContext(ctx, config, next, name) return NewWithContext(ctx, config, next, name)
} }
@@ -214,7 +201,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
}(), }(),
forceHTTPS: config.ForceHTTPS, forceHTTPS: config.ForceHTTPS,
enablePKCE: config.EnablePKCE, enablePKCE: config.EnablePKCE,
extraAuthParams: config.ExtraAuthParams, trustForwardedURI: config.TrustForwardedURI,
overrideScopes: config.OverrideScopes, overrideScopes: config.OverrideScopes,
strictAudienceValidation: config.StrictAudienceValidation, strictAudienceValidation: config.StrictAudienceValidation,
allowOpaqueTokens: config.AllowOpaqueTokens, allowOpaqueTokens: config.AllowOpaqueTokens,
@@ -530,19 +517,6 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
introspectionURL := t.introspectionURL introspectionURL := t.introspectionURL
registrationURL := t.registrationURL registrationURL := t.registrationURL
// Publish the read-mostly URL bundle atomically. Hot-path readers Load
// this directly instead of acquiring metadataMu.RLock per request.
t.metadataSnapshot.Store(&MetadataSnapshot{
IssuerURL: metadata.Issuer,
JWKSURL: metadata.JWKSURL,
TokenURL: metadata.TokenURL,
AuthURL: metadata.AuthURL,
RevocationURL: metadata.RevokeURL,
EndSessionURL: metadata.EndSessionURL,
IntrospectionURL: metadata.IntrospectionURL,
RegistrationURL: metadata.RegistrationURL,
})
t.metadataMu.Unlock() t.metadataMu.Unlock()
// Log introspection endpoint availability for opaque token support // Log introspection endpoint availability for opaque token support
+30 -14
View File
@@ -8,7 +8,6 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -485,8 +484,9 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close() defer server.Close()
oidc := &TraefikOidc{ oidc := &TraefikOidc{
providerURL: server.URL, providerURL: server.URL,
firstRequestStarted: 0, firstRequestReceived: false,
firstRequestMutex: sync.Mutex{},
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
@@ -508,13 +508,19 @@ func TestFirstRequestHandling(t *testing.T) {
}, },
} }
// Simulate first request processing — single-firing via CAS. // Simulate first request processing
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) { oidc.firstRequestMutex.Lock()
if !oidc.firstRequestReceived {
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
// This would normally be called asynchronously // This would normally be called asynchronously
go func() { go func() {
oidc.initializeMetadata(server.URL) oidc.initializeMetadata(server.URL)
// initComplete is closed internally by initializeMetadata // initComplete is closed internally by initializeMetadata
}() }()
} else {
oidc.firstRequestMutex.Unlock()
} }
// Wait for initialization // Wait for initialization
@@ -550,8 +556,9 @@ func TestFirstRequestHandling(t *testing.T) {
defer server.Close() defer server.Close()
oidc := &TraefikOidc{ oidc := &TraefikOidc{
providerURL: server.URL, providerURL: server.URL,
firstRequestStarted: 0, firstRequestReceived: false,
firstRequestMutex: sync.Mutex{},
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
@@ -573,22 +580,31 @@ func TestFirstRequestHandling(t *testing.T) {
}, },
} }
// Simulate multiple concurrent "first" requests — only one CAS winner // Simulate multiple concurrent "first" requests
// fires the bootstrap path.
const numRequests = 10 const numRequests = 10
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(numRequests) wg.Add(numRequests)
var initStarted int32 initStarted := 0
var initMu sync.Mutex
for i := 0; i < numRequests; i++ { for i := 0; i < numRequests; i++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
if atomic.CompareAndSwapInt32(&oidc.firstRequestStarted, 0, 1) { oidc.firstRequestMutex.Lock()
atomic.AddInt32(&initStarted, 1) if !oidc.firstRequestReceived {
oidc.firstRequestReceived = true
oidc.firstRequestMutex.Unlock()
initMu.Lock()
initStarted++
initMu.Unlock()
// Only one should actually start initialization // Only one should actually start initialization
oidc.initializeMetadata(server.URL) oidc.initializeMetadata(server.URL)
} else {
oidc.firstRequestMutex.Unlock()
} }
}() }()
} }
@@ -596,8 +612,8 @@ func TestFirstRequestHandling(t *testing.T) {
wg.Wait() wg.Wait()
// Verify only one initialization was started // Verify only one initialization was started
if atomic.LoadInt32(&initStarted) != 1 { if initStarted != 1 {
t.Errorf("expected exactly 1 initialization, got %d", atomic.LoadInt32(&initStarted)) t.Errorf("expected exactly 1 initialization, got %d", initStarted)
} }
// The metadata endpoint might be called once or not at all depending on timing // 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"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", // Required for initialization check issuerURL: "https://provider.example.com", // Required for initialization check
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -92,8 +92,8 @@ func TestServeHTTP_EventStream(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -175,8 +175,8 @@ func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -272,8 +272,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close this to simulate timeout initComplete: make(chan struct{}), // Never close this to simulate timeout
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
} }
req := httptest.NewRequest("GET", "/protected", nil) req := httptest.NewRequest("GET", "/protected", nil)
@@ -307,8 +307,8 @@ func TestServeHTTP_InitializationTimeout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -337,8 +337,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -367,8 +367,8 @@ func TestServeHTTP_CallbackAndLogout(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -740,8 +740,8 @@ func TestMinimalHeaders(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders, minimalHeaders: tt.minimalHeaders,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -817,8 +817,8 @@ func TestMinimalHeaders_TokenHeaderNotSet(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
minimalHeaders: true, // Enable minimal headers minimalHeaders: true, // Enable minimal headers
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -903,8 +903,8 @@ func TestStripAuthCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: tt.stripAuthCookies, stripAuthCookies: tt.stripAuthCookies,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -987,8 +987,8 @@ func TestStripAuthCookies_NoCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1034,8 +1034,8 @@ func TestStripAuthCookies_OnlyOIDCCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1085,8 +1085,8 @@ func TestStripAuthCookies_OnlyAppCookies(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
@@ -1148,8 +1148,8 @@ func TestStripAuthCookies_CustomPrefix(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sm, sessionManager: sm,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
stripAuthCookies: true, stripAuthCookies: true,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { extractClaimsFunc: func(token string) (map[string]interface{}, error) {
+4 -4
View File
@@ -16,7 +16,6 @@ import (
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -2686,9 +2685,10 @@ func TestMetadataRecoveryOnProviderFailure(t *testing.T) {
providerAvailable = true providerAvailable = true
mu.Unlock() mu.Unlock()
// Reset the retry timer to allow immediate retry. The field is atomic // Reset the retry timer to allow immediate retry
// now, so no lock is needed. m.metadataRetryMutex.Lock()
atomic.StoreInt64(&m.lastMetadataRetryNano, 0) m.lastMetadataRetryTime = time.Time{} // Reset to zero time
m.metadataRetryMutex.Unlock()
// Second request should trigger recovery attempt // Second request should trigger recovery attempt
req2 := httptest.NewRequest("GET", "/protected", nil) req2 := httptest.NewRequest("GET", "/protected", nil)
+23 -141
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/lukaszraczylo/traefikoidc/internal/utils" "github.com/lukaszraczylo/traefikoidc/internal/utils"
@@ -146,20 +145,19 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
if !strings.HasPrefix(req.URL.Path, "/health") { if !strings.HasPrefix(req.URL.Path, "/health") {
// Lock-free one-shot bootstrap. The previous firstRequestMutex.Lock() t.firstRequestMutex.Lock()
// fired on EVERY non-health request forever (even after the boolean if !t.firstRequestReceived {
// flipped true), which under Yaegi added a per-request serialization t.firstRequestReceived = true
// 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.logger.Debug("Starting background tasks on first request")
t.startTokenCleanup() t.startTokenCleanup()
if t.providerURL != "" && if !t.metadataRefreshStarted && t.providerURL != "" {
atomic.CompareAndSwapInt32(&t.metadataRefreshStartedAtomic, 0, 1) { t.metadataRefreshStarted = true
// Metadata refresh is handled by singleton resource manager // Metadata refresh is handled by singleton resource manager
t.startMetadataRefresh(t.providerURL) t.startMetadataRefresh(t.providerURL)
} }
} }
t.firstRequestMutex.Unlock()
} }
// Evaluate auth-bypass once, before waiting for initialization. Excluded // Evaluate auth-bypass once, before waiting for initialization. Excluded
@@ -209,31 +207,20 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
select { select {
case <-t.initComplete: case <-t.initComplete:
// Read issuerURL via atomic snapshot when available — replaces the // Read issuerURL with RLock
// metadataMu.RLock that previously fired on every non-bypass request. t.metadataMu.RLock()
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter issuerURL := t.issuerURL
// dispatch; the snapshot is a single atomic.Value.Load. Falls back t.metadataMu.RUnlock()
// to the legacy field+RLock for paths that haven't published a
// snapshot yet (notably some test setups that initialize the struct
// fields directly).
var issuerURL string
if snap := t.metadataSnap(); snap != nil {
issuerURL = snap.IssuerURL
} else {
t.metadataMu.RLock()
issuerURL = t.issuerURL
t.metadataMu.RUnlock()
}
if issuerURL == "" { if issuerURL == "" {
// Provider metadata initialization failed - try to recover. // Provider metadata initialization failed - try to recover
// Retry every 30 seconds to allow automatic recovery. Lock-free // Retry every 30 seconds to allow automatic recovery when provider comes back online
// throttle via CAS on lastMetadataRetryNano: one goroutine wins t.metadataRetryMutex.Lock()
// the window, others see shouldRetry=false. shouldRetry := time.Since(t.lastMetadataRetryTime) >= 30*time.Second
nowNano := time.Now().UnixNano() if shouldRetry {
last := atomic.LoadInt64(&t.lastMetadataRetryNano) t.lastMetadataRetryTime = time.Now()
shouldRetry := time.Duration(nowNano-last) >= 30*time.Second && }
atomic.CompareAndSwapInt64(&t.lastMetadataRetryNano, last, nowNano) t.metadataRetryMutex.Unlock()
if shouldRetry && t.providerURL != "" { if shouldRetry && t.providerURL != "" {
t.logger.Info("Attempting to recover OIDC provider metadata...") t.logger.Info("Attempting to recover OIDC provider metadata...")
@@ -311,19 +298,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
host := utils.DetermineHost(req) host := utils.DetermineHost(req)
redirectURL := buildFullURL(scheme, host, t.redirURLPath) redirectURL := buildFullURL(scheme, host, t.redirURLPath)
// Capture per-request state: one RLock on sd.sessionMutex covers all the
// getter values the handler chain needs (instead of 5-7 separate
// session.GetX() calls each acquiring their own RLock under Yaegi).
// metadataSnap is also stored once so downstream handlers don't repeat
// the atomic.Value.Load.
rs := (&requestState{
scheme: scheme,
host: host,
redirectURL: redirectURL,
next: t.next,
metadata: t.metadataSnap(),
}).captureSession(session)
// Check if the current request is the OIDC callback // Check if the current request is the OIDC callback
t.logger.Debugf("Checking callback URL match: request_path=%q, configured_callback=%q", req.URL.Path, t.redirURLPath) t.logger.Debugf("Checking callback URL match: request_path=%q, configured_callback=%q", req.URL.Path, t.redirURLPath)
if req.URL.Path == t.redirURLPath { if req.URL.Path == t.redirURLPath {
@@ -333,10 +307,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} }
t.logger.Debugf("Callback URL did not match (request_path=%q != configured=%q), continuing auth flow", req.URL.Path, t.redirURLPath) t.logger.Debugf("Callback URL did not match (request_path=%q != configured=%q), continuing auth flow", req.URL.Path, t.redirURLPath)
// Token validation reads session via the captured snapshot — saves ~21 authenticated, needsRefresh, expired := t.isUserAuthenticated(session)
// sd.sessionMutex.RLock acquisitions (Yaegi-dispatched, ~1-5ms each)
// across the validation path.
authenticated, needsRefresh, expired := t.isUserAuthenticatedRS(rs)
if expired { if expired {
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth") t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
@@ -344,7 +315,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return return
} }
userIdentifier := rs.userIdentifier userIdentifier := session.GetUserIdentifier()
// User authorization check // User authorization check
if authenticated && userIdentifier != "" { if authenticated && userIdentifier != "" {
if !t.isAllowedUser(userIdentifier) { if !t.isAllowedUser(userIdentifier) {
@@ -361,11 +332,11 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// methods (validateAzureTokens/validateStandardTokens) before reaching this point. // methods (validateAzureTokens/validateStandardTokens) before reaching this point.
// Redundant validation here was causing issues with Azure AD tokens that have // Redundant validation here was causing issues with Azure AD tokens that have
// JWT format but unverifiable signatures. See issue #89. // JWT format but unverifiable signatures. See issue #89.
t.processAuthorizedRequestRS(rw, req, rs) t.processAuthorizedRequest(rw, req, session, redirectURL)
return return
} }
refreshTokenPresent := rs.refreshToken != "" refreshTokenPresent := session.GetRefreshToken() != ""
// Decide whether to answer with 401 instead of a redirect. AJAX requests // Decide whether to answer with 401 instead of a redirect. AJAX requests
// cannot follow a 302 into an IdP, and sub-resource loads (script/image/ // cannot follow a 302 into an IdP, and sub-resource loads (script/image/
@@ -472,95 +443,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// - req: The HTTP request to process. // - req: The HTTP request to process.
// - session: The user's session data containing tokens and claims. // - session: The user's session data containing tokens and claims.
// - redirectURL: The callback URL for re-authentication if needed. // - redirectURL: The callback URL for re-authentication if needed.
// processAuthorizedRequestRS is the requestState-aware variant of
// processAuthorizedRequest. It reads SessionData fields from the captured
// snapshot in rs instead of calling session.GetX() (each of which acquires
// sd.sessionMutex.RLock — under Yaegi every RLock pays ~1-5ms of interpreter
// dispatch). Only session-mutating operations (Save, ResetRedirectCount,
// Clear, IsDirty) still go through the session pointer because those write
// state and have no snapshot.
func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *http.Request, rs *requestState) {
session := rs.session
redirectURL := rs.redirectURL
userIdentifier := rs.userIdentifier
if userIdentifier == "" {
t.logger.Info("No user identifier found in session during final processing, initiating re-auth")
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Check if session has been invalidated via backchannel or front-channel logout
idToken := rs.idToken
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken != "" {
sid, sub, createdAt := t.extractSessionInfo(idToken)
if t.isSessionInvalidated(sid, sub, createdAt) {
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing invalidated session: %v", err)
}
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
}
}
// Resolve ID-token claims at most once per request. SessionData caches
// the parsed claims keyed on the raw ID token.
var (
idClaims map[string]interface{}
idClaimsErr error
)
if idToken != "" {
idClaims, idClaimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
}
var (
groupClaims map[string]interface{}
groupClaimsErr error
)
if idToken != "" {
groupClaims, groupClaimsErr = idClaims, idClaimsErr
} else if rs.accessToken != "" {
groupClaims, groupClaimsErr = t.extractClaimsFunc(rs.accessToken)
} else if len(t.allowedRolesAndGroups) > 0 {
t.logger.Error("No token available but roles/groups checks are required")
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
if groupClaimsErr != nil && len(t.allowedRolesAndGroups) > 0 {
t.logger.Errorf("Failed to extract claims for roles/groups check: %v", groupClaimsErr)
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Persist any dirty session state BEFORE forwardAuthorized writes the
// response.
if session.IsDirty() {
if err := session.Save(req, rw); err != nil {
t.logger.Errorf("Failed to save session after processing headers: %v", err)
}
} else {
t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest")
}
p := &principal{
Source: sourceSession,
Identifier: userIdentifier,
AccessToken: rs.accessToken,
IDToken: idToken,
RefreshToken: rs.refreshToken,
Claims: groupClaims,
}
t.forwardAuthorized(rw, req, p)
}
func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
userIdentifier := session.GetUserIdentifier() userIdentifier := session.GetUserIdentifier()
if userIdentifier == "" { if userIdentifier == "" {
@@ -721,7 +603,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors // When minimalHeaders is enabled, skip extra headers to prevent 431 errors
if !t.minimalHeaders { if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI()) req.Header.Set("X-Auth-Request-Redirect", t.originalRequestURI(req))
req.Header.Set("X-Auth-Request-User", p.Identifier) req.Header.Set("X-Auth-Request-User", p.Identifier)
if p.IDToken != "" { if p.IDToken != "" {
req.Header.Set("X-Auth-Request-Token", 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"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate waiting initComplete: make(chan struct{}), // Never close to simulate waiting
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
} }
// Create request with canceled context // Create request with canceled context
@@ -39,8 +39,8 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -73,8 +73,8 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -102,8 +102,8 @@ func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate provider unavailable initComplete: make(chan struct{}), // Never close to simulate provider unavailable
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
logoutURLPath: "/logout", logoutURLPath: "/logout",
postLogoutRedirectURI: "/", postLogoutRedirectURI: "/",
forceHTTPS: false, forceHTTPS: false,
@@ -142,8 +142,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -187,8 +187,8 @@ func TestMiddlewareDomainRestrictions(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
@@ -236,8 +236,8 @@ func TestMiddlewareOpaqueTokenHandling(t *testing.T) {
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: sessionManager, sessionManager: sessionManager,
firstRequestStarted: 1, firstRequestReceived: true,
metadataRefreshStartedAtomic: 1, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
+12
View File
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,20 @@
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")
}
}
+201 -341
View File
@@ -15,28 +15,18 @@ import (
// It implements request coalescing, rate limiting, and circuit breaking // It implements request coalescing, rate limiting, and circuit breaking
// specifically for token refresh operations. // specifically for token refresh operations.
type RefreshCoordinator struct { type RefreshCoordinator struct {
// inFlightRefreshes maps tokenHash -> *refreshOperation. sync.Map is used inFlightRefreshes map[string]*refreshOperation
// instead of a plain map + RWMutex so concurrent refreshes do not cleanupTimers map[string]*time.Timer
// serialize on a single global lock. Under Yaegi the previous sessionRefreshAttempts map[string]*refreshAttemptTracker
// 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
circuitBreaker *RefreshCircuitBreaker circuitBreaker *RefreshCircuitBreaker
metrics *RefreshMetrics metrics *RefreshMetrics
logger *Logger logger *Logger
stopChan chan struct{} stopChan chan struct{}
config RefreshCoordinatorConfig config RefreshCoordinatorConfig
wg sync.WaitGroup wg sync.WaitGroup
attemptsMutex sync.RWMutex
refreshMutex sync.RWMutex
cleanupTimerMu sync.Mutex
} }
// RefreshCoordinatorConfig configures the refresh coordinator behavior // RefreshCoordinatorConfig configures the refresh coordinator behavior
@@ -94,46 +84,14 @@ type refreshResult struct {
fromCache bool fromCache bool
} }
// attemptState is the immutable snapshot of a session's refresh-attempt // refreshAttemptTracker tracks refresh attempts for a session
// state. Lives behind refreshAttemptTracker.state (atomic.Value). Every type refreshAttemptTracker struct {
// transition (record, success, failure, window-reset, cooldown-enter, lastAttemptTime time.Time
// cooldown-exit) constructs a fresh attemptState and publishes it via windowStartTime time.Time
// CompareAndSwap so the entire field set is updated together. cooldownEndTime time.Time
//
// Per-field atomic.Load/Store (the previous v1.0.15 design) had a benign
// but observable hazard: the cooldown-exit reset wrote cooldownEndNano = 0
// first, then separately stored attempts = 1 and windowStartNano = now.
// A concurrent isInCooldown call could see cooldownEndNano = 0 (reset
// just completed) with attempts still at MaxRefreshAttempts, triggering
// a fresh cooldown immediately. The snapshot approach eliminates the
// intermediate state entirely.
type attemptState struct {
lastAttemptNano int64 // UnixNano of last attempt
windowStartNano int64 // UnixNano of attempt-window start
cooldownEndNano int64 // UnixNano; 0 = not in cooldown
attempts int32 attempts int32
consecutiveFailures int32 consecutiveFailures int32
} inCooldown bool
// refreshAttemptTracker tracks refresh attempts for a session via a single
// atomic.Value holding a *attemptState pointer. Readers do exactly one Load.
// Writers do Load → construct new → CompareAndSwap (retry on conflict).
// Under Yaegi this collapses 3-4 per-field atomic dispatches into one Load,
// and eliminates the cross-field race in the window-reset path.
type refreshAttemptTracker struct {
state atomic.Value // *attemptState
}
// stateOf returns the current attemptState, or a zero-value snapshot if none
// has been published yet. The empty snapshot represents "no attempts recorded".
func (t *refreshAttemptTracker) stateOf() *attemptState {
if v := t.state.Load(); v != nil {
s, _ := v.(*attemptState)
if s != nil {
return s
}
}
return &attemptState{}
} }
// RefreshMetrics tracks coordinator performance metrics // RefreshMetrics tracks coordinator performance metrics
@@ -148,18 +106,14 @@ type RefreshMetrics struct {
currentInFlightRefreshes int32 currentInFlightRefreshes int32
} }
// RefreshCircuitBreaker implements a circuit breaker specifically for refresh // RefreshCircuitBreaker implements a circuit breaker specifically for refresh operations
// 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 { type RefreshCircuitBreaker struct {
lastFailureNano int64 // atomic, UnixNano of most recent failure lastFailureTime time.Time
lastSuccessNano int64 // atomic, UnixNano of most recent success lastSuccessTime time.Time
config RefreshCircuitBreakerConfig config RefreshCircuitBreakerConfig
state int32 // atomic: 0=closed, 1=open, 2=half-open mutex sync.RWMutex
failures int32 // atomic state int32
failures int32
} }
// RefreshCircuitBreakerConfig configures the refresh circuit breaker // RefreshCircuitBreakerConfig configures the refresh circuit breaker
@@ -176,12 +130,13 @@ func NewRefreshCoordinator(config RefreshCoordinatorConfig, logger *Logger) *Ref
} }
rc := &RefreshCoordinator{ rc := &RefreshCoordinator{
// inFlightRefreshes and sessionRefreshAttempts are both sync.Map; inFlightRefreshes: make(map[string]*refreshOperation),
// their zero values are ready to use. sessionRefreshAttempts: make(map[string]*refreshAttemptTracker),
config: config, config: config,
metrics: &RefreshMetrics{}, metrics: &RefreshMetrics{},
logger: logger, logger: logger,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
cleanupTimers: make(map[string]*time.Timer),
circuitBreaker: &RefreshCircuitBreaker{ circuitBreaker: &RefreshCircuitBreaker{
config: RefreshCircuitBreakerConfig{ config: RefreshCircuitBreakerConfig{
MaxFailures: 3, MaxFailures: 3,
@@ -272,28 +227,13 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
tokenHash string, tokenHash string,
refreshToken string, refreshToken string,
) (*refreshOperation, bool, error) { ) (*refreshOperation, bool, error) {
// Speculatively construct the operation we WOULD register if we win the rc.refreshMutex.Lock()
// race. Allocating here keeps the LoadOrStore call below atomic and defer rc.refreshMutex.Unlock()
// 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,
}
if existing, loaded := rc.inFlightRefreshes.LoadOrStore(tokenHash, candidate); loaded { // Check for existing operation while holding the lock
existingOp, ok := existing.(*refreshOperation) if existingOp, exists := rc.inFlightRefreshes[tokenHash]; exists {
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 { if existingOp.refreshToken == refreshToken {
// Join existing operation
atomic.AddInt32(&existingOp.waiterCount, 1) atomic.AddInt32(&existingOp.waiterCount, 1)
return existingOp, false, nil return existingOp, false, nil
} }
@@ -301,71 +241,41 @@ func (rc *RefreshCoordinator) getOrCreateOperation(
return nil, false, fmt.Errorf("refresh token mismatch") return nil, false, fmt.Errorf("refresh token mismatch")
} }
// We won the race and registered `candidate`. Apply gates now. If any // No existing operation - check if we can create a new one
// gate fails we must remove our entry from the map and signal failure // All checks happen while holding the lock to prevent races
// 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
}
// Reserve concurrent slot via ticket-and-return: increment optimistically, // Check and record refresh attempt for rate limiting
// decrement if we overshot the limit. The previous CAS-loop allowed a rc.recordRefreshAttempt(sessionID)
// transient overshoot of up to N-1 leaders when several goroutines all
// observed `current < max` in the same scheduling slice before any one
// of them succeeded their CAS — visible to readers as
// currentInFlightRefreshes > MaxConcurrentRefreshes for a brief window.
// The ticket pattern is strictly bounded: the counter momentarily reads
// max+k for k concurrent attempts past the limit, but only the k that
// produced max+1..max+k decrement back, and only k=1 ever observes max+1
// as committed.
newCount := atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, 1)
if int(newCount) > rc.config.MaxConcurrentRefreshes {
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
err := fmt.Errorf("maximum concurrent refresh operations reached")
rc.failCandidate(tokenHash, candidate, err)
return nil, false, err
}
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 {
// Cooldown check FIRST, BEFORE incrementing the attempt counter.
// Previously this function recorded the attempt and then read the
// cooldown state. Under burst load (many concurrent leaders with
// different token hashes but same session) every goroutine could
// increment past MaxRefreshAttempts before any one of them observed
// the threshold, so the cooldown gate fired too late — the same
// thundering-herd shape that drove v1.0.14 into the ground.
if rc.isInCooldown(sessionID) { if rc.isInCooldown(sessionID) {
atomic.AddInt64(&rc.metrics.cooldownsTriggered, 1) atomic.AddInt64(&rc.metrics.cooldownsTriggered, 1)
return fmt.Errorf("refresh attempts exceeded for session, in cooldown period") return nil, false, fmt.Errorf("refresh attempts exceeded for session, in cooldown period")
} }
// Check memory pressure
if rc.config.EnableMemoryPressureDetection && rc.isUnderMemoryPressure() { if rc.config.EnableMemoryPressureDetection && rc.isUnderMemoryPressure() {
atomic.AddInt64(&rc.metrics.memoryPressureEvents, 1) atomic.AddInt64(&rc.metrics.memoryPressureEvents, 1)
return fmt.Errorf("system under memory pressure, refresh denied") return nil, false, fmt.Errorf("system under memory pressure, refresh denied")
} }
// Only count attempts that actually progress past the gates.
rc.recordRefreshAttempt(sessionID)
return nil
}
// failCandidate removes the leader's just-registered operation from the // Check and reserve concurrent refresh slot atomically
// in-flight map and signals the error to any joiners by recording the result current := atomic.LoadInt32(&rc.metrics.currentInFlightRefreshes)
// and closing the done channel. This keeps the (nil, false, err) return path if int(current) >= rc.config.MaxConcurrentRefreshes {
// equivalent to the pre-sync.Map version: callers see the error directly, return nil, false, fmt.Errorf("maximum concurrent refresh operations reached")
// joiners see it via operation.done. }
func (rc *RefreshCoordinator) failCandidate(tokenHash string, op *refreshOperation, err error) {
rc.inFlightRefreshes.Delete(tokenHash) // Reserve the slot - we're still holding the lock so this is safe
op.mutex.Lock() atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, 1)
op.result = &refreshResult{err: err}
op.mutex.Unlock() // Create and register new operation
close(op.done) operation := &refreshOperation{
refreshToken: refreshToken,
done: make(chan struct{}),
startTime: time.Now(),
waiterCount: 1,
}
rc.inFlightRefreshes[tokenHash] = operation
return operation, true, nil
} }
// executeRefreshAsync performs the actual refresh operation asynchronously // executeRefreshAsync performs the actual refresh operation asynchronously
@@ -428,196 +338,130 @@ func (rc *RefreshCoordinator) executeRefreshAsync(
} }
} }
// scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning // scheduleDelayedCleanup schedules a cleanup using a timer instead of spawning a goroutine
// a goroutine — time.AfterFunc uses the runtime's timer heap and never spawns // This prevents goroutine explosion under high load (500+ req/sec)
// a per-timer goroutine until the callback actually fires.
//
// The previous implementation tracked every pending timer in a map guarded by
// cleanupTimerMu so a duplicate scheduling could cancel the prior timer. That
// "shouldn't happen" path was the only consumer of the map, but the mutex
// fired on every successful refresh completion — yet another per-request
// Yaegi-dispatched lock acquisition. performCleanup is already idempotent
// (LoadAndDelete on the sync.Map), so a duplicate scheduling at worst fires
// performCleanup twice; the second call is a no-op. Dropping the map removes
// the whole class of contention on this code path.
func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) { func (rc *RefreshCoordinator) scheduleDelayedCleanup(tokenHash string) {
delay := rc.config.DeduplicationCleanupDelay delay := rc.config.DeduplicationCleanupDelay
if delay <= 0 { if delay <= 0 {
// Immediate cleanup
rc.performCleanup(tokenHash) rc.performCleanup(tokenHash)
return return
} }
time.AfterFunc(delay, func() { rc.performCleanup(tokenHash) })
// Use time.AfterFunc which is more efficient than spawning a goroutine with Sleep
// time.AfterFunc uses the runtime's timer heap which is much more efficient
rc.cleanupTimerMu.Lock()
// Cancel any existing timer for this hash (shouldn't happen, but just in case)
if existingTimer, exists := rc.cleanupTimers[tokenHash]; exists {
existingTimer.Stop()
}
rc.cleanupTimers[tokenHash] = time.AfterFunc(delay, func() {
rc.performCleanup(tokenHash)
// Remove timer from map
rc.cleanupTimerMu.Lock()
delete(rc.cleanupTimers, tokenHash)
rc.cleanupTimerMu.Unlock()
})
rc.cleanupTimerMu.Unlock()
} }
// performCleanup removes the operation from the in-flight map. // performCleanup removes the operation from the in-flight map.
// Idempotent: only decrements the in-flight counter if an entry was actually // Idempotent: only decrements the in-flight counter if an entry was actually
// removed. LoadAndDelete is atomic so any concurrent failCandidate or repeat // removed. This guards against any future path accidentally calling cleanup
// cleanup call will see exactly one removal — the budget cannot be corrupted // twice for the same tokenHash (which would corrupt the refresh budget).
// by double-decrement.
func (rc *RefreshCoordinator) performCleanup(tokenHash string) { func (rc *RefreshCoordinator) performCleanup(tokenHash string) {
if _, existed := rc.inFlightRefreshes.LoadAndDelete(tokenHash); existed { rc.refreshMutex.Lock()
_, existed := rc.inFlightRefreshes[tokenHash]
if existed {
delete(rc.inFlightRefreshes, tokenHash)
}
rc.refreshMutex.Unlock()
if existed {
atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1) atomic.AddInt32(&rc.metrics.currentInFlightRefreshes, -1)
} }
} }
// getOrCreateTracker fetches the tracker for sessionID or atomically creates a // isInCooldown checks if a session is in cooldown after recording an attempt
// 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
}
func (rc *RefreshCoordinator) getOrCreateTracker(sessionID string) *refreshAttemptTracker {
if v, ok := rc.sessionRefreshAttempts.Load(sessionID); ok {
return trackerFromMapValue(v)
}
fresh := &refreshAttemptTracker{}
fresh.state.Store(&attemptState{windowStartNano: time.Now().UnixNano()})
actual, _ := rc.sessionRefreshAttempts.LoadOrStore(sessionID, fresh)
return trackerFromMapValue(actual)
}
// mutateState performs a CompareAndSwap loop that applies mutate to the
// current snapshot. mutate must be PURE: it receives an immutable view of
// the current state and returns a fresh *attemptState. If mutate returns nil
// the update is skipped (used by isInCooldown for "no change needed" paths).
//
// Retries on CAS conflict are bounded by the number of concurrent writers —
// in practice 1-3. Under Yaegi each retry pays the dispatch cost of one Load
// + one CompareAndSwap; still cheaper than the previous per-field atomic
// sequence and immune to the cross-field race the v1.0.15 design had.
func (t *refreshAttemptTracker) mutateState(mutate func(cur *attemptState) *attemptState) *attemptState {
for {
cur := t.stateOf()
next := mutate(cur)
if next == nil {
return cur
}
if t.state.CompareAndSwap(cur, next) {
return next
}
}
}
// isInCooldown checks if a session is in cooldown. Snapshot-based: every
// transition publishes a fresh *attemptState atomically so readers never see
// a partially-updated state. The previous per-field atomic design had a
// benign race in the cooldown-exit path (cooldownEndNano reset before
// attempts reset) that could double-trigger cooldown.
func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool { func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
v, ok := rc.sessionRefreshAttempts.Load(sessionID) rc.attemptsMutex.Lock()
if !ok { defer rc.attemptsMutex.Unlock()
tracker, exists := rc.sessionRefreshAttempts[sessionID]
if !exists {
return false // No tracker means first attempt, not in cooldown return false // No tracker means first attempt, not in cooldown
} }
tracker := trackerFromMapValue(v)
now := time.Now() now := time.Now()
nowNano := now.UnixNano()
maxAttempts := rc.config.MaxRefreshAttempts
window := rc.config.RefreshAttemptWindow
cooldownPeriod := rc.config.RefreshCooldownPeriod
cur := tracker.stateOf() // Check if already in cooldown
if tracker.inCooldown {
// Already in cooldown? if now.After(tracker.cooldownEndTime) {
if cur.cooldownEndNano != 0 { // Cooldown expired, reset tracker
if nowNano <= cur.cooldownEndNano { tracker.inCooldown = false
return true // still in cooldown tracker.attempts = 1 // Already recorded one attempt
tracker.consecutiveFailures = 0
tracker.windowStartTime = now
return false
} }
// Cooldown expired: atomically publish a fresh state with the window return true // Still in cooldown
// restarted from one attempt. Whichever goroutine wins the CAS sets }
// the new snapshot; losers see it via the next stateOf load.
tracker.mutateState(func(s *attemptState) *attemptState { // Check if window expired
if s.cooldownEndNano == 0 || nowNano <= s.cooldownEndNano { if now.Sub(tracker.windowStartTime) > rc.config.RefreshAttemptWindow {
return nil // someone else already reset, or back in cooldown // Reset window
} tracker.attempts = 1 // Already recorded one attempt
return &attemptState{ tracker.windowStartTime = now
windowStartNano: nowNano,
attempts: 1,
}
})
return false return false
} }
// Window expired? // Check if just exceeded attempt limit
if time.Duration(nowNano-cur.windowStartNano) > window { if int(tracker.attempts) >= rc.config.MaxRefreshAttempts {
tracker.mutateState(func(s *attemptState) *attemptState { // Enter cooldown now
if time.Duration(nowNano-s.windowStartNano) <= window { tracker.inCooldown = true
return nil tracker.cooldownEndTime = now.Add(rc.config.RefreshCooldownPeriod)
} rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
next := *s sessionID, tracker.attempts)
next.windowStartNano = nowNano
next.attempts = 1
return &next
})
return false
}
// Just exceeded attempt limit?
if int(cur.attempts) >= maxAttempts {
end := now.Add(cooldownPeriod).UnixNano()
published := tracker.mutateState(func(s *attemptState) *attemptState {
if s.cooldownEndNano != 0 {
return nil
}
next := *s
next.cooldownEndNano = end
return &next
})
if published.cooldownEndNano == end {
rc.logger.Infof("Session %s entering refresh cooldown after %d attempts",
sessionID, published.attempts)
}
return true return true
} }
return false return false
} }
// recordRefreshAttempt records a refresh attempt for rate limiting. Lock-free // recordRefreshAttempt records a refresh attempt for rate limiting
// snapshot mutation; attempts and lastAttemptNano are advanced atomically.
func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) { func (rc *RefreshCoordinator) recordRefreshAttempt(sessionID string) {
tracker := rc.getOrCreateTracker(sessionID) rc.attemptsMutex.Lock()
nowNano := time.Now().UnixNano() defer rc.attemptsMutex.Unlock()
tracker.mutateState(func(s *attemptState) *attemptState {
next := *s
next.attempts++
next.lastAttemptNano = nowNano
return &next
})
}
// recordRefreshSuccess records a successful refresh: zero consecutiveFailures. tracker, exists := rc.sessionRefreshAttempts[sessionID]
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) { if !exists {
v, ok := rc.sessionRefreshAttempts.Load(sessionID) tracker = &refreshAttemptTracker{
if !ok { windowStartTime: time.Now(),
return
}
trackerFromMapValue(v).mutateState(func(s *attemptState) *attemptState {
if s.consecutiveFailures == 0 {
return nil
} }
next := *s rc.sessionRefreshAttempts[sessionID] = tracker
next.consecutiveFailures = 0 }
return &next
}) atomic.AddInt32(&tracker.attempts, 1)
tracker.lastAttemptTime = time.Now()
} }
// recordRefreshFailure records a failed refresh: increments consecutiveFailures. // recordRefreshSuccess records a successful refresh
func (rc *RefreshCoordinator) recordRefreshSuccess(sessionID string) {
rc.attemptsMutex.Lock()
defer rc.attemptsMutex.Unlock()
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
tracker.consecutiveFailures = 0
}
}
// recordRefreshFailure records a failed refresh
func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) { func (rc *RefreshCoordinator) recordRefreshFailure(sessionID string) {
v, ok := rc.sessionRefreshAttempts.Load(sessionID) rc.attemptsMutex.Lock()
if !ok { defer rc.attemptsMutex.Unlock()
return
if tracker, exists := rc.sessionRefreshAttempts[sessionID]; exists {
atomic.AddInt32(&tracker.consecutiveFailures, 1)
} }
trackerFromMapValue(v).mutateState(func(s *attemptState) *attemptState {
next := *s
next.consecutiveFailures++
return &next
})
} }
// hashRefreshToken creates a hash of the refresh token for deduplication // hashRefreshToken creates a hash of the refresh token for deduplication
@@ -668,22 +512,20 @@ func (rc *RefreshCoordinator) cleanupRoutine() {
} }
} }
// cleanupStaleEntries removes outdated tracking entries. Lock-free iteration // cleanupStaleEntries removes outdated tracking entries
// via sync.Map.Range; safe to race with concurrent reads/writes.
func (rc *RefreshCoordinator) cleanupStaleEntries() { func (rc *RefreshCoordinator) cleanupStaleEntries() {
cutoff := time.Now().Add(-2 * rc.config.RefreshAttemptWindow).UnixNano() now := time.Now()
rc.sessionRefreshAttempts.Range(func(key, value interface{}) bool {
tracker := trackerFromMapValue(value) rc.attemptsMutex.Lock()
if tracker == nil { defer rc.attemptsMutex.Unlock()
return true
// 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)
} }
if tracker.stateOf().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 // GetMetrics returns current coordinator metrics
@@ -701,60 +543,78 @@ func (rc *RefreshCoordinator) GetMetrics() map[string]interface{} {
} }
} }
// Shutdown gracefully shuts down the coordinator. Pending delayed-cleanup // Shutdown gracefully shuts down the coordinator
// timers are NOT canceled explicitly: time.AfterFunc callbacks are tiny
// (one map LoadAndDelete) and harmless after Shutdown — sync.Map operations
// remain safe on an unused coordinator until GC.
func (rc *RefreshCoordinator) Shutdown() { func (rc *RefreshCoordinator) Shutdown() {
close(rc.stopChan) close(rc.stopChan)
// Cancel all pending cleanup timers
rc.cleanupTimerMu.Lock()
for _, timer := range rc.cleanupTimers {
timer.Stop()
}
rc.cleanupTimers = make(map[string]*time.Timer)
rc.cleanupTimerMu.Unlock()
rc.wg.Wait() rc.wg.Wait()
} }
// AllowRequest reports whether the circuit breaker allows a request. Lock-free. // AllowRequest checks if the circuit breaker allows a request
func (cb *RefreshCircuitBreaker) AllowRequest() bool { func (cb *RefreshCircuitBreaker) AllowRequest() bool {
switch atomic.LoadInt32(&cb.state) { cb.mutex.RLock()
case 0: // closed defer cb.mutex.RUnlock()
state := atomic.LoadInt32(&cb.state)
switch state {
case 0: // Closed
return true return true
case 1: // open case 1: // Open
lastFail := atomic.LoadInt64(&cb.lastFailureNano) if time.Since(cb.lastFailureTime) > cb.config.OpenDuration {
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration { // Try to transition to half-open
// Transition to half-open; first CAS winner gets the probe.
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) { if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
return true return true
} }
} }
return false return false
case 2: // half-open case 2: // Half-open
return true return true
default: default:
return false return false
} }
} }
// RecordSuccess records a successful operation. Lock-free. // RecordSuccess records a successful operation
func (cb *RefreshCircuitBreaker) RecordSuccess() { func (cb *RefreshCircuitBreaker) RecordSuccess() {
switch atomic.LoadInt32(&cb.state) { cb.mutex.Lock()
case 2: // half-open -> close defer cb.mutex.Unlock()
state := atomic.LoadInt32(&cb.state)
if state == 2 { // Half-open
// Close the circuit
atomic.StoreInt32(&cb.state, 0) atomic.StoreInt32(&cb.state, 0)
atomic.StoreInt32(&cb.failures, 0) atomic.StoreInt32(&cb.failures, 0)
case 0: // closed } else if state == 0 { // Closed
// Reset failure count on success
atomic.StoreInt32(&cb.failures, 0) atomic.StoreInt32(&cb.failures, 0)
} }
atomic.StoreInt64(&cb.lastSuccessNano, time.Now().UnixNano()) cb.lastSuccessTime = time.Now()
} }
// RecordFailure records a failed operation. Lock-free. // RecordFailure records a failed operation
func (cb *RefreshCircuitBreaker) RecordFailure() { func (cb *RefreshCircuitBreaker) RecordFailure() {
failures := atomic.AddInt32(&cb.failures, 1) cb.mutex.Lock()
atomic.StoreInt64(&cb.lastFailureNano, time.Now().UnixNano()) defer cb.mutex.Unlock()
switch atomic.LoadInt32(&cb.state) { failures := atomic.AddInt32(&cb.failures, 1)
case 0: cb.lastFailureTime = time.Now()
if int(failures) >= cb.config.MaxFailures {
atomic.StoreInt32(&cb.state, 1) state := atomic.LoadInt32(&cb.state)
}
case 2: if state == 0 && int(failures) >= cb.config.MaxFailures {
// Half-open probe failed -> back to open. // Open the circuit
atomic.StoreInt32(&cb.state, 1)
} else if state == 2 {
// Half-open failed, return to open
atomic.StoreInt32(&cb.state, 1) atomic.StoreInt32(&cb.state, 1)
} }
} }
+34 -28
View File
@@ -165,14 +165,9 @@ func TestRefreshRateLimiting(t *testing.T) {
time.Sleep(150 * time.Millisecond) time.Sleep(150 * time.Millisecond)
} }
// Verify that cooldown was triggered after max attempts. // Verify that cooldown was triggered after max attempts
// With applyLeaderGates checking cooldown BEFORE recording the attempt // With the new logic, the Nth attempt triggers cooldown, so we get N-1 successful attempts
// (the v1.0.16 reorder fixing the thundering-herd off-by-one), N attempts expectedSuccessfulAttempts := config.MaxRefreshAttempts - 1
// run to completion and the (N+1)th is denied. Previously the Nth was
// denied as it tried to record, which under burst load let multiple
// concurrent leaders increment past the limit before any one of them
// observed the gate.
expectedSuccessfulAttempts := config.MaxRefreshAttempts
if attempts != expectedSuccessfulAttempts { if attempts != expectedSuccessfulAttempts {
t.Errorf("Expected %d successful attempts before cooldown, got %d", expectedSuccessfulAttempts, attempts) t.Errorf("Expected %d successful attempts before cooldown, got %d", expectedSuccessfulAttempts, attempts)
} }
@@ -370,12 +365,10 @@ func TestMemoryLeakPrevention(t *testing.T) {
} }
} }
// Verify cleanup is working. sync.Map has no Len(); count via Range. // Verify cleanup is working
sessionCount := 0 coordinator.attemptsMutex.RLock()
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool { sessionCount := len(coordinator.sessionRefreshAttempts)
sessionCount++ coordinator.attemptsMutex.RUnlock()
return true
})
// Should have cleaned up old sessions (only recent ones remain) // Should have cleaned up old sessions (only recent ones remain)
if sessionCount > numWorkers*2 { if sessionCount > numWorkers*2 {
@@ -657,23 +650,24 @@ func TestCleanupRoutine(t *testing.T) {
coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i)) coordinator.recordRefreshAttempt(fmt.Sprintf("session_%d", i))
} }
countSessions := func() int { // Verify sessions exist
n := 0 coordinator.attemptsMutex.RLock()
coordinator.sessionRefreshAttempts.Range(func(_, _ interface{}) bool { initialCount := len(coordinator.sessionRefreshAttempts)
n++ coordinator.attemptsMutex.RUnlock()
return true
})
return n
}
if initialCount := countSessions(); initialCount != 5 { if initialCount != 5 {
t.Errorf("Expected 5 sessions, got %d", initialCount) t.Errorf("Expected 5 sessions, got %d", initialCount)
} }
// Wait for cleanup to run (2x window + cleanup interval) // Wait for cleanup to run (2x window + cleanup interval)
time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval) time.Sleep(2*config.RefreshAttemptWindow + 2*config.CleanupInterval)
if finalCount := countSessions(); finalCount != 0 { // Verify sessions were cleaned up
coordinator.attemptsMutex.RLock()
finalCount := len(coordinator.sessionRefreshAttempts)
coordinator.attemptsMutex.RUnlock()
if finalCount != 0 {
t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount) t.Errorf("Expected 0 sessions after cleanup, got %d", finalCount)
} }
} }
@@ -726,9 +720,11 @@ func TestNoGoroutineExplosionWithTimers(t *testing.T) {
currentGoroutines := runtime.NumGoroutine() currentGoroutines := runtime.NumGoroutine()
t.Logf("Goroutines after %d refresh operations: %d", numRefreshes, currentGoroutines) t.Logf("Goroutines after %d refresh operations: %d", numRefreshes, currentGoroutines)
// (Coordinator no longer tracks pending timers; time.AfterFunc closures // Check timer count
// fire performCleanup directly. This test now only checks the goroutine coordinator.cleanupTimerMu.Lock()
// budget, which was always the real invariant.) timerCount := len(coordinator.cleanupTimers)
coordinator.cleanupTimerMu.Unlock()
t.Logf("Active cleanup timers: %d", timerCount)
// With timer-based cleanup, goroutine increase should be minimal // With timer-based cleanup, goroutine increase should be minimal
// Timers don't create goroutines - they use the runtime timer heap // Timers don't create goroutines - they use the runtime timer heap
@@ -744,9 +740,19 @@ func TestNoGoroutineExplosionWithTimers(t *testing.T) {
initialGoroutines, currentGoroutines, goroutineIncrease) initialGoroutines, currentGoroutines, goroutineIncrease)
} }
// Wait for timers to fire and cleanup. // Wait for timers to fire and cleanup
time.Sleep(config.DeduplicationCleanupDelay + 50*time.Millisecond) time.Sleep(config.DeduplicationCleanupDelay + 50*time.Millisecond)
// Verify timers were cleaned up
coordinator.cleanupTimerMu.Lock()
remainingTimers := len(coordinator.cleanupTimers)
coordinator.cleanupTimerMu.Unlock()
// Most timers should have fired and been removed
if remainingTimers > 10 {
t.Errorf("Too many cleanup timers remaining: %d", remainingTimers)
}
// Verify goroutines returned to near initial // Verify goroutines returned to near initial
runtime.GC() runtime.GC()
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
-71
View File
@@ -1,71 +0,0 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik.
// requestState bundles read-mostly fields for a single ServeHTTP call.
package traefikoidc
import "net/http"
// requestState is a per-request context object allocated at the top of
// ServeHTTP and threaded through to downstream handlers. It caches values
// that would otherwise require a Yaegi-dispatched lock acquisition each time
// they're read:
//
// - The metadata snapshot (atomic.Value.Load once, not per-handler).
// - SessionData getter results (one RLock on sd.sessionMutex covers all
// fields, instead of 5-7 separate RLock/RUnlock pairs scattered through
// the handler chain).
//
// The struct is alloc'd at request entry, populated under at most one RLock
// of sd.sessionMutex, and discarded at request exit. It is NOT shared across
// requests and never written from another goroutine, so no synchronization
// on its fields is required.
//
// Cross-request global caches (tokenCache, JWKCache, sessionEntries,
// sessionInvalidationCache) remain — they're orthogonal. requestState's job
// is to eliminate redundant per-handler reads of values that don't change
// within a single request.
type requestState struct {
// Globals snapshotted once.
metadata *MetadataSnapshot
// SessionData fields snapshotted under one RLock. The pointer to the
// SessionData is retained so handlers that genuinely need to mutate
// (Save, Clear, etc.) still have access.
session *SessionData
authenticated bool
accessToken string
idToken string
refreshToken string
userIdentifier string
createdAtUnixSec int64
// Output: scheme/host/redirect path determined at top of ServeHTTP.
scheme string
host string
redirectURL string
// Carry the next handler so forwardAuthorized doesn't need to close over t.
next http.Handler
}
// captureSession populates requestState's SessionData-derived fields under a
// single RLock of sd.sessionMutex. Returns the populated rs for chaining.
//
// Replaces a sequence of SessionData.GetX() calls each of which acquires
// sd.sessionMutex.RLock(). Under Yaegi each RLock costs ~1-5ms of
// interpreter dispatch; batching saves the rest.
func (rs *requestState) captureSession(sd *SessionData) *requestState {
if sd == nil {
return rs
}
rs.session = sd
sd.sessionMutex.RLock()
rs.authenticated = sd.getAuthenticatedUnsafe()
rs.accessToken = sd.getAccessTokenUnsafe()
rs.idToken = sd.getIDTokenUnsafe()
rs.refreshToken = sd.getRefreshTokenUnsafe()
rs.userIdentifier = sd.getUserIdentifierUnsafe()
rs.createdAtUnixSec = sd.getCreatedAtUnsafe()
sd.sessionMutex.RUnlock()
return rs
}
+7 -1
View File
@@ -54,7 +54,6 @@ type Config struct {
AllowedUserDomains []string `json:"allowedUserDomains"` AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"` AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"` Headers []TemplatedHeader `json:"headers"`
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of // MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
// a stored refresh token. Once the token has been in the session longer // a stored refresh token. Once the token has been in the session longer
@@ -127,6 +126,13 @@ type Config struct {
// Supported: RS256/384/512, PS256/384/512, ES256/384/512. // Supported: RS256/384/512, PS256/384/512, ES256/384/512.
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"` 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) --- // --- Bearer-token auth (opt-in M2M path) ---
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path. // EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
+433
View File
@@ -5,6 +5,8 @@ package traefikoidc
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -858,6 +860,437 @@ func (t *TraefikOidc) isAzureProvider() bool {
strings.Contains(issuerURL, "login.windows.net") strings.Contains(issuerURL, "login.windows.net")
} }
// validateAzureTokens validates tokens with Azure AD-specific logic.
// Azure tokens may be opaque access tokens that cannot be verified as JWTs,
// so this method handles both JWT and opaque token scenarios.
// Parameters:
// - session: The session data containing tokens to validate.
//
// Returns:
// - authenticated: Whether the user has valid authentication.
// - needsRefresh: Whether tokens need to be refreshed.
// - expired: Whether tokens have expired and cannot be refreshed.
//
//nolint:gocognit // Azure-specific validation requires multiple token type checks
func (t *TraefikOidc) validateAzureTokens(session *SessionData) (bool, bool, bool) {
if !session.GetAuthenticated() {
t.logger.Debug("Azure user is not authenticated according to session flag")
if session.GetRefreshToken() != "" {
t.logger.Debug("Azure session not authenticated, but refresh token exists. Signaling need for refresh.")
return false, true, false
}
return false, true, false
}
accessToken := session.GetAccessToken()
idToken := session.GetIDToken()
if accessToken != "" {
if strings.Count(accessToken, ".") == 2 {
// Microsoft documents that client apps cannot validate access
// tokens issued for Microsoft-owned APIs (Graph, Azure Mgmt) due
// to their proprietary signing format (nonce in JWT header is
// the marker — signed bytes hash the nonce, wire bytes ship the
// raw value, so rsa verification always fails). Treat such
// tokens as opaque, matching Microsoft's guidance and avoiding
// per-request signature-error log spam (issue #134 followup).
//
// https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
// "you can't validate tokens for Microsoft Graph according to
// these rules due to their proprietary format"
if t.isUnverifiableAzureAccessToken(accessToken) {
t.logger.Debug("Azure access token is Microsoft-proprietary (Graph/Mgmt) — treating as opaque per Microsoft guidance")
if idToken != "" {
if err := t.verifyToken(idToken); err != nil {
t.logger.Debugf("Azure: ID token validation failed while access token was opaque: %v", err)
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiry(session, idToken)
}
return true, false, false
}
if err := t.verifyToken(accessToken); err != nil {
if idToken != "" {
if err := t.verifyToken(idToken); err != nil {
t.logger.Debugf("Azure: Both access and ID token validation failed: %v", err)
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiry(session, idToken)
}
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiry(session, accessToken)
}
t.logger.Debug("Azure access token appears opaque, treating as valid")
if idToken != "" {
return t.validateTokenExpiry(session, idToken)
}
return true, false, false
}
if idToken != "" {
if err := t.verifyToken(idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiry(session, idToken)
}
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
// validateGoogleTokens handles Google-specific token validation logic.
// Currently delegates to standard token validation but provides a hook
// for Google-specific validation requirements in the future.
// Parameters:
// - session: The session data containing tokens to validate.
//
// Returns:
// - authenticated: Whether the user has valid authentication.
// - needsRefresh: Whether tokens need to be refreshed.
// - expired: Whether tokens have expired and cannot be refreshed.
func (t *TraefikOidc) validateGoogleTokens(session *SessionData) (bool, bool, bool) {
return t.validateStandardTokens(session)
}
// validateStandardTokens handles standard OIDC token validation logic.
// This is the default validation method for generic OIDC providers.
// It verifies ID tokens and handles access tokens appropriately.
// Parameters:
// - session: The session data containing tokens to validate.
//
// Returns:
// - authenticated: Whether the user has valid authentication.
// - needsRefresh: Whether tokens need to be refreshed.
// - expired: Whether tokens have expired and cannot be refreshed.
//
//nolint:gocognit,gocyclo // Complex validation logic handles multiple token scenarios and edge cases
func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool, bool) {
authenticated := session.GetAuthenticated()
// Removed debug output
if !authenticated {
t.logger.Debug("User is not authenticated according to session flag")
if session.GetRefreshToken() != "" {
t.logger.Debug("Session not authenticated, but refresh token exists. Signaling need for refresh.")
return false, true, false
}
return false, false, false
}
accessToken := session.GetAccessToken()
// Removed debug output
if accessToken == "" {
t.logger.Debug("Authenticated flag set, but no access token found in session")
if session.GetRefreshToken() != "" {
// Check if we have an ID token to determine if we're beyond grace period
// When access token is missing, check ID token expiry to determine if refresh is viable
idToken := session.GetIDToken()
t.logger.Debugf("Checking ID token for grace period: ID token present: %v", idToken != "")
if idToken != "" {
// Try to parse the ID token to check its expiry
parts := strings.Split(idToken, ".")
if len(parts) == 3 {
// Decode the claims part
claimsData, err := base64.RawURLEncoding.DecodeString(parts[1])
if err == nil {
var claims map[string]interface{}
if err := json.Unmarshal(claimsData, &claims); err == nil {
if expClaim, ok := claims["exp"].(float64); ok {
expTime := time.Unix(int64(expClaim), 0)
if time.Now().After(expTime) {
expiredDuration := time.Since(expTime)
if expiredDuration > t.refreshGracePeriod {
t.logger.Debugf("ID token expired beyond grace period (%v > %v), must re-authenticate",
expiredDuration, t.refreshGracePeriod)
return false, false, true // expired, cannot refresh
}
t.logger.Debugf("ID token expired %v ago, within grace period %v, allowing refresh",
expiredDuration, t.refreshGracePeriod)
}
}
}
}
}
}
t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.")
return false, true, false
}
return false, false, true
}
// Check if access token is opaque (doesn't have JWT structure)
dotCount := strings.Count(accessToken, ".")
isOpaqueToken := dotCount != 2
// For opaque access tokens, use introspection if available (RFC 7662 - Option C: Scenario 3)
if isOpaqueToken {
t.logger.Debugf("Access token appears to be opaque (dots: %d)", dotCount)
// Try introspection first if opaque tokens are allowed
if t.allowOpaqueTokens {
if err := t.validateOpaqueToken(accessToken); err != nil {
errMsg := err.Error()
t.logger.Infof("⚠️ Opaque access token validation via introspection failed: %v", err)
// Check if the token was explicitly marked as inactive/revoked/expired by the provider
// In these cases, we should NOT fall back to ID token - the provider has explicitly
// told us this token is no longer valid. We must refresh or re-authenticate.
isTokenInvalid := strings.Contains(errMsg, "token is not active") ||
strings.Contains(errMsg, "revoked") ||
strings.Contains(errMsg, "token has expired")
if isTokenInvalid {
t.logger.Infof("⚠️ Token explicitly marked as invalid by provider, cannot fall back to ID token")
if session.GetRefreshToken() != "" {
t.logger.Debug("Refresh token available, attempting refresh")
return false, true, false
}
t.logger.Debug("No refresh token available, must re-authenticate")
return false, false, true
}
// If introspection required, reject the session
if t.requireTokenIntrospection {
t.logger.Errorf("❌ SECURITY: Opaque token rejected (introspection required but failed)")
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
// Only fall back to ID token validation for transient errors (network issues, etc.)
// where the introspection endpoint couldn't be reached
t.logger.Infof("⚠️ Falling back to ID token validation for opaque access token (transient error)")
} else {
// Introspection successful
t.logger.Debugf("✓ Opaque access token validated via introspection")
// Still need to check ID token for session expiry
idToken := session.GetIDToken()
if idToken != "" {
return t.validateTokenExpiry(session, idToken)
}
return true, false, false
}
} else {
// Opaque tokens not allowed - log warning and reject or fall back
t.logger.Infof("⚠️ Opaque access token detected but allowOpaqueTokens=false")
}
// Fall back to ID token validation
idToken := session.GetIDToken()
if idToken == "" {
t.logger.Debug("Opaque access token present but no ID token found")
if session.GetRefreshToken() != "" {
t.logger.Debug("ID token missing but refresh token exists. Signaling need for refresh.")
return false, true, false
}
// Accept session with opaque access token even without ID token
// The OAuth provider validated it when issued
t.logger.Debug("Accepting session with opaque access token")
return true, false, false
}
// Validate ID token if present
if err := t.verifyToken(idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
t.logger.Debugf("ID token expired with opaque access token, needs refresh")
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
t.logger.Errorf("ID token verification failed with opaque access token: %v", err)
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
// Use ID token for expiry validation
return t.validateTokenExpiry(session, idToken)
}
// JWT access token present - validate it explicitly to detect Scenario 2
// (Option C: Scenario 2 detection and strict mode)
accessTokenValid := false
accessTokenError := ""
if err := t.verifyToken(accessToken); err != nil {
// Access token validation failed
accessTokenError = err.Error()
// Check if it's an audience validation failure (Scenario 2)
if strings.Contains(accessTokenError, "invalid audience") || strings.Contains(accessTokenError, "audience") {
// SCENARIO 2 DETECTED: Access token has wrong audience
t.logger.Infof("⚠️ SCENARIO 2 DETECTED: Access token validation failed due to audience mismatch: %v", err)
if t.strictAudienceValidation {
// Strict mode: Reject the session (don't fall back to ID token)
t.logger.Errorf("❌ SECURITY: Session rejected due to access token audience mismatch (strictAudienceValidation=true)")
t.logger.Errorf("❌ This prevents potential cross-API token confusion attacks (Auth0 Scenario 2)")
if session.GetRefreshToken() != "" {
return false, true, false // try refresh
}
return false, false, true // must re-authenticate
}
// Backward compatibility mode: Log loud warning but allow fallback to ID token
t.logger.Infof("⚠️⚠️⚠️ SECURITY WARNING: Falling back to ID token validation despite access token audience mismatch!")
t.logger.Infof("⚠️ This could allow tokens intended for different APIs to grant access")
t.logger.Infof("⚠️ Set strictAudienceValidation=true to enforce proper audience validation")
t.logger.Infof("⚠️ See: https://github.com/lukaszraczylo/traefikoidc/issues/74")
} else if !strings.Contains(accessTokenError, "token has expired") {
// Other validation errors (not expiration, not audience)
t.logger.Debugf("Access token validation failed (non-expiration, non-audience): %v", err)
}
} else {
// Access token is valid
accessTokenValid = true
}
idToken := session.GetIDToken()
if idToken == "" {
if accessTokenValid {
// Access token is valid, no ID token needed
t.logger.Debug("Access token valid, no ID token present")
return t.validateTokenExpiry(session, accessToken)
}
t.logger.Debug("Authenticated flag set with access token, but no ID token found in session")
if session.GetRefreshToken() != "" {
t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.")
return true, true, false
}
return true, false, false
}
// Validate ID token
if err := t.verifyToken(idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh")
if session.GetRefreshToken() != "" {
return false, true, false
}
return false, false, true
}
t.logger.Errorf("ID token verification failed (non-expiration): %v", err)
if session.GetRefreshToken() != "" {
t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.")
return false, true, false
}
return false, false, true
}
// If access token was valid, use it for expiry; otherwise use ID token
if accessTokenValid {
return t.validateTokenExpiry(session, accessToken)
}
return t.validateTokenExpiry(session, idToken)
}
// validateTokenExpiry checks if a token is nearing expiration and needs refresh.
// It uses the configured grace period to determine when proactive refresh should occur.
// Parameters:
// - session: The session data for refresh token availability.
// - token: The token to check expiry for.
//
// Returns:
// - authenticated: Whether the token is currently valid.
// - needsRefresh: Whether the token is nearing expiration and should be refreshed.
// - expired: Whether the token is invalid or verification failed.
func (t *TraefikOidc) validateTokenExpiry(session *SessionData, token string) (bool, bool, bool) {
cachedClaims, found := t.tokenCache.Get(token)
if !found {
t.logger.Debug("Claims not found in cache after successful token verification")
if session.GetRefreshToken() != "" {
t.logger.Debug("Claims missing post-verification, attempting refresh to recover.")
return false, true, false
}
return false, false, true
}
expClaim, ok := cachedClaims["exp"].(float64)
if !ok {
t.logger.Error("Failed to get expiration time ('exp' claim) from verified token")
if session.GetRefreshToken() != "" {
t.logger.Debug("Token missing 'exp' claim, but refresh token exists. Signaling need for refresh.")
return false, true, false
}
return false, false, true
}
expTime := int64(expClaim)
expTimeObj := time.Unix(expTime, 0)
nowObj := time.Now()
// Check if token has already expired
if expTimeObj.Before(nowObj) {
// Token has expired
expiredDuration := nowObj.Sub(expTimeObj)
t.logger.Debugf("Token expired %v ago, grace period is %v",
expiredDuration, t.refreshGracePeriod)
// If we have a refresh token, always attempt to use it regardless of grace period
// The refresh token has its own expiry and the provider will reject it if invalid
if session.GetRefreshToken() != "" {
t.logger.Debugf("Token expired, attempting refresh with available refresh token")
return false, true, false // needs refresh
}
// No refresh token available - must re-authenticate
t.logger.Debugf("Token expired and no refresh token available, must re-authenticate")
return false, false, true // expired, cannot refresh
}
// Token not yet expired - check if nearing expiration
refreshThreshold := nowObj.Add(t.refreshGracePeriod)
t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v",
expTimeObj.Format(time.RFC3339),
nowObj.Format(time.RFC3339),
refreshThreshold.Format(time.RFC3339))
if expTimeObj.Before(refreshThreshold) {
remainingSeconds := int64(time.Until(expTimeObj).Seconds())
t.logger.Debugf("Token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh",
remainingSeconds, t.refreshGracePeriod)
if session.GetRefreshToken() != "" {
return true, true, false
}
t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.")
return true, false, false
}
t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)",
int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod)
return true, false, false
}
// startTokenCleanup starts background cleanup goroutines for cache maintenance. // startTokenCleanup starts background cleanup goroutines for cache maintenance.
// It runs periodic cleanup of token cache, JWK cache, and session chunks. // It runs periodic cleanup of token cache, JWK cache, and session chunks.
-286
View File
@@ -1,286 +0,0 @@
// Package traefikoidc provides OIDC authentication middleware for Traefik.
// This file contains requestState-aware variants of the token validation
// functions. They read session field values from the captured snapshot in
// *requestState instead of calling session.GetX(), eliminating ~21 RLock
// acquisitions on sd.sessionMutex per request through the validation path
// (validateStandardTokens reads 17, validateAzureTokens reads 10,
// validateTokenExpiry reads 4 — and many are the SAME field). Under Yaegi
// each RLock costs ~1-5ms of interpreter dispatch.
//
// The non-RS variants are retained for paths that don't have a captured
// snapshot (tests that drive the validators directly, the Azure/Google path
// when reached without rs threading, etc).
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"strings"
"time"
)
// isUserAuthenticatedRS is the requestState-aware variant of
// isUserAuthenticated. Dispatches to the right per-provider validator based
// on the configured provider, all of which read from rs instead of session.
func (t *TraefikOidc) isUserAuthenticatedRS(rs *requestState) (bool, bool, bool) {
if t.isAzureProvider() {
return t.validateAzureTokensRS(rs)
} else if t.isGoogleProvider() {
return t.validateGoogleTokensRS(rs)
}
return t.validateStandardTokensRS(rs)
}
// validateGoogleTokensRS handles Google-specific token validation. Currently
// delegates to standard token validation; retained as a hook for any future
// Google-specific behavior (matches the v1.0.20 layout of the non-RS variant).
func (t *TraefikOidc) validateGoogleTokensRS(rs *requestState) (bool, bool, bool) {
return t.validateStandardTokensRS(rs)
}
// validateTokenExpiryRS is the requestState-aware variant of validateTokenExpiry.
// Reads rs.refreshToken instead of session.GetRefreshToken() (4 RLocks avoided).
func (t *TraefikOidc) validateTokenExpiryRS(rs *requestState, token string) (bool, bool, bool) {
cachedClaims, found := t.tokenCache.Get(token)
if !found {
t.logger.Debug("Claims not found in cache after successful token verification")
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
expClaim, ok := cachedClaims["exp"].(float64)
if !ok {
t.logger.Error("Failed to get expiration time ('exp' claim) from verified token")
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
expTimeObj := time.Unix(int64(expClaim), 0)
nowObj := time.Now()
if expTimeObj.Before(nowObj) {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
refreshThreshold := nowObj.Add(t.refreshGracePeriod)
if expTimeObj.Before(refreshThreshold) {
if rs.refreshToken != "" {
return true, true, false
}
return true, false, false
}
return true, false, false
}
// validateStandardTokensRS is the requestState-aware variant of
// validateStandardTokens. Replaces all session.GetX() calls (17 of them in
// the non-RS variant, dominated by GetRefreshToken called 11 times) with
// rs field reads. Same control flow.
//
//nolint:gocognit,gocyclo // Mirrors validateStandardTokens complexity by design.
func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bool) {
if !rs.authenticated {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, false
}
if rs.accessToken == "" {
if rs.refreshToken != "" {
// ID-token grace-period check (only when accessToken is absent).
if rs.idToken != "" {
parts := strings.Split(rs.idToken, ".")
if len(parts) == 3 {
if claimsData, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
var claims map[string]interface{}
if err := json.Unmarshal(claimsData, &claims); err == nil {
if expClaim, ok := claims["exp"].(float64); ok {
expTime := time.Unix(int64(expClaim), 0)
if time.Now().After(expTime) {
expiredDuration := time.Since(expTime)
if expiredDuration > t.refreshGracePeriod {
return false, false, true
}
}
}
}
}
}
}
return false, true, false
}
return false, false, true
}
dotCount := strings.Count(rs.accessToken, ".")
isOpaqueToken := dotCount != 2
if isOpaqueToken {
if t.allowOpaqueTokens {
if err := t.validateOpaqueToken(rs.accessToken); err != nil {
errMsg := err.Error()
isTokenInvalid := strings.Contains(errMsg, "token is not active") ||
strings.Contains(errMsg, "revoked") ||
strings.Contains(errMsg, "token has expired")
if isTokenInvalid {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
if t.requireTokenIntrospection {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
// Transient introspection error: fall through to ID-token validation.
} else {
// Introspection succeeded.
if rs.idToken != "" {
return t.validateTokenExpiryRS(rs, rs.idToken)
}
return true, false, false
}
}
// Fall back to ID-token validation when opaque + no successful introspection.
if rs.idToken == "" {
if rs.refreshToken != "" {
return false, true, false
}
return true, false, false
}
if err := t.verifyToken(rs.idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiryRS(rs, rs.idToken)
}
// JWT access token present.
accessTokenValid := false
if err := t.verifyToken(rs.accessToken); err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
if t.strictAudienceValidation {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
// Fall through to ID-token validation.
}
} else {
accessTokenValid = true
}
if rs.idToken == "" {
if accessTokenValid {
return t.validateTokenExpiryRS(rs, rs.accessToken)
}
if rs.refreshToken != "" {
return true, true, false
}
return true, false, false
}
if err := t.verifyToken(rs.idToken); err != nil {
if strings.Contains(err.Error(), "token has expired") {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
if accessTokenValid {
return t.validateTokenExpiryRS(rs, rs.accessToken)
}
return t.validateTokenExpiryRS(rs, rs.idToken)
}
// validateAzureTokensRS is the requestState-aware variant of validateAzureTokens.
// Eliminates 10 session.GetX() RLocks per Azure-path request.
func (t *TraefikOidc) validateAzureTokensRS(rs *requestState) (bool, bool, bool) {
if !rs.authenticated {
if rs.refreshToken != "" {
return false, true, false
}
return false, true, false
}
if rs.accessToken != "" {
if strings.Count(rs.accessToken, ".") == 2 {
if t.isUnverifiableAzureAccessToken(rs.accessToken) {
if rs.idToken != "" {
if err := t.verifyToken(rs.idToken); err != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiryRS(rs, rs.idToken)
}
return true, false, false
}
if err := t.verifyToken(rs.accessToken); err != nil {
if rs.idToken != "" {
if err := t.verifyToken(rs.idToken); err != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiryRS(rs, rs.idToken)
}
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiryRS(rs, rs.accessToken)
}
// Opaque access token.
if rs.idToken != "" {
return t.validateTokenExpiryRS(rs, rs.idToken)
}
return true, false, false
}
if rs.idToken != "" {
if err := t.verifyToken(rs.idToken); err != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
return t.validateTokenExpiryRS(rs, rs.idToken)
}
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
+6 -41
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"net/http" "net/http"
"sync" "sync"
"sync/atomic"
"text/template" "text/template"
"time" "time"
@@ -65,46 +64,8 @@ type ProviderMetadata struct {
// It integrates with various OIDC providers, manages sessions, caches tokens, and handles // It integrates with various OIDC providers, manages sessions, caches tokens, and handles
// the complete authentication flow. It's designed to work seamlessly with Traefik's // the complete authentication flow. It's designed to work seamlessly with Traefik's
// plugin system and provides flexible configuration options. // plugin system and provides flexible configuration options.
// MetadataSnapshot is an immutable bundle of provider-metadata URLs that the
// plugin needs on the hot request path. Published atomically via
// TraefikOidc.metadataSnapshot; readers do exactly one atomic.Value.Load to
// access all fields. Replaces 3 per-request metadataMu.RLock acquisitions
// in middleware.ServeHTTP + token_manager paths, each of which paid
// 1-5ms of Yaegi-dispatch overhead.
//
// The fields are a strict subset of the metadataMu-guarded TraefikOidc
// fields; the legacy fields are still written under metadataMu for
// less-frequent code paths that have not been migrated.
type MetadataSnapshot struct {
IssuerURL string
JWKSURL string
TokenURL string
AuthURL string
RevocationURL string
EndSessionURL string
IntrospectionURL string
RegistrationURL string
}
type TraefikOidc struct { type TraefikOidc struct {
// metadataSnapshot atomically publishes the read-mostly URL bundle. lastMetadataRetryTime time.Time
// Hot-path readers (middleware.ServeHTTP, token verification) load it
// directly; less-frequent paths still acquire metadataMu.RLock and
// read the individual fields below.
metadataSnapshot atomic.Value
// 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 jwkCache JWKCacheInterface
jwtVerifier JWTVerifier jwtVerifier JWTVerifier
ctx context.Context ctx context.Context
@@ -165,18 +126,21 @@ type TraefikOidc struct {
frontchannelLogoutPath string frontchannelLogoutPath string
scopesSupported []string scopesSupported []string
scopes []string scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex metadataMu sync.RWMutex
shutdownOnce sync.Once shutdownOnce sync.Once
metadataRetryMutex sync.Mutex
firstRequestMutex sync.Mutex
sessionInvalidationCache CacheInterface sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface refreshResultCache CacheInterface
minimalHeaders bool minimalHeaders bool
stripAuthCookies bool stripAuthCookies bool
enableBackchannelLogout bool enableBackchannelLogout bool
enableFrontchannelLogout bool enableFrontchannelLogout bool
firstRequestReceived bool
requireTokenIntrospection bool requireTokenIntrospection bool
metadataRefreshStarted bool
allowPrivateIPAddresses bool allowPrivateIPAddresses bool
disableReplayDetection bool disableReplayDetection bool
allowOpaqueTokens bool allowOpaqueTokens bool
@@ -185,6 +149,7 @@ type TraefikOidc struct {
enablePKCE bool enablePKCE bool
forceHTTPS bool forceHTTPS bool
suppressDiagnosticLogs bool suppressDiagnosticLogs bool
trustForwardedURI bool
// Bearer-auth runtime state (populated only when EnableBearerAuth=true). // Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string bearerIdentifierClaim string
+10 -44
View File
@@ -396,16 +396,8 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) {
return value, true return value, true
} }
c.mu.RUnlock() c.mu.RUnlock()
// Expired — return miss immediately. The periodic cleanup goroutine // Expired — fall through to the write-locked slow path below to
// will evict the stale entry. NEVER fall through to the write-locked // remove the entry under exclusive access.
// slow path for Token/JWK/Session caches: under Yaegi the write Lock
// at line 403 costs 10-100ms per acquisition, and Go's RWMutex
// writer-priority semantics block ALL new RLock callers while a Lock
// is pending. A single expired-token event turns every concurrent
// request from read-parallel into write-serialized — the exact
// convoy that produced the 737-goroutine pileup at 0x400275a608.
atomic.AddInt64(&c.misses, 1)
return nil, false
} }
c.mu.Lock() c.mu.Lock()
@@ -603,28 +595,15 @@ func (c *UniversalCache) removeItem(key string, item *CacheItem) {
// evictOldest evicts the oldest item from the cache (must be called with lock held) // evictOldest evicts the oldest item from the cache (must be called with lock held)
func (c *UniversalCache) evictOldest() { func (c *UniversalCache) evictOldest() {
elem := c.lruList.Back() if elem := c.lruList.Back(); elem != nil {
if elem == nil { key, _ := elem.Value.(string) // Safe to ignore: cache internal type assertion
return if item, exists := c.items[key]; exists {
} c.removeItem(key, item)
key, _ := elem.Value.(string) // Safe to ignore: cache internal type assertion atomic.AddInt64(&c.evictions, 1)
if item, exists := c.items[key]; exists && item.element == elem { if c.logger.IsDebug() {
c.removeItem(key, item) c.logger.Debugf("UniversalCache[%s]: Evicted key=%s", c.config.Type, key)
atomic.AddInt64(&c.evictions, 1) }
if c.logger.IsDebug() {
c.logger.Debugf("UniversalCache[%s]: Evicted key=%s", c.config.Type, key)
} }
return
}
// Defensive forward-progress guard: the back node is dangling — its key is
// absent from c.items, or c.items[key] points at a newer node (a stale
// duplicate). Drop the node directly so an eviction loop
// (`for ... && c.lruList.Len() > 0`) is guaranteed to terminate and can
// never spin holding c.mu.Lock(). With the updateLocalCache replace-in-place
// fix this branch should be unreachable, but it makes the spin impossible.
c.lruList.Remove(elem)
if c.currentSize > 0 {
c.currentSize--
} }
} }
@@ -957,19 +936,6 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim
} }
now := time.Now() now := time.Now()
// Replace any existing entry in place. Without this, a repeat populate of
// the same key (the per-request Get->backend-hit path at line ~359)
// PushFronts a second list node and overwrites c.items[key], orphaning the
// previous node. Orphans inflate currentMemory/currentSize and — once the
// eviction loop deletes the key — leave Back() nodes whose key is absent
// from c.items, so evictOldest() no-ops while lruList.Len()>0 stays true:
// an infinite loop while holding c.mu.Lock(), i.e. the 100%-CPU holder and
// write-lock convoy. setLocal already dedups on this path; this mirrors it.
if existing, ok := c.items[key]; ok {
c.removeItem(key, existing)
}
item := &CacheItem{ item := &CacheItem{
Key: key, Key: key,
Value: value, Value: value,
-84
View File
@@ -1,84 +0,0 @@
package traefikoidc
import (
"testing"
"time"
)
// newOrphanTestCache builds a Token-type cache with background cleanup disabled
// so the test fully controls lruList/items state.
func newOrphanTestCache(maxMem int64) *UniversalCache {
return NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
DefaultTTL: time.Hour,
MaxSize: 1_000_000, // large: keep the size-branch out of the way
MaxMemoryBytes: maxMem,
EnableMemoryLimit: maxMem > 0,
SkipAutoCleanup: true,
EnableAutoCleanup: false,
})
}
// TestUpdateLocalCache_NoOrphanElements is the direct red test: repeatedly
// populating the SAME key via updateLocalCache (the per-request Get->backend-hit
// path) must NOT leave dangling lruList elements. Today updateLocalCache blindly
// PushFronts + overwrites c.items[key] without removing the prior element, so the
// list grows one orphan per call while items stays at 1 entry.
func TestUpdateLocalCache_NoOrphanElements(t *testing.T) {
c := newOrphanTestCache(0) // memory limit off: isolate the orphan, no eviction
const key = "same-key"
for range 5 {
if err := c.updateLocalCache(key, "v", time.Hour); err != nil {
t.Fatalf("updateLocalCache: %v", err)
}
}
c.mu.RLock()
listLen := c.lruList.Len()
itemCount := len(c.items)
c.mu.RUnlock()
if itemCount != 1 {
t.Fatalf("items: got %d want 1", itemCount)
}
if listLen != 1 {
t.Fatalf("ORPHAN BUG: lruList.Len()=%d but items=%d (one list element per key expected)", listLen, itemCount)
}
}
// TestUpdateLocalCache_EvictionTerminates is the convoy reproducer: once orphans
// for a key exist and the memory-eviction loop runs, evictOldest() deletes the
// key from items on the first eviction, after which every remaining orphan at
// Back() has a key absent from items -> evictOldest() no-ops while lruList.Len()>0
// stays true -> infinite loop while holding c.mu.Lock(). That is the 100%-CPU
// holder + write-lock convoy observed in pprof.
func TestUpdateLocalCache_EvictionTerminates(t *testing.T) {
c := newOrphanTestCache(0) // start with memory limit OFF to accumulate orphans
const key = "same-key"
// Build 3 same-key list elements (3 orphans, items={key}).
for range 3 {
if err := c.updateLocalCache(key, "v", time.Hour); err != nil {
t.Fatalf("seed updateLocalCache: %v", err)
}
}
// Arm the trap: tiny memory limit so the next call enters the eviction loop.
c.mu.Lock()
c.config.MaxMemoryBytes = 1
c.mu.Unlock()
done := make(chan struct{})
go func() {
_ = c.updateLocalCache(key, "v", time.Hour) // triggers the eviction loop
close(done)
}()
select {
case <-done:
// fix present: loop made forward progress and returned
case <-time.After(2 * time.Second):
t.Fatal("INFINITE LOOP: eviction loop did not terminate within 2s (orphan whose key was deleted is never removed from lruList)")
}
}
-15
View File
@@ -146,21 +146,6 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString) t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString)
} }
// Apply operator-configured extra authorization parameters (e.g.
// screen_hint, login_hint, ui_locales, prompt). These are added last but
// can never override parameters the plugin itself manages (client_id,
// state, nonce, redirect_uri, code_challenge, scope, response_type, ...):
// a key already present in params is left untouched, so this cannot
// weaken security-critical parameters.
for key, value := range t.extraAuthParams {
if params.Get(key) == "" {
params.Set(key, value)
t.logger.Debugf("TraefikOidc.buildAuthURL: Added extra auth param %s", key)
} else {
t.logger.Debugf("TraefikOidc.buildAuthURL: Skipped extra auth param %s (already set by plugin)", key)
}
}
// Read authURL with RLock // Read authURL with RLock
t.metadataMu.RLock() t.metadataMu.RLock()
authURL := t.authURL authURL := t.authURL
-51
View File
@@ -554,54 +554,3 @@ func TestForceHTTPSIntegration(t *testing.T) {
"should use https from X-Forwarded-Proto when forceHTTPS is false") "should use https from X-Forwarded-Proto when forceHTTPS is false")
}) })
} }
// TestBuildAuthURLExtraAuthParams verifies operator-configured extra
// authorization parameters are appended to the authorization URL, and that
// they can never override parameters the plugin itself manages.
func TestBuildAuthURLExtraAuthParams(t *testing.T) {
t.Run("extra params are added (e.g. screen_hint=signup)", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.extraAuthParams = map[string]string{
"screen_hint": "signup",
"ui_locales": "en",
}
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
assert.Contains(t, authURL, "screen_hint=signup")
assert.Contains(t, authURL, "ui_locales=en")
})
t.Run("nil/empty extraAuthParams is a no-op", func(t *testing.T) {
middleware := createMinimalMiddleware()
// extraAuthParams left nil
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
assert.Contains(t, authURL, "client_id=test-client")
assert.NotContains(t, authURL, "screen_hint")
})
t.Run("extra params CANNOT override plugin-managed params", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.extraAuthParams = map[string]string{
"client_id": "ATTACKER",
"state": "ATTACKER",
"redirect_uri": "https://evil.example.com",
"response_type": "token",
}
authURL := middleware.buildAuthURL(
"https://app.com/callback", "state123", "nonce456", "",
)
// Plugin-managed values must win; injected values must be absent.
assert.Contains(t, authURL, "client_id=test-client")
assert.NotContains(t, authURL, "ATTACKER")
assert.NotContains(t, authURL, "evil.example.com")
assert.Contains(t, authURL, "response_type=code")
})
}
-13
View File
@@ -14,19 +14,6 @@ import (
"time" "time"
) )
// metadataSnap returns the most recently published *MetadataSnapshot, or nil
// if metadata has not yet been resolved. Single atomic.Value.Load — the hot
// ServeHTTP path uses this instead of acquiring metadataMu.RLock, which under
// Yaegi pays 1-5ms of interpreter-dispatch overhead per acquisition.
func (t *TraefikOidc) metadataSnap() *MetadataSnapshot {
v := t.metadataSnapshot.Load()
if v == nil {
return nil
}
s, _ := v.(*MetadataSnapshot)
return s
}
// safeLogDebug provides nil-safe logging for debug messages // safeLogDebug provides nil-safe logging for debug messages
func (t *TraefikOidc) safeLogDebug(msg string) { func (t *TraefikOidc) safeLogDebug(msg string) {
if t.logger != nil { if t.logger != nil {
@@ -1 +0,0 @@
.docs
-36
View File
@@ -1,36 +0,0 @@
version: "2"
run:
timeout: 2m
linters:
default: none
enable:
- bodyclose
- errcheck
- errorlint
- gocritic
- gocyclo
- govet
- ineffassign
- misspell
- prealloc
- revive
- staticcheck
- unconvert
- unused
settings:
gocyclo:
min-complexity: 12
revive:
rules:
- name: var-naming
- name: indent-error-flow
- name: superfluous-else
- name: unused-parameter
- name: redefines-builtin-id
formatters:
enable:
- gofmt
- goimports
-42
View File
@@ -1,42 +0,0 @@
# Configuration for lukaszraczylo/semver-generator.
# Reference: https://github.com/lukaszraczylo/semver-generator
#
# Word matching is fuzzy + case-insensitive. The keywords below mirror the
# Conventional Commits prefixes used in this repo's git history. Same pattern
# as github.com/lukaszraczylo/go-telegram/.semver.yaml.
version: 1
# Respect existing v* tags as the version baseline. semver-generator finds
# the highest existing tag and bumps from there. With no tags yet, the first
# release computes from the empty base.
force:
existing: true
# Skip merge commits and machine-generated traffic that would otherwise
# spuriously bump the version.
blacklist:
- "Merge branch"
- "Merge pull request"
- "Merge remote-tracking branch"
- "go mod tidy"
wording:
patch:
- "fix"
- "chore"
- "docs"
- "test"
- "style"
- "refactor"
- "build"
- "ci"
- "perf"
minor:
- "feat"
major:
# Match only the canonical Conventional Commits trailer. The bare word
# "breaking" is too greedy under semver-generator's fuzzy match — it
# triggers on substrings inside a commit body and wrongly produces a
# major bump.
- "BREAKING CHANGE"
-122
View File
@@ -1,122 +0,0 @@
# oss-telemetry
A tiny Go client that fires one anonymous "this binary started" ping at a
central ingest endpoint. Designed to be embedded in your own open-source
projects so you can see approximate adoption and version spread without
collecting anything that could identify a user.
This is the **client library only**. The ingest endpoint, server-side
deduplication, rate limiting, and metrics are out of scope here.
## What it sends
A single HTTP `POST` per call to `Send`:
```json
{
"project": "my-tool",
"version": "1.2.3",
"ts": 1747782200
}
```
No identifiers, no IP, no machine info, no user data. The server dedupes
incoming requests; the client just fires and forgets.
## Failproof by design
- Never blocks the caller — work runs in a goroutine.
- Never panics — the goroutine recovers internally.
- Never returns errors — bad input and network failures are silently dropped.
- Never retries, never persists state, never reads back.
- 2-second hard timeout on every request.
- Zero third-party dependencies (Go stdlib only).
The endpoint is hardcoded and not overridable from consuming code, by design.
## Install
```bash
go get github.com/lukaszraczylo/oss-telemetry
```
Requires Go 1.22+.
## Usage
```go
package main
import (
"time"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
const version = "1.2.3"
func main() {
telemetry.Send("my-tool", version)
// ... your program runs ...
// Only needed for short-lived CLIs that may exit before the goroutine
// finishes its POST. Long-running services do not need this.
telemetry.Wait(2 * time.Second)
}
```
Call `Send` once at boot. Calling it more often just sends more pings; the
server deduplicates.
## Disabling telemetry
If you ship a binary that imports this library, link your users to this
section (`https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry`)
so they can find the opt-out paths.
Any one of these turns it off:
| Mechanism | How |
| ---------------------------------------- | ---------------------------------------------------------------- |
| Universal opt-out | `DO_NOT_TRACK=1` |
| Library-wide opt-out | `OSS_TELEMETRY_DISABLED=1` |
| Project-specific opt-out | `<UPPER_PROJECT>_DISABLE_TELEMETRY=1` |
| Programmatic (e.g. behind a `--no-telemetry` flag) | `telemetry.Disable()` before the first `Send` |
Project-specific env var derivation: uppercase the project name and replace
`-` with `_`. For `my-tool` the variable is `MY_TOOL_DISABLE_TELEMETRY`.
Truthy values: `1`, `true`, `yes`, `on` (case-insensitive). Anything else is
treated as "not set".
## Validation rules (silently dropped if violated)
- `project`: matches `^[a-z0-9-]+$`, length 1–64.
- `version`: matches `^[A-Za-z0-9.+_-]+$`, length 1–32.
Bad input is a no-op — the library never logs, never errors, never crashes.
## API
```go
// Fire a single ping in the background. Returns immediately.
func Send(project, version string)
// Suppress all subsequent Send calls in this process. Idempotent.
func Disable()
// Block until in-flight pings complete or timeout elapses, whichever first.
// Useful for short-lived CLI processes.
func Wait(timeout time.Duration)
```
## Testing
```bash
go test -race ./...
```
## License
Pick one before publishing. None bundled.
-367
View File
@@ -1,367 +0,0 @@
// Package telemetry sends anonymous usage pings for open-source Go projects.
//
// Wire format (POST application/json):
//
// {"project":"<name>","version":"<ver>","ts":<unix-seconds>}
//
// Design contract (failproof):
// - never blocks the caller (work happens in a goroutine)
// - never panics (background goroutine recovers internally)
// - never returns errors (silently no-ops on bad input or network failure)
// - never retries, never deduplicates, never persists state — the client
// fires a single ping and forgets; the server is responsible for
// deduplication, abuse protection, and aggregation
//
// Typical usage at program startup:
//
// telemetry.Send("my-tool", "1.2.3")
//
// For short-lived CLI processes that may exit before the goroutine finishes:
//
// telemetry.Send("my-tool", "1.2.3")
// defer telemetry.Wait(2 * time.Second)
//
// Disablement (any one of these suppresses pings):
// - environment variable DO_NOT_TRACK=1
// - environment variable OSS_TELEMETRY_DISABLED=1
// - environment variable <UPPER_PROJECT>_DISABLE_TELEMETRY=1
// (project name uppercased, dashes replaced with underscores)
// - calling telemetry.Disable() at runtime
package telemetry
import (
"bytes"
"context"
"net/http"
"os"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
defaultEndpoint = "https://oss.raczylo.com/v1/ping"
httpTimeout = 2 * time.Second
maxProjectLen = 64
maxVersionLen = 32
)
// Yaegi note: this package is consumed by the traefikoidc Traefik plugin, which
// Traefik interprets with Yaegi (it vendors and interprets dependency source).
// It therefore avoids generic stdlib types (atomic.Pointer[T], atomic.Bool) and
// range-over-int (Go 1.22), which some Traefik/Yaegi runtimes cannot interpret.
// Endpoint mutation uses a mutex-guarded string; the disabled flag uses the
// function-based sync/atomic int32 API (atomic.LoadInt32/StoreInt32).
var (
// endpointURL holds the ingest URL. Production code never mutates it; the
// setter exists only so the test suite can retarget it at httptest servers
// while goroutines started by Send are still in flight.
endpointMu sync.RWMutex
endpointURL = defaultEndpoint
disabled int32 // 0 = enabled, 1 = disabled; accessed via sync/atomic only
inflight sync.WaitGroup
client = &http.Client{Timeout: httpTimeout}
)
func currentEndpoint() string {
endpointMu.RLock()
defer endpointMu.RUnlock()
return endpointURL
}
func setEndpointURL(u string) {
endpointMu.Lock()
endpointURL = u
endpointMu.Unlock()
}
// Send fires a single anonymous telemetry ping in the background and returns
// immediately. It never blocks, never panics, and never reports errors.
// Invalid inputs, disabled state, and network failures are silently dropped.
//
// Version strings are validated against a SemVer-ish shape that mirrors the
// receiver. An optional leading "v" or "V" is accepted and stripped before
// transmission so that callers can pass either "v1.2.3" or "1.2.3"; the
// wire form is always the unprefixed canonical version.
//
// Call once at program startup. Calling repeatedly will send repeated pings;
// the server is responsible for deduplication.
func Send(project, version string) {
if atomic.LoadInt32(&disabled) != 0 {
return
}
if isDisabledByEnv(project) {
return
}
if !validProject(project) || !validVersion(version) {
return
}
canonical := normalizeVersion(version)
inflight.Add(1)
go func() {
defer inflight.Done()
defer func() { _ = recover() }()
dispatch(project, canonical)
}()
}
// SendForModule is the recommended call form for Go libraries: it resolves
// the version automatically from Go's build info for the given module path
// so consumers do not need to maintain a hand-bumped version constant in
// source. Behaviour and contract are otherwise identical to [Send].
//
// Resolution order:
//
// 1. debug.ReadBuildInfo Deps entry for modulePath (authoritative when the
// library is consumed via go.mod);
// 2. debug.ReadBuildInfo Main when the library is itself the main module
// (e.g. running its own tests or examples);
// 3. fallback parameter, used only when build info is unavailable or
// unhelpful (replace directives, detached `go run`, ldflag override).
//
// Any leading "v" reported by build info is stripped to match the canonical
// wire form. Empty / "(devel)" build versions are skipped in favour of the
// next resolution source. Typical usage:
//
// telemetry.SendForModule("my-tool", "github.com/me/my-tool", "0.0.0-dev")
func SendForModule(project, modulePath, fallback string) {
Send(project, ResolveModuleVersion(modulePath, fallback))
}
// ResolveModuleVersion implements the version resolution used by
// SendForModule. Exposed for callers that need to format the resolved
// version (e.g. logging) without firing a ping.
func ResolveModuleVersion(modulePath, fallback string) string {
if info, ok := debug.ReadBuildInfo(); ok {
for _, d := range info.Deps {
if d != nil && d.Path == modulePath && isUsableBuildVersion(d.Version) {
return strings.TrimPrefix(d.Version, "v")
}
}
if info.Main.Path == modulePath && isUsableBuildVersion(info.Main.Version) {
return strings.TrimPrefix(info.Main.Version, "v")
}
}
return fallback
}
func isUsableBuildVersion(v string) bool {
return v != "" && v != "(devel)"
}
// Disable suppresses all subsequent Send calls in this process.
// Idempotent and safe to call from any goroutine.
func Disable() {
atomic.StoreInt32(&disabled, 1)
}
// Wait blocks until all in-flight pings have completed, or until timeout
// elapses — whichever comes first. Useful for short-lived CLI processes
// that may otherwise exit before the background goroutine finishes its POST.
//
// A non-positive timeout returns immediately.
func Wait(timeout time.Duration) {
if timeout <= 0 {
return
}
done := make(chan struct{})
go func() {
inflight.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
}
}
func dispatch(project, version string) {
body := buildPayload(project, version, time.Now().Unix())
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, currentEndpoint(), bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}
// buildPayload writes the JSON body without encoding/json. The validators
// restrict project and version to characters that never require JSON
// escaping, so direct concatenation is safe.
func buildPayload(project, version string, ts int64) []byte {
// Wrapper text plus 20 chars for a signed int64.
const overhead = len(`{"project":"","version":"","ts":}`) + 20
buf := make([]byte, 0, len(project)+len(version)+overhead)
buf = append(buf, `{"project":"`...)
buf = append(buf, project...)
buf = append(buf, `","version":"`...)
buf = append(buf, version...)
buf = append(buf, `","ts":`...)
buf = strconv.AppendInt(buf, ts, 10)
buf = append(buf, '}')
return buf
}
func validProject(p string) bool {
n := len(p)
if n == 0 || n > maxProjectLen {
return false
}
for i := 0; i < n; i++ {
c := p[i]
switch {
case c >= 'a' && c <= 'z',
c >= '0' && c <= '9',
c == '-':
default:
return false
}
}
return true
}
// validVersion accepts SemVer-ish version strings with an optional leading
// "v"/"V" prefix. Acceptable shape (after stripping the leading v):
//
// MAJOR[.MINOR[.PATCH]] ("-"prerelease)? ("+"build)?
//
// where MAJOR/MINOR/PATCH are ASCII digit sequences and the prerelease/build
// payloads are non-empty runs of [0-9A-Za-z.-]. This intentionally mirrors
// the receiver's version regex so junk like "dev" or "git-2026-05-22" never
// leaves the client (where it would only be rejected with HTTP 400 anyway).
func validVersion(v string) bool {
n := len(v)
if n == 0 || n > maxVersionLen {
return false
}
if v[0] == 'v' || v[0] == 'V' {
v = v[1:]
}
if len(v) == 0 {
return false
}
return checkSemverShape(v)
}
// normalizeVersion strips an optional leading "v"/"V" so the on-the-wire
// version matches the form stored server-side by the version refresher cron
// (which also strips the leading v from release tags). Callers may pass
// either "v1.2.3" or "1.2.3" — only the unprefixed form is transmitted.
func normalizeVersion(v string) string {
if len(v) > 0 && (v[0] == 'v' || v[0] == 'V') {
return v[1:]
}
return v
}
func checkSemverShape(s string) bool {
i := 0
if !readDigitRun(s, &i) {
return false
}
for groups := 0; groups < 2 && i < len(s) && s[i] == '.'; groups++ {
i++
if !readDigitRun(s, &i) {
return false
}
}
if i < len(s) && s[i] == '-' {
i++
if !readIdentRun(s, &i, '+') {
return false
}
}
if i < len(s) && s[i] == '+' {
i++
if !readIdentRun(s, &i, 0) {
return false
}
}
return i == len(s)
}
func readDigitRun(s string, i *int) bool {
start := *i
for *i < len(s) && s[*i] >= '0' && s[*i] <= '9' {
*i++
}
return *i > start
}
// readIdentRun consumes [0-9A-Za-z.-] until end-of-string or until `stop`
// is hit (stop=0 disables the early-stop check). Returns false if no
// characters were consumed (i.e. empty payload).
func readIdentRun(s string, i *int, stop byte) bool {
start := *i
for *i < len(s) {
c := s[*i]
if stop != 0 && c == stop {
break
}
valid := (c >= '0' && c <= '9') ||
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
c == '.' || c == '-'
if !valid {
return false
}
*i++
}
return *i > start
}
func isDisabledByEnv(project string) bool {
if truthy(os.Getenv("DO_NOT_TRACK")) {
return true
}
if truthy(os.Getenv("OSS_TELEMETRY_DISABLED")) {
return true
}
if project == "" {
return false
}
key := projectEnvKey(project)
return truthy(os.Getenv(key))
}
// projectEnvKey returns "<UPPER_PROJECT>_DISABLE_TELEMETRY" using a single
// allocation rather than chained strings.ToUpper(strings.ReplaceAll(...)).
func projectEnvKey(project string) string {
const suffix = "_DISABLE_TELEMETRY"
buf := make([]byte, 0, len(project)+len(suffix))
for i := 0; i < len(project); i++ {
c := project[i]
switch {
case c == '-':
c = '_'
case c >= 'a' && c <= 'z':
c -= 'a' - 'A'
}
buf = append(buf, c)
}
buf = append(buf, suffix...)
return string(buf)
}
func truthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
}
return false
}
-3
View File
@@ -24,9 +24,6 @@ github.com/gorilla/securecookie
# github.com/gorilla/sessions v1.3.0 # github.com/gorilla/sessions v1.3.0
## explicit; go 1.20 ## explicit; go 1.20
github.com/gorilla/sessions github.com/gorilla/sessions
# github.com/lukaszraczylo/oss-telemetry v0.2.3
## explicit; go 1.22
github.com/lukaszraczylo/oss-telemetry
# github.com/pmezard/go-difflib v1.0.0 # github.com/pmezard/go-difflib v1.0.0
## explicit ## explicit
github.com/pmezard/go-difflib/difflib github.com/pmezard/go-difflib/difflib
-17
View File
@@ -1,17 +0,0 @@
package traefikoidc
// devPluginVersion is the placeholder carried by source-tree / local / test
// builds. Telemetry is suppressed while the plugin still reports this sentinel,
// so only stamped release builds emit a "plugin loaded" ping.
const devPluginVersion = "0.0.0-dev"
// traefikoidcPluginVersion is the released version of this plugin. It is stamped
// at release time by ./workflow-prepare.sh (invoked by the shared go-release
// workflow before GoReleaser builds and tags), which rewrites the string below
// to the computed semver.
//
// Traefik runs this plugin under Yaegi, where the version cannot be resolved
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.25"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
#
# workflow-prepare.sh — stamp the release version into version.go at build time.
#
# The shared go-release workflow (lukaszraczylo/shared-actions go-release.yaml)
# runs this script, if present, from the repository root BEFORE GoReleaser
# builds and tags. Traefik runs this plugin under Yaegi, where the version
# cannot be resolved from build info at runtime, so the released semver must be
# baked into source here.
#
# Version source — first non-empty wins:
# $VERSION $VERSION_TAG $SEMVER $NEW_VERSION $RELEASE_VERSION
# A leading "v"/"V" is stripped.
#
# NOTE: go-release.yaml @main does not yet pass the computed version into this
# step's environment. Add it to the "Run workflow prepare script" step, e.g.:
# env:
# VERSION: ${{ needs.version.outputs.version }} # bare, no leading v
#
# The shared workflow runs this script in its test, version AND release jobs,
# but only the release job has a computed version. So a missing version is a
# no-op (leave the dev sentinel) — NOT a hard failure, otherwise the test/version
# jobs would break. A malformed version that IS provided is a hard error. Wire
# the env only on the release job's prepare step (see header note above).
set -euo pipefail
FILE="version.go"
CONST="traefikoidcPluginVersion"
VER="${VERSION:-${VERSION_TAG:-${SEMVER:-${NEW_VERSION:-${RELEASE_VERSION:-}}}}}"
VER="${VER#v}"
VER="${VER#V}"
if [ -z "$VER" ]; then
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
echo "workflow-prepare: WARNING no version provided; leaving ${FILE} at the dev placeholder. If this is the release build, set 'env: VERSION: \${{ needs.version.outputs.version }}' on the release job's prepare step — otherwise the release ships 0.0.0-dev and emits no telemetry." >&2
else
echo "workflow-prepare: no version provided; leaving dev placeholder in ${FILE} (local build)"
fi
exit 0
fi
# Accept MAJOR[.MINOR[.PATCH]] with optional -prerelease / +build (semver-ish,
# matching the oss-telemetry receiver's validator).
if ! printf '%s' "$VER" | grep -Eq '^[0-9]+(\.[0-9]+){0,2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo "workflow-prepare: ERROR version '${VER}' is not semver-shaped" >&2
exit 1
fi
if [ ! -f "$FILE" ]; then
echo "workflow-prepare: ERROR ${FILE} not found (run from repository root)" >&2
exit 1
fi
# Rewrite only the value of ${CONST}, anchored on the constant name so the
# sibling devPluginVersion sentinel is left untouched.
tmp="$(mktemp)"
sed -E "s/(${CONST}[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1${VER}\2/" "$FILE" > "$tmp"
mv "$tmp" "$FILE"
if ! grep -Eq "${CONST}[[:space:]]*=[[:space:]]*\"${VER}\"" "$FILE"; then
echo "workflow-prepare: ERROR failed to stamp version into ${FILE}" >&2
exit 1
fi
command -v gofmt >/dev/null 2>&1 && gofmt -w "$FILE"
echo "workflow-prepare: stamped ${CONST} = \"${VER}\" in ${FILE}"