Files
traefikoidc/helpers.go
T
lukaszraczylo 6e522fd212 fix(security): scope the verified-token cache to its middleware instance
The verified-token cache is one process-wide cache keyed by the raw
token. A verdict stored by one middleware instance satisfied any other
instance, so a token valid for one client or audience skipped the
issuer, audience and signature checks on another route. Each instance
now prefixes keys with its provider, client ID and audience. Dynamic
client registration updates the prefix.
2026-11-03 01:15:45 +00:00

626 lines
24 KiB
Go

package traefikoidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync/atomic"
"time"
)
// newUUIDv4 returns an RFC 4122 v4 UUID string (e.g.
// "f47ac10b-58cc-4372-a567-0e02b2c3d479") backed by crypto/rand. Used for CSRF
// tokens and other opaque random identifiers — replaces github.com/google/uuid
// to keep the plugin stdlib-only on the production path.
func newUUIDv4() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("could not generate UUID: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// generateNonce creates a cryptographically secure random nonce for OIDC flows.
// The nonce is used to prevent replay attacks and associate client sessions with ID tokens.
// Returns:
// - A base64 URL-encoded nonce string (43 characters)
// - An error if the random byte generation fails
func generateNonce() (string, error) {
nonceBytes := make([]byte, 32)
_, err := rand.Read(nonceBytes)
if err != nil {
return "", fmt.Errorf("could not generate nonce: %w", err)
}
return base64.URLEncoding.EncodeToString(nonceBytes), nil
}
// generateCodeVerifier creates a PKCE code verifier according to RFC 7636.
// The code verifier is a cryptographically random string used for the PKCE flow
// to prevent authorization code interception attacks.
// Returns:
// - A base64 raw URL-encoded code verifier string (43 characters)
// - An error if the random byte generation fails
func generateCodeVerifier() (string, error) {
verifierBytes := make([]byte, 32)
_, err := rand.Read(verifierBytes)
if err != nil {
return "", fmt.Errorf("could not generate code verifier: %w", err)
}
return base64.RawURLEncoding.EncodeToString(verifierBytes), nil
}
// deriveCodeChallenge creates a PKCE code challenge from the code verifier.
// It computes the SHA-256 hash of the code verifier and base64 URL-encodes it
// according to RFC 7636 specification.
// Parameters:
// - codeVerifier: The code verifier string
//
// Returns:
// - The base64 URL encoded SHA-256 hash of the code verifier (code challenge)
func deriveCodeChallenge(codeVerifier string) string {
hasher := sha256.New()
hasher.Write([]byte(codeVerifier))
hash := hasher.Sum(nil)
return base64.RawURLEncoding.EncodeToString(hash)
}
// TokenResponse represents the standard OAuth 2.0/OIDC token response.
// It contains the tokens and metadata returned by the authorization server during
// code exchange or token refresh operations.
type TokenResponse struct {
// IDToken contains the OpenID Connect identity token (JWT)
IDToken string `json:"id_token"`
// AccessToken is the OAuth 2.0 access token for API access
AccessToken string `json:"access_token"`
// RefreshToken allows obtaining new tokens when the access token expires
RefreshToken string `json:"refresh_token"`
// TokenType specifies the token type (typically "Bearer")
TokenType string `json:"token_type"`
// ExpiresIn indicates token lifetime in seconds
ExpiresIn int `json:"expires_in"`
}
// exchangeTokens performs OAuth 2.0 token exchange with the authorization server.
// It supports both authorization code and refresh token grant types with PKCE support.
// Parameters:
// - ctx: Context for request timeout and cancellation
// - grantType: OAuth grant type ("authorization_code" or "refresh_token")
// - codeOrToken: Authorization code or refresh token depending on grant type
// - redirectURL: Redirect URI used in authorization (required for code exchange)
// - codeVerifier: PKCE code verifier (optional, used with PKCE flow)
//
// Returns:
// - *TokenResponse: Parsed token response from the authorization server
// - An error if the token exchange fails (e.g., network error, provider error, invalid grant)
func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
data := url.Values{
"grant_type": {grantType},
}
// Snapshot client credentials under metadataMu: DCR rewrites clientID/
// clientSecret/audience at runtime (R137).
clientID, clientSecret, clientAuthMethod, _, clientAssertion := t.clientCredentials()
// client_id is sent in the body for every method except client_secret_basic,
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
if clientAuthMethod != "client_secret_basic" || clientAssertion != nil {
data.Set("client_id", clientID)
}
if grantType == "authorization_code" {
data.Set("code", codeOrToken)
data.Set("redirect_uri", redirectURL)
if codeVerifier != "" {
data.Set("code_verifier", codeVerifier)
}
} else if grantType == "refresh_token" {
data.Set("refresh_token", codeOrToken)
}
// RFC 8707 §2.2: repeat the same resource indicator sent on the
// authorization request so refreshed tokens keep the same audience
// binding as the original grant.
if t.resource != "" {
data.Set("resource", t.resource)
}
client := t.tokenHTTPClient
if client == nil {
// Use shared transport pool to prevent memory leaks
jar, _ := cookiejar.New(nil) // Safe to ignore: cookiejar creation with nil options rarely fails
pooledClient := CreateTokenHTTPClient()
client = &http.Client{
Transport: pooledClient.Transport,
Timeout: pooledClient.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("stopped after 50 redirects")
}
return nil
},
Jar: jar,
}
}
// Read tokenURL with RLock — needed as audience for private_key_jwt (RFC 7523 §3).
t.metadataMu.RLock()
tokenURL := t.tokenURL
t.metadataMu.RUnlock()
useBasicAuth := false
if clientAssertion != nil {
assertion, err := clientAssertion.Sign(tokenURL, clientID)
if err != nil {
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
}
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
data.Set("client_assertion", assertion)
} else if clientAuthMethod == "client_secret_basic" {
useBasicAuth = true
} else {
data.Set("client_secret", clientSecret)
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if useBasicAuth {
setOAuthBasicAuth(req, clientID, clientSecret)
}
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
if err != nil {
return nil, fmt.Errorf("failed to exchange tokens: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body) // Safe to ignore: draining response body on defer
_ = resp.Body.Close() // Safe to ignore: closing body on defer
}()
if resp.StatusCode != http.StatusOK {
limitReader := io.LimitReader(resp.Body, 1024*10)
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
// Return a *HTTPError so the retry executor's isRetryableError can
// classify transient 5xx/429 responses and actually retry (R118).
// A plain error here made the 5xx/429 retry branch dead in
// production — every token-endpoint status was treated as permanent.
return nil, &HTTPError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes)),
}
}
var tokenResponse TokenResponse
// Cap the decoded body so a corrupted or malicious oversized 200-body cannot
// drive an unbounded read/allocation. Token responses are small (a few KB).
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tokenResponse); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
// RFC 6749 §5.1: a token response MUST include an access_token. A 200
// that omitted it is a protocol violation, not a success — treat it as an
// error here so no caller authenticates with a blank token (R158).
// Previously this seam returned success with an empty access_token and
// each caller had to re-check "AccessToken == \"\"", which several
// paths missed.
if tokenResponse.AccessToken == "" {
return nil, fmt.Errorf("token endpoint returned 200 without an access_token (RFC 6749 §5.1)")
}
return &tokenResponse, nil
}
// getNewTokenWithRefreshToken refreshes access and ID tokens using a refresh token.
// This is used when the current tokens are expired but the refresh token is still valid.
// It now uses the TokenResilienceManager for circuit breaker and retry logic.
// Parameters:
// - refreshToken: The refresh token to exchange for new tokens
//
// Returns:
// - *TokenResponse: New token set from the authorization server
// - An error if the refresh operation fails
//
// redactToken returns a short, stable hash of a token credential suitable for
// debug logging without leaking the credential itself (R138).
func redactToken(token string) string {
if token == "" {
return "(empty)"
}
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:4])
}
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
ctx := context.Background()
// Use token resilience manager if available, otherwise fall back to direct call
if t.tokenResilienceManager != nil {
return t.tokenResilienceManager.ExecuteTokenRefresh(ctx, t, refreshToken)
}
// Fallback for backward compatibility
tokenResponse, err := t.exchangeTokens(ctx, "refresh_token", refreshToken, "", "")
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
return tokenResponse, nil
}
// extractClaims extracts and parses claims from a JWT token without signature verification.
// This is a utility function for quickly accessing token payload data when signature
// verification is not required or has already been performed.
// Parameters:
// - tokenString: The JWT token string to parse
//
// Returns:
// - map[string]interface{}: Parsed claims from the token payload
// - An error if the token format is invalid, decoding fails, or JSON unmarshaling fails
func extractClaims(tokenString string) (map[string]interface{}, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid token format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode token payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
}
return claims, nil
}
// TokenCache provides a specialized cache for JWT tokens and their parsed claims.
// It wraps the UniversalCache with token-specific operations.
type TokenCache struct {
// ns scopes entries to one middleware instance (issuer|client|audience)
// so a verdict cached by one instance cannot satisfy another.
ns atomic.Value
// cache is the underlying universal cache implementation
cache *UniversalCache
}
// SetNamespace sets the verification context that scopes this cache's keys.
func (tc *TokenCache) SetNamespace(ns string) {
if tc == nil {
return
}
tc.ns.Store(ns)
}
func (tc *TokenCache) key(token string) string {
if ns, _ := tc.ns.Load().(string); ns != "" {
return "t-" + ns + "|" + token
}
return "t-" + token
}
// NewTokenCache creates and initializes a new TokenCache.
// It uses the global cache manager to ensure singleton behavior.
func NewTokenCache() *TokenCache {
manager := GetUniversalCacheManager(nil)
return &TokenCache{
cache: manager.GetTokenCache(),
}
}
// Set stores parsed token claims in the cache with expiration.
// The token is prefixed to prevent collisions with other cache entries.
// Parameters:
// - token: The JWT token string (used as cache key)
// - claims: Parsed claims from the token
// - expiration: The duration for which the cache entry should be valid
func (tc *TokenCache) Set(token string, claims map[string]interface{}, expiration time.Duration) {
token = tc.key(token)
_ = tc.cache.Set(token, claims, expiration) // Safe to ignore: cache failures are non-critical
}
// Get retrieves cached claims for a token.
// Parameters:
// - token: The JWT token string to look up
//
// Returns:
// - map[string]interface{}: The cached claims if found
// - A boolean indicating whether the token was found in the cache (true if found, false otherwise)
func (tc *TokenCache) Get(token string) (map[string]interface{}, bool) {
token = tc.key(token)
value, found := tc.cache.Get(token)
if !found {
return nil, false
}
claims, ok := value.(map[string]interface{})
return claims, ok
}
// Delete removes a token from the cache.
// Parameters:
// - token: The raw token string to remove from the cache
func (tc *TokenCache) Delete(token string) {
token = tc.key(token)
tc.cache.Delete(token)
}
// Cleanup removes expired entries from the token cache.
// This is a no-op as cleanup is handled internally by UniversalCache.
func (tc *TokenCache) Cleanup() {
// Cleanup is handled internally by UniversalCache
}
// Close stops the cleanup goroutine and releases resources.
// This is a no-op as the cache is managed globally.
func (tc *TokenCache) Close() {
// Cache is managed globally by UniversalCacheManager
}
// Clear removes all items from the cache
func (tc *TokenCache) Clear() {
tc.cache.Clear()
}
// exchangeCodeForToken exchanges an authorization code for tokens.
// This implements the OAuth 2.0 authorization code flow with optional PKCE support.
// It now uses the TokenResilienceManager for circuit breaker and retry logic.
// Parameters:
// - code: The authorization code received from the authorization server
// - redirectURL: The redirect URI used in the authorization request
// - codeVerifier: PKCE code verifier (used if PKCE is enabled)
//
// Returns:
// - *TokenResponse: The token response containing access, refresh, and ID tokens
// - An error if the code exchange fails
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
ctx := context.Background()
effectiveCodeVerifier := ""
if t.enablePKCE && codeVerifier != "" {
effectiveCodeVerifier = codeVerifier
}
// Use token resilience manager if available, otherwise fall back to direct call
if t.tokenResilienceManager != nil {
return t.tokenResilienceManager.ExecuteTokenExchange(ctx, t, "authorization_code", code, redirectURL, effectiveCodeVerifier)
}
// Fallback for backward compatibility
tokenResponse, err := t.exchangeTokens(ctx, "authorization_code", code, redirectURL, effectiveCodeVerifier)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
return tokenResponse, nil
}
// createStringMap converts a slice of strings to a set-like map for fast lookups.
// This is a utility function for creating efficient membership tests.
// Parameters:
// - keys: Slice of strings to convert to a map
//
// Returns:
// - A map where the keys are the strings from the input slice and the values are empty structs
func createStringMap(keys []string) map[string]struct{} {
result := make(map[string]struct{})
for _, key := range keys {
result[key] = struct{}{}
}
return result
}
// handleLogout processes user logout requests and performs proper session cleanup.
// It retrieves the ID token for logout URL construction, clears the session,
// and redirects to the provider's logout endpoint or configured post-logout URI.
// It handles potential errors during session retrieval or clearing.
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
t.logger.Debug("Processing logout request")
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Errorf("Error getting session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
idToken := session.GetIDToken()
accessToken := session.GetAccessToken()
refreshToken := session.GetRefreshToken()
// Capture the trusted origin BEFORE Clear wipes the session. redirect_url
// was persisted at authentication-initiate time (R136) and is therefore
// the exact scheme+host the IdP already accepted as this deployment's
// redirect_uri — unlike the current request's Host/X-Forwarded-Host,
// the browser cannot spoof it on this logout request (FIX-07).
trustedOrigin := trustedOriginFromRedirectURL(session.GetRedirectURL())
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing session: %v", err)
http.Error(rw, "Session error", http.StatusInternalServerError)
return
}
// Blacklist the session's tokens so a token captured before logout (e.g. a
// bearer mode access token) cannot be reused until its natural expiry.
// RevokeToken is a no-op-safe local blacklist (guards nil) and skips
// empty values.
for _, tok := range []string{accessToken, idToken, refreshToken} {
if tok != "" {
t.RevokeToken(tok)
}
}
// Best-effort provider-side revocation (single logout): the local
// blacklist only stops reuse in-process, but a token captured before
// logout (e.g. a bearer-mode access token or the refresh token) stays
// valid at the IdP and a captured refresh token could keep minting new
// tokens. Revoke both access and refresh tokens at the provider when a
// revocation endpoint is available. Each call is bounded by
// revocationRequestTimeout so a slow or unreachable provider cannot
// hold the logout redirect open indefinitely (FIX-33). A genuine
// failure is logged at error level; the expected "no revocation
// endpoint configured" case (most deployments) stays silent so it
// doesn't log on every logout.
if accessToken != "" {
if err := t.RevokeTokenWithProvider(accessToken, "access_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
t.logger.Errorf("Provider-side revocation failed for access_token: %v", err)
}
}
if refreshToken != "" {
if err := t.RevokeTokenWithProvider(refreshToken, "refresh_token"); err != nil && !errors.Is(err, ErrRevocationEndpointNotConfigured) {
t.logger.Errorf("Provider-side revocation failed for refresh_token: %v", err)
}
}
postLogoutRedirectURI := t.postLogoutRedirectURI
// localRedirect is used when there is no provider end-session endpoint and
// the plugin redirects the browser itself. It must never be an absolute URL
// derived from the request host (X-Forwarded-Host is client-controllable and
// would be an open redirect); use a host-relative path, or the operator's
// own configured absolute URL, instead.
//
// The post_logout_redirect_uri we hand to the IdP has the same constraint:
// deriving it from baseURL (scheme://client-controlled host) lets an
// attacker's X-Forwarded-Host steer the browser to an arbitrary origin
// after logout (open redirect through the IdP). So we build it from
// trustedOrigin (the redirect_uri origin the IdP already accepted at
// login, R136) instead; when no trusted origin was recorded (pre-R136
// session, or logout without a completed login), BuildLogoutURL omits
// the parameter entirely rather than guessing (FIX-07 corrects R68,
// which dropped the parameter unconditionally for every non-absolute
// config value, including the documented default of '/').
localRedirect := "/"
switch {
case strings.HasPrefix(postLogoutRedirectURI, "http"):
// Operator configured an explicit absolute URL: already a trusted
// origin on its own, independent of trustedOrigin.
localRedirect = postLogoutRedirectURI
case postLogoutRedirectURI == "":
postLogoutRedirectURI = "" // no trusted origin below leaves it empty
if trustedOrigin != "" {
postLogoutRedirectURI = trustedOrigin + "/"
}
default:
// Relative operator-configured path: trusted for the plugin's own
// redirect, rebuilt against the trusted origin for the IdP.
localRedirect = normalizeLogoutPath(postLogoutRedirectURI)
postLogoutRedirectURI = ""
if trustedOrigin != "" {
postLogoutRedirectURI = trustedOrigin + localRedirect
}
}
// Read endSessionURL with RLock
t.metadataMu.RLock()
endSessionURL := t.endSessionURL
t.metadataMu.RUnlock()
if endSessionURL != "" && idToken != "" {
logoutURL, err := BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI)
if err != nil {
t.logger.Errorf("Failed to build logout URL: %v", err)
http.Error(rw, "Logout error", http.StatusInternalServerError)
return
}
// Logout redirects must never be cached: a heuristic-caching proxy
// replaying the 302 would re-trigger logout for an already
// logged-out session (R147 cache-directive contract).
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, logoutURL, http.StatusFound)
return
}
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, localRedirect, http.StatusFound)
}
// trustedOriginFromRedirectURL extracts "scheme://host" from the redirect_uri
// persisted at authentication-initiate time (R136), for reuse as the trusted
// origin when building post_logout_redirect_uri (FIX-07). That redirect_uri
// was already accepted by the IdP as this deployment's own callback, so its
// origin is safe to reuse — unlike the current request's Host/
// X-Forwarded-Host, which the browser controls. Returns "" when redirectURL
// is empty or is not an absolute http(s) URL.
func trustedOriginFromRedirectURL(redirectURL string) string {
if redirectURL == "" {
return ""
}
u, err := url.Parse(redirectURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return ""
}
return u.Scheme + "://" + u.Host
}
// BuildLogoutURL constructs a logout URL for the OIDC provider's end session endpoint.
// It includes the ID token hint and post-logout redirect URI according to OIDC specifications.
// Parameters:
// - endSessionURL: The provider's logout/end session endpoint
// - idToken: The ID token to include as a hint
// - postLogoutRedirectURI: Where to redirect after logout
//
// Returns:
// - The complete logout URL with query parameters
// - An error if the provided endSessionURL is invalid
func BuildLogoutURL(endSessionURL, idToken, postLogoutRedirectURI string) (string, error) {
u, err := url.Parse(endSessionURL)
if err != nil {
return "", fmt.Errorf("failed to parse end session URL: %w", err)
}
q := u.Query()
q.Set("id_token_hint", idToken)
if postLogoutRedirectURI != "" {
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// setOAuthBasicAuth sets the Authorization header per RFC 6749 §2.3.1: the
// client_id and client_secret are form-urlencoded individually, joined with a
// colon, then base64-encoded. This differs from http.Request.SetBasicAuth,
// which skips the form-urlencode step — that matters for credentials with
// reserved characters (`:`, `@`, `+`, `%`, etc.) where the wire format would
// otherwise diverge from what the spec mandates.
func setOAuthBasicAuth(req *http.Request, clientID, clientSecret string) {
user := url.QueryEscape(clientID)
pass := url.QueryEscape(clientSecret)
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
req.Header.Set("Authorization", "Basic "+auth)
}
// deduplicateScopes removes duplicate scopes from a slice while preserving order.
// This ensures that OAuth scope parameters don't contain duplicates which could
// cause issues with some authorization servers.
// The first occurrence of each scope is kept.
func deduplicateScopes(scopes []string) []string {
if len(scopes) == 0 {
return []string{}
}
seen := make(map[string]struct{})
result := []string{}
for _, scope := range scopes {
if _, ok := seen[scope]; !ok {
seen[scope] = struct{}{}
result = append(result, scope)
}
}
return result
}