mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
- replay entry covers exp + clock-skew window; callback GET-only (RFC 6749) - callback rate-limit returns 429 + Retry-After; panic-recovery sets no-store - cross-chunk integrity for chunked tokens; bounded chunked-token read scan - HTTPError classification so 5xx/429 token-endpoint responses retry - redaction of token/code/state in logs; per-source external auth gate
465 lines
16 KiB
Go
465 lines
16 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-apply the same
|
|
// clock-skew-aware checks the main path applies (verifyExpiration,
|
|
// verifyIssuedAt with iat required, and verifyNotBefore when present) so a
|
|
// token that is expired, used before its issue time, or not yet valid (nbf)
|
|
// is not trusted for authorization here.
|
|
func (t *TraefikOidc) accessTokenUnexpired(token string) bool {
|
|
parsed, err := parseJWT(token)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
claims := parsed.Claims
|
|
|
|
exp, ok := claims["exp"].(float64)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if err := verifyExpiration(exp); err != nil {
|
|
return false
|
|
}
|
|
|
|
iat, ok := claims["iat"].(float64)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if err := verifyIssuedAt(iat); err != nil {
|
|
return false
|
|
}
|
|
|
|
if nbf, ok := claims["nbf"].(float64); ok {
|
|
if err := verifyNotBefore(nbf); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
// A definitive 4xx from the introspection endpoint (e.g. 401
|
|
// for an unknown/revoked token) means the token itself is bad,
|
|
// not that the endpoint transiently failed. Treat it as
|
|
// invalid (refresh) rather than falling through to
|
|
// ID-token-only auth, which would authenticate a revoked
|
|
// opaque token (R156).
|
|
var httpErr *HTTPError
|
|
if errors.As(err, &httpErr) && httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 {
|
|
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)
|
|
}
|
|
// 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
|
|
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
|
|
}
|
|
// 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
|
|
}
|