mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
@@ -0,0 +1,203 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||
// This file implements OAuth 2.0 Token Introspection (RFC 7662) for opaque token validation.
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntrospectionResponse represents the response from an OAuth 2.0 token introspection endpoint.
|
||||
// Per RFC 7662, this contains information about the token's validity and properties.
|
||||
type IntrospectionResponse struct {
|
||||
Active bool `json:"active"` // REQUIRED - whether the token is currently active
|
||||
Scope string `json:"scope,omitempty"` // Space-separated list of scopes
|
||||
ClientID string `json:"client_id,omitempty"` // Client identifier for the token
|
||||
Username string `json:"username,omitempty"` // Human-readable identifier for the resource owner
|
||||
TokenType string `json:"token_type,omitempty"` // Type of token (e.g., "Bearer")
|
||||
Exp int64 `json:"exp,omitempty"` // Expiration time (seconds since epoch)
|
||||
Iat int64 `json:"iat,omitempty"` // Issued at time (seconds since epoch)
|
||||
Nbf int64 `json:"nbf,omitempty"` // Not before time (seconds since epoch)
|
||||
Sub string `json:"sub,omitempty"` // Subject of the token
|
||||
Aud string `json:"aud,omitempty"` // Intended audience
|
||||
Iss string `json:"iss,omitempty"` // Issuer
|
||||
Jti string `json:"jti,omitempty"` // JWT ID
|
||||
}
|
||||
|
||||
// introspectToken performs OAuth 2.0 Token Introspection (RFC 7662) for an opaque token.
|
||||
// It queries the provider's introspection endpoint to determine token validity and properties.
|
||||
// Results are cached to minimize repeated introspection requests.
|
||||
//
|
||||
// Parameters:
|
||||
// - token: The opaque access token to introspect
|
||||
//
|
||||
// Returns:
|
||||
// - *IntrospectionResponse: The introspection result
|
||||
// - error: Any error that occurred during introspection
|
||||
func (t *TraefikOidc) introspectToken(token string) (*IntrospectionResponse, error) {
|
||||
// Check cache first
|
||||
if t.introspectionCache != nil {
|
||||
if cached, found := t.introspectionCache.Get(token); found {
|
||||
if response, ok := cached.(*IntrospectionResponse); ok {
|
||||
t.logger.Debugf("Using cached introspection result for token")
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get introspection URL
|
||||
t.metadataMu.RLock()
|
||||
introspectionURL := t.introspectionURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
if introspectionURL == "" {
|
||||
return nil, fmt.Errorf("introspection endpoint not available from provider")
|
||||
}
|
||||
|
||||
// Prepare introspection request per RFC 7662 Section 2.1
|
||||
data := url.Values{}
|
||||
data.Set("token", token)
|
||||
data.Set("token_type_hint", "access_token") // Hint that it's an access token
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(context.Background(), "POST", introspectionURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create introspection request: %w", err)
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Authenticate using client credentials (per RFC 7662 Section 2.1)
|
||||
// The introspection endpoint requires authentication
|
||||
req.SetBasicAuth(t.clientID, t.clientSecret)
|
||||
|
||||
// Send request with circuit breaker if available
|
||||
var resp *http.Response
|
||||
if t.errorRecoveryManager != nil {
|
||||
t.metadataMu.RLock()
|
||||
serviceName := fmt.Sprintf("token-introspection-%s", t.issuerURL)
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
err = t.errorRecoveryManager.ExecuteWithRecovery(context.Background(), serviceName, func() error {
|
||||
var reqErr error
|
||||
resp, reqErr = t.httpClient.Do(req)
|
||||
return reqErr
|
||||
})
|
||||
} else {
|
||||
resp, err = t.httpClient.Do(req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("introspection request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Check HTTP status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
limitReader := io.LimitReader(resp.Body, 1024*10)
|
||||
body, _ := io.ReadAll(limitReader)
|
||||
return nil, fmt.Errorf("introspection endpoint returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response per RFC 7662 Section 2.2
|
||||
var introspectionResp IntrospectionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&introspectionResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode introspection response: %w", err)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if t.introspectionCache != nil {
|
||||
// Cache for a short duration or until token expiry (whichever is shorter)
|
||||
cacheDuration := 5 * time.Minute
|
||||
if introspectionResp.Exp > 0 {
|
||||
expTime := time.Unix(introspectionResp.Exp, 0)
|
||||
untilExp := time.Until(expTime)
|
||||
if untilExp > 0 && untilExp < cacheDuration {
|
||||
cacheDuration = untilExp
|
||||
}
|
||||
}
|
||||
t.introspectionCache.Set(token, &introspectionResp, cacheDuration)
|
||||
t.logger.Debugf("Cached introspection result for %v", cacheDuration)
|
||||
}
|
||||
|
||||
return &introspectionResp, nil
|
||||
}
|
||||
|
||||
// validateOpaqueToken validates an opaque access token using token introspection.
|
||||
// It checks if the token is active, not expired, and has the correct audience if specified.
|
||||
//
|
||||
// Parameters:
|
||||
// - token: The opaque access token to validate
|
||||
//
|
||||
// Returns:
|
||||
// - error: Validation error if token is invalid, nil if valid
|
||||
func (t *TraefikOidc) validateOpaqueToken(token string) error {
|
||||
// Check if opaque tokens are allowed
|
||||
if !t.allowOpaqueTokens {
|
||||
return fmt.Errorf("opaque tokens are not enabled (set allowOpaqueTokens to true)")
|
||||
}
|
||||
|
||||
// Check if introspection is required but not available
|
||||
t.metadataMu.RLock()
|
||||
introspectionURL := t.introspectionURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
if introspectionURL == "" {
|
||||
if t.requireTokenIntrospection {
|
||||
return fmt.Errorf("token introspection required but endpoint not available")
|
||||
}
|
||||
// Allow fallback to ID token validation
|
||||
t.logger.Debugf("Introspection endpoint not available, will rely on ID token validation")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform introspection
|
||||
resp, err := t.introspectToken(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token introspection failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is active (per RFC 7662 Section 2.2)
|
||||
if !resp.Active {
|
||||
return fmt.Errorf("token is not active (revoked or expired)")
|
||||
}
|
||||
|
||||
// Validate expiration if present
|
||||
if resp.Exp > 0 {
|
||||
expTime := time.Unix(resp.Exp, 0)
|
||||
if time.Now().After(expTime) {
|
||||
return fmt.Errorf("token has expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate not-before if present
|
||||
if resp.Nbf > 0 {
|
||||
nbfTime := time.Unix(resp.Nbf, 0)
|
||||
if time.Now().Before(nbfTime) {
|
||||
return fmt.Errorf("token not yet valid (nbf)")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate audience if configured
|
||||
// Note: For opaque tokens, audience validation via introspection may be limited
|
||||
// depending on what the introspection endpoint returns
|
||||
if t.audience != "" && t.audience != t.clientID && resp.Aud != "" {
|
||||
if resp.Aud != t.audience {
|
||||
return fmt.Errorf("invalid audience: expected %s, got %s", t.audience, resp.Aud)
|
||||
}
|
||||
}
|
||||
|
||||
t.logger.Debugf("Opaque token validation successful via introspection")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user