mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
fix(auth): harden OIDC flow, token validation, session, and bearer handling
- 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
This commit is contained in:
+97
-2
@@ -15,6 +15,7 @@ package traefikoidc
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -81,6 +82,19 @@ func (t *TraefikOidc) accessTokenUnexpired(token string) bool {
|
||||
// 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")
|
||||
@@ -102,7 +116,13 @@ func (t *TraefikOidc) validateTokenExpiryRS(rs *requestState, token string) (boo
|
||||
expTimeObj := time.Unix(int64(expClaim), 0)
|
||||
nowObj := time.Now()
|
||||
|
||||
if expTimeObj.Before(nowObj) {
|
||||
// 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
|
||||
}
|
||||
@@ -177,6 +197,19 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
|
||||
}
|
||||
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
|
||||
@@ -231,7 +264,9 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
|
||||
// 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 {
|
||||
@@ -298,6 +333,21 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -326,6 +376,41 @@ func (t *TraefikOidc) validateAzureTokensRS(rs *requestState) (bool, bool, bool)
|
||||
}
|
||||
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 {
|
||||
@@ -349,7 +434,17 @@ func (t *TraefikOidc) validateAzureTokensRS(rs *requestState) (bool, bool, bool)
|
||||
if rs.idToken != "" {
|
||||
return t.validateTokenExpiryRS(rs, rs.idToken)
|
||||
}
|
||||
return true, false, false
|
||||
// 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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user