mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
1b6c8616fd
* fix(refresh): wire RefreshCoordinator into the live refresh path
The RefreshCoordinator existed but was never instantiated. The actual
refresh path used only session.refreshMutex, which is per-SessionData
instance - and SessionData is pulled from a sync.Pool per request -
so concurrent requests sharing a refresh token had ZERO coordination.
Symptom: when access_token expired (e.g. 5min Zitadel default), every
in-flight request from a polling client (Grafana panels) entered the
refresh path simultaneously and POSTed the same refresh_token to the
IdP. With refresh-token rotation enabled (Zitadel/Authentik default),
only one grant succeeded; the rest got invalid_grant and each cleared
the entire session. Subsequent requests then thrashed in re-auth loops.
This commit:
- adds refreshCoordinator field on TraefikOidc
- instantiates it in NewWithContext with DefaultRefreshCoordinatorConfig
- shuts it down in Close() under shutdownOnce
- routes refreshToken() through the coordinator via coordinatedTokenRefresh,
which collapses concurrent grants to a single upstream call per
refresh_token hash
- exports refreshCoordinatorSessionID for both internal hashing and the
middleware-level wireup so dedup keys stay aligned
Behavioural notes:
- nil-coordinator fallback preserves existing tests that build TraefikOidc
literals without going through the constructor
- followers receive the same TokenResponse/error as the leader, so no
per-instance code paths change
- existing TestGetNewTokenWithRefreshToken_Concurrency still passes
because it hits GetNewTokenWithRefreshToken directly, below the
coordinator boundary
Tests:
- refresh_coordinator_wireup_test.go: 50 concurrent refreshes coalesce
to <=2 upstream calls; distinct tokens still run in parallel; nil
coordinator falls back cleanly
* perf(cache): bound L1 backfill goroutines in HybridBackend
Get() and GetMany() previously spawned a goroutine per L2 hit to write
the value through to L1. Under sustained polling traffic (e.g. a Grafana
dashboard refreshing every 30s with N panels) this minted thousands of
goroutines, each running in Yaegi - directly contributing to the
~1000% CPU spike that pairs with the refresh-token herd.
Replace the per-hit goroutines with a single l1BackfillWorker fed by
l1BackfillBuffer, mirroring the existing asyncWriteBuffer/asyncWriteWorker
pattern for L2 writes. Buffer overflow drops the backfill (counted via
l1BackfillDrops) - a dropped backfill just means the next L2 hit for
that key re-queues it, which is safe.
Tests:
- TestHybridBackend_L1BackfillBounded: 1000 distinct L2 hits keep
goroutine count within +20 of baseline (pre-fix it grew by ~1000)
- TestHybridBackend_L1BackfillFullDrops: drops are accounted for when
the buffer is saturated and the worker is stopped
* feat(refresh): implement isRefreshTokenExpired heuristic
Replace the placeholder `return false` with a real check based on the
issued_at timestamp that SetRefreshToken already stamps into the session.
Gated by a new MaxRefreshTokenAgeSeconds config field (default 21600 =
6h, matching the existing comment). 0 disables the check.
This wires the previously-dead refreshTokenExpired branch in middleware.go,
which short-circuits AJAX requests with a 401 instead of letting them
hammer the IdP for a refresh token that's almost certainly stale - the
classic Grafana-after-long-pause failure mode.
Behaviour:
- maxRefreshTokenAge=0 disables the check (preserves prior behaviour)
- legacy sessions without issued_at still attempt one refresh; the IdP
remains the source of truth on first try
- nil-receiver and nil-session guards keep test code that builds
TraefikOidc literals safe
Tests:
- TestIsRefreshTokenExpired_DisabledWhenAgeZero
- TestIsRefreshTokenExpired_LegacySessionWithoutTimestamp
- TestIsRefreshTokenExpired_WithinWindow
- TestIsRefreshTokenExpired_BeyondWindow
- TestIsRefreshTokenExpired_NilGuards
* perf(token): skip parseJWT on cache hit in VerifyToken
The token cache fast-return existed but ran AFTER parseJWT, so every
validation paid for base64 + JSON unmarshal even on a hit. Under bursty
traffic (e.g. 10+ concurrent panel requests on every Grafana dashboard
refresh, each calling validateStandardTokens which verifies BOTH the
access token and the ID token), this is two redundant parses per
request multiplied by the panel count.
Move the cache lookup ahead of parseJWT. On a hit the function returns
nil immediately. On a miss the original flow runs unchanged.
Also nil-guard t.tokenCache to keep partial-literal test instances safe
(matches the same pattern we already use for tokenBlacklist).
Tests:
- TestVerifyToken_CacheHitSkipsParse: cache pre-populated with claims
for a token whose body would fail parseJWT - returns nil iff the
fast-path bypasses the parse
- TestVerifyToken_CacheMissStillParses: a syntactically valid but
unsigned token still errors past parseJWT on cache miss
* feat(refresh): cross-replica refresh-grant dedup via shared cache
The in-process RefreshCoordinator added in 9f96d8c already collapses
concurrent refresh-token grants on a single Traefik replica. With the
plugin's existing Redis (Dragonfly) cache infrastructure available, we
can extend that dedup across replicas: if pod A refreshes a token at
T+0 and pod B receives a request for the same session at T+1, pod B
should reuse pod A's result rather than POSTing the now-rotated refresh
token to the IdP.
Implementation:
- Add a refreshResultCache to UniversalCacheManager (memory-only when
Redis is disabled, Redis-backed in production via the existing
hybrid/Redis-only mode selection)
- Expose it through CacheManager.GetSharedRefreshResultCache and on the
TraefikOidc struct as refreshResultCache (CacheInterface)
- Inside the closure passed to RefreshCoordinator.CoordinateRefresh,
consult the cache first; on hit return immediately, on miss exchange
with the IdP and populate the cache for peers
- 5s TTL: long enough for siblings to observe, short enough that a
rotated refresh token cannot be re-supplied after the IdP has moved on
- Errors are intentionally NOT cached - peers must always be able to
retry on their own
Pragmatic choice: optimistic cache rather than a hard distributed lock.
- A hard lock (SET NX + poll) doubles Redis RTT and risks dead-locks
if a Traefik pod dies mid-grant.
- The user's BGP+Local externalTrafficPolicy already pins ingress for
a session to one node in steady state, so cross-pod racing is rare.
- This optimistic path catches the rare failover case without adding
failure modes.
Tests:
- TestCoordinatedTokenRefresh_CrossReplicaCacheHit: pre-populated cache
short-circuits the upstream call entirely (0 IdP calls)
- TestCoordinatedTokenRefresh_PopulatesCrossReplicaCache: leader stores
a successful result for peers to find
- TestCoordinatedTokenRefresh_ErrorIsNotCached: invalid_grant must not
poison the dedup cache - peers must retry independently
392 lines
15 KiB
Go
392 lines
15 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// validateRedirectCount checks if redirect limit is exceeded and handles the error
|
|
func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error {
|
|
const maxRedirects = 5
|
|
redirectCount := session.GetRedirectCount()
|
|
if redirectCount >= maxRedirects {
|
|
t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects)
|
|
session.ResetRedirectCount()
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected)
|
|
return fmt.Errorf("redirect limit exceeded")
|
|
}
|
|
|
|
session.IncrementRedirectCount()
|
|
return nil
|
|
}
|
|
|
|
// generatePKCEParameters generates PKCE code verifier and challenge if PKCE is enabled
|
|
func (t *TraefikOidc) generatePKCEParameters() (string, string, error) {
|
|
if !t.enablePKCE {
|
|
return "", "", nil
|
|
}
|
|
|
|
codeVerifier, err := generateCodeVerifier()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to generate code verifier: %w", err)
|
|
}
|
|
|
|
codeChallenge := deriveCodeChallenge(codeVerifier)
|
|
t.logger.Debugf("PKCE enabled, generated code challenge")
|
|
|
|
return codeVerifier, codeChallenge, nil
|
|
}
|
|
|
|
// prepareSessionForAuthentication clears existing session data and sets new authentication state
|
|
func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrfToken, nonce, codeVerifier, incomingPath string) {
|
|
// Clear all existing session data
|
|
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication state on new flow
|
|
session.SetEmail("")
|
|
session.SetAccessToken("")
|
|
session.SetRefreshToken("")
|
|
session.SetIDToken("")
|
|
session.SetNonce("")
|
|
session.SetCodeVerifier("")
|
|
|
|
// Set new authentication state
|
|
session.SetCSRF(csrfToken)
|
|
session.SetNonce(nonce)
|
|
if t.enablePKCE && codeVerifier != "" {
|
|
session.SetCodeVerifier(codeVerifier)
|
|
}
|
|
session.SetIncomingPath(incomingPath)
|
|
t.logger.Debugf("Storing incoming path: %s", incomingPath)
|
|
}
|
|
|
|
// defaultInitiateAuthentication initiates the OIDC authentication flow.
|
|
// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session,
|
|
// stores authentication state, and redirects the user to the OIDC provider.
|
|
// Parameters:
|
|
// - rw: The HTTP response writer.
|
|
// - req: The HTTP request initiating authentication.
|
|
// - session: The session data to prepare for authentication.
|
|
// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance.
|
|
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
|
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
|
|
|
// Check and handle redirect limits
|
|
if err := t.validateRedirectCount(session, rw, req); err != nil {
|
|
return
|
|
}
|
|
|
|
csrfToken, err := newUUIDv4()
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to generate CSRF token: %v", err)
|
|
http.Error(rw, "Failed to generate CSRF token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
nonce, err := generateNonce()
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to generate nonce: %v", err)
|
|
http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Generate PKCE parameters if enabled
|
|
codeVerifier, codeChallenge, err := t.generatePKCEParameters()
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to generate PKCE parameters: %v", err)
|
|
http.Error(rw, "Failed to generate PKCE parameters", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Clear existing session data and set new authentication state
|
|
t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI())
|
|
|
|
session.MarkDirty()
|
|
|
|
if err := session.Save(req, rw); err != nil {
|
|
t.logger.Errorf("Failed to save session before redirecting to provider: %v", err)
|
|
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s",
|
|
csrfToken, nonce)
|
|
|
|
authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge)
|
|
t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL)
|
|
|
|
http.Redirect(rw, req, authURL, http.StatusFound)
|
|
}
|
|
|
|
// handleCallback processes the OIDC callback after user authentication.
|
|
// It validates state/CSRF tokens, exchanges authorization code for tokens,
|
|
// verifies the received tokens, extracts claims, and establishes the session.
|
|
// Parameters:
|
|
// - rw: The HTTP response writer.
|
|
// - req: The callback request containing authorization code and state.
|
|
// - redirectURL: The fully qualified callback URL (used in the token exchange request).
|
|
func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
|
|
session, err := t.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.logger.Errorf("Session error during callback: %v", err)
|
|
t.sendErrorResponse(rw, req, "Session error during callback", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer session.returnToPoolSafely()
|
|
|
|
t.logger.Debugf("Handling callback, URL: %s", req.URL.String())
|
|
|
|
if req.URL.Query().Get("error") != "" {
|
|
errorDescription := req.URL.Query().Get("error_description")
|
|
if errorDescription == "" {
|
|
errorDescription = req.URL.Query().Get("error")
|
|
}
|
|
t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
|
|
t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
state := req.URL.Query().Get("state")
|
|
if state == "" {
|
|
t.logger.Error("No state in callback")
|
|
t.sendErrorResponse(rw, req, "State parameter missing in callback", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
csrfToken := session.GetCSRF()
|
|
if csrfToken == "" {
|
|
t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s",
|
|
session.GetAuthenticated(), req.URL.String())
|
|
|
|
cookie, err := req.Cookie("_oidc_raczylo_m")
|
|
if err != nil {
|
|
t.logger.Errorf("Main session cookie not found in request: %v", err)
|
|
} else {
|
|
t.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value))
|
|
}
|
|
|
|
t.sendErrorResponse(rw, req, "CSRF token missing in session", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if state != csrfToken {
|
|
t.logger.Error("State parameter does not match CSRF token in session during callback")
|
|
t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := req.URL.Query().Get("code")
|
|
if code == "" {
|
|
t.logger.Error("No code in callback")
|
|
t.sendErrorResponse(rw, req, "No authorization code received in callback", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
codeVerifier := session.GetCodeVerifier()
|
|
|
|
tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to exchange code for token during callback: %v", err)
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err = t.verifyToken(tokenResponse.IDToken); err != nil {
|
|
t.logger.Errorf("Failed to verify id_token during callback: %v", err)
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
claims, err := t.extractClaimsFunc(tokenResponse.IDToken)
|
|
if err != nil {
|
|
t.logger.Errorf("Failed to extract claims during callback: %v", err)
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
nonceClaim, ok := claims["nonce"].(string)
|
|
if !ok || nonceClaim == "" {
|
|
t.logger.Error("Nonce claim missing in id_token during callback")
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sessionNonce := session.GetNonce()
|
|
if sessionNonce == "" {
|
|
t.logger.Error("Nonce not found in session during callback")
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if nonceClaim != sessionNonce {
|
|
t.logger.Error("Nonce claim does not match session nonce during callback")
|
|
t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Extract user identifier from the configured claim (defaults to "email" for backward compatibility)
|
|
userIdentifier, _ := claims[t.userIdentifierClaim].(string)
|
|
if userIdentifier == "" {
|
|
// Try "sub" as fallback since it's required by OIDC spec
|
|
if t.userIdentifierClaim != "sub" {
|
|
userIdentifier, _ = claims["sub"].(string)
|
|
}
|
|
if userIdentifier == "" {
|
|
t.logger.Errorf("User identifier claim '%s' missing or empty in token during callback", t.userIdentifierClaim)
|
|
t.sendErrorResponse(rw, req, "Authentication failed: User identifier missing in token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
t.logger.Debugf("Configured claim '%s' not found, using 'sub' claim as fallback", t.userIdentifierClaim)
|
|
}
|
|
|
|
// Validate user authorization
|
|
if !t.isAllowedUser(userIdentifier) {
|
|
t.logger.Errorf("User not authorized during callback: %s", userIdentifier)
|
|
t.sendErrorResponse(rw, req, "Authentication failed: User not authorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := session.SetAuthenticated(true); err != nil {
|
|
t.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err)
|
|
t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
session.SetEmail(userIdentifier) // SetEmail stores the user identifier (email or other claim)
|
|
session.SetIDToken(tokenResponse.IDToken)
|
|
session.SetAccessToken(tokenResponse.AccessToken)
|
|
session.SetRefreshToken(tokenResponse.RefreshToken)
|
|
|
|
session.SetCSRF("")
|
|
session.SetNonce("")
|
|
session.SetCodeVerifier("")
|
|
|
|
session.ResetRedirectCount()
|
|
|
|
redirectPath := "/"
|
|
if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath {
|
|
redirectPath = incomingPath
|
|
}
|
|
session.SetIncomingPath("")
|
|
|
|
if err := session.Save(req, rw); err != nil {
|
|
t.logger.Errorf("Failed to save session after callback: %v", err)
|
|
t.sendErrorResponse(rw, req, "Failed to save session after callback", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
t.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
|
|
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
|
}
|
|
|
|
// handleExpiredToken handles requests with expired or invalid tokens.
|
|
// It clears the session data and initiates a new authentication flow.
|
|
// Parameters:
|
|
// - rw: The HTTP response writer.
|
|
// - req: The HTTP request with expired token.
|
|
// - session: The session data to clear.
|
|
// - redirectURL: The callback URL to be used in the new authentication flow.
|
|
func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
|
t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.")
|
|
_ = session.SetAuthenticated(false) // Safe to ignore: clearing authentication on expired token
|
|
session.SetIDToken("")
|
|
session.SetAccessToken("")
|
|
session.SetRefreshToken("")
|
|
session.SetEmail("")
|
|
// Clear CSRF tokens to prevent replay attacks
|
|
session.SetCSRF("")
|
|
session.SetNonce("")
|
|
session.SetCodeVerifier("")
|
|
// Reset redirect count to prevent loops when handling expired tokens
|
|
session.ResetRedirectCount()
|
|
|
|
if err := session.Save(req, rw); err != nil {
|
|
t.logger.Errorf("Failed to save cleared session during expired token handling: %v", err)
|
|
}
|
|
|
|
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")
|
|
contentType := req.Header.Get("Content-Type")
|
|
accept := req.Header.Get("Accept")
|
|
|
|
return xhr == "XMLHttpRequest" ||
|
|
strings.Contains(contentType, "application/json") ||
|
|
strings.Contains(accept, "application/json")
|
|
}
|
|
|
|
// isNonNavigationRequest reports whether the request is a browser
|
|
// sub-resource (script, image, stylesheet, fetch, serviceWorker) rather than
|
|
// a top-level HTML navigation. Non-navigation requests MUST NOT trigger an
|
|
// OIDC redirect flow: several sub-resource loads happening in parallel would
|
|
// each call defaultInitiateAuthentication, each overwriting the session's
|
|
// CSRF/nonce, breaking the eventual callback (issue #129).
|
|
//
|
|
// Detection prefers Sec-Fetch-Mode, which all modern browsers send
|
|
// (Chrome/Edge/Firefox/Safari). For older or non-browser clients we fall
|
|
// back to Accept: if Accept is present and does not list text/html, treat
|
|
// it as a sub-resource. An empty/missing Accept is assumed to be navigation
|
|
// (safer to redirect than 401 on an ambiguous request).
|
|
func (t *TraefikOidc) isNonNavigationRequest(req *http.Request) bool {
|
|
if mode := req.Header.Get("Sec-Fetch-Mode"); mode != "" {
|
|
return mode != "navigate"
|
|
}
|
|
accept := req.Header.Get("Accept")
|
|
if accept == "" || accept == "*/*" {
|
|
return false
|
|
}
|
|
return !strings.Contains(accept, "text/html")
|
|
}
|
|
|
|
// isRefreshTokenExpired checks whether the stored refresh token is likely
|
|
// past its useful lifetime, using the cookie-side issued_at timestamp set by
|
|
// SetRefreshToken. IdPs do not expose RT TTL on the wire, so this is a
|
|
// conservative heuristic gated by t.maxRefreshTokenAge (default 6h, set via
|
|
// MaxRefreshTokenAgeSeconds; 0 disables the check).
|
|
//
|
|
// The point of this check is to short-circuit the refresh path BEFORE the
|
|
// thundering herd hits the IdP for a token the provider has almost certainly
|
|
// revoked. Together with the RefreshCoordinator wireup, it keeps Grafana-
|
|
// style polling clients from looping on invalid_grant after a long pause.
|
|
func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool {
|
|
if t == nil || session == nil {
|
|
return false
|
|
}
|
|
if t.maxRefreshTokenAge <= 0 {
|
|
return false
|
|
}
|
|
|
|
issuedAt := session.GetRefreshTokenIssuedAt()
|
|
if issuedAt.IsZero() {
|
|
// No timestamp recorded (legacy session pre-dating the issued_at
|
|
// field). Don't force a re-auth - attempt refresh once and let the
|
|
// IdP be the source of truth.
|
|
return false
|
|
}
|
|
|
|
return time.Since(issuedAt) > t.maxRefreshTokenAge
|
|
}
|