Files
traefikoidc/token_validation_rs.go
T
lukaszraczylo fd3fbbdbab fix(security): classify audience failures by sentinel error
The lenient-audience path chose itself when the verify error text
contained the word audience. The key lookup error embeds the kid from the
unverified token header. A kid of audience made a failed signature look
like an audience-only failure. verifyAudience now returns a sentinel and
the caller uses errors.Is.
2026-11-03 01:24:28 +00:00

464 lines
17 KiB
Go

// 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"
"errors"
"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)
}
// accessTokenUnexpired reports whether a signature-verified access
// token's time claims still hold. Used on the lenient-audience path where
// jwt.Verify short-circuits at the aud check BEFORE reaching exp/iat/nbf
// validation, leaving all time claims unvalidated. Re-applies the shared
// verifyTimeClaims helper (jwt.go) — exp required, iat validated only when
// present (RFC 7519 §4.1.6), nbf validated when present — so this path
// cannot drift onto a stricter iat contract than jwt.Verify itself
// (R126/FIX-27: this function once hard-required iat after jwt.Verify made
// it optional, forcing refresh/re-auth for an otherwise-valid, iat-less
// lenient-audience token).
func (t *TraefikOidc) accessTokenUnexpired(token string) bool {
parsed, err := parseJWT(token)
if err != nil {
return false
}
return verifyTimeClaims(parsed.Claims) == nil
}
// 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) {
// Defense-in-depth (F2/R133): this is the final authenticated gate.
// Every current caller has already passed the blacklist check via
// verifyToken, but guard independently so a revoked-but-still-cached
// token can never be served authenticated if the call order changes.
if t.tokenBlacklist != nil {
if b, ok := t.tokenBlacklist.Get(token); ok && b != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
}
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()
// Apply the same clock-skew leeway as jwt.Verify's verifyExpiration
// (ClockSkewToleranceFuture). This is the final auth gate run after
// successful signature verification; without the leeway a token
// within the 2-minute post-exp window was granted by Verify but
// downgraded to here as expired, forcing a needless refresh or
// re-auth (R124).
if nowObj.After(expTimeObj.Add(ClockSkewToleranceFuture)) {
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 {
// validateOpaqueToken returns introspectToken's *HTTPError
// (a non-200 response from the introspection endpoint)
// UNWRAPPED (not via fmt.Errorf %w), specifically so a plain
// type assertion here is sufficient and yaegi-safe: under
// yaegi v0.16.1 errors.As(err, &target) panics whenever
// target's pointed-to type is interpreted (*HTTPError is
// declared in this plugin), and an interpreted *HTTPError
// wrapped with %w cannot be recovered by a manual
// errors.Unwrap walk under yaegi either (verified this
// session) — so the producer must not wrap it at all. See
// asHTTPError in error_recovery.go for the general pattern.
//
// HTTPError.Message embeds up to 10 KiB of the IdP's own
// response body. That body is attacker/IdP-controlled text,
// not a verdict on the presented token — a 401/403/429 whose
// body happens to contain "revoked" or similar must not be
// read as "token is not active" (FIX-13). Detect an
// *HTTPError specifically and route it straight to the
// requireTokenIntrospection/transient handling below,
// BEFORE the substring match runs. Only validateOpaqueToken's
// own generated messages (active=false, expired, nbf — never
// an *HTTPError) may still reach the substring classification.
_, isHTTPError := err.(*HTTPError)
errMsg := err.Error()
isTokenInvalid := !isHTTPError && (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
}
// Any other introspection error — a network failure, or a
// 4xx from the introspection endpoint itself — is not a
// statement about the presented token. RFC 7662 s2.2 defines
// only a 200 response with active=false as "not active";
// s2.3 defines a 401 as the RESOURCE's (this plugin's) own
// client credentials being rejected, not the token, and a
// 408/429 as the endpoint throttling or timing out (FIX-13,
// supersedes R156's blanket 4xx-as-revoked classification).
// requireTokenIntrospection keeps the fail-closed behavior
// below; otherwise fall through to ID-token validation
// rather than forcing a refresh for a token that may still
// be perfectly valid.
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)
}
// No ID token to corroborate an access token we cannot verify
// (Azure nonce-bearing Graph access tokens carry a proprietary,
// client-unverifiable signature). Do NOT authenticate on an
// unverified token: refresh if a refresh token is available,
// otherwise force re-authentication.
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
}
// Fall back to ID-token validation when opaque + no successful introspection.
if rs.idToken == "" {
if rs.refreshToken != "" {
return false, true, false
}
// Opaque access token, no ID token to corroborate it, and
// introspection was unavailable/disabled/errored (e.g.
// circuit-breaker open). There is nothing left to verify the token
// against, so fail closed and force re-authentication rather than
// trusting an unverified opaque token.
return false, false, true
}
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
lenientAudienceOnly := false
var accessVerifyErr error
if err := t.verifyToken(rs.accessToken); err != nil {
accessVerifyErr = err
if errors.Is(err, errInvalidAudience) {
if t.strictAudienceValidation {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
// Lenient audience validation: the token's signature verified; only
// the audience was left unchecked. Fall through to ID-token
// validation, remembering the access token was structurally valid.
lenientAudienceOnly = true
}
} else {
accessTokenValid = true
}
if rs.idToken == "" {
if accessTokenValid {
return t.validateTokenExpiryRS(rs, rs.accessToken)
}
if lenientAudienceOnly {
// Access token signature verified; audience check was lenient and
// there is no ID token to compare against. jwt.Verify returns at
// the aud check BEFORE reaching the exp check, so expiry was never
// validated on this path — an expired, wrong-audience token would
// otherwise be accepted as authenticated. Require the token's time
// claims (exp, iat, nbf) to all still hold before trusting its
// claims for authorization.
if !t.accessTokenUnexpired(rs.accessToken) {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
if rs.refreshToken != "" {
return true, true, false
}
return true, false, false
}
// Access token failed verification (expired, bad signature, issuer, ...)
// and there is no ID token to corroborate it. Fail closed rather than
// trusting an unverified token: refresh if possible, otherwise force
// re-authentication.
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
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)
}
// Access token is a JWT whose verification failed, yet the session's
// ID token is still valid. If the failure was specifically EXPIRY —
// verifyToken checks the signature before exp, so "token has expired"
// implies the signature and other claims validated — the access token
// is unusable at the upstream resource. Refresh to obtain a fresh one
// rather than forwarding a stale access token (R131). Without this an
// expired access token (with a still-valid ID token) was authenticated
// with needsRefresh=false and forwarded as-is until the ID token
// itself neared expiry, giving the backend an expired bearer token.
if accessVerifyErr != nil && strings.Contains(accessVerifyErr.Error(), "token has expired") {
if rs.refreshToken != "" {
return true, true, false
}
return false, false, true
}
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
}
// No refresh token to use: match the standard path and fail without
// pretending refresh is possible (the previous copy-paste returned
// needsRefresh=true with nothing to refresh with).
return false, false, 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)
}
// No ID token to corroborate the Azure token. Its signature
// can't be verified client-side, but it still carries exp;
// authenticate only while unexpired. Previously this
// returned authenticated=true with no expiry check, so an
// expired (or unparseable) Azure access token authenticated
// indefinitely until the session itself expired (R129).
if claims, err := extractClaims(rs.accessToken); err != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
} else {
// Defense-in-depth (mirrors validateTokenExpiryRS): a
// revoked-but-still-cached token must not authenticate
// through this unverifiable branch either (R147).
if t.tokenBlacklist != nil {
if b, ok := t.tokenBlacklist.Get(rs.accessToken); ok && b != nil {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
}
// exp must be present and numeric to establish the token
// is current; an unverifiable token with no (or
// non-numeric) exp authenticates with zero verification,
// so fail closed when it can't be bounded (R130).
exp, ok := claims["exp"].(float64)
if !ok || time.Now().After(time.Unix(int64(exp), 0).Add(ClockSkewToleranceFuture)) {
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
}
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)
}
// Opaque access token with no ID token to corroborate it. Do not
// authenticate on an unverified (or unverifiable) token: refresh
// if a refresh token is available, otherwise force
// re-authentication. Mirrors validateStandardTokensRS, which
// documents this exact decision ("Do NOT authenticate on an
// unverified token"). The previous `return true, false, false`
// accepted any opaque value here with no verification (R99).
if rs.refreshToken != "" {
return false, true, false
}
return false, false, true
}
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
}