mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
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.
This commit is contained in:
+4
-1
@@ -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")
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// 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.validateStandardTokensRS(rs)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user