package traefikoidc import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "strings" "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 { // cache is the underlying universal cache implementation cache *UniversalCache } // 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 = "t-" + 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 = "t-" + 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 = "t-" + 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 }