Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot] bc02901972 chore(release): v1.0.25 2026-05-30 12:26:23 +00:00
lukaszraczylo f75b2f20e0 fix: resolve cache eviction lock-up and migrate telemetry [patch-release]
universal_cache: stop the write-lock convoy / 100%-CPU spin (observed via pprof: one ServeHTTP goroutine holding c.mu.Lock for hours while 119 requests queued). The per-request populate path (updateLocalCache) PushFronted a duplicate LRU node + overwrote items[key] without removing the prior node; once eviction deleted the key, orphan nodes at Back() were never removable and the eviction loop spun forever under the write lock. Replace the entry in place (mirroring setLocal) and harden evictOldest with a forward-progress guard. Adds universal_cache_orphan_test.go.

telemetry: delete the hand-rolled client; call oss-telemetry v0.2.3 (vendored, Yaegi-safe) directly from New(), once per process via sync.Once.

version: add version.go + workflow-prepare.sh so the release semver is stamped into source at build time (the value cannot be resolved at runtime under Yaegi). dev/source builds keep the 0.0.0-dev sentinel and emit no telemetry.
2026-05-30 13:22:03 +01:00
paiking1 cf6ed1da55 feat: feat: add extraAuthParams (extra authorization request parameters) (#139)
Adds optional extraAuthParams map[string]string config.

Extra params are appended to the authorization request but can never
override plugin-managed params (client_id, state, nonce, etc.).
2026-05-27 21:41:09 +01:00
lukaszraczylo f821b8829b fix: remove write-lock convoy in getLocal + fix mutateState CAS bug
UniversalCache.getLocal(): when a cached token expires, the RLock fast
path (line 385-398) previously fell through to c.mu.Lock() (write lock).
Under Yaegi, the write-lock holder takes 10-100ms for LRU manipulation,
and Go's RWMutex writer-priority blocks ALL new RLock callers. A single
expired-token event turned every concurrent request from read-parallel
into write-serialized — the convoy that produced the 737-goroutine
pileup at 0x400275a608 (pprof captured at /tmp/traefik-spike-1779663149).

Fix: return (nil, false) immediately on expiry for Token/JWK/Session
cache types. The periodic cleanup goroutine handles eviction. Write lock
is never taken on the read path for these cache types.

refreshAttemptTracker.mutateState(): the CAS loop used
t.state.CompareAndSwap(t.state.Load(), next) — a second Load that can
see a different value from a concurrent writer, silently overwriting
their update. Fixed to CompareAndSwap(cur, next) using the snapshot we
computed the mutation from.
2026-05-25 00:06:47 +01:00
lukaszraczylo 5f9c574f95 refactor: delete dead non-RS validators; tests use RS variants
After v1.0.20 the non-RS validation chain had no production callers —
middleware.ServeHTTP dispatched exclusively through isUserAuthenticatedRS.
The orphaned functions stayed reachable only from a handful of test
files and risked silent logic drift against their RS counterparts.

Deleted from production code (~440 LOC):
  - auth_flow.go:        isUserAuthenticated
  - token_manager.go:    validateAzureTokens
  - token_manager.go:    validateGoogleTokens
  - token_manager.go:    validateStandardTokens
  - token_manager.go:    validateTokenExpiry
  - removed now-unused encoding/base64 and encoding/json imports
    from token_manager.go (only the deleted validateStandardTokens
    needed them; the RS variant in token_validation_rs.go keeps its
    own imports).

Added (3 LOC):
  - token_validation_rs.go: validateGoogleTokensRS (trivial delegator,
    parity with the deleted non-RS variant so isUserAuthenticatedRS
    can dispatch cleanly).

Tests ported (10 call sites across 3 files):
  - audience_test.go:                ts.tOidc.validateStandardTokens
  - azure_oidc_test.go:              tOidc.validateAzureTokens,
                                     ts.tOidc.validateGoogleTokens,
                                     ts.tOidc.validateAzureTokens,
                                     ts.tOidc.isUserAuthenticated
  - issue134_followup_graph_test.go: oidc.validateAzureTokens (4x)

Each ported site now constructs a *requestState from its existing
*SessionData via (&requestState{}).captureSession(session) and calls
the *RS variant. Same data, different read source.

Net diff: -440 LOC production, ~+25 LOC tests, +3 LOC stub.
Production now has a single source of truth for token validation;
no parallel implementations to keep in sync.

All tests pass with -race; golangci-lint clean.
2026-05-23 13:04:26 +01:00
lukaszraczylo 7c6f09fb20 feat(middleware): RS-aware token validators (kill ~21 RLocks/request)
Adds token_validation_rs.go with requestState-aware variants of the
token validation path:

  isUserAuthenticatedRS(rs) -> dispatches by provider
    validateStandardTokensRS(rs) -> standard path (eliminates 17 RLocks)
    validateAzureTokensRS(rs)    -> Azure path     (eliminates 10 RLocks)
    validateGoogleTokensRS(rs)   -> delegates to standard
  validateTokenExpiryRS(rs, tok) -> shared expiry check (eliminates 4 RLocks)

middleware.ServeHTTP now calls isUserAuthenticatedRS(rs) on the hot
path. The pre-v1.0.20 non-RS variants are kept untouched for tests
and any future caller that doesn't have a captured snapshot.

Why
---
The standard validation path read SessionData via session.GetX() 17
times, with GetRefreshToken alone called 11 times (every "return
'needs refresh'" branch re-reads it). Each call acquires
sd.sessionMutex.RLock(). Under Yaegi each RLock costs ~1-5ms of
interpreter dispatch. The captured snapshot already lives on rs, so
the RS variants substitute direct struct field reads.

Per-request cost on the hot authenticated path
----------------------------------------------
  ServeHTTP enters:
    + 1 RLock to populate rs (was 0)
  Validation path:
    Standard: was 17 RLocks, now 0
    Azure:    was 10 RLocks, now 0
  processAuthorizedRequestRS:
    was 4-6 GetX calls, now 0 (already in v1.0.19)

Net: ~22-27 fewer Yaegi-dispatched RLock acquisitions per authenticated
request on the hot path.

Caveats
-------
* Refresh / expired / callback paths still use the non-RS validators
  because they can mutate session state between validation and use.
* The RS variants are by-design line-for-line equivalents of the
  originals. If logic in the originals changes, the RS variants need
  matching updates. This is acceptable for now; a future refactor
  could collapse them once the non-RS callers are gone.

All tests pass with -race; golangci-lint clean.
2026-05-23 12:38:42 +01:00
28 changed files with 1180 additions and 787 deletions
+1
View File
@@ -111,6 +111,7 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| `logoutURL` | `callbackURL + "/logout"` | RP-initiated logout path. |
| `postLogoutRedirectURI` | `/` | Where to send users after logout. |
| `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. |
| `allowedUserDomains` | none | Restrict to email domains. |
| `allowedUsers` | none | Restrict to specific addresses (or claim values when `userIdentifierClaim != email`). |
+4 -2
View File
@@ -484,7 +484,8 @@ func TestAuth0Scenario3OpaqueAccessToken(t *testing.T) {
session.SetAccessToken(opaqueAccessToken)
session.SetIDToken(idToken)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated || needsRefresh || expired {
t.Errorf("Session with opaque access token and valid ID token should be authenticated. Got: auth=%v, refresh=%v, expired=%v",
authenticated, needsRefresh, expired)
@@ -623,7 +624,8 @@ func TestAuth0Scenario2StrictMode(t *testing.T) {
session.SetRefreshToken("test-refresh-token") // Add refresh token so it can attempt refresh
// In strict mode, this should FAIL (no fallback to ID token)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if authenticated {
t.Errorf("Strict mode: Session with wrong access token audience should be rejected, but got authenticated=true")
}
-22
View File
@@ -305,28 +305,6 @@ func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Reque
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
}
// isUserAuthenticated determines the authentication status and refresh requirements.
// It delegates to provider-specific validation methods that handle different token types
// and expiration behaviors.
// Parameters:
// - session: The session data containing authentication tokens.
//
// Returns:
// - authenticated (bool): True if the user has valid tokens.
// - needsRefresh (bool): True if tokens are valid but nearing expiration.
// - expired (bool): True if the session is unauthenticated, the token is missing,
// or the token verification failed for reasons other than nearing/actual expiration.
func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) {
if t.isAzureProvider() {
return t.validateAzureTokens(session)
} else if t.isGoogleProvider() {
return t.validateGoogleTokens(session)
}
// Auth0 and other providers can now use standard validation
// which handles opaque tokens generically
return t.validateStandardTokens(session)
}
// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect
func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool {
xhr := req.Header.Get("X-Requested-With")
+8 -4
View File
@@ -262,7 +262,8 @@ func TestAzureOIDCRegression(t *testing.T) {
defer func() { tOidc.tokenVerifier = originalTokenVerifier }()
// Test that CSRF is preserved during Azure validation failures
authenticated, needsRefresh, expired := tOidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := tOidc.validateAzureTokensRS(rs)
// Should not be authenticated due to validation failure
if authenticated {
@@ -453,7 +454,8 @@ func TestValidateGoogleTokens(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.validateGoogleTokens(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.validateGoogleTokensRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -637,7 +639,8 @@ func TestIsUserAuthenticated(t *testing.T) {
defer func() { ts.tOidc.issuerURL = originalIssuer }()
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.isUserAuthenticated(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.isUserAuthenticatedRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
@@ -762,7 +765,8 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
session := tt.setupSession()
auth, refresh, expired := ts.tOidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
auth, refresh, expired := ts.tOidc.validateAzureTokensRS(rs)
if auth != tt.expectedAuth {
t.Errorf("Expected authenticated=%v, got %v. %s", tt.expectedAuth, auth, tt.description)
+1
View File
@@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/alicebob/miniredis/v2 v2.35.0
github.com/gorilla/sessions v1.3.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.10.0
golang.org/x/time v0.14.0
+2
View File
@@ -16,6 +16,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
+8 -4
View File
@@ -234,7 +234,8 @@ func TestIssue134_Followup_ValidateAzureTokensSkipsGraphAccessToken(t *testing.T
oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, graphAccessToken, idToken)
authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs)
output := errBuf.String()
assert.NotContains(t, output, "crypto/rsa: verification error",
@@ -344,7 +345,8 @@ func TestIssue134_Followup_StandardAzureAccessTokenStillVerifies(t *testing.T) {
oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, accessToken, idToken)
authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := oidc.validateAzureTokensRS(rs)
assert.True(t, authenticated, "standard Azure access token must verify and authenticate")
assert.False(t, needsRefresh)
@@ -381,7 +383,8 @@ func TestIssue134_Followup_GraphAccessTokenWithoutIDToken(t *testing.T) {
oidc, errBuf := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, graphAccessToken, "")
authenticated, needsRefresh, expired := oidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(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.False(t, needsRefresh)
@@ -443,7 +446,8 @@ func TestIssue134_Followup_ConfusedDeputyAttackDoesNotBypassVerification(t *test
oidc, _ := newAzureFollowupOIDC(t, jwks)
session := authedSessionWithTokens(t, forgedAccessToken, forgedIDToken)
authenticated, _, _ := oidc.validateAzureTokens(session)
rs := (&requestState{}).captureSession(session)
authenticated, _, _ := oidc.validateAzureTokensRS(rs)
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")
}
+14 -1
View File
@@ -16,6 +16,7 @@ import (
"text/template"
"time"
telemetry "github.com/lukaszraczylo/oss-telemetry"
"golang.org/x/time/rate"
)
@@ -23,6 +24,11 @@ const (
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.
func isTestMode() bool {
if os.Getenv("SUPPRESS_DIAGNOSTIC_LOGS") == "1" {
@@ -89,7 +95,13 @@ var defaultExcludedURLs = map[string]struct{}{
// - The configured TraefikOidc handler ready to process requests.
// - An error if essential configuration is missing or invalid (e.g., short encryption key).
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
sendTelemetry(pluginVersion)
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)
}
@@ -202,6 +214,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
}(),
forceHTTPS: config.ForceHTTPS,
enablePKCE: config.EnablePKCE,
extraAuthParams: config.ExtraAuthParams,
overrideScopes: config.OverrideScopes,
strictAudienceValidation: config.StrictAudienceValidation,
allowOpaqueTokens: config.AllowOpaqueTokens,
+4 -1
View File
@@ -333,7 +333,10 @@ 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)
authenticated, needsRefresh, expired := t.isUserAuthenticated(session)
// Token validation reads session via the captured snapshot — saves ~21
// sd.sessionMutex.RLock acquisitions (Yaegi-dispatched, ~1-5ms each)
// across the validation path.
authenticated, needsRefresh, expired := t.isUserAuthenticatedRS(rs)
if expired {
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
+1 -1
View File
@@ -498,7 +498,7 @@ func (t *refreshAttemptTracker) mutateState(mutate func(cur *attemptState) *atte
if next == nil {
return cur
}
if t.state.CompareAndSwap(t.state.Load(), next) {
if t.state.CompareAndSwap(cur, next) {
return next
}
}
+1
View File
@@ -54,6 +54,7 @@ type Config struct {
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
// a stored refresh token. Once the token has been in the session longer
-142
View File
@@ -1,142 +0,0 @@
package traefikoidc
import (
"bytes"
"context"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
// pluginVersion is bumped manually on each release. Keep in sync with the
// most recent git tag (see `git tag --sort=-v:refname | head -1`).
const pluginVersion = "1.0.11"
const (
telemetryProject = "traefikoidc"
telemetryTimeout = 2 * time.Second
)
// telemetryEndpoint is intentionally a var rather than a const so the test
// suite in this package can retarget it at an httptest server. Production
// code never mutates it.
var telemetryEndpoint = "https://oss.raczylo.com/v1/ping"
// telemetryOnce guarantees a single anonymous "plugin loaded" ping per
// process lifetime. Traefik can instantiate a middleware many times per
// process (one per route using the plugin); the sync.Once gate keeps the
// fire-and-forget call from amplifying into many pings.
//
// Reset in tests via `telemetryOnce = sync.Once{}`.
var telemetryOnce sync.Once
// telemetryInflight tracks any background goroutine started by sendTelemetry.
// Tests Wait on it to drain in-flight goroutines before mutating package
// state. Production code never calls Wait — the goroutine is fire-and-forget.
var telemetryInflight sync.WaitGroup
// sendTelemetry fires one anonymous usage ping in the background. It is
// failproof by contract:
//
// - never blocks the caller
// - never panics (the goroutine recovers internally)
// - never returns errors
// - silently dropped on invalid input, env-driven opt-out, or network failure
//
// Opt-out is honored via any of:
//
// - DO_NOT_TRACK=1
// - OSS_TELEMETRY_DISABLED=1
// - TRAEFIKOIDC_DISABLE_TELEMETRY=1
//
// Yaegi note: this file deliberately avoids generics (atomic.Pointer[T]) and
// range-over-int (Go 1.22) so it interprets under any reasonably recent
// Traefik yaegi runtime.
func sendTelemetry(version string) {
telemetryOnce.Do(func() {
if telemetryDisabledByEnv() {
return
}
if !validTelemetryVersion(version) {
return
}
telemetryInflight.Add(1)
go func() {
defer telemetryInflight.Done()
defer func() { _ = recover() }()
doTelemetryPost(version)
}()
})
}
func telemetryDisabledByEnv() bool {
keys := []string{
"DO_NOT_TRACK",
"OSS_TELEMETRY_DISABLED",
"TRAEFIKOIDC_DISABLE_TELEMETRY",
}
for _, k := range keys {
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
if v == "1" || v == "true" || v == "yes" || v == "on" {
return true
}
}
return false
}
// validTelemetryVersion mirrors the server-side regex ^[A-Za-z0-9.+_-]{1,32}$
// using a byte loop. No allocation, no regexp dependency.
//
// Yaegi note: written as an `||` chain rather than `switch{case A,B,C:}` —
// some yaegi releases mis-evaluate comma-separated case expressions in
// switch-true blocks, returning false for all inputs.
func validTelemetryVersion(v string) bool {
if len(v) == 0 || len(v) > 32 {
return false
}
for i := 0; i < len(v); i++ {
c := v[i]
ok := (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '+' || c == '_' || c == '-'
if !ok {
return false
}
}
return true
}
// doTelemetryPost builds the JSON body manually. The project name is a
// constant and the version is pre-validated against an ASCII-only allowlist,
// so direct concatenation needs no JSON escaping.
func doTelemetryPost(version string) {
body := make([]byte, 0, 96)
body = append(body, `{"project":"`...)
body = append(body, telemetryProject...)
body = append(body, `","version":"`...)
body = append(body, version...)
body = append(body, `","ts":`...)
body = strconv.AppendInt(body, time.Now().Unix(), 10)
body = append(body, '}')
ctx, cancel := context.WithTimeout(context.Background(), telemetryTimeout)
defer cancel()
url := telemetryEndpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: telemetryTimeout}
resp, err := client.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}
-167
View File
@@ -1,167 +0,0 @@
package traefikoidc
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// resetTelemetryState restores package-level mutable state so tests do not
// contaminate one another. The cleanup waits for any in-flight ping goroutine
// to finish before restoring telemetryEndpoint — without that drain step the
// goroutine and the cleanup would race on the var.
func resetTelemetryState(t *testing.T) {
t.Helper()
telemetryOnce = sync.Once{}
prev := telemetryEndpoint
t.Cleanup(func() {
telemetryInflight.Wait()
telemetryEndpoint = prev
telemetryOnce = sync.Once{}
})
}
func newTelemetryServer(t *testing.T, status int) (hits *int32, lastBody func() string) {
t.Helper()
var counter int32
var mu sync.Mutex
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&counter, 1)
b, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
mu.Lock()
body = string(b)
mu.Unlock()
w.WriteHeader(status)
}))
telemetryEndpoint = srv.URL
t.Cleanup(srv.Close)
return &counter, func() string {
mu.Lock()
defer mu.Unlock()
return body
}
}
func TestValidTelemetryVersion(t *testing.T) {
good := []string{"1.2.3", "1.4.0-beta1", "2.0", "v1.0.0", "1.0.0+meta", "dev"}
for _, v := range good {
if !validTelemetryVersion(v) {
t.Errorf("validTelemetryVersion(%q) = false, want true", v)
}
}
bad := []string{"", "has space", "semi;colon", strings.Repeat("1", 33)}
for _, v := range bad {
if validTelemetryVersion(v) {
t.Errorf("validTelemetryVersion(%q) = true, want false", v)
}
}
}
func TestTelemetryDisabledByEnv(t *testing.T) {
for _, k := range []string{"DO_NOT_TRACK", "OSS_TELEMETRY_DISABLED", "TRAEFIKOIDC_DISABLE_TELEMETRY"} {
t.Run(k, func(t *testing.T) {
t.Setenv(k, "1")
if !telemetryDisabledByEnv() {
t.Fatalf("%s=1 should disable", k)
}
})
}
t.Run("falsy_values_do_not_disable", func(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "0")
t.Setenv("OSS_TELEMETRY_DISABLED", "false")
t.Setenv("TRAEFIKOIDC_DISABLE_TELEMETRY", "no")
if telemetryDisabledByEnv() {
t.Fatal("falsy env values should not disable")
}
})
}
func TestSendTelemetry_FiresOnceAcrossManyCalls(t *testing.T) {
resetTelemetryState(t)
hits, lastBody := newTelemetryServer(t, http.StatusNoContent)
for i := 0; i < 50; i++ {
sendTelemetry("1.2.3")
}
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("expected exactly 1 hit, got %d", got)
}
var payload struct {
Project string `json:"project"`
Version string `json:"version"`
Ts int64 `json:"ts"`
}
if err := json.Unmarshal([]byte(lastBody()), &payload); err != nil {
t.Fatalf("server received non-JSON body: %q (err: %v)", lastBody(), err)
}
if payload.Project != "traefikoidc" || payload.Version != "1.2.3" || payload.Ts <= 0 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestSendTelemetry_RespectsDisableEnv(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusNoContent)
t.Setenv("DO_NOT_TRACK", "1")
sendTelemetry("1.2.3")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 0 {
t.Fatalf("DO_NOT_TRACK should suppress; got %d hits", got)
}
}
func TestSendTelemetry_DropsInvalidVersion(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusNoContent)
sendTelemetry("has space")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 0 {
t.Fatalf("invalid version should suppress; got %d hits", got)
}
}
func TestSendTelemetry_DoesNotBlock(t *testing.T) {
resetTelemetryState(t)
// Hanging server proves the caller is never blocked. The 2s context
// timeout in doTelemetryPost ensures the goroutine eventually exits;
// resetTelemetryState's cleanup waits for that drain before restoring
// telemetryEndpoint so there is no race with this test's mutation.
hung := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
time.Sleep(5 * time.Second)
}))
t.Cleanup(hung.Close)
telemetryEndpoint = hung.URL
start := time.Now()
sendTelemetry("1.2.3")
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("sendTelemetry blocked for %v, expected near-instant return", elapsed)
}
}
func TestSendTelemetry_SurvivesServerError(t *testing.T) {
resetTelemetryState(t)
hits, _ := newTelemetryServer(t, http.StatusInternalServerError)
sendTelemetry("1.2.3")
telemetryInflight.Wait()
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("request should still reach server even on 500; got %d hits", got)
}
}
-433
View File
@@ -5,8 +5,6 @@ package traefikoidc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -860,437 +858,6 @@ func (t *TraefikOidc) isAzureProvider() bool {
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.
// It runs periodic cleanup of token cache, JWK cache, and session chunks.
+286
View File
@@ -0,0 +1,286 @@
// 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
}
+1
View File
@@ -165,6 +165,7 @@ type TraefikOidc struct {
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
+44 -10
View File
@@ -396,8 +396,16 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) {
return value, true
}
c.mu.RUnlock()
// Expired — fall through to the write-locked slow path below to
// remove the entry under exclusive access.
// Expired — return miss immediately. The periodic cleanup goroutine
// will evict the stale entry. NEVER fall through to the write-locked
// 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()
@@ -595,15 +603,28 @@ func (c *UniversalCache) removeItem(key string, item *CacheItem) {
// evictOldest evicts the oldest item from the cache (must be called with lock held)
func (c *UniversalCache) evictOldest() {
if elem := c.lruList.Back(); elem != nil {
key, _ := elem.Value.(string) // Safe to ignore: cache internal type assertion
if item, exists := c.items[key]; exists {
c.removeItem(key, item)
atomic.AddInt64(&c.evictions, 1)
if c.logger.IsDebug() {
c.logger.Debugf("UniversalCache[%s]: Evicted key=%s", c.config.Type, key)
}
elem := c.lruList.Back()
if elem == nil {
return
}
key, _ := elem.Value.(string) // Safe to ignore: cache internal type assertion
if item, exists := c.items[key]; exists && item.element == elem {
c.removeItem(key, item)
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--
}
}
@@ -936,6 +957,19 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim
}
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{
Key: key,
Value: value,
+84
View File
@@ -0,0 +1,84 @@
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,6 +146,21 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
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
t.metadataMu.RLock()
authURL := t.authURL
+51
View File
@@ -554,3 +554,54 @@ func TestForceHTTPSIntegration(t *testing.T) {
"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")
})
}
@@ -0,0 +1 @@
.docs
+36
View File
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,42 @@
# 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
@@ -0,0 +1,122 @@
# 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 164.
- `version`: matches `^[A-Za-z0-9.+_-]+$`, length 132.
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
@@ -0,0 +1,367 @@
// 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,6 +24,9 @@ github.com/gorilla/securecookie
# github.com/gorilla/sessions v1.3.0
## explicit; go 1.20
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
## explicit
github.com/pmezard/go-difflib/difflib
+17
View File
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,67 @@
#!/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}"