diff --git a/auth_flow.go b/auth_flow.go new file mode 100644 index 0000000..b79badc --- /dev/null +++ b/auth_flow.go @@ -0,0 +1,336 @@ +package traefikoidc + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" +) + +// ============================================================================ +// AUTHENTICATION FLOW +// ============================================================================ + +// validateRedirectCount checks if redirect limit is exceeded and handles the error +func (t *TraefikOidc) validateRedirectCount(session *SessionData, rw http.ResponseWriter, req *http.Request) error { + const maxRedirects = 5 + redirectCount := session.GetRedirectCount() + if redirectCount >= maxRedirects { + t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects) + session.ResetRedirectCount() + t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected) + return fmt.Errorf("redirect limit exceeded") + } + + session.IncrementRedirectCount() + return nil +} + +// generatePKCEParameters generates PKCE code verifier and challenge if PKCE is enabled +func (t *TraefikOidc) generatePKCEParameters() (string, string, error) { + if !t.enablePKCE { + return "", "", nil + } + + codeVerifier, err := generateCodeVerifier() + if err != nil { + return "", "", fmt.Errorf("failed to generate code verifier: %w", err) + } + + codeChallenge := deriveCodeChallenge(codeVerifier) + t.logger.Debugf("PKCE enabled, generated code challenge") + + return codeVerifier, codeChallenge, nil +} + +// prepareSessionForAuthentication clears existing session data and sets new authentication state +func (t *TraefikOidc) prepareSessionForAuthentication(session *SessionData, csrfToken, nonce, codeVerifier, incomingPath string) { + // Clear all existing session data + session.SetAuthenticated(false) + session.SetEmail("") + session.SetAccessToken("") + session.SetRefreshToken("") + session.SetIDToken("") + session.SetNonce("") + session.SetCodeVerifier("") + + // Set new authentication state + session.SetCSRF(csrfToken) + session.SetNonce(nonce) + if t.enablePKCE && codeVerifier != "" { + session.SetCodeVerifier(codeVerifier) + } + session.SetIncomingPath(incomingPath) + t.logger.Debugf("Storing incoming path: %s", incomingPath) +} + +// defaultInitiateAuthentication initiates the OIDC authentication flow. +// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session, +// stores authentication state, and redirects the user to the OIDC provider. +// Parameters: +// - rw: The HTTP response writer. +// - req: The HTTP request initiating authentication. +// - session: The session data to prepare for authentication. +// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance. +func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { + t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI()) + + // Check and handle redirect limits + if err := t.validateRedirectCount(session, rw, req); err != nil { + return + } + + csrfToken := uuid.NewString() + nonce, err := generateNonce() + if err != nil { + t.logger.Errorf("Failed to generate nonce: %v", err) + http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError) + return + } + + // Generate PKCE parameters if enabled + codeVerifier, codeChallenge, err := t.generatePKCEParameters() + if err != nil { + t.logger.Errorf("Failed to generate PKCE parameters: %v", err) + http.Error(rw, "Failed to generate PKCE parameters", http.StatusInternalServerError) + return + } + + // Clear existing session data and set new authentication state + t.prepareSessionForAuthentication(session, csrfToken, nonce, codeVerifier, req.URL.RequestURI()) + + session.MarkDirty() + + if err := session.Save(req, rw); err != nil { + t.logger.Errorf("Failed to save session before redirecting to provider: %v", err) + http.Error(rw, "Failed to save session", http.StatusInternalServerError) + return + } + + t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s", + csrfToken, nonce) + + authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge) + t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL) + + http.Redirect(rw, req, authURL, http.StatusFound) +} + +// handleCallback processes the OIDC callback after user authentication. +// It validates state/CSRF tokens, exchanges authorization code for tokens, +// verifies the received tokens, extracts claims, and establishes the session. +// Parameters: +// - rw: The HTTP response writer. +// - req: The callback request containing authorization code and state. +// - redirectURL: The fully qualified callback URL (used in the token exchange request). +func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) { + session, err := t.sessionManager.GetSession(req) + if err != nil { + t.logger.Errorf("Session error during callback: %v", err) + t.sendErrorResponse(rw, req, "Session error during callback", http.StatusInternalServerError) + return + } + defer session.returnToPoolSafely() + + t.logger.Debugf("Handling callback, URL: %s", req.URL.String()) + + if req.URL.Query().Get("error") != "" { + errorDescription := req.URL.Query().Get("error_description") + if errorDescription == "" { + errorDescription = req.URL.Query().Get("error") + } + t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription) + t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest) + return + } + + state := req.URL.Query().Get("state") + if state == "" { + t.logger.Error("No state in callback") + t.sendErrorResponse(rw, req, "State parameter missing in callback", http.StatusBadRequest) + return + } + + csrfToken := session.GetCSRF() + if csrfToken == "" { + t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s", + session.GetAuthenticated(), req.URL.String()) + + cookie, err := req.Cookie("_oidc_raczylo_m") + if err != nil { + t.logger.Errorf("Main session cookie not found in request: %v", err) + } else { + t.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value)) + } + + t.sendErrorResponse(rw, req, "CSRF token missing in session", http.StatusBadRequest) + return + } + + if state != csrfToken { + t.logger.Error("State parameter does not match CSRF token in session during callback") + t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest) + return + } + + code := req.URL.Query().Get("code") + if code == "" { + t.logger.Error("No code in callback") + t.sendErrorResponse(rw, req, "No authorization code received in callback", http.StatusBadRequest) + return + } + + codeVerifier := session.GetCodeVerifier() + + tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier) + if err != nil { + t.logger.Errorf("Failed to exchange code for token during callback: %v", err) + t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError) + return + } + + if err = t.verifyToken(tokenResponse.IDToken); err != nil { + t.logger.Errorf("Failed to verify id_token during callback: %v", err) + t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError) + return + } + + claims, err := t.extractClaimsFunc(tokenResponse.IDToken) + if err != nil { + t.logger.Errorf("Failed to extract claims during callback: %v", err) + t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError) + return + } + + nonceClaim, ok := claims["nonce"].(string) + if !ok || nonceClaim == "" { + t.logger.Error("Nonce claim missing in id_token during callback") + t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError) + return + } + + sessionNonce := session.GetNonce() + if sessionNonce == "" { + t.logger.Error("Nonce not found in session during callback") + t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError) + return + } + + if nonceClaim != sessionNonce { + t.logger.Error("Nonce claim does not match session nonce during callback") + t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError) + return + } + + email, _ := claims["email"].(string) + if email == "" { + t.logger.Errorf("Email claim missing or empty in token during callback") + t.sendErrorResponse(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError) + return + } + if !t.isAllowedDomain(email) { + t.logger.Errorf("Disallowed email domain during callback: %s", email) + t.sendErrorResponse(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden) + return + } + + if err := session.SetAuthenticated(true); err != nil { + t.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err) + t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError) + return + } + session.SetEmail(email) + session.SetIDToken(tokenResponse.IDToken) + session.SetAccessToken(tokenResponse.AccessToken) + session.SetRefreshToken(tokenResponse.RefreshToken) + + session.SetCSRF("") + session.SetNonce("") + session.SetCodeVerifier("") + + session.ResetRedirectCount() + + redirectPath := "/" + if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath { + redirectPath = incomingPath + } + session.SetIncomingPath("") + + if err := session.Save(req, rw); err != nil { + t.logger.Errorf("Failed to save session after callback: %v", err) + t.sendErrorResponse(rw, req, "Failed to save session after callback", http.StatusInternalServerError) + return + } + + t.logger.Debugf("Callback successful, redirecting to %s", redirectPath) + http.Redirect(rw, req, redirectPath, http.StatusFound) +} + +// handleExpiredToken handles requests with expired or invalid tokens. +// It clears the session data and initiates a new authentication flow. +// Parameters: +// - rw: The HTTP response writer. +// - req: The HTTP request with expired token. +// - session: The session data to clear. +// - redirectURL: The callback URL to be used in the new authentication flow. +func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { + t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.") + session.SetAuthenticated(false) + session.SetIDToken("") + session.SetAccessToken("") + session.SetRefreshToken("") + session.SetEmail("") + // Clear CSRF tokens to prevent replay attacks + session.SetCSRF("") + session.SetNonce("") + session.SetCodeVerifier("") + // Reset redirect count to prevent loops when handling expired tokens + session.ResetRedirectCount() + + if err := session.Save(req, rw); err != nil { + t.logger.Errorf("Failed to save cleared session during expired token handling: %v", err) + } + + t.defaultInitiateAuthentication(rw, req, session, redirectURL) +} + +// isUserAuthenticated determines the authentication status and refresh requirements. +// It delegates to provider-specific validation methods that handle different token types +// and expiration behaviors. +// Parameters: +// - session: The session data containing authentication tokens. +// +// Returns: +// - authenticated (bool): True if the user has valid tokens. +// - needsRefresh (bool): True if tokens are valid but nearing expiration. +// - expired (bool): True if the session is unauthenticated, the token is missing, +// or the token verification failed for reasons other than nearing/actual expiration. +func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) { + if t.isAzureProvider() { + return t.validateAzureTokens(session) + } else if t.isGoogleProvider() { + return t.validateGoogleTokens(session) + } + // Auth0 and other providers can now use standard validation + // which handles opaque tokens generically + return t.validateStandardTokens(session) +} + +// isAjaxRequest determines if this is an AJAX request that should receive 401 instead of redirect +func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool { + xhr := req.Header.Get("X-Requested-With") + contentType := req.Header.Get("Content-Type") + accept := req.Header.Get("Accept") + + return xhr == "XMLHttpRequest" || + strings.Contains(contentType, "application/json") || + strings.Contains(accept, "application/json") +} + +// isRefreshTokenExpired checks if refresh token is likely expired (older than 6 hours) +func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool { + // This is a heuristic check - actual implementation would depend on + // the specific provider and token metadata + return false // Placeholder implementation +} diff --git a/internal/middleware/request_handler.go b/internal/middleware/request_handler.go new file mode 100644 index 0000000..103ef19 --- /dev/null +++ b/internal/middleware/request_handler.go @@ -0,0 +1,122 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// RequestContext holds request processing context +type RequestContext struct { + Writer http.ResponseWriter + Request *http.Request + RedirectURL string + Scheme string + Host string +} + +// RequestProcessor handles common request processing operations +type RequestProcessor struct { + logger Logger +} + +// Logger interface for logging operations +type Logger interface { + Debug(msg string) + Debugf(format string, args ...interface{}) + Error(msg string) + Errorf(format string, args ...interface{}) + Info(msg string) + Infof(format string, args ...interface{}) +} + +// NewRequestProcessor creates a new request processor +func NewRequestProcessor(logger Logger) *RequestProcessor { + return &RequestProcessor{ + logger: logger, + } +} + +// BuildRequestContext creates a request context with scheme and host detection +func (rp *RequestProcessor) BuildRequestContext(rw http.ResponseWriter, req *http.Request, redirectPath string) *RequestContext { + scheme := rp.determineScheme(req) + host := rp.determineHost(req) + redirectURL := buildFullURL(scheme, host, redirectPath) + + return &RequestContext{ + Writer: rw, + Request: req, + RedirectURL: redirectURL, + Scheme: scheme, + Host: host, + } +} + +// IsHealthCheckRequest checks if request is a health check +func (rp *RequestProcessor) IsHealthCheckRequest(req *http.Request) bool { + return strings.HasPrefix(req.URL.Path, "/health") +} + +// IsEventStreamRequest checks if request expects event stream +func (rp *RequestProcessor) IsEventStreamRequest(req *http.Request) bool { + acceptHeader := req.Header.Get("Accept") + return strings.Contains(acceptHeader, "text/event-stream") +} + +// IsAjaxRequest determines if this is an AJAX request +func (rp *RequestProcessor) IsAjaxRequest(req *http.Request) bool { + xhr := req.Header.Get("X-Requested-With") + contentType := req.Header.Get("Content-Type") + accept := req.Header.Get("Accept") + + return xhr == "XMLHttpRequest" || + strings.Contains(contentType, "application/json") || + strings.Contains(accept, "application/json") +} + +// WaitForInitialization waits for OIDC provider initialization with timeout +func (rp *RequestProcessor) WaitForInitialization(req *http.Request, initComplete <-chan struct{}) error { + select { + case <-initComplete: + return nil + case <-req.Context().Done(): + rp.logger.Debug("Request cancelled while waiting for OIDC initialization") + return fmt.Errorf("request cancelled") + case <-time.After(30 * time.Second): + rp.logger.Error("Timeout waiting for OIDC initialization") + return fmt.Errorf("timeout waiting for OIDC provider initialization") + } +} + +// determineScheme determines the URL scheme for building redirect URLs +func (rp *RequestProcessor) determineScheme(req *http.Request) string { + if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" { + return scheme + } + if req.TLS != nil { + return "https" + } + return "http" +} + +// determineHost determines the host for building redirect URLs +func (rp *RequestProcessor) determineHost(req *http.Request) string { + if host := req.Header.Get("X-Forwarded-Host"); host != "" { + return host + } + return req.Host +} + +// buildFullURL constructs a complete URL from scheme, host, and path components +func buildFullURL(scheme, host, path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + return fmt.Sprintf("%s://%s%s", scheme, host, path) +} diff --git a/main.go b/main.go index 96f100f..1f701bb 100644 --- a/main.go +++ b/main.go @@ -4,15 +4,9 @@ package traefikoidc import ( - "bytes" "context" - "encoding/base64" - "encoding/json" "fmt" - "io" - "net" "net/http" - "net/url" "os" "runtime" "strings" @@ -20,7 +14,6 @@ import ( "text/template" "time" - "github.com/google/uuid" "golang.org/x/time/rate" ) @@ -81,217 +74,11 @@ var defaultExcludedURLs = map[string]struct{}{ "/favicon": {}, } -// VerifyToken verifies the validity of an ID token or access token. -// It performs comprehensive validation including format checks, blacklist verification, -// signature validation using JWKs, and standard claims validation. It also caches -// successfully verified tokens to avoid repeated verification. -// Parameters: -// - token: The JWT token string to verify. -// -// Returns: -// - An error if verification fails (e.g., blacklisted token, invalid format, -// signature failure, or claims error), nil if verification succeeds. -func (t *TraefikOidc) VerifyToken(token string) error { - if token == "" { - return fmt.Errorf("invalid JWT format: token is empty") - } +// NOTE: VerifyToken method moved to token_manager.go - if strings.Count(token, ".") != 2 { - return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1) - } +// NOTE: cacheVerifiedToken method moved to token_manager.go - if len(token) < 10 { - return fmt.Errorf("token too short to be valid JWT") - } - - if t.tokenBlacklist != nil { - if blacklisted, exists := t.tokenBlacklist.Get(token); exists && blacklisted != nil { - return fmt.Errorf("token is blacklisted (raw string) in cache") - } - } - - parsedJWT, parseErr := parseJWT(token) - if parseErr != nil { - return fmt.Errorf("failed to parse JWT for blacklist check: %w", parseErr) - } - - tokenType := "UNKNOWN" - if aud, ok := parsedJWT.Claims["aud"]; ok { - if audStr, ok := aud.(string); ok && audStr == t.clientID { - tokenType = "ID_TOKEN" - } - } - if scope, ok := parsedJWT.Claims["scope"]; ok { - if _, ok := scope.(string); ok { - tokenType = "ACCESS_TOKEN" - } - } - - if jti, ok := parsedJWT.Claims["jti"].(string); ok && jti != "" { - if !strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") { - if t.tokenBlacklist != nil { - if blacklisted, exists := t.tokenBlacklist.Get(jti); exists && blacklisted != nil { - return fmt.Errorf("token replay detected (jti: %s) in cache", jti) - } - } - } - } - - if claims, exists := t.tokenCache.Get(token); exists && len(claims) > 0 { - return nil - } - - if !t.limiter.Allow() { - return fmt.Errorf("rate limit exceeded") - } - - jwt := parsedJWT - - if err := t.VerifyJWTSignatureAndClaims(jwt, token); err != nil { - if !strings.Contains(err.Error(), "token has expired") { - t.safeLogErrorf("%s token verification failed: %v", tokenType, err) - } - return err - } - - t.cacheVerifiedToken(token, jwt.Claims) - - if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" { - expiry := time.Now().Add(defaultBlacklistDuration) - if expClaim, expOk := jwt.Claims["exp"].(float64); expOk { - expTime := time.Unix(int64(expClaim), 0) - tokenDuration := time.Until(expTime) - if tokenDuration > defaultBlacklistDuration && tokenDuration < (24*time.Hour) { - expiry = expTime - } else if tokenDuration <= 0 { - expiry = time.Now().Add(defaultBlacklistDuration) - } else { - expiry = time.Now().Add(defaultBlacklistDuration) - } - } - - if t.tokenBlacklist != nil { - t.tokenBlacklist.Set(jti, true, time.Until(expiry)) - t.safeLogDebugf("Added JTI %s to blacklist cache", jti) - } else { - t.safeLogErrorf("Token blacklist not available, skipping JTI %s blacklist", jti) - } - - replayCacheMu.Lock() - if replayCache == nil { - initReplayCache() - } - duration := time.Until(expiry) - if duration > 0 { - replayCache.Set(jti, true, duration) - } - replayCacheMu.Unlock() - } - - return nil -} - -// cacheVerifiedToken stores a successfully verified token and its claims in the cache. -// The token is cached until its expiration time to avoid repeated verification. -// Parameters: -// - token: The verified token string to cache. -// - claims: The map of claims extracted from the verified token. -func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interface{}) { - expClaim, ok := claims["exp"].(float64) - if !ok { - t.safeLogError("Failed to cache token: invalid 'exp' claim type") - return - } - - expirationTime := time.Unix(int64(expClaim), 0) - now := time.Now() - duration := expirationTime.Sub(now) - t.tokenCache.Set(token, claims, duration) -} - -// VerifyJWTSignatureAndClaims verifies JWT signature using provider's public keys and validates standard claims. -// It retrieves the appropriate public key from the JWKS cache, verifies the token signature, -// and validates standard OIDC claims like issuer, audience, and expiration. -// Parameters: -// - jwt: The parsed JWT structure containing header and claims. -// - token: The raw token string for signature verification. -// -// Returns: -// - An error if verification fails (e.g., JWKS retrieval failed, no matching key, -// signature verification failed, standard claim validation failed), nil if successful. -func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { - t.safeLogDebugf("Verifying JWT signature and claims") - - jwks, err := t.jwkCache.GetJWKS(context.Background(), t.jwksURL, t.httpClient) - if err != nil { - return fmt.Errorf("failed to get JWKS: %w", err) - } - - if !t.suppressDiagnosticLogs && jwks != nil { - t.safeLogDebugf("DIAGNOSTIC: Retrieved JWKS with %d keys from URL: %s", len(jwks.Keys), t.jwksURL) - } - - kid, ok := jwt.Header["kid"].(string) - if !ok { - return fmt.Errorf("missing key ID in token header") - } - alg, ok := jwt.Header["alg"].(string) - if !ok { - return fmt.Errorf("missing algorithm in token header") - } - - if !t.suppressDiagnosticLogs { - t.safeLogDebugf("DIAGNOSTIC: Looking for kid=%s, alg=%s in JWKS", kid, alg) - } - - if jwks == nil { - return fmt.Errorf("JWKS is nil, cannot verify token") - } - - // Find the matching key in JWKS - var matchingKey *JWK - availableKids := make([]string, 0, len(jwks.Keys)) - for _, key := range jwks.Keys { - availableKids = append(availableKids, key.Kid) - if key.Kid == kid { - matchingKey = &key - break - } - } - - if matchingKey == nil { - if !t.suppressDiagnosticLogs { - t.safeLogErrorf("DIAGNOSTIC: No matching key found for kid=%s. Available kids: %v", kid, availableKids) - } - return fmt.Errorf("no matching public key found for kid: %s", kid) - } - - if !t.suppressDiagnosticLogs { - t.safeLogDebugf("DIAGNOSTIC: Found matching key for kid=%s, key type: %s", kid, matchingKey.Kty) - } - - publicKeyPEM, err := jwkToPEM(matchingKey) - if err != nil { - return fmt.Errorf("failed to convert JWK to PEM: %w", err) - } - - if err := verifySignature(token, publicKeyPEM, alg); err != nil { - if !t.suppressDiagnosticLogs { - t.safeLogErrorf("DIAGNOSTIC: Signature verification failed for kid=%s, alg=%s: %v", kid, alg, err) - } - return fmt.Errorf("signature verification failed: %w", err) - } - - if !t.suppressDiagnosticLogs { - t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid) - } - - if err := jwt.Verify(t.issuerURL, t.clientID, true); err != nil { - return fmt.Errorf("standard claim verification failed: %w", err) - } - - return nil -} +// NOTE: VerifyJWTSignatureAndClaims method moved to token_manager.go // New creates a new TraefikOidc middleware instance. // It initializes all components including caches, HTTP clients, session management, @@ -508,6 +295,10 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name return t, nil } +// ============================================================================ +// PROVIDER METADATA MANAGEMENT +// ============================================================================ + // initializeMetadata initializes OIDC provider metadata by fetching configuration. // It retrieves the provider's .well-known/openid-configuration and updates // internal endpoint URLs. Uses error recovery if available for resilient fetching. @@ -606,1305 +397,49 @@ func (t *TraefikOidc) startMetadataRefresh(providerURL string) { } } -// ServeHTTP implements the main middleware logic for processing HTTP requests. -// It handles the complete OIDC authentication flow including: -// - Excluded URL bypass -// - Session validation and management -// - Authentication callback processing -// - Logout handling -// - Token verification and refresh -// - Header injection for authenticated requests -// -// Parameters: -// - rw: The HTTP response writer. -// - req: The incoming HTTP request. -func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - if !strings.HasPrefix(req.URL.Path, "/health") { - t.firstRequestMutex.Lock() - if !t.firstRequestReceived { - t.firstRequestReceived = true - t.logger.Debug("Starting background tasks on first request") - t.startTokenCleanup() +// NOTE: ServeHTTP method moved to middleware.go - if !t.metadataRefreshStarted && t.providerURL != "" { - t.metadataRefreshStarted = true - // Metadata refresh is handled by singleton resource manager - t.startMetadataRefresh(t.providerURL) - } - } - t.firstRequestMutex.Unlock() - } +// NOTE: processAuthorizedRequest method moved to middleware.go - select { - case <-t.initComplete: - if t.issuerURL == "" { - t.logger.Error("OIDC provider metadata initialization failed or incomplete") - t.sendErrorResponse(rw, req, "OIDC provider metadata initialization failed - please check provider availability and configuration", http.StatusServiceUnavailable) - return - } - case <-req.Context().Done(): - t.logger.Debug("Request cancelled while waiting for OIDC initialization") - t.sendErrorResponse(rw, req, "Request cancelled", http.StatusRequestTimeout) - return - case <-time.After(30 * time.Second): - t.logger.Error("Timeout waiting for OIDC initialization") - t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable) - return - } +// NOTE: handleExpiredToken method moved to auth_flow.go - if t.determineExcludedURL(req.URL.Path) { - t.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path) - t.next.ServeHTTP(rw, req) - return - } - acceptHeader := req.Header.Get("Accept") - if strings.Contains(acceptHeader, "text/event-stream") { - t.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader) - t.next.ServeHTTP(rw, req) - return - } +// NOTE: handleCallback method moved to auth_flow.go - t.sessionManager.CleanupOldCookies(rw, req) +// NOTE: determineExcludedURL method moved to url_helpers.go - session, err := t.sessionManager.GetSession(req) - if err != nil { - t.logger.Errorf("Error getting session: %v. Initiating authentication.", err) - cleanReq := req.Clone(req.Context()) - session, _ = t.sessionManager.GetSession(cleanReq) - if session != nil { - defer session.returnToPoolSafely() - if clearErr := session.Clear(cleanReq, rw); clearErr != nil { - t.logger.Errorf("Error clearing potentially corrupted session: %v", clearErr) - } - } else { - t.logger.Error("Critical session error: Failed to get even a new session.") - t.sendErrorResponse(rw, req, "Critical session error", http.StatusInternalServerError) - return - } - scheme := t.determineScheme(req) - host := t.determineHost(req) - redirectURL := buildFullURL(scheme, host, t.redirURLPath) - t.defaultInitiateAuthentication(rw, req, session, redirectURL) - return - } +// NOTE: determineScheme method moved to url_helpers.go - defer session.returnToPoolSafely() +// NOTE: determineHost method moved to url_helpers.go - scheme := t.determineScheme(req) - host := t.determineHost(req) - redirectURL := buildFullURL(scheme, host, t.redirURLPath) +// NOTE: isUserAuthenticated method moved to auth_flow.go - if req.URL.Path == t.logoutURLPath { - t.handleLogout(rw, req) - return - } - if req.URL.Path == t.redirURLPath { - t.handleCallback(rw, req, redirectURL) - return - } +// NOTE: defaultInitiateAuthentication method moved to auth_flow.go - authenticated, needsRefresh, expired := t.isUserAuthenticated(session) +// NOTE: verifyToken method moved to token_manager.go - if expired { - t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth") - t.handleExpiredToken(rw, req, session, redirectURL) - return - } +// NOTE: safeLog methods moved to utilities.go - email := session.GetEmail() - // Domain restriction check removed debug output - if authenticated && email != "" { - if !t.isAllowedDomain(email) { - t.logger.Infof("User with email %s is not from an allowed domain", email) - errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath) - t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) - return - } - } +// NOTE: buildAuthURL method moved to url_helpers.go - if authenticated && !needsRefresh { - t.logger.Debug("User authenticated and token valid, proceeding to process authorized request") - if accessToken := session.GetAccessToken(); accessToken != "" { - if strings.Count(accessToken, ".") == 2 { - if err := t.verifyToken(accessToken); err != nil { - t.logger.Errorf("Access token validation failed: %v", err) - t.handleExpiredToken(rw, req, session, redirectURL) - return - } - } else { - t.logger.Debugf("Access token appears opaque, skipping JWT verification for it.") - } - } - t.processAuthorizedRequest(rw, req, session, redirectURL) - return - } +// NOTE: buildURLWithParams method moved to url_helpers.go - refreshTokenPresent := session.GetRefreshToken() != "" +// NOTE: validateURL method moved to url_helpers.go - // Check if this is an AJAX request that should receive 401 instead of redirect - isAjaxRequest := t.isAjaxRequest(req) +// NOTE: validateParsedURL method moved to url_helpers.go - // Check if refresh token is likely expired (older than 6 hours) - refreshTokenExpired := refreshTokenPresent && t.isRefreshTokenExpired(session) +// NOTE: validateHost method moved to url_helpers.go - shouldAttemptRefresh := needsRefresh && refreshTokenPresent && !refreshTokenExpired +// NOTE: startTokenCleanup method moved to token_manager.go - // If AJAX request and refresh token expired, return 401 immediately - if isAjaxRequest && refreshTokenExpired { - t.logger.Debug("AJAX request with expired refresh token, returning 401") - t.sendErrorResponse(rw, req, "Session expired", http.StatusUnauthorized) - return - } +// NOTE: RevokeToken method moved to token_manager.go - if shouldAttemptRefresh { - idToken := session.GetIDToken() - if idToken != "" { - jwt, err := parseJWT(idToken) - if err == nil { - claims := jwt.Claims - if expClaim, ok := claims["exp"].(float64); ok { - expTime := int64(expClaim) - expTimeObj := time.Unix(expTime, 0) - refreshThreshold := time.Now().Add(t.refreshGracePeriod) +// NOTE: RevokeTokenWithProvider method moved to token_manager.go - if !expTimeObj.Before(refreshThreshold) { - t.logger.Debug("Token is valid and outside grace period, skipping refresh") - t.processAuthorizedRequest(rw, req, session, redirectURL) - return - } - } else { - t.logger.Debug("Could not extract 'exp' claim for grace period check, proceeding with refresh") - } - } - } +// NOTE: refreshToken method moved to token_manager.go - if needsRefresh && authenticated { - t.logger.Debug("Session token needs proactive refresh, attempting refresh") - } else if needsRefresh && !authenticated { - t.logger.Debug("ID token invalid/expired, but refresh token found. Attempting refresh.") - } +// NOTE: isAllowedDomain method moved to utilities.go - refreshed := t.refreshToken(rw, req, session) - if refreshed { - email = session.GetEmail() - if email != "" && !t.isAllowedDomain(email) { - t.logger.Infof("User with refreshed token email %s is not from an allowed domain", email) - errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath) - t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) - return - } - - t.logger.Debug("Token refresh successful, proceeding to process authorized request") - t.processAuthorizedRequest(rw, req, session, redirectURL) - return - } - - t.logger.Debug("Token refresh failed, requiring re-authentication") - if isAjaxRequest { - t.logger.Debug("AJAX request with failed token refresh, sending 401 Unauthorized") - t.sendErrorResponse(rw, req, "Token refresh failed", http.StatusUnauthorized) - } else { - t.logger.Debug("Browser request with failed token refresh, initiating re-auth") - // Reset redirect count when starting fresh auth after failed refresh to prevent redirect loops - session.ResetRedirectCount() - t.defaultInitiateAuthentication(rw, req, session, redirectURL) - } - return - } - - t.logger.Debugf("Initiating full OIDC authentication flow (authenticated=%v, needsRefresh=%v, refreshTokenPresent=%v)", authenticated, needsRefresh, refreshTokenPresent) - - // If AJAX request without valid authentication, return 401 - if isAjaxRequest { - t.logger.Debug("AJAX request requires authentication, sending 401 Unauthorized") - t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized) - return - } - - // Reset redirect count when starting fresh authentication flow - session.ResetRedirectCount() - t.defaultInitiateAuthentication(rw, req, session, redirectURL) -} - -// processAuthorizedRequest processes requests for authenticated users. -// It extracts claims, validates roles/groups if configured, sets authentication headers, -// processes header templates, and forwards the request to the next handler. -// Domain checks should be performed before calling this method. -// Parameters: -// - rw: The HTTP response writer. -// - req: The HTTP request to process. -// - session: The user's session data containing tokens and claims. -// - redirectURL: The callback URL for re-authentication if needed. -func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { - email := session.GetEmail() - if email == "" { - t.logger.Info("No email found in session during final processing, initiating re-auth") - // Reset redirect count to prevent loops when session is invalid - session.ResetRedirectCount() - t.defaultInitiateAuthentication(rw, req, session, redirectURL) - return - } - - tokenForClaims := session.GetIDToken() - if tokenForClaims == "" { - tokenForClaims = session.GetAccessToken() - if tokenForClaims == "" && len(t.allowedRolesAndGroups) > 0 { - t.logger.Error("No token available but roles/groups checks are required") - // Reset redirect count to prevent loops when token is missing - session.ResetRedirectCount() - t.defaultInitiateAuthentication(rw, req, session, redirectURL) - return - } - } - - // Initialize empty slices - var groups, roles []string - - if tokenForClaims != "" { - var err error - groups, roles, err = t.extractGroupsAndRoles(tokenForClaims) - if err != nil && len(t.allowedRolesAndGroups) > 0 { - t.logger.Errorf("Failed to extract groups and roles: %v", err) - // Reset redirect count to prevent loops when claim extraction fails - session.ResetRedirectCount() - t.defaultInitiateAuthentication(rw, req, session, redirectURL) - return - } else if err == nil { - if len(groups) > 0 { - req.Header.Set("X-User-Groups", strings.Join(groups, ",")) - } - if len(roles) > 0 { - req.Header.Set("X-User-Roles", strings.Join(roles, ",")) - } - } - } - - if len(t.allowedRolesAndGroups) > 0 { - allowed := false - for _, roleOrGroup := range append(groups, roles...) { - if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok { - allowed = true - break - } - } - if !allowed { - t.logger.Infof("User with email %s does not have any allowed roles or groups", email) - errorMsg := fmt.Sprintf("Access denied: You do not have any of the allowed roles or groups. To log out, visit: %s", t.logoutURLPath) - t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) - return - } - } - - req.Header.Set("X-Forwarded-User", email) - - req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI()) - req.Header.Set("X-Auth-Request-User", email) - if idToken := session.GetIDToken(); idToken != "" { - req.Header.Set("X-Auth-Request-Token", idToken) - } - - if len(t.headerTemplates) > 0 { - claims, err := t.extractClaimsFunc(session.GetIDToken()) - if err != nil { - t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", err) - } else { - templateData := map[string]interface{}{ - "AccessToken": session.GetAccessToken(), - "IDToken": session.GetIDToken(), - "RefreshToken": session.GetRefreshToken(), - "Claims": claims, - } - - for headerName, tmpl := range t.headerTemplates { - var buf bytes.Buffer - - if err := tmpl.Execute(&buf, templateData); err != nil { - t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err) - continue - } - headerValue := buf.String() - - req.Header.Set(headerName, headerValue) - - t.logger.Debugf("Set templated header %s = %s", headerName, headerValue) - } - session.MarkDirty() - t.logger.Debugf("Session marked dirty after templated header processing.") - } - } - - if session.IsDirty() { - if err := session.Save(req, rw); err != nil { - t.logger.Errorf("Failed to save session after processing headers: %v", err) - } - } else { - t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest") - } - - // Apply security headers if configured - if t.securityHeadersApplier != nil { - t.securityHeadersApplier(rw, req) - } else { - // Fallback to basic security headers - rw.Header().Set("X-Frame-Options", "DENY") - rw.Header().Set("X-Content-Type-Options", "nosniff") - rw.Header().Set("X-XSS-Protection", "1; mode=block") - rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - } - - t.logger.Debugf("Request authorized for user %s, forwarding to next handler", email) - - t.next.ServeHTTP(rw, req) -} - -// handleExpiredToken handles requests with expired or invalid tokens. -// It clears the session data and initiates a new authentication flow. -// Parameters: -// - rw: The HTTP response writer. -// - req: The HTTP request with expired token. -// - session: The session data to clear. -// - redirectURL: The callback URL to be used in the new authentication flow. -func (t *TraefikOidc) handleExpiredToken(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { - t.logger.Debug("Handling expired token: Clearing session and initiating re-authentication.") - session.SetAuthenticated(false) - session.SetIDToken("") - session.SetAccessToken("") - session.SetRefreshToken("") - session.SetEmail("") - // Clear CSRF tokens to prevent replay attacks - session.SetCSRF("") - session.SetNonce("") - session.SetCodeVerifier("") - // Reset redirect count to prevent loops when handling expired tokens - session.ResetRedirectCount() - - if err := session.Save(req, rw); err != nil { - t.logger.Errorf("Failed to save cleared session during expired token handling: %v", err) - } - - t.defaultInitiateAuthentication(rw, req, session, redirectURL) -} - -// handleCallback processes the OIDC callback after user authentication. -// It validates state/CSRF tokens, exchanges authorization code for tokens, -// verifies the received tokens, extracts claims, and establishes the session. -// Parameters: -// - rw: The HTTP response writer. -// - req: The callback request containing authorization code and state. -// - redirectURL: The fully qualified callback URL (used in the token exchange request). -func (t *TraefikOidc) handleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) { - session, err := t.sessionManager.GetSession(req) - if err != nil { - t.logger.Errorf("Session error during callback: %v", err) - t.sendErrorResponse(rw, req, "Session error during callback", http.StatusInternalServerError) - return - } - defer session.returnToPoolSafely() - - t.logger.Debugf("Handling callback, URL: %s", req.URL.String()) - - if req.URL.Query().Get("error") != "" { - errorDescription := req.URL.Query().Get("error_description") - if errorDescription == "" { - errorDescription = req.URL.Query().Get("error") - } - t.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription) - t.sendErrorResponse(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest) - return - } - - state := req.URL.Query().Get("state") - if state == "" { - t.logger.Error("No state in callback") - t.sendErrorResponse(rw, req, "State parameter missing in callback", http.StatusBadRequest) - return - } - - csrfToken := session.GetCSRF() - if csrfToken == "" { - t.logger.Errorf("CSRF token missing in session during callback. Authenticated: %v, Request URL: %s", - session.GetAuthenticated(), req.URL.String()) - - cookie, err := req.Cookie("_oidc_raczylo_m") - if err != nil { - t.logger.Errorf("Main session cookie not found in request: %v", err) - } else { - t.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value)) - } - - t.sendErrorResponse(rw, req, "CSRF token missing in session", http.StatusBadRequest) - return - } - - if state != csrfToken { - t.logger.Error("State parameter does not match CSRF token in session during callback") - t.sendErrorResponse(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest) - return - } - - code := req.URL.Query().Get("code") - if code == "" { - t.logger.Error("No code in callback") - t.sendErrorResponse(rw, req, "No authorization code received in callback", http.StatusBadRequest) - return - } - - codeVerifier := session.GetCodeVerifier() - - tokenResponse, err := t.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier) - if err != nil { - t.logger.Errorf("Failed to exchange code for token during callback: %v", err) - t.sendErrorResponse(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError) - return - } - - if err = t.verifyToken(tokenResponse.IDToken); err != nil { - t.logger.Errorf("Failed to verify id_token during callback: %v", err) - t.sendErrorResponse(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError) - return - } - - claims, err := t.extractClaimsFunc(tokenResponse.IDToken) - if err != nil { - t.logger.Errorf("Failed to extract claims during callback: %v", err) - t.sendErrorResponse(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError) - return - } - - nonceClaim, ok := claims["nonce"].(string) - if !ok || nonceClaim == "" { - t.logger.Error("Nonce claim missing in id_token during callback") - t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError) - return - } - - sessionNonce := session.GetNonce() - if sessionNonce == "" { - t.logger.Error("Nonce not found in session during callback") - t.sendErrorResponse(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError) - return - } - - if nonceClaim != sessionNonce { - t.logger.Error("Nonce claim does not match session nonce during callback") - t.sendErrorResponse(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError) - return - } - - email, _ := claims["email"].(string) - if email == "" { - t.logger.Errorf("Email claim missing or empty in token during callback") - t.sendErrorResponse(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError) - return - } - if !t.isAllowedDomain(email) { - t.logger.Errorf("Disallowed email domain during callback: %s", email) - t.sendErrorResponse(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden) - return - } - - if err := session.SetAuthenticated(true); err != nil { - t.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err) - t.sendErrorResponse(rw, req, "Failed to update session", http.StatusInternalServerError) - return - } - session.SetEmail(email) - session.SetIDToken(tokenResponse.IDToken) - session.SetAccessToken(tokenResponse.AccessToken) - session.SetRefreshToken(tokenResponse.RefreshToken) - - session.SetCSRF("") - session.SetNonce("") - session.SetCodeVerifier("") - - session.ResetRedirectCount() - - redirectPath := "/" - if incomingPath := session.GetIncomingPath(); incomingPath != "" && incomingPath != t.redirURLPath { - redirectPath = incomingPath - } - session.SetIncomingPath("") - - if err := session.Save(req, rw); err != nil { - t.logger.Errorf("Failed to save session after callback: %v", err) - t.sendErrorResponse(rw, req, "Failed to save session after callback", http.StatusInternalServerError) - return - } - - t.logger.Debugf("Callback successful, redirecting to %s", redirectPath) - http.Redirect(rw, req, redirectPath, http.StatusFound) -} - -// determineExcludedURL checks if a URL path should bypass OIDC authentication. -// It compares the request path against configured excluded URL prefixes. -// Parameters: -// - currentRequest: The request path to check. -// -// Returns: -// - true if the URL should be excluded from authentication, false otherwise. -func (t *TraefikOidc) determineExcludedURL(currentRequest string) bool { - for excludedURL := range t.excludedURLs { - if strings.HasPrefix(currentRequest, excludedURL) { - t.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL) - return true - } - } - return false -} - -// determineScheme determines the URL scheme for building redirect URLs. -// It checks X-Forwarded-Proto header first, then TLS presence. -// Parameters: -// - req: The HTTP request to analyze. -// -// Returns: -// - The determined scheme: "https" or "http". -func (t *TraefikOidc) determineScheme(req *http.Request) string { - if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" { - return scheme - } - if req.TLS != nil { - return "https" - } - return "http" -} - -// determineHost determines the host for building redirect URLs. -// It checks X-Forwarded-Host header first, then falls back to req.Host. -// Parameters: -// - req: The HTTP request to analyze. -// -// Returns: -// - The determined host string (e.g., "example.com:8080"). -func (t *TraefikOidc) determineHost(req *http.Request) string { - if host := req.Header.Get("X-Forwarded-Host"); host != "" { - return host - } - return req.Host -} - -// isUserAuthenticated determines the authentication status and refresh requirements. -// It delegates to provider-specific validation methods that handle different token types -// and expiration behaviors. -// Parameters: -// - session: The session data containing authentication tokens. -// -// Returns: -// - authenticated (bool): True if the user has valid tokens. -// - needsRefresh (bool): True if tokens are valid but nearing expiration. -// - expired (bool): True if the session is unauthenticated, the token is missing, -// or the token verification failed for reasons other than nearing/actual expiration. -func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, bool) { - if t.isAzureProvider() { - return t.validateAzureTokens(session) - } else if t.isGoogleProvider() { - return t.validateGoogleTokens(session) - } - // Auth0 and other providers can now use standard validation - // which handles opaque tokens generically - return t.validateStandardTokens(session) -} - -// defaultInitiateAuthentication initiates the OIDC authentication flow. -// It generates CSRF tokens, nonce, PKCE parameters (if enabled), clears the session, -// stores authentication state, and redirects the user to the OIDC provider. -// Parameters: -// - rw: The HTTP response writer. -// - req: The HTTP request initiating authentication. -// - session: The session data to prepare for authentication. -// - redirectURL: The pre-calculated callback URL (redirect_uri) for this middleware instance. -func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { - t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI()) - - const maxRedirects = 5 - redirectCount := session.GetRedirectCount() - if redirectCount >= maxRedirects { - t.logger.Errorf("Maximum redirect limit (%d) exceeded, possible redirect loop detected", maxRedirects) - session.ResetRedirectCount() - t.sendErrorResponse(rw, req, "Authentication failed: Too many redirects", http.StatusLoopDetected) - return - } - - session.IncrementRedirectCount() - - csrfToken := uuid.NewString() - nonce, err := generateNonce() - if err != nil { - t.logger.Errorf("Failed to generate nonce: %v", err) - http.Error(rw, "Failed to generate nonce", http.StatusInternalServerError) - return - } - - // Generate PKCE code verifier and challenge if PKCE is enabled - var codeVerifier, codeChallenge string - if t.enablePKCE { - var err error - codeVerifier, err = generateCodeVerifier() - if err != nil { - t.logger.Errorf("Failed to generate code verifier: %v", err) - http.Error(rw, "Failed to generate code verifier", http.StatusInternalServerError) - return - } - codeChallenge = deriveCodeChallenge(codeVerifier) - t.logger.Debugf("PKCE enabled, generated code challenge") - } - - session.SetAuthenticated(false) - session.SetEmail("") - session.SetAccessToken("") - session.SetRefreshToken("") - session.SetIDToken("") - session.SetNonce("") - session.SetCodeVerifier("") - - session.SetCSRF(csrfToken) - session.SetNonce(nonce) - if t.enablePKCE { - session.SetCodeVerifier(codeVerifier) - } - session.SetIncomingPath(req.URL.RequestURI()) - t.logger.Debugf("Storing incoming path: %s", req.URL.RequestURI()) - - session.MarkDirty() - - if err := session.Save(req, rw); err != nil { - t.logger.Errorf("Failed to save session before redirecting to provider: %v", err) - http.Error(rw, "Failed to save session", http.StatusInternalServerError) - return - } - - t.logger.Debugf("Session saved before redirect. CSRF: %s, Nonce: %s", - csrfToken, nonce) - - authURL := t.buildAuthURL(redirectURL, csrfToken, nonce, codeChallenge) - t.logger.Debugf("Redirecting user to OIDC provider: %s", authURL) - - http.Redirect(rw, req, authURL, http.StatusFound) -} - -// verifyToken is a convenience wrapper for token verification. -// It delegates to the configured token verifier interface. -// Parameters: -// - token: The token string to verify. -// -// Returns: -// - The result of calling t.tokenVerifier.VerifyToken(token). -func (t *TraefikOidc) verifyToken(token string) error { - return t.tokenVerifier.VerifyToken(token) -} - -// safeLog provides nil-safe logging helpers -func (t *TraefikOidc) safeLogDebug(msg string) { - if t.logger != nil { - t.logger.Debug("%s", msg) - } -} - -func (t *TraefikOidc) safeLogDebugf(format string, args ...interface{}) { - if t.logger != nil { - t.logger.Debugf(format, args...) - } -} - -func (t *TraefikOidc) safeLogError(msg string) { - if t.logger != nil { - t.logger.Error("%s", msg) - } -} - -func (t *TraefikOidc) safeLogErrorf(format string, args ...interface{}) { - if t.logger != nil { - t.logger.Errorf(format, args...) - } -} - -func (t *TraefikOidc) safeLogInfo(msg string) { - if t.logger != nil { - t.logger.Info("%s", msg) - } -} - -// buildAuthURL constructs the OIDC provider authorization URL. -// It builds the URL with all necessary parameters including client_id, scopes, -// PKCE parameters, and provider-specific parameters for Google and Azure. -// Parameters: -// - redirectURL: The callback URL for after authentication. -// - state: The CSRF token for state validation. -// - nonce: The nonce for replay protection. -// - codeChallenge: The PKCE code challenge (if PKCE is enabled). -// -// Returns: -// - The fully constructed authorization URL string. -func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge string) string { - params := url.Values{} - params.Set("client_id", t.clientID) - params.Set("response_type", "code") - params.Set("redirect_uri", redirectURL) - params.Set("state", state) - params.Set("nonce", nonce) - - if t.enablePKCE && codeChallenge != "" { - params.Set("code_challenge", codeChallenge) - params.Set("code_challenge_method", "S256") - } - - scopes := make([]string, len(t.scopes)) - copy(scopes, t.scopes) - - if t.isGoogleProvider() { - params.Set("access_type", "offline") - t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens") - - params.Set("prompt", "consent") - t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens") - } else if t.isAzureProvider() { - params.Set("response_mode", "query") - t.logger.Debug("Azure AD provider detected, added response_mode=query") - - hasOfflineAccess := false - - for _, scope := range scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - - if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { - if !hasOfflineAccess { - scopes = append(scopes, "offline_access") - t.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) - } - } else { - t.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) - } - } else { - if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { - hasOfflineAccess := false - for _, scope := range scopes { - if scope == "offline_access" { - hasOfflineAccess = true - break - } - } - if !hasOfflineAccess { - scopes = append(scopes, "offline_access") - t.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) - } - } else { - t.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) - } - } - - if len(scopes) > 0 { - finalScopeString := strings.Join(scopes, " ") - params.Set("scope", finalScopeString) - t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString) - } - - return t.buildURLWithParams(t.authURL, params) -} - -// buildURLWithParams constructs a URL by combining a base URL with query parameters. -// It handles both relative and absolute URLs, validates URL security, -// and properly encodes query parameters. -// Parameters: -// - baseURL: The base URL to append parameters to. -// - params: The query parameters to append. -// -// Returns: -// - The fully constructed URL string with appended query parameters. -func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string { - if baseURL != "" { - if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") { - if err := t.validateURL(baseURL); err != nil { - t.logger.Errorf("URL validation failed for %s: %v", baseURL, err) - return "" - } - } - } - - if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { - issuerURLParsed, err := url.Parse(t.issuerURL) - if err != nil { - t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", t.issuerURL, err) - return "" - } - - baseURLParsed, err := url.Parse(baseURL) - if err != nil { - t.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err) - return "" - } - - resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed) - - if err := t.validateURL(resolvedURL.String()); err != nil { - t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err) - return "" - } - - resolvedURL.RawQuery = params.Encode() - return resolvedURL.String() - } - - u, err := url.Parse(baseURL) - if err != nil { - t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err) - return "" - } - - if err := t.validateParsedURL(u); err != nil { - t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err) - return "" - } - - u.RawQuery = params.Encode() - return u.String() -} - -// validateURL performs security validation on URLs to prevent SSRF attacks. -// It checks for allowed schemes, validates hosts, and prevents access to private networks. -// Parameters: -// - urlStr: The URL string to validate. -// -// Returns: -// - An error if the URL is invalid or poses security risks, nil if valid. -func (t *TraefikOidc) validateURL(urlStr string) error { - if urlStr == "" { - return fmt.Errorf("empty URL") - } - - u, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("invalid URL format: %w", err) - } - - return t.validateParsedURL(u) -} - -// validateParsedURL validates a parsed URL structure for security. -// It checks schemes, hosts, and paths to prevent malicious URLs. -// Parameters: -// - u: The parsed URL to validate. -// -// Returns: -// - An error if the URL is invalid or dangerous, nil if safe. -func (t *TraefikOidc) validateParsedURL(u *url.URL) error { - allowedSchemes := map[string]bool{ - "https": true, - "http": true, - } - - if !allowedSchemes[u.Scheme] { - return fmt.Errorf("disallowed URL scheme: %s", u.Scheme) - } - - if u.Scheme == "http" { - t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String()) - } - - if u.Host == "" { - return fmt.Errorf("missing host in URL") - } - - if err := t.validateHost(u.Host); err != nil { - return fmt.Errorf("invalid host: %w", err) - } - - if strings.Contains(u.Path, "..") { - return fmt.Errorf("path traversal detected in URL path") - } - - return nil -} - -// validateHost validates a hostname or IP address for security. -// It prevents access to localhost, private networks, and known metadata endpoints. -// Parameters: -// - host: The host string to validate (may include port). -// -// Returns: -// - An error if the host is dangerous or not allowed, nil if safe. -func (t *TraefikOidc) validateHost(host string) error { - hostname := host - if strings.Contains(host, ":") { - var err error - hostname, _, err = net.SplitHostPort(host) - if err != nil { - return fmt.Errorf("invalid host format: %w", err) - } - } - - ip := net.ParseIP(hostname) - if ip != nil { - if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String()) - } - - if ip.IsUnspecified() || ip.IsMulticast() { - return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String()) - } - } - - dangerousHosts := map[string]bool{ - "localhost": true, - "127.0.0.1": true, - "::1": true, - "0.0.0.0": true, - "169.254.169.254": true, - "metadata.google.internal": true, - } - - if dangerousHosts[strings.ToLower(hostname)] { - return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname) - } - - return nil -} - -// startTokenCleanup starts background cleanup goroutines for cache maintenance. -// It runs periodic cleanup of token cache, JWK cache, and session chunks. -// Includes panic recovery to ensure stability. -func (t *TraefikOidc) startTokenCleanup() { - if t == nil { - return - } - - // Use singleton resource manager for token cleanup - rm := GetResourceManager() - taskName := "singleton-token-cleanup" - - // Capture values for the cleanup function - tokenCache := t.tokenCache - jwkCache := t.jwkCache - sessionManager := t.sessionManager - logger := t.logger - - cleanupInterval := 1 * time.Minute - if isTestMode() { - cleanupInterval = 50 * time.Millisecond // Fast interval for tests - } - - // Create cleanup function - cleanupFunc := func() { - if logger != nil && !isTestMode() { - logger.Debug("Starting token cleanup cycle") - } - if tokenCache != nil { - tokenCache.Cleanup() - } - if jwkCache != nil { - jwkCache.Cleanup() - } - if sessionManager != nil { - sessionManager.PeriodicChunkCleanup() - if logger != nil && !isTestMode() { - logger.Debug("Running session health monitoring") - } - } - } - - // Register as singleton task - will return existing if already registered - err := rm.RegisterBackgroundTask(taskName, cleanupInterval, cleanupFunc) - if err != nil { - logger.Errorf("Failed to register token cleanup task: %v", err) - return - } - - // Start the task if not already running - if !rm.IsTaskRunning(taskName) { - rm.StartBackgroundTask(taskName) - logger.Debug("Started singleton token cleanup task") - } else { - logger.Debug("Token cleanup task already running, skipping duplicate") - } -} - -// RevokeToken revokes a token locally by adding it to the blacklist cache. -// It removes the token from the verification cache and adds both the token -// and its JTI (if present) to the blacklist to prevent future use. -// Parameters: -// - token: The raw token string to revoke locally. -func (t *TraefikOidc) RevokeToken(token string) { - t.tokenCache.Delete(token) - - if jwt, err := parseJWT(token); err == nil { - if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" { - expiry := time.Now().Add(24 * time.Hour) - if t.tokenBlacklist != nil { - t.tokenBlacklist.Set(jti, true, time.Until(expiry)) - t.logger.Debugf("Locally revoked token JTI %s (added to blacklist)", jti) - } - } - } - - expiry := time.Now().Add(24 * time.Hour) - if t.tokenBlacklist != nil { - t.tokenBlacklist.Set(token, true, time.Until(expiry)) - t.logger.Debugf("Locally revoked token (added to blacklist)") - } -} - -// RevokeTokenWithProvider revokes a token with the OIDC provider. -// It sends a revocation request to the provider's revocation endpoint -// with proper authentication and error recovery if available. -// Parameters: -// - token: The token to revoke. -// - tokenType: The type of token ("access_token" or "refresh_token"). -// -// Returns: -// - An error if the request fails or the provider returns a non-OK status. -func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error { - if t.revocationURL == "" { - return fmt.Errorf("token revocation endpoint is not configured or discovered") - } - t.logger.Debugf("Attempting to revoke token (type: %s) with provider at %s", tokenType, t.revocationURL) - - data := url.Values{ - "token": {token}, - "token_type_hint": {tokenType}, - "client_id": {t.clientID}, - "client_secret": {t.clientSecret}, - } - - req, err := http.NewRequestWithContext(context.Background(), "POST", t.revocationURL, strings.NewReader(data.Encode())) - if err != nil { - return fmt.Errorf("failed to create token revocation request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - // Send the request with circuit breaker protection if available - var resp *http.Response - if t.errorRecoveryManager != nil { - serviceName := fmt.Sprintf("token-revocation-%s", t.issuerURL) - 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 fmt.Errorf("failed to send token revocation request: %w", err) - } - defer func() { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - limitReader := io.LimitReader(resp.Body, 1024*10) - body, _ := io.ReadAll(limitReader) - t.logger.Errorf("Token revocation failed with status %d: %s", resp.StatusCode, string(body)) - return fmt.Errorf("token revocation failed with status %d", resp.StatusCode) - } - - t.logger.Debugf("Token successfully revoked with provider") - return nil -} - -// refreshToken attempts to refresh authentication tokens using the refresh token. -// It handles provider-specific refresh logic, validates new tokens, updates the session, -// and includes concurrency protection to prevent race conditions. -// Parameters: -// - rw: The HTTP response writer. -// - req: The HTTP request context. -// - session: The session data containing the refresh token. -// -// Returns: -// - true if refresh succeeded and session was updated, false if refresh failed, -// a concurrency conflict was detected, or saving the session failed. -func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *SessionData) bool { - session.refreshMutex.Lock() - defer session.refreshMutex.Unlock() - - t.logger.Debug("Attempting to refresh token (mutex acquired)") - - if !session.inUse { - t.logger.Debug("refreshToken aborted: Session no longer in use") - return false - } - - initialRefreshToken := session.GetRefreshToken() - if initialRefreshToken == "" { - t.logger.Debug("No refresh token found in session") - return false - } - - if t.isGoogleProvider() { - t.logger.Debug("Google OIDC provider detected for token refresh operation") - } else if t.isAzureProvider() { - t.logger.Debug("Azure AD provider detected for token refresh operation") - } - - tokenPrefix := initialRefreshToken - if len(initialRefreshToken) > 10 { - tokenPrefix = initialRefreshToken[:10] - } - t.logger.Debugf("Attempting refresh with token starting with %s...", tokenPrefix) - - newToken, err := t.tokenExchanger.GetNewTokenWithRefreshToken(initialRefreshToken) - if err != nil { - errMsg := err.Error() - if strings.Contains(errMsg, "invalid_grant") || strings.Contains(errMsg, "token expired") { - t.logger.Debug("Refresh token expired or revoked: %v", err) - // Clear all tokens and authentication state when refresh token is invalid - session.SetAuthenticated(false) - session.SetRefreshToken("") - session.SetAccessToken("") - session.SetIDToken("") - session.SetEmail("") - // Clear CSRF tokens as well to prevent any replay attacks - session.SetCSRF("") - session.SetNonce("") - session.SetCodeVerifier("") - if err = session.Save(req, rw); err != nil { - t.logger.Errorf("Failed to clear session after invalid refresh token: %v", err) - } - } else if strings.Contains(errMsg, "invalid_client") { - t.logger.Errorf("Client credentials rejected: %v - check client_id and client_secret configuration", err) - } else if t.isGoogleProvider() && strings.Contains(errMsg, "invalid_request") { - t.logger.Errorf("Google OIDC provider error: %v - check scope configuration includes 'offline_access' and prompt=consent is used during authentication", err) - } else { - t.logger.Errorf("Token refresh failed: %v", err) - } - - return false - } - - if newToken.IDToken == "" { - t.logger.Info("Provider did not return a new ID token during refresh") - return false - } - - if err = t.verifyToken(newToken.IDToken); err != nil { - t.logger.Debug("Failed to verify newly obtained ID token: %v", err) - return false - } - - currentRefreshToken := session.GetRefreshToken() - if initialRefreshToken != currentRefreshToken { - t.logger.Infof("refreshToken aborted: Session refresh token changed concurrently during refresh attempt.") - return false - } - - t.logger.Debugf("Concurrency check passed. Updating session with new tokens.") - - claims, err := t.extractClaimsFunc(newToken.IDToken) - if err != nil { - t.logger.Errorf("refreshToken failed: Failed to extract claims from refreshed token: %v", err) - return false - } - email, _ := claims["email"].(string) - if email == "" { - t.logger.Errorf("refreshToken failed: Email claim missing or empty in refreshed token") - return false - } - session.SetEmail(email) - - // Get token expiry information for logging - var expiryTime time.Time - if expClaim, ok := claims["exp"].(float64); ok { - expiryTime = time.Unix(int64(expClaim), 0) - t.logger.Debugf("New token expires at: %v (in %v)", expiryTime, time.Until(expiryTime)) - } - - session.SetIDToken(newToken.IDToken) - session.SetAccessToken(newToken.AccessToken) - - if newToken.RefreshToken != "" { - t.logger.Debug("Received new refresh token from provider") - session.SetRefreshToken(newToken.RefreshToken) - } else { - t.logger.Debug("Provider did not return a new refresh token, keeping the existing one") - session.SetRefreshToken(initialRefreshToken) - } - - if err := session.SetAuthenticated(true); err != nil { - t.logger.Errorf("refreshToken failed: Failed to set authenticated flag: %v", err) - // Clear tokens on failure to maintain consistent state - session.SetAccessToken("") - session.SetIDToken("") - session.SetRefreshToken("") - session.SetEmail("") - return false - } - - if err := session.Save(req, rw); err != nil { - t.logger.Errorf("refreshToken failed: Failed to save session after successful token refresh: %v", err) - // Reset authentication state since we couldn't persist it - session.SetAuthenticated(false) - return false - } - - t.logger.Debugf("Token refresh successful and session saved") - return true -} - -// isAllowedDomain checks if an email address is authorized based on domain or user whitelist. -// It validates against both allowed user domains and specific allowed users. -// Parameters: -// - email: The email address to validate. -// -// Returns: -// - true if the email is authorized (domain or user allowed), false if not authorized -// or if the email format is invalid. -func (t *TraefikOidc) isAllowedDomain(email string) bool { - if len(t.allowedUserDomains) == 0 && len(t.allowedUsers) == 0 { - return true - } - - if len(t.allowedUsers) > 0 { - _, userAllowed := t.allowedUsers[strings.ToLower(email)] - if userAllowed { - t.logger.Debugf("Email %s is explicitly allowed in allowedUsers", email) - return true - } - } - - if len(t.allowedUserDomains) > 0 { - parts := strings.Split(email, "@") - if len(parts) != 2 { - t.logger.Errorf("Invalid email format encountered: %s", email) - return false - } - - domain := parts[1] - _, domainAllowed := t.allowedUserDomains[domain] - - if domainAllowed { - t.logger.Debugf("Email domain %s is allowed", domain) - return true - } else { - t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v", - domain, keysFromMap(t.allowedUserDomains)) - } - } else if len(t.allowedUsers) > 0 { - t.logger.Debugf("Email %s is not in the allowed users list: %v", - email, keysFromMap(t.allowedUsers)) - } - - return false -} - -// keysFromMap extracts string keys from a map for logging purposes. -// Helper function to get keys from a map for logging. -// Parameters: -// - m: The map to extract keys from. -// -// Returns: -// - A slice of string keys. -func keysFromMap(m map[string]struct{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} +// NOTE: keysFromMap function moved to utilities.go // createCaseInsensitiveStringMap creates a map with lowercase keys for case-insensitive matching. // This is used for case-insensitive matching of email addresses. @@ -1921,59 +456,7 @@ func createCaseInsensitiveStringMap(items []string) map[string]struct{} { return result } -// extractGroupsAndRoles extracts group and role information from token claims. -// It parses the 'groups' and 'roles' claims from the ID token and validates their format. -// Parameters: -// - idToken: The ID token containing claims to extract. -// -// Returns: -// - groups: Array of group names from the 'groups' claim. -// - roles: Array of role names from the 'roles' claim. -// - An error if claim extraction fails or if the 'groups' or 'roles' claims are present -// but not arrays of strings. -func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) { - claims, err := t.extractClaimsFunc(idToken) - if err != nil { - return nil, nil, fmt.Errorf("failed to extract claims: %w", err) - } - - var groups []string - var roles []string - - if groupsClaim, exists := claims["groups"]; exists { - groupsSlice, ok := groupsClaim.([]interface{}) - if !ok { - return nil, nil, fmt.Errorf("groups claim is not an array") - } else { - for _, group := range groupsSlice { - if groupStr, ok := group.(string); ok { - t.logger.Debugf("Found group: %s", groupStr) - groups = append(groups, groupStr) - } else { - t.logger.Errorf("Non-string value found in groups claim array: %v", group) - } - } - } - } - - if rolesClaim, exists := claims["roles"]; exists { - rolesSlice, ok := rolesClaim.([]interface{}) - if !ok { - return nil, nil, fmt.Errorf("roles claim is not an array") - } else { - for _, role := range rolesSlice { - if roleStr, ok := role.(string); ok { - t.logger.Debugf("Found role: %s", roleStr) - roles = append(roles, roleStr) - } else { - t.logger.Errorf("Non-string value found in roles claim array: %v", role) - } - } - } - } - - return groups, roles, nil -} +// NOTE: extractGroupsAndRoles method moved to token_manager.go // buildFullURL constructs a complete URL from scheme, host, and path components. // It handles absolute URLs in the path and ensures proper URL formatting. @@ -1996,571 +479,26 @@ func buildFullURL(scheme, host, path string) string { return fmt.Sprintf("%s://%s%s", scheme, host, path) } -// ExchangeCodeForToken exchanges an authorization code for tokens. -// This is a wrapper method that delegates to the internal token exchange logic -// while still allowing mocking for tests. -// Parameters: -// - ctx: The request context. -// - grantType: The OAuth 2.0 grant type ("authorization_code"). -// - codeOrToken: The authorization code received from the provider. -// - redirectURL: The redirect URI used in the authorization request. -// - codeVerifier: The PKCE code verifier (if PKCE is enabled). -// -// Returns: -// - The token response containing access token, ID token, and refresh token. -// - An error if the token exchange fails. -func (t *TraefikOidc) ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) { - return t.exchangeTokens(ctx, grantType, codeOrToken, redirectURL, codeVerifier) -} +// NOTE: ExchangeCodeForToken method moved to token_manager.go -// GetNewTokenWithRefreshToken refreshes tokens using a refresh token. -// This is a wrapper method that delegates to the internal refresh token logic -// while still allowing mocking for tests. -// Parameters: -// - refreshToken: The refresh token to use for obtaining new tokens. -// -// Returns: -// - The token response containing new access token, ID token, and potentially new refresh token. -// - An error if the refresh fails. -func (t *TraefikOidc) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) { - return t.getNewTokenWithRefreshToken(refreshToken) -} +// NOTE: GetNewTokenWithRefreshToken method moved to token_manager.go -// sendErrorResponse sends an appropriate error response based on the request's Accept header. -// It sends JSON responses for clients that accept JSON, otherwise sends HTML error pages. -// Parameters: -// - rw: The HTTP response writer. -// - req: The HTTP request (used to check Accept header). -// - message: The error message to display. -// - code: The HTTP status code to set for the response. -func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Request, message string, code int) { - acceptHeader := req.Header.Get("Accept") +// NOTE: sendErrorResponse method moved to utilities.go - if strings.Contains(acceptHeader, "application/json") { - t.logger.Debugf("Sending JSON error response (code %d): %s", code, message) - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(code) - json.NewEncoder(rw).Encode(map[string]interface{}{ - "error": http.StatusText(code), - "error_description": message, - "status_code": code, - }) - return - } +// NOTE: isGoogleProvider method moved to token_manager.go - t.logger.Debugf("Sending HTML error response (code %d): %s", code, message) +// NOTE: isAzureProvider method moved to token_manager.go - returnURL := "/" +// NOTE: validateAzureTokens method moved to token_manager.go - htmlBody := fmt.Sprintf(` - - - - Authentication Error - - - -
-

Authentication Error

-

%s

-

Return to application

-
- -`, message, returnURL) +// NOTE: validateGoogleTokens method moved to token_manager.go - rw.Header().Set("Content-Type", "text/html; charset=utf-8") - rw.WriteHeader(code) - _, _ = rw.Write([]byte(htmlBody)) -} +// NOTE: validateStandardTokens method moved to token_manager.go -// isGoogleProvider detects if the configured OIDC provider is Google. -// It checks the issuer URL for Google-specific domains. -// Returns: -// - true if the provider is Google, false otherwise. -func (t *TraefikOidc) isGoogleProvider() bool { - return strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") -} +// NOTE: validateTokenExpiry method moved to token_manager.go -// isAzureProvider detects if the configured OIDC provider is Azure AD. -// It checks the issuer URL for Microsoft Azure AD domains. -// Returns: -// - true if the provider is Azure AD, false otherwise. -func (t *TraefikOidc) isAzureProvider() bool { - return strings.Contains(t.issuerURL, "login.microsoftonline.com") || - strings.Contains(t.issuerURL, "sts.windows.net") || - strings.Contains(t.issuerURL, "login.windows.net") -} +// NOTE: Close method moved to utilities.go -// validateAzureTokens validates tokens with Azure AD-specific logic. -// Azure tokens may be opaque access tokens that cannot be verified as JWTs, -// so this method handles both JWT and opaque token scenarios. -// Parameters: -// - session: The session data containing tokens to validate. -// -// Returns: -// - authenticated: Whether the user has valid authentication. -// - needsRefresh: Whether tokens need to be refreshed. -// - expired: Whether tokens have expired and cannot be refreshed. -func (t *TraefikOidc) validateAzureTokens(session *SessionData) (bool, bool, bool) { - if !session.GetAuthenticated() { - t.logger.Debug("Azure user is not authenticated according to session flag") - if session.GetRefreshToken() != "" { - t.logger.Debug("Azure session not authenticated, but refresh token exists. Signaling need for refresh.") - return false, true, false - } - return false, true, false - } +// NOTE: isAjaxRequest method moved to auth_flow.go - accessToken := session.GetAccessToken() - idToken := session.GetIDToken() - - if accessToken != "" { - if strings.Count(accessToken, ".") == 2 { - if err := t.verifyToken(accessToken); err != nil { - if idToken != "" { - if err := t.verifyToken(idToken); err != nil { - t.logger.Debugf("Azure: Both access and ID token validation failed: %v", err) - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - return t.validateTokenExpiry(session, idToken) - } - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - return t.validateTokenExpiry(session, accessToken) - } else { - t.logger.Debug("Azure access token appears opaque, treating as valid") - if idToken != "" { - return t.validateTokenExpiry(session, idToken) - } - return true, false, false - } - } - - if idToken != "" { - if err := t.verifyToken(idToken); err != nil { - if strings.Contains(err.Error(), "token has expired") { - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - return t.validateTokenExpiry(session, idToken) - } - - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true -} - -// validateGoogleTokens handles Google-specific token validation logic. -// Currently delegates to standard token validation but provides a hook -// for Google-specific validation requirements in the future. -// Parameters: -// - session: The session data containing tokens to validate. -// -// Returns: -// - authenticated: Whether the user has valid authentication. -// - needsRefresh: Whether tokens need to be refreshed. -// - expired: Whether tokens have expired and cannot be refreshed. -func (t *TraefikOidc) validateGoogleTokens(session *SessionData) (bool, bool, bool) { - return t.validateStandardTokens(session) -} - -// validateStandardTokens handles standard OIDC token validation logic. -// This is the default validation method for generic OIDC providers. -// It verifies ID tokens and handles access tokens appropriately. -// Parameters: -// - session: The session data containing tokens to validate. -// -// Returns: -// - authenticated: Whether the user has valid authentication. -// - needsRefresh: Whether tokens need to be refreshed. -// - expired: Whether tokens have expired and cannot be refreshed. -func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool, bool) { - authenticated := session.GetAuthenticated() - // Removed debug output - if !authenticated { - t.logger.Debug("User is not authenticated according to session flag") - if session.GetRefreshToken() != "" { - t.logger.Debug("Session not authenticated, but refresh token exists. Signaling need for refresh.") - return false, true, false - } - return false, false, false - } - - accessToken := session.GetAccessToken() - // Removed debug output - if accessToken == "" { - t.logger.Debug("Authenticated flag set, but no access token found in session") - if session.GetRefreshToken() != "" { - // Check if we have an ID token to determine if we're beyond grace period - // When access token is missing, check ID token expiry to determine if refresh is viable - idToken := session.GetIDToken() - t.logger.Debugf("Checking ID token for grace period: ID token present: %v", idToken != "") - if idToken != "" { - // Try to parse the ID token to check its expiry - parts := strings.Split(idToken, ".") - if len(parts) == 3 { - // Decode the claims part - claimsData, err := base64.RawURLEncoding.DecodeString(parts[1]) - if 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 { - t.logger.Debugf("ID token expired beyond grace period (%v > %v), must re-authenticate", - expiredDuration, t.refreshGracePeriod) - return false, false, true // expired, cannot refresh - } - t.logger.Debugf("ID token expired %v ago, within grace period %v, allowing refresh", - expiredDuration, t.refreshGracePeriod) - } - } - } - } - } - } - t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.") - return false, true, false - } - return false, false, true - } - - // Check if access token is opaque (doesn't have JWT structure) - dotCount := strings.Count(accessToken, ".") - isOpaqueToken := dotCount != 2 - - // For opaque access tokens, rely on ID token for session validation - if isOpaqueToken { - t.logger.Debugf("Access token appears to be opaque (dots: %d), validating session via ID token", dotCount) - - // For opaque access tokens, check ID token for authentication status - idToken := session.GetIDToken() - if idToken == "" { - t.logger.Debug("Opaque access token present but no ID token found") - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token missing but refresh token exists. Signaling need for refresh.") - return false, true, false - } - // Accept session with opaque access token even without ID token - // The OAuth provider validated it when issued - t.logger.Debug("Accepting session with opaque access token") - return true, false, false - } - - // Validate ID token if present - if err := t.verifyToken(idToken); err != nil { - if strings.Contains(err.Error(), "token has expired") { - t.logger.Debugf("ID token expired with opaque access token, needs refresh") - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - - t.logger.Errorf("ID token verification failed with opaque access token: %v", err) - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - - // Use ID token for expiry validation - return t.validateTokenExpiry(session, idToken) - } - - idToken := session.GetIDToken() - if idToken == "" { - t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)") - session.SetAuthenticated(true) - - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.") - return true, true, false - } - return true, false, false - } - - if err := t.verifyToken(idToken); err != nil { - if strings.Contains(err.Error(), "token has expired") { - t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh") - if session.GetRefreshToken() != "" { - return false, true, false - } - return false, false, true - } - - t.logger.Errorf("ID token verification failed (non-expiration): %v", err) - if session.GetRefreshToken() != "" { - t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.") - return false, true, false - } - return false, false, true - } - - return t.validateTokenExpiry(session, idToken) -} - -// validateTokenExpiry checks if a token is nearing expiration and needs refresh. -// It uses the configured grace period to determine when proactive refresh should occur. -// Parameters: -// - session: The session data for refresh token availability. -// - token: The token to check expiry for. -// -// Returns: -// - authenticated: Whether the token is currently valid. -// - needsRefresh: Whether the token is nearing expiration and should be refreshed. -// - expired: Whether the token is invalid or verification failed. -func (t *TraefikOidc) validateTokenExpiry(session *SessionData, token string) (bool, bool, bool) { - cachedClaims, found := t.tokenCache.Get(token) - if !found { - t.logger.Debug("Claims not found in cache after successful token verification") - if session.GetRefreshToken() != "" { - t.logger.Debug("Claims missing post-verification, attempting refresh to recover.") - 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 session.GetRefreshToken() != "" { - t.logger.Debug("Token missing 'exp' claim, but refresh token exists. Signaling need for refresh.") - return false, true, false - } - return false, false, true - } - - expTime := int64(expClaim) - expTimeObj := time.Unix(expTime, 0) - nowObj := time.Now() - - // Check if token has already expired - if expTimeObj.Before(nowObj) { - // Token has expired - expiredDuration := nowObj.Sub(expTimeObj) - - t.logger.Debugf("Token expired %v ago, grace period is %v", - expiredDuration, t.refreshGracePeriod) - - // If we have a refresh token, always attempt to use it regardless of grace period - // The refresh token has its own expiry and the provider will reject it if invalid - if session.GetRefreshToken() != "" { - t.logger.Debugf("Token expired, attempting refresh with available refresh token") - return false, true, false // needs refresh - } - - // No refresh token available - must re-authenticate - t.logger.Debugf("Token expired and no refresh token available, must re-authenticate") - return false, false, true // expired, cannot refresh - } - - // Token not yet expired - check if nearing expiration - refreshThreshold := nowObj.Add(t.refreshGracePeriod) - - t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v", - expTimeObj.Format(time.RFC3339), - nowObj.Format(time.RFC3339), - refreshThreshold.Format(time.RFC3339)) - - if expTimeObj.Before(refreshThreshold) { - remainingSeconds := int64(time.Until(expTimeObj).Seconds()) - t.logger.Debugf("Token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", - remainingSeconds, t.refreshGracePeriod) - - if session.GetRefreshToken() != "" { - return true, true, false - } - - t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.") - return true, false, false - } - - t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)", - int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod) - - return true, false, false -} - -// Close gracefully shuts down the TraefikOidc middleware instance. -// It cancels contexts, stops background goroutines, closes HTTP connections, -// cleans up caches, and releases all resources. Safe to call multiple times. -// Returns: -// - An error if shutdown times out or resource cleanup fails. -func (t *TraefikOidc) Close() error { - var closeErr error - t.shutdownOnce.Do(func() { - t.safeLogDebug("Closing TraefikOidc plugin instance") - - // Get resource manager for cleanup - rm := GetResourceManager() - - // Stop singleton tasks related to this instance - rm.StopBackgroundTask("singleton-token-cleanup") - rm.StopBackgroundTask("singleton-metadata-refresh") - - // Remove reference for this instance - rm.RemoveReference(t.name) - - if t.cancelFunc != nil { - t.cancelFunc() - t.safeLogDebug("Context cancellation signaled to all goroutines") - } - - // Clean up legacy stop channels if they exist - if t.tokenCleanupStopChan != nil { - close(t.tokenCleanupStopChan) - t.safeLogDebug("tokenCleanupStopChan closed") - } - if t.metadataRefreshStopChan != nil { - close(t.metadataRefreshStopChan) - t.safeLogDebug("metadataRefreshStopChan closed") - } - - if t.goroutineWG != nil { - done := make(chan struct{}) - go func() { - t.goroutineWG.Wait() - close(done) - }() - - select { - case <-done: - t.safeLogDebug("All background goroutines stopped gracefully") - case <-time.After(10 * time.Second): - t.safeLogError("Timeout waiting for background goroutines to stop") - } - } else { - t.safeLogDebug("No goroutineWG to wait for (likely in test)") - } - - if t.httpClient != nil { - if transport, ok := t.httpClient.Transport.(*http.Transport); ok { - transport.CloseIdleConnections() - t.safeLogDebug("HTTP client idle connections closed") - } - } - - if t.tokenHTTPClient != nil { - if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok { - transport.CloseIdleConnections() - t.safeLogDebug("Token HTTP client idle connections closed") - } - if t.tokenHTTPClient.Transport != t.httpClient.Transport { - if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok { - transport.CloseIdleConnections() - t.safeLogDebug("Token HTTP client transport closed (separate from main)") - } - } - } - - if t.tokenBlacklist != nil { - t.tokenBlacklist.Close() - t.safeLogDebug("tokenBlacklist closed") - } - if t.metadataCache != nil { - t.metadataCache.Close() - t.safeLogDebug("metadataCache closed") - } - if t.tokenCache != nil { - t.tokenCache.Close() - t.safeLogDebug("tokenCache closed") - } - - if t.jwkCache != nil { - t.jwkCache.Close() - t.safeLogDebug("t.jwkCache.Close() called as per original instruction.") - } - - // Shutdown session manager and its background cleanup routines - if t.sessionManager != nil { - if err := t.sessionManager.Shutdown(); err != nil { - t.safeLogErrorf("Error shutting down session manager: %v", err) - } else { - t.safeLogDebug("sessionManager shutdown completed") - } - } - - // Clean up error recovery manager - if t.errorRecoveryManager != nil && t.errorRecoveryManager.gracefulDegradation != nil { - t.errorRecoveryManager.gracefulDegradation.Close() - t.safeLogDebug("Error recovery manager graceful degradation closed") - } - - // Stop all global background tasks - taskRegistry := GetGlobalTaskRegistry() - taskRegistry.StopAllTasks() - t.safeLogDebug("All global background tasks stopped") - - CleanupGlobalMemoryPools() - t.safeLogDebug("Global memory pools cleaned up") - - // Force garbage collection to help with memory cleanup after shutdown - runtime.GC() - t.safeLogDebug("Forced garbage collection after shutdown") - - t.safeLogDebug("TraefikOidc plugin instance closed successfully.") - }) - return closeErr -} - -// isAjaxRequest determines if the request is an AJAX/fetch request that should -// receive JSON responses instead of HTML redirects. -// Returns true if the request contains AJAX indicators. -func (t *TraefikOidc) isAjaxRequest(req *http.Request) bool { - // Check for XMLHttpRequest header (set by jQuery and many AJAX libraries) - if req.Header.Get("X-Requested-With") == "XMLHttpRequest" { - return true - } - - // Check if client prefers JSON response - acceptHeader := req.Header.Get("Accept") - if strings.Contains(acceptHeader, "application/json") { - return true - } - - // Check for fetch API requests (often contain these headers) - if req.Header.Get("Sec-Fetch-Mode") == "cors" { - return true - } - - return false -} - -// isRefreshTokenExpired checks if the refresh token is likely expired based on -// when it was last obtained. Refresh tokens typically expire after 6+ hours. -// Returns true if the refresh token is likely expired and refresh should be skipped. -func (t *TraefikOidc) isRefreshTokenExpired(session *SessionData) bool { - refreshTokenIssuedAt := session.GetRefreshTokenIssuedAt() - if refreshTokenIssuedAt.IsZero() { - // If we don't have issue time, assume it might be old but try refresh anyway - return false - } - - // Consider refresh token expired if it's older than 6 hours - // This is a conservative estimate as most providers use 6-24 hour expiry - refreshTokenMaxAge := 6 * time.Hour - return time.Since(refreshTokenIssuedAt) > refreshTokenMaxAge -} +// NOTE: isRefreshTokenExpired method moved to auth_flow.go diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..2282fb2 --- /dev/null +++ b/middleware.go @@ -0,0 +1,371 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik. +// This file contains the core HTTP middleware functionality for request processing +// and authentication flow management. +package traefikoidc + +import ( + "bytes" + "fmt" + "net/http" + "strings" + "time" +) + +// ============================================================================ +// HTTP MIDDLEWARE +// ============================================================================ + +// ServeHTTP implements the main middleware logic for processing HTTP requests. +// It handles the complete OIDC authentication flow including: +// - Excluded URL bypass +// - Session validation and management +// - Authentication callback processing +// - Logout handling +// - Token verification and refresh +// - Header injection for authenticated requests +// +// Parameters: +// - rw: The HTTP response writer. +// - req: The incoming HTTP request. +func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if !strings.HasPrefix(req.URL.Path, "/health") { + t.firstRequestMutex.Lock() + if !t.firstRequestReceived { + t.firstRequestReceived = true + t.logger.Debug("Starting background tasks on first request") + t.startTokenCleanup() + + if !t.metadataRefreshStarted && t.providerURL != "" { + t.metadataRefreshStarted = true + // Metadata refresh is handled by singleton resource manager + t.startMetadataRefresh(t.providerURL) + } + } + t.firstRequestMutex.Unlock() + } + + select { + case <-t.initComplete: + if t.issuerURL == "" { + t.logger.Error("OIDC provider metadata initialization failed or incomplete") + t.sendErrorResponse(rw, req, "OIDC provider metadata initialization failed - please check provider availability and configuration", http.StatusServiceUnavailable) + return + } + case <-req.Context().Done(): + t.logger.Debug("Request cancelled while waiting for OIDC initialization") + t.sendErrorResponse(rw, req, "Request cancelled", http.StatusRequestTimeout) + return + case <-time.After(30 * time.Second): + t.logger.Error("Timeout waiting for OIDC initialization") + t.sendErrorResponse(rw, req, "Timeout waiting for OIDC provider initialization - please try again later", http.StatusServiceUnavailable) + return + } + + if t.determineExcludedURL(req.URL.Path) { + t.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path) + t.next.ServeHTTP(rw, req) + return + } + acceptHeader := req.Header.Get("Accept") + if strings.Contains(acceptHeader, "text/event-stream") { + t.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader) + t.next.ServeHTTP(rw, req) + return + } + + t.sessionManager.CleanupOldCookies(rw, req) + + session, err := t.sessionManager.GetSession(req) + if err != nil { + t.logger.Errorf("Error getting session: %v. Initiating authentication.", err) + cleanReq := req.Clone(req.Context()) + session, _ = t.sessionManager.GetSession(cleanReq) + if session != nil { + defer session.returnToPoolSafely() + if clearErr := session.Clear(cleanReq, rw); clearErr != nil { + t.logger.Errorf("Error clearing potentially corrupted session: %v", clearErr) + } + } else { + t.logger.Error("Critical session error: Failed to get even a new session.") + t.sendErrorResponse(rw, req, "Critical session error", http.StatusInternalServerError) + return + } + scheme := t.determineScheme(req) + host := t.determineHost(req) + redirectURL := buildFullURL(scheme, host, t.redirURLPath) + t.defaultInitiateAuthentication(rw, req, session, redirectURL) + return + } + + defer session.returnToPoolSafely() + + scheme := t.determineScheme(req) + host := t.determineHost(req) + redirectURL := buildFullURL(scheme, host, t.redirURLPath) + + if req.URL.Path == t.logoutURLPath { + t.handleLogout(rw, req) + return + } + if req.URL.Path == t.redirURLPath { + t.handleCallback(rw, req, redirectURL) + return + } + + authenticated, needsRefresh, expired := t.isUserAuthenticated(session) + + if expired { + t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth") + t.handleExpiredToken(rw, req, session, redirectURL) + return + } + + email := session.GetEmail() + // Domain restriction check removed debug output + if authenticated && email != "" { + if !t.isAllowedDomain(email) { + t.logger.Infof("User with email %s is not from an allowed domain", email) + errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath) + t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) + return + } + } + + if authenticated && !needsRefresh { + t.logger.Debug("User authenticated and token valid, proceeding to process authorized request") + if accessToken := session.GetAccessToken(); accessToken != "" { + if strings.Count(accessToken, ".") == 2 { + if err := t.verifyToken(accessToken); err != nil { + t.logger.Errorf("Access token validation failed: %v", err) + t.handleExpiredToken(rw, req, session, redirectURL) + return + } + } else { + t.logger.Debugf("Access token appears opaque, skipping JWT verification for it.") + } + } + t.processAuthorizedRequest(rw, req, session, redirectURL) + return + } + + refreshTokenPresent := session.GetRefreshToken() != "" + + // Check if this is an AJAX request that should receive 401 instead of redirect + isAjaxRequest := t.isAjaxRequest(req) + + // Check if refresh token is likely expired (older than 6 hours) + refreshTokenExpired := refreshTokenPresent && t.isRefreshTokenExpired(session) + + shouldAttemptRefresh := needsRefresh && refreshTokenPresent && !refreshTokenExpired + + // If AJAX request and refresh token expired, return 401 immediately + if isAjaxRequest && refreshTokenExpired { + t.logger.Debug("AJAX request with expired refresh token, returning 401") + t.sendErrorResponse(rw, req, "Session expired", http.StatusUnauthorized) + return + } + + if shouldAttemptRefresh { + idToken := session.GetIDToken() + if idToken != "" { + jwt, err := parseJWT(idToken) + if err == nil { + claims := jwt.Claims + if expClaim, ok := claims["exp"].(float64); ok { + expTime := int64(expClaim) + expTimeObj := time.Unix(expTime, 0) + refreshThreshold := time.Now().Add(t.refreshGracePeriod) + + if !expTimeObj.Before(refreshThreshold) { + t.logger.Debug("Token is valid and outside grace period, skipping refresh") + t.processAuthorizedRequest(rw, req, session, redirectURL) + return + } + } else { + t.logger.Debug("Could not extract 'exp' claim for grace period check, proceeding with refresh") + } + } + } + + if needsRefresh && authenticated { + t.logger.Debug("Session token needs proactive refresh, attempting refresh") + } else if needsRefresh && !authenticated { + t.logger.Debug("ID token invalid/expired, but refresh token found. Attempting refresh.") + } + + refreshed := t.refreshToken(rw, req, session) + if refreshed { + email = session.GetEmail() + if email != "" && !t.isAllowedDomain(email) { + t.logger.Infof("User with refreshed token email %s is not from an allowed domain", email) + errorMsg := fmt.Sprintf("Access denied: Your email domain is not allowed. To log out, visit: %s", t.logoutURLPath) + t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) + return + } + + t.logger.Debug("Token refresh successful, proceeding to process authorized request") + t.processAuthorizedRequest(rw, req, session, redirectURL) + return + } + + t.logger.Debug("Token refresh failed, requiring re-authentication") + if isAjaxRequest { + t.logger.Debug("AJAX request with failed token refresh, sending 401 Unauthorized") + t.sendErrorResponse(rw, req, "Token refresh failed", http.StatusUnauthorized) + } else { + t.logger.Debug("Browser request with failed token refresh, initiating re-auth") + // Reset redirect count when starting fresh auth after failed refresh to prevent redirect loops + session.ResetRedirectCount() + t.defaultInitiateAuthentication(rw, req, session, redirectURL) + } + return + } + + t.logger.Debugf("Initiating full OIDC authentication flow (authenticated=%v, needsRefresh=%v, refreshTokenPresent=%v)", authenticated, needsRefresh, refreshTokenPresent) + + // If AJAX request without valid authentication, return 401 + if isAjaxRequest { + t.logger.Debug("AJAX request requires authentication, sending 401 Unauthorized") + t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized) + return + } + + // Reset redirect count when starting fresh authentication flow + session.ResetRedirectCount() + t.defaultInitiateAuthentication(rw, req, session, redirectURL) +} + +// ============================================================================ +// REQUEST PROCESSING +// ============================================================================ + +// processAuthorizedRequest processes requests for authenticated users. +// It extracts claims, validates roles/groups if configured, sets authentication headers, +// processes header templates, and forwards the request to the next handler. +// Domain checks should be performed before calling this method. +// Parameters: +// - rw: The HTTP response writer. +// - req: The HTTP request to process. +// - session: The user's session data containing tokens and claims. +// - redirectURL: The callback URL for re-authentication if needed. +func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { + email := session.GetEmail() + if email == "" { + t.logger.Info("No email found in session during final processing, initiating re-auth") + // Reset redirect count to prevent loops when session is invalid + session.ResetRedirectCount() + t.defaultInitiateAuthentication(rw, req, session, redirectURL) + return + } + + tokenForClaims := session.GetIDToken() + if tokenForClaims == "" { + tokenForClaims = session.GetAccessToken() + if tokenForClaims == "" && len(t.allowedRolesAndGroups) > 0 { + t.logger.Error("No token available but roles/groups checks are required") + // Reset redirect count to prevent loops when token is missing + session.ResetRedirectCount() + t.defaultInitiateAuthentication(rw, req, session, redirectURL) + return + } + } + + // Initialize empty slices + var groups, roles []string + + if tokenForClaims != "" { + var err error + groups, roles, err = t.extractGroupsAndRoles(tokenForClaims) + if err != nil && len(t.allowedRolesAndGroups) > 0 { + t.logger.Errorf("Failed to extract groups and roles: %v", err) + // Reset redirect count to prevent loops when claim extraction fails + session.ResetRedirectCount() + t.defaultInitiateAuthentication(rw, req, session, redirectURL) + return + } else if err == nil { + if len(groups) > 0 { + req.Header.Set("X-User-Groups", strings.Join(groups, ",")) + } + if len(roles) > 0 { + req.Header.Set("X-User-Roles", strings.Join(roles, ",")) + } + } + } + + if len(t.allowedRolesAndGroups) > 0 { + allowed := false + for _, roleOrGroup := range append(groups, roles...) { + if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok { + allowed = true + break + } + } + if !allowed { + t.logger.Infof("User with email %s does not have any allowed roles or groups", email) + errorMsg := fmt.Sprintf("Access denied: You do not have any of the allowed roles or groups. To log out, visit: %s", t.logoutURLPath) + t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden) + return + } + } + + req.Header.Set("X-Forwarded-User", email) + + req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI()) + req.Header.Set("X-Auth-Request-User", email) + if idToken := session.GetIDToken(); idToken != "" { + req.Header.Set("X-Auth-Request-Token", idToken) + } + + if len(t.headerTemplates) > 0 { + claims, err := t.extractClaimsFunc(session.GetIDToken()) + if err != nil { + t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", err) + } else { + templateData := map[string]interface{}{ + "AccessToken": session.GetAccessToken(), + "IDToken": session.GetIDToken(), + "RefreshToken": session.GetRefreshToken(), + "Claims": claims, + } + + for headerName, tmpl := range t.headerTemplates { + var buf bytes.Buffer + + if err := tmpl.Execute(&buf, templateData); err != nil { + t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err) + continue + } + headerValue := buf.String() + + req.Header.Set(headerName, headerValue) + + t.logger.Debugf("Set templated header %s = %s", headerName, headerValue) + } + session.MarkDirty() + t.logger.Debugf("Session marked dirty after templated header processing.") + } + } + + if session.IsDirty() { + if err := session.Save(req, rw); err != nil { + t.logger.Errorf("Failed to save session after processing headers: %v", err) + } + } else { + t.logger.Debug("Session not dirty, skipping save in processAuthorizedRequest") + } + + // Apply security headers if configured + if t.securityHeadersApplier != nil { + t.securityHeadersApplier(rw, req) + } else { + // Fallback to basic security headers + rw.Header().Set("X-Frame-Options", "DENY") + rw.Header().Set("X-Content-Type-Options", "nosniff") + rw.Header().Set("X-XSS-Protection", "1; mode=block") + rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + } + + t.logger.Debugf("Request authorized for user %s, forwarding to next handler", email) + + t.next.ServeHTTP(rw, req) +} diff --git a/token_manager.go b/token_manager.go new file mode 100644 index 0000000..90ee9d0 --- /dev/null +++ b/token_manager.go @@ -0,0 +1,962 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik. +// This file contains token management functionality including verification, +// caching, refresh, and provider-specific validation logic. +package traefikoidc + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// ============================================================================ +// TOKEN VERIFICATION +// ============================================================================ + +// VerifyToken verifies the validity of an ID token or access token. +// It performs comprehensive validation including format checks, blacklist verification, +// signature validation using JWKs, and standard claims validation. It also caches +// successfully verified tokens to avoid repeated verification. +// Parameters: +// - token: The JWT token string to verify. +// +// Returns: +// - An error if verification fails (e.g., blacklisted token, invalid format, +// signature failure, or claims error), nil if verification succeeds. +func (t *TraefikOidc) VerifyToken(token string) error { + if token == "" { + return fmt.Errorf("invalid JWT format: token is empty") + } + + if strings.Count(token, ".") != 2 { + return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1) + } + + if len(token) < 10 { + return fmt.Errorf("token too short to be valid JWT") + } + + if t.tokenBlacklist != nil { + if blacklisted, exists := t.tokenBlacklist.Get(token); exists && blacklisted != nil { + return fmt.Errorf("token is blacklisted (raw string) in cache") + } + } + + parsedJWT, parseErr := parseJWT(token) + if parseErr != nil { + return fmt.Errorf("failed to parse JWT for blacklist check: %w", parseErr) + } + + tokenType := "UNKNOWN" + if aud, ok := parsedJWT.Claims["aud"]; ok { + if audStr, ok := aud.(string); ok && audStr == t.clientID { + tokenType = "ID_TOKEN" + } + } + if scope, ok := parsedJWT.Claims["scope"]; ok { + if _, ok := scope.(string); ok { + tokenType = "ACCESS_TOKEN" + } + } + + if jti, ok := parsedJWT.Claims["jti"].(string); ok && jti != "" { + if !strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") { + if t.tokenBlacklist != nil { + if blacklisted, exists := t.tokenBlacklist.Get(jti); exists && blacklisted != nil { + return fmt.Errorf("token replay detected (jti: %s) in cache", jti) + } + } + } + } + + if claims, exists := t.tokenCache.Get(token); exists && len(claims) > 0 { + return nil + } + + if !t.limiter.Allow() { + return fmt.Errorf("rate limit exceeded") + } + + jwt := parsedJWT + + if err := t.VerifyJWTSignatureAndClaims(jwt, token); err != nil { + if !strings.Contains(err.Error(), "token has expired") { + t.safeLogErrorf("%s token verification failed: %v", tokenType, err) + } + return err + } + + t.cacheVerifiedToken(token, jwt.Claims) + + if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" { + expiry := time.Now().Add(defaultBlacklistDuration) + if expClaim, expOk := jwt.Claims["exp"].(float64); expOk { + expTime := time.Unix(int64(expClaim), 0) + tokenDuration := time.Until(expTime) + if tokenDuration > defaultBlacklistDuration && tokenDuration < (24*time.Hour) { + expiry = expTime + } else if tokenDuration <= 0 { + expiry = time.Now().Add(defaultBlacklistDuration) + } else { + expiry = time.Now().Add(defaultBlacklistDuration) + } + } + + if t.tokenBlacklist != nil { + t.tokenBlacklist.Set(jti, true, time.Until(expiry)) + t.safeLogDebugf("Added JTI %s to blacklist cache", jti) + } else { + t.safeLogErrorf("Token blacklist not available, skipping JTI %s blacklist", jti) + } + + replayCacheMu.Lock() + if replayCache == nil { + initReplayCache() + } + duration := time.Until(expiry) + if duration > 0 { + replayCache.Set(jti, true, duration) + } + replayCacheMu.Unlock() + } + + return nil +} + +// verifyToken is a convenience wrapper for token verification. +// It delegates to the configured token verifier interface. +// Parameters: +// - token: The token string to verify. +// +// Returns: +// - The result of calling t.tokenVerifier.VerifyToken(token). +func (t *TraefikOidc) verifyToken(token string) error { + return t.tokenVerifier.VerifyToken(token) +} + +// cacheVerifiedToken stores a successfully verified token and its claims in the cache. +// The token is cached until its expiration time to avoid repeated verification. +// Parameters: +// - token: The verified token string to cache. +// - claims: The map of claims extracted from the verified token. +func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interface{}) { + expClaim, ok := claims["exp"].(float64) + if !ok { + t.safeLogError("Failed to cache token: invalid 'exp' claim type") + return + } + + expirationTime := time.Unix(int64(expClaim), 0) + now := time.Now() + duration := expirationTime.Sub(now) + t.tokenCache.Set(token, claims, duration) +} + +// VerifyJWTSignatureAndClaims verifies JWT signature using provider's public keys and validates standard claims. +// It retrieves the appropriate public key from the JWKS cache, verifies the token signature, +// and validates standard OIDC claims like issuer, audience, and expiration. +// Parameters: +// - jwt: The parsed JWT structure containing header and claims. +// - token: The raw token string for signature verification. +// +// Returns: +// - An error if verification fails (e.g., JWKS retrieval failed, no matching key, +// signature verification failed, standard claim validation failed), nil if successful. +func (t *TraefikOidc) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error { + t.safeLogDebugf("Verifying JWT signature and claims") + + jwks, err := t.jwkCache.GetJWKS(context.Background(), t.jwksURL, t.httpClient) + if err != nil { + return fmt.Errorf("failed to get JWKS: %w", err) + } + + if !t.suppressDiagnosticLogs && jwks != nil { + t.safeLogDebugf("DIAGNOSTIC: Retrieved JWKS with %d keys from URL: %s", len(jwks.Keys), t.jwksURL) + } + + kid, ok := jwt.Header["kid"].(string) + if !ok { + return fmt.Errorf("missing key ID in token header") + } + alg, ok := jwt.Header["alg"].(string) + if !ok { + return fmt.Errorf("missing algorithm in token header") + } + + if !t.suppressDiagnosticLogs { + t.safeLogDebugf("DIAGNOSTIC: Looking for kid=%s, alg=%s in JWKS", kid, alg) + } + + if jwks == nil { + return fmt.Errorf("JWKS is nil, cannot verify token") + } + + // Find the matching key in JWKS + var matchingKey *JWK + availableKids := make([]string, 0, len(jwks.Keys)) + for _, key := range jwks.Keys { + availableKids = append(availableKids, key.Kid) + if key.Kid == kid { + matchingKey = &key + break + } + } + + if matchingKey == nil { + if !t.suppressDiagnosticLogs { + t.safeLogErrorf("DIAGNOSTIC: No matching key found for kid=%s. Available kids: %v", kid, availableKids) + } + return fmt.Errorf("no matching public key found for kid: %s", kid) + } + + if !t.suppressDiagnosticLogs { + t.safeLogDebugf("DIAGNOSTIC: Found matching key for kid=%s, key type: %s", kid, matchingKey.Kty) + } + + publicKeyPEM, err := jwkToPEM(matchingKey) + if err != nil { + return fmt.Errorf("failed to convert JWK to PEM: %w", err) + } + + if err := verifySignature(token, publicKeyPEM, alg); err != nil { + if !t.suppressDiagnosticLogs { + t.safeLogErrorf("DIAGNOSTIC: Signature verification failed for kid=%s, alg=%s: %v", kid, alg, err) + } + return fmt.Errorf("signature verification failed: %w", err) + } + + if !t.suppressDiagnosticLogs { + t.safeLogDebugf("DIAGNOSTIC: Signature verification successful for kid=%s", kid) + } + + if err := jwt.Verify(t.issuerURL, t.clientID, true); err != nil { + return fmt.Errorf("standard claim verification failed: %w", err) + } + + return nil +} + +// ============================================================================ +// TOKEN REFRESH & MANAGEMENT +// ============================================================================ + +// refreshToken attempts to refresh authentication tokens using the refresh token. +// It handles provider-specific refresh logic, validates new tokens, updates the session, +// and includes concurrency protection to prevent race conditions. +// Parameters: +// - rw: The HTTP response writer. +// - req: The HTTP request context. +// - session: The session data containing the refresh token. +// +// Returns: +// - true if refresh succeeded and session was updated, false if refresh failed, +// a concurrency conflict was detected, or saving the session failed. +func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *SessionData) bool { + session.refreshMutex.Lock() + defer session.refreshMutex.Unlock() + + t.logger.Debug("Attempting to refresh token (mutex acquired)") + + if !session.inUse { + t.logger.Debug("refreshToken aborted: Session no longer in use") + return false + } + + initialRefreshToken := session.GetRefreshToken() + if initialRefreshToken == "" { + t.logger.Debug("No refresh token found in session") + return false + } + + if t.isGoogleProvider() { + t.logger.Debug("Google OIDC provider detected for token refresh operation") + } else if t.isAzureProvider() { + t.logger.Debug("Azure AD provider detected for token refresh operation") + } + + tokenPrefix := initialRefreshToken + if len(initialRefreshToken) > 10 { + tokenPrefix = initialRefreshToken[:10] + } + t.logger.Debugf("Attempting refresh with token starting with %s...", tokenPrefix) + + newToken, err := t.tokenExchanger.GetNewTokenWithRefreshToken(initialRefreshToken) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "invalid_grant") || strings.Contains(errMsg, "token expired") { + t.logger.Debug("Refresh token expired or revoked: %v", err) + // Clear all tokens and authentication state when refresh token is invalid + session.SetAuthenticated(false) + session.SetRefreshToken("") + session.SetAccessToken("") + session.SetIDToken("") + session.SetEmail("") + // Clear CSRF tokens as well to prevent any replay attacks + session.SetCSRF("") + session.SetNonce("") + session.SetCodeVerifier("") + if err = session.Save(req, rw); err != nil { + t.logger.Errorf("Failed to clear session after invalid refresh token: %v", err) + } + } else if strings.Contains(errMsg, "invalid_client") { + t.logger.Errorf("Client credentials rejected: %v - check client_id and client_secret configuration", err) + } else if t.isGoogleProvider() && strings.Contains(errMsg, "invalid_request") { + t.logger.Errorf("Google OIDC provider error: %v - check scope configuration includes 'offline_access' and prompt=consent is used during authentication", err) + } else { + t.logger.Errorf("Token refresh failed: %v", err) + } + + return false + } + + if newToken.IDToken == "" { + t.logger.Info("Provider did not return a new ID token during refresh") + return false + } + + if err = t.verifyToken(newToken.IDToken); err != nil { + t.logger.Debug("Failed to verify newly obtained ID token: %v", err) + return false + } + + currentRefreshToken := session.GetRefreshToken() + if initialRefreshToken != currentRefreshToken { + t.logger.Infof("refreshToken aborted: Session refresh token changed concurrently during refresh attempt.") + return false + } + + t.logger.Debugf("Concurrency check passed. Updating session with new tokens.") + + claims, err := t.extractClaimsFunc(newToken.IDToken) + if err != nil { + t.logger.Errorf("refreshToken failed: Failed to extract claims from refreshed token: %v", err) + return false + } + email, _ := claims["email"].(string) + if email == "" { + t.logger.Errorf("refreshToken failed: Email claim missing or empty in refreshed token") + return false + } + session.SetEmail(email) + + // Get token expiry information for logging + var expiryTime time.Time + if expClaim, ok := claims["exp"].(float64); ok { + expiryTime = time.Unix(int64(expClaim), 0) + t.logger.Debugf("New token expires at: %v (in %v)", expiryTime, time.Until(expiryTime)) + } + + session.SetIDToken(newToken.IDToken) + session.SetAccessToken(newToken.AccessToken) + + if newToken.RefreshToken != "" { + t.logger.Debug("Received new refresh token from provider") + session.SetRefreshToken(newToken.RefreshToken) + } else { + t.logger.Debug("Provider did not return a new refresh token, keeping the existing one") + session.SetRefreshToken(initialRefreshToken) + } + + if err := session.SetAuthenticated(true); err != nil { + t.logger.Errorf("refreshToken failed: Failed to set authenticated flag: %v", err) + // Clear tokens on failure to maintain consistent state + session.SetAccessToken("") + session.SetIDToken("") + session.SetRefreshToken("") + session.SetEmail("") + return false + } + + if err := session.Save(req, rw); err != nil { + t.logger.Errorf("refreshToken failed: Failed to save session after successful token refresh: %v", err) + // Reset authentication state since we couldn't persist it + session.SetAuthenticated(false) + return false + } + + t.logger.Debugf("Token refresh successful and session saved") + return true +} + +// ============================================================================ +// TOKEN REVOCATION +// ============================================================================ + +// RevokeToken revokes a token locally by adding it to the blacklist cache. +// It removes the token from the verification cache and adds both the token +// and its JTI (if present) to the blacklist to prevent future use. +// Parameters: +// - token: The raw token string to revoke locally. +func (t *TraefikOidc) RevokeToken(token string) { + t.tokenCache.Delete(token) + + if jwt, err := parseJWT(token); err == nil { + if jti, ok := jwt.Claims["jti"].(string); ok && jti != "" { + expiry := time.Now().Add(24 * time.Hour) + if t.tokenBlacklist != nil { + t.tokenBlacklist.Set(jti, true, time.Until(expiry)) + t.logger.Debugf("Locally revoked token JTI %s (added to blacklist)", jti) + } + } + } + + expiry := time.Now().Add(24 * time.Hour) + if t.tokenBlacklist != nil { + t.tokenBlacklist.Set(token, true, time.Until(expiry)) + t.logger.Debugf("Locally revoked token (added to blacklist)") + } +} + +// RevokeTokenWithProvider revokes a token with the OIDC provider. +// It sends a revocation request to the provider's revocation endpoint +// with proper authentication and error recovery if available. +// Parameters: +// - token: The token to revoke. +// - tokenType: The type of token ("access_token" or "refresh_token"). +// +// Returns: +// - An error if the request fails or the provider returns a non-OK status. +func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error { + if t.revocationURL == "" { + return fmt.Errorf("token revocation endpoint is not configured or discovered") + } + t.logger.Debugf("Attempting to revoke token (type: %s) with provider at %s", tokenType, t.revocationURL) + + data := url.Values{ + "token": {token}, + "token_type_hint": {tokenType}, + "client_id": {t.clientID}, + "client_secret": {t.clientSecret}, + } + + req, err := http.NewRequestWithContext(context.Background(), "POST", t.revocationURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create token revocation request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + // Send the request with circuit breaker protection if available + var resp *http.Response + if t.errorRecoveryManager != nil { + serviceName := fmt.Sprintf("token-revocation-%s", t.issuerURL) + 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 fmt.Errorf("failed to send token revocation request: %w", err) + } + defer func() { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + limitReader := io.LimitReader(resp.Body, 1024*10) + body, _ := io.ReadAll(limitReader) + t.logger.Errorf("Token revocation failed with status %d: %s", resp.StatusCode, string(body)) + return fmt.Errorf("token revocation failed with status %d", resp.StatusCode) + } + + t.logger.Debugf("Token successfully revoked with provider") + return nil +} + +// ============================================================================ +// TOKEN EXCHANGE OPERATIONS +// ============================================================================ + +// ExchangeCodeForToken exchanges an authorization code for tokens. +// This is a wrapper method that delegates to the internal token exchange logic +// while still allowing mocking for tests. +// Parameters: +// - ctx: The request context. +// - grantType: The OAuth 2.0 grant type ("authorization_code"). +// - codeOrToken: The authorization code received from the provider. +// - redirectURL: The redirect URI used in the authorization request. +// - codeVerifier: The PKCE code verifier (if PKCE is enabled). +// +// Returns: +// - The token response containing access token, ID token, and refresh token. +// - An error if the token exchange fails. +func (t *TraefikOidc) ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error) { + return t.exchangeTokens(ctx, grantType, codeOrToken, redirectURL, codeVerifier) +} + +// GetNewTokenWithRefreshToken refreshes tokens using a refresh token. +// This is a wrapper method that delegates to the internal refresh token logic +// while still allowing mocking for tests. +// Parameters: +// - refreshToken: The refresh token to use for obtaining new tokens. +// +// Returns: +// - The token response containing new access token, ID token, and potentially new refresh token. +// - An error if the refresh fails. +func (t *TraefikOidc) GetNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) { + return t.getNewTokenWithRefreshToken(refreshToken) +} + +// ============================================================================ +// PROVIDER DETECTION +// ============================================================================ + +// isGoogleProvider detects if the configured OIDC provider is Google. +// It checks the issuer URL for Google-specific domains. +// Returns: +// - true if the provider is Google, false otherwise. +func (t *TraefikOidc) isGoogleProvider() bool { + return strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com") +} + +// isAzureProvider detects if the configured OIDC provider is Azure AD. +// It checks the issuer URL for Microsoft Azure AD domains. +// Returns: +// - true if the provider is Azure AD, false otherwise. +func (t *TraefikOidc) isAzureProvider() bool { + return strings.Contains(t.issuerURL, "login.microsoftonline.com") || + strings.Contains(t.issuerURL, "sts.windows.net") || + strings.Contains(t.issuerURL, "login.windows.net") +} + +// ============================================================================ +// PROVIDER VALIDATION +// ============================================================================ + +// validateAzureTokens validates tokens with Azure AD-specific logic. +// Azure tokens may be opaque access tokens that cannot be verified as JWTs, +// so this method handles both JWT and opaque token scenarios. +// Parameters: +// - session: The session data containing tokens to validate. +// +// Returns: +// - authenticated: Whether the user has valid authentication. +// - needsRefresh: Whether tokens need to be refreshed. +// - expired: Whether tokens have expired and cannot be refreshed. +func (t *TraefikOidc) validateAzureTokens(session *SessionData) (bool, bool, bool) { + if !session.GetAuthenticated() { + t.logger.Debug("Azure user is not authenticated according to session flag") + if session.GetRefreshToken() != "" { + t.logger.Debug("Azure session not authenticated, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, true, false + } + + accessToken := session.GetAccessToken() + idToken := session.GetIDToken() + + if accessToken != "" { + if strings.Count(accessToken, ".") == 2 { + if err := t.verifyToken(accessToken); err != nil { + if idToken != "" { + if err := t.verifyToken(idToken); err != nil { + t.logger.Debugf("Azure: Both access and ID token validation failed: %v", err) + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + return t.validateTokenExpiry(session, idToken) + } + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + return t.validateTokenExpiry(session, accessToken) + } else { + t.logger.Debug("Azure access token appears opaque, treating as valid") + if idToken != "" { + return t.validateTokenExpiry(session, idToken) + } + return true, false, false + } + } + + if idToken != "" { + if err := t.verifyToken(idToken); err != nil { + if strings.Contains(err.Error(), "token has expired") { + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + return t.validateTokenExpiry(session, idToken) + } + + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true +} + +// validateGoogleTokens handles Google-specific token validation logic. +// Currently delegates to standard token validation but provides a hook +// for Google-specific validation requirements in the future. +// Parameters: +// - session: The session data containing tokens to validate. +// +// Returns: +// - authenticated: Whether the user has valid authentication. +// - needsRefresh: Whether tokens need to be refreshed. +// - expired: Whether tokens have expired and cannot be refreshed. +func (t *TraefikOidc) validateGoogleTokens(session *SessionData) (bool, bool, bool) { + return t.validateStandardTokens(session) +} + +// validateStandardTokens handles standard OIDC token validation logic. +// This is the default validation method for generic OIDC providers. +// It verifies ID tokens and handles access tokens appropriately. +// Parameters: +// - session: The session data containing tokens to validate. +// +// Returns: +// - authenticated: Whether the user has valid authentication. +// - needsRefresh: Whether tokens need to be refreshed. +// - expired: Whether tokens have expired and cannot be refreshed. +func (t *TraefikOidc) validateStandardTokens(session *SessionData) (bool, bool, bool) { + authenticated := session.GetAuthenticated() + // Removed debug output + if !authenticated { + t.logger.Debug("User is not authenticated according to session flag") + if session.GetRefreshToken() != "" { + t.logger.Debug("Session not authenticated, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, false + } + + accessToken := session.GetAccessToken() + // Removed debug output + if accessToken == "" { + t.logger.Debug("Authenticated flag set, but no access token found in session") + if session.GetRefreshToken() != "" { + // Check if we have an ID token to determine if we're beyond grace period + // When access token is missing, check ID token expiry to determine if refresh is viable + idToken := session.GetIDToken() + t.logger.Debugf("Checking ID token for grace period: ID token present: %v", idToken != "") + if idToken != "" { + // Try to parse the ID token to check its expiry + parts := strings.Split(idToken, ".") + if len(parts) == 3 { + // Decode the claims part + claimsData, err := base64.RawURLEncoding.DecodeString(parts[1]) + if 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 { + t.logger.Debugf("ID token expired beyond grace period (%v > %v), must re-authenticate", + expiredDuration, t.refreshGracePeriod) + return false, false, true // expired, cannot refresh + } + t.logger.Debugf("ID token expired %v ago, within grace period %v, allowing refresh", + expiredDuration, t.refreshGracePeriod) + } + } + } + } + } + } + t.logger.Debug("Access token missing, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + // Check if access token is opaque (doesn't have JWT structure) + dotCount := strings.Count(accessToken, ".") + isOpaqueToken := dotCount != 2 + + // For opaque access tokens, rely on ID token for session validation + if isOpaqueToken { + t.logger.Debugf("Access token appears to be opaque (dots: %d), validating session via ID token", dotCount) + + // For opaque access tokens, check ID token for authentication status + idToken := session.GetIDToken() + if idToken == "" { + t.logger.Debug("Opaque access token present but no ID token found") + if session.GetRefreshToken() != "" { + t.logger.Debug("ID token missing but refresh token exists. Signaling need for refresh.") + return false, true, false + } + // Accept session with opaque access token even without ID token + // The OAuth provider validated it when issued + t.logger.Debug("Accepting session with opaque access token") + return true, false, false + } + + // Validate ID token if present + if err := t.verifyToken(idToken); err != nil { + if strings.Contains(err.Error(), "token has expired") { + t.logger.Debugf("ID token expired with opaque access token, needs refresh") + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + + t.logger.Errorf("ID token verification failed with opaque access token: %v", err) + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + + // Use ID token for expiry validation + return t.validateTokenExpiry(session, idToken) + } + + idToken := session.GetIDToken() + if idToken == "" { + t.logger.Debug("Authenticated flag set with access token, but no ID token found in session (possibly opaque token)") + session.SetAuthenticated(true) + + if session.GetRefreshToken() != "" { + t.logger.Debug("ID token missing but refresh token exists. Signaling conditional refresh to obtain ID token.") + return true, true, false + } + return true, false, false + } + + if err := t.verifyToken(idToken); err != nil { + if strings.Contains(err.Error(), "token has expired") { + t.logger.Debugf("ID token signature/claims valid but token expired, needs refresh") + if session.GetRefreshToken() != "" { + return false, true, false + } + return false, false, true + } + + t.logger.Errorf("ID token verification failed (non-expiration): %v", err) + if session.GetRefreshToken() != "" { + t.logger.Debug("ID token verification failed, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + return t.validateTokenExpiry(session, idToken) +} + +// validateTokenExpiry checks if a token is nearing expiration and needs refresh. +// It uses the configured grace period to determine when proactive refresh should occur. +// Parameters: +// - session: The session data for refresh token availability. +// - token: The token to check expiry for. +// +// Returns: +// - authenticated: Whether the token is currently valid. +// - needsRefresh: Whether the token is nearing expiration and should be refreshed. +// - expired: Whether the token is invalid or verification failed. +func (t *TraefikOidc) validateTokenExpiry(session *SessionData, token string) (bool, bool, bool) { + cachedClaims, found := t.tokenCache.Get(token) + if !found { + t.logger.Debug("Claims not found in cache after successful token verification") + if session.GetRefreshToken() != "" { + t.logger.Debug("Claims missing post-verification, attempting refresh to recover.") + 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 session.GetRefreshToken() != "" { + t.logger.Debug("Token missing 'exp' claim, but refresh token exists. Signaling need for refresh.") + return false, true, false + } + return false, false, true + } + + expTime := int64(expClaim) + expTimeObj := time.Unix(expTime, 0) + nowObj := time.Now() + + // Check if token has already expired + if expTimeObj.Before(nowObj) { + // Token has expired + expiredDuration := nowObj.Sub(expTimeObj) + + t.logger.Debugf("Token expired %v ago, grace period is %v", + expiredDuration, t.refreshGracePeriod) + + // If we have a refresh token, always attempt to use it regardless of grace period + // The refresh token has its own expiry and the provider will reject it if invalid + if session.GetRefreshToken() != "" { + t.logger.Debugf("Token expired, attempting refresh with available refresh token") + return false, true, false // needs refresh + } + + // No refresh token available - must re-authenticate + t.logger.Debugf("Token expired and no refresh token available, must re-authenticate") + return false, false, true // expired, cannot refresh + } + + // Token not yet expired - check if nearing expiration + refreshThreshold := nowObj.Add(t.refreshGracePeriod) + + t.logger.Debugf("Token expires at %v, now is %v, refresh threshold is %v", + expTimeObj.Format(time.RFC3339), + nowObj.Format(time.RFC3339), + refreshThreshold.Format(time.RFC3339)) + + if expTimeObj.Before(refreshThreshold) { + remainingSeconds := int64(time.Until(expTimeObj).Seconds()) + t.logger.Debugf("Token nearing expiration (expires in %d seconds, grace period %s), scheduling proactive refresh", + remainingSeconds, t.refreshGracePeriod) + + if session.GetRefreshToken() != "" { + return true, true, false + } + + t.logger.Debugf("Token nearing expiration but no refresh token available, cannot proactively refresh.") + return true, false, false + } + + t.logger.Debugf("Token is valid and not nearing expiration (expires in %d seconds, outside %s grace period)", + int64(time.Until(expTimeObj).Seconds()), t.refreshGracePeriod) + + return true, false, false +} + +// ============================================================================ +// BACKGROUND TASKS & CLEANUP +// ============================================================================ + +// startTokenCleanup starts background cleanup goroutines for cache maintenance. +// It runs periodic cleanup of token cache, JWK cache, and session chunks. +// Includes panic recovery to ensure stability. +func (t *TraefikOidc) startTokenCleanup() { + if t == nil { + return + } + + // Use singleton resource manager for token cleanup + rm := GetResourceManager() + taskName := "singleton-token-cleanup" + + // Capture values for the cleanup function + tokenCache := t.tokenCache + jwkCache := t.jwkCache + sessionManager := t.sessionManager + logger := t.logger + + cleanupInterval := 1 * time.Minute + if isTestMode() { + cleanupInterval = 50 * time.Millisecond // Fast interval for tests + } + + // Create cleanup function + cleanupFunc := func() { + if logger != nil && !isTestMode() { + logger.Debug("Starting token cleanup cycle") + } + if tokenCache != nil { + tokenCache.Cleanup() + } + if jwkCache != nil { + jwkCache.Cleanup() + } + if sessionManager != nil { + sessionManager.PeriodicChunkCleanup() + if logger != nil && !isTestMode() { + logger.Debug("Running session health monitoring") + } + } + } + + // Register as singleton task - will return existing if already registered + err := rm.RegisterBackgroundTask(taskName, cleanupInterval, cleanupFunc) + if err != nil { + logger.Errorf("Failed to register token cleanup task: %v", err) + return + } + + // Start the task if not already running + if !rm.IsTaskRunning(taskName) { + rm.StartBackgroundTask(taskName) + logger.Debug("Started singleton token cleanup task") + } else { + logger.Debug("Token cleanup task already running, skipping duplicate") + } +} + +// ============================================================================ +// AUTHORIZATION & ACCESS CONTROL +// ============================================================================ + +// extractGroupsAndRoles extracts group and role information from token claims. +// It parses the 'groups' and 'roles' claims from the ID token and validates their format. +// Parameters: +// - idToken: The ID token containing claims to extract. +// +// Returns: +// - groups: Array of group names from the 'groups' claim. +// - roles: Array of role names from the 'roles' claim. +// - An error if claim extraction fails or if the 'groups' or 'roles' claims are present +// but not arrays of strings. +func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) { + claims, err := t.extractClaimsFunc(idToken) + if err != nil { + return nil, nil, fmt.Errorf("failed to extract claims: %w", err) + } + + var groups []string + var roles []string + + if groupsClaim, exists := claims["groups"]; exists { + groupsSlice, ok := groupsClaim.([]interface{}) + if !ok { + return nil, nil, fmt.Errorf("groups claim is not an array") + } else { + for _, group := range groupsSlice { + if groupStr, ok := group.(string); ok { + t.logger.Debugf("Found group: %s", groupStr) + groups = append(groups, groupStr) + } else { + t.logger.Errorf("Non-string value found in groups claim array: %v", group) + } + } + } + } + + if rolesClaim, exists := claims["roles"]; exists { + rolesSlice, ok := rolesClaim.([]interface{}) + if !ok { + return nil, nil, fmt.Errorf("roles claim is not an array") + } else { + for _, role := range rolesSlice { + if roleStr, ok := role.(string); ok { + t.logger.Debugf("Found role: %s", roleStr) + roles = append(roles, roleStr) + } else { + t.logger.Errorf("Non-string value found in roles claim array: %v", role) + } + } + } + } + + return groups, roles, nil +} diff --git a/url_helpers.go b/url_helpers.go new file mode 100644 index 0000000..38d8f7d --- /dev/null +++ b/url_helpers.go @@ -0,0 +1,315 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik. +// This file contains URL-related helper methods for building, validating, and processing URLs +// used in the OIDC authentication flow. +package traefikoidc + +import ( + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +// ============================================================================= +// URL Exclusion Methods +// ============================================================================= + +// determineExcludedURL checks if a URL path should bypass OIDC authentication. +// It compares the request path against configured excluded URL prefixes. +// Parameters: +// - currentRequest: The request path to check. +// +// Returns: +// - true if the URL should be excluded from authentication, false otherwise. +func (t *TraefikOidc) determineExcludedURL(currentRequest string) bool { + for excludedURL := range t.excludedURLs { + if strings.HasPrefix(currentRequest, excludedURL) { + t.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL) + return true + } + } + return false +} + +// ============================================================================= +// Request Analysis Methods +// ============================================================================= + +// determineScheme determines the URL scheme for building redirect URLs. +// It checks X-Forwarded-Proto header first, then TLS presence. +// Parameters: +// - req: The HTTP request to analyze. +// +// Returns: +// - The determined scheme: "https" or "http". +func (t *TraefikOidc) determineScheme(req *http.Request) string { + if scheme := req.Header.Get("X-Forwarded-Proto"); scheme != "" { + return scheme + } + if req.TLS != nil { + return "https" + } + return "http" +} + +// determineHost determines the host for building redirect URLs. +// It checks X-Forwarded-Host header first, then falls back to req.Host. +// Parameters: +// - req: The HTTP request to analyze. +// +// Returns: +// - The determined host string (e.g., "example.com:8080"). +func (t *TraefikOidc) determineHost(req *http.Request) string { + if host := req.Header.Get("X-Forwarded-Host"); host != "" { + return host + } + return req.Host +} + +// ============================================================================= +// URL Building Methods +// ============================================================================= + +// buildAuthURL constructs the OIDC provider authorization URL. +// It builds the URL with all necessary parameters including client_id, scopes, +// PKCE parameters, and provider-specific parameters for Google and Azure. +// Parameters: +// - redirectURL: The callback URL for after authentication. +// - state: The CSRF token for state validation. +// - nonce: The nonce for replay protection. +// - codeChallenge: The PKCE code challenge (if PKCE is enabled). +// +// Returns: +// - The fully constructed authorization URL string. +func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge string) string { + params := url.Values{} + params.Set("client_id", t.clientID) + params.Set("response_type", "code") + params.Set("redirect_uri", redirectURL) + params.Set("state", state) + params.Set("nonce", nonce) + + if t.enablePKCE && codeChallenge != "" { + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + } + + scopes := make([]string, len(t.scopes)) + copy(scopes, t.scopes) + + if t.isGoogleProvider() { + params.Set("access_type", "offline") + t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens") + + params.Set("prompt", "consent") + t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens") + } else if t.isAzureProvider() { + params.Set("response_mode", "query") + t.logger.Debug("Azure AD provider detected, added response_mode=query") + + hasOfflineAccess := false + + for _, scope := range scopes { + if scope == "offline_access" { + hasOfflineAccess = true + break + } + } + + if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { + if !hasOfflineAccess { + scopes = append(scopes, "offline_access") + t.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) + } + } else { + t.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) + } + } else { + if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { + hasOfflineAccess := false + for _, scope := range scopes { + if scope == "offline_access" { + hasOfflineAccess = true + break + } + } + if !hasOfflineAccess { + scopes = append(scopes, "offline_access") + t.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) + } + } else { + t.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) + } + } + + if len(scopes) > 0 { + finalScopeString := strings.Join(scopes, " ") + params.Set("scope", finalScopeString) + t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString) + } + + return t.buildURLWithParams(t.authURL, params) +} + +// buildURLWithParams constructs a URL by combining a base URL with query parameters. +// It handles both relative and absolute URLs, validates URL security, +// and properly encodes query parameters. +// Parameters: +// - baseURL: The base URL to append parameters to. +// - params: The query parameters to append. +// +// Returns: +// - The fully constructed URL string with appended query parameters. +func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string { + if baseURL != "" { + if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") { + if err := t.validateURL(baseURL); err != nil { + t.logger.Errorf("URL validation failed for %s: %v", baseURL, err) + return "" + } + } + } + + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + issuerURLParsed, err := url.Parse(t.issuerURL) + if err != nil { + t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", t.issuerURL, err) + return "" + } + + baseURLParsed, err := url.Parse(baseURL) + if err != nil { + t.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err) + return "" + } + + resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed) + + if err := t.validateURL(resolvedURL.String()); err != nil { + t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err) + return "" + } + + resolvedURL.RawQuery = params.Encode() + return resolvedURL.String() + } + + u, err := url.Parse(baseURL) + if err != nil { + t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err) + return "" + } + + if err := t.validateParsedURL(u); err != nil { + t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err) + return "" + } + + u.RawQuery = params.Encode() + return u.String() +} + +// ============================================================================= +// URL Validation Methods +// ============================================================================= + +// validateURL performs security validation on URLs to prevent SSRF attacks. +// It checks for allowed schemes, validates hosts, and prevents access to private networks. +// Parameters: +// - urlStr: The URL string to validate. +// +// Returns: +// - An error if the URL is invalid or poses security risks, nil if valid. +func (t *TraefikOidc) validateURL(urlStr string) error { + if urlStr == "" { + return fmt.Errorf("empty URL") + } + + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + return t.validateParsedURL(u) +} + +// validateParsedURL validates a parsed URL structure for security. +// It checks schemes, hosts, and paths to prevent malicious URLs. +// Parameters: +// - u: The parsed URL to validate. +// +// Returns: +// - An error if the URL is invalid or dangerous, nil if safe. +func (t *TraefikOidc) validateParsedURL(u *url.URL) error { + allowedSchemes := map[string]bool{ + "https": true, + "http": true, + } + + if !allowedSchemes[u.Scheme] { + return fmt.Errorf("disallowed URL scheme: %s", u.Scheme) + } + + if u.Scheme == "http" { + t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String()) + } + + if u.Host == "" { + return fmt.Errorf("missing host in URL") + } + + if err := t.validateHost(u.Host); err != nil { + return fmt.Errorf("invalid host: %w", err) + } + + if strings.Contains(u.Path, "..") { + return fmt.Errorf("path traversal detected in URL path") + } + + return nil +} + +// validateHost validates a hostname or IP address for security. +// It prevents access to localhost, private networks, and known metadata endpoints. +// Parameters: +// - host: The host string to validate (may include port). +// +// Returns: +// - An error if the host is dangerous or not allowed, nil if safe. +func (t *TraefikOidc) validateHost(host string) error { + hostname := host + if strings.Contains(host, ":") { + var err error + hostname, _, err = net.SplitHostPort(host) + if err != nil { + return fmt.Errorf("invalid host format: %w", err) + } + } + + ip := net.ParseIP(hostname) + if ip != nil { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String()) + } + + if ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String()) + } + } + + dangerousHosts := map[string]bool{ + "localhost": true, + "127.0.0.1": true, + "::1": true, + "0.0.0.0": true, + "169.254.169.254": true, + "metadata.google.internal": true, + } + + if dangerousHosts[strings.ToLower(hostname)] { + return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname) + } + + return nil +} diff --git a/utilities.go b/utilities.go new file mode 100644 index 0000000..aef9087 --- /dev/null +++ b/utilities.go @@ -0,0 +1,299 @@ +// Package traefikoidc provides OIDC authentication middleware for Traefik. +// This file contains utility/helper methods extracted from main.go for better code organization. +package traefikoidc + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + "time" +) + +// ============================================================================= +// LOGGING UTILITIES +// ============================================================================= + +// safeLogDebug provides nil-safe logging for debug messages +func (t *TraefikOidc) safeLogDebug(msg string) { + if t.logger != nil { + t.logger.Debug("%s", msg) + } +} + +// safeLogDebugf provides nil-safe logging for formatted debug messages +func (t *TraefikOidc) safeLogDebugf(format string, args ...interface{}) { + if t.logger != nil { + t.logger.Debugf(format, args...) + } +} + +// safeLogError provides nil-safe logging for error messages +func (t *TraefikOidc) safeLogError(msg string) { + if t.logger != nil { + t.logger.Error("%s", msg) + } +} + +// safeLogErrorf provides nil-safe logging for formatted error messages +func (t *TraefikOidc) safeLogErrorf(format string, args ...interface{}) { + if t.logger != nil { + t.logger.Errorf(format, args...) + } +} + +// safeLogInfo provides nil-safe logging for info messages +func (t *TraefikOidc) safeLogInfo(msg string) { + if t.logger != nil { + t.logger.Info("%s", msg) + } +} + +// ============================================================================= +// DOMAIN VALIDATION +// ============================================================================= + +// isAllowedDomain checks if an email address is authorized based on domain or user whitelist. +// It validates against both allowed user domains and specific allowed users. +// Parameters: +// - email: The email address to validate. +// +// Returns: +// - true if the email is authorized (domain or user allowed), false if not authorized +// or if the email format is invalid. +func (t *TraefikOidc) isAllowedDomain(email string) bool { + if len(t.allowedUserDomains) == 0 && len(t.allowedUsers) == 0 { + return true + } + + if len(t.allowedUsers) > 0 { + _, userAllowed := t.allowedUsers[strings.ToLower(email)] + if userAllowed { + t.logger.Debugf("Email %s is explicitly allowed in allowedUsers", email) + return true + } + } + + if len(t.allowedUserDomains) > 0 { + parts := strings.Split(email, "@") + if len(parts) != 2 { + t.logger.Errorf("Invalid email format encountered: %s", email) + return false + } + + domain := parts[1] + _, domainAllowed := t.allowedUserDomains[domain] + + if domainAllowed { + t.logger.Debugf("Email domain %s is allowed", domain) + return true + } else { + t.logger.Debugf("Email domain %s is NOT allowed. Allowed domains: %v", + domain, keysFromMap(t.allowedUserDomains)) + } + } else if len(t.allowedUsers) > 0 { + t.logger.Debugf("Email %s is not in the allowed users list: %v", + email, keysFromMap(t.allowedUsers)) + } + + return false +} + +// keysFromMap extracts string keys from a map for logging purposes. +// Helper function to get keys from a map for logging. +// Parameters: +// - m: The map to extract keys from. +// +// Returns: +// - A slice of string keys. +func keysFromMap(m map[string]struct{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// ============================================================================= +// ERROR HANDLING +// ============================================================================= + +// sendErrorResponse sends an appropriate error response based on the request's Accept header. +// It sends JSON responses for clients that accept JSON, otherwise sends HTML error pages. +// Parameters: +// - rw: The HTTP response writer. +// - req: The HTTP request (used to check Accept header). +// - message: The error message to display. +// - code: The HTTP status code to set for the response. +func (t *TraefikOidc) sendErrorResponse(rw http.ResponseWriter, req *http.Request, message string, code int) { + acceptHeader := req.Header.Get("Accept") + + if strings.Contains(acceptHeader, "application/json") { + t.logger.Debugf("Sending JSON error response (code %d): %s", code, message) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(code) + json.NewEncoder(rw).Encode(map[string]interface{}{ + "error": http.StatusText(code), + "error_description": message, + "status_code": code, + }) + return + } + + t.logger.Debugf("Sending HTML error response (code %d): %s", code, message) + + returnURL := "/" + + htmlBody := fmt.Sprintf(` + + + + Authentication Error + + + +
+

Authentication Error

+

%s

+

Return to application

+
+ +`, message, returnURL) + + rw.Header().Set("Content-Type", "text/html; charset=utf-8") + rw.WriteHeader(code) + _, _ = rw.Write([]byte(htmlBody)) +} + +// ============================================================================= +// CLEANUP +// ============================================================================= + +// Close gracefully shuts down the TraefikOidc middleware instance. +// It cancels contexts, stops background goroutines, closes HTTP connections, +// cleans up caches, and releases all resources. Safe to call multiple times. +// Returns: +// - An error if shutdown times out or resource cleanup fails. +func (t *TraefikOidc) Close() error { + var closeErr error + t.shutdownOnce.Do(func() { + t.safeLogDebug("Closing TraefikOidc plugin instance") + + // Get resource manager for cleanup + rm := GetResourceManager() + + // Stop singleton tasks related to this instance + rm.StopBackgroundTask("singleton-token-cleanup") + rm.StopBackgroundTask("singleton-metadata-refresh") + + // Remove reference for this instance + rm.RemoveReference(t.name) + + if t.cancelFunc != nil { + t.cancelFunc() + t.safeLogDebug("Context cancellation signaled to all goroutines") + } + + // Clean up legacy stop channels if they exist + if t.tokenCleanupStopChan != nil { + close(t.tokenCleanupStopChan) + t.safeLogDebug("tokenCleanupStopChan closed") + } + if t.metadataRefreshStopChan != nil { + close(t.metadataRefreshStopChan) + t.safeLogDebug("metadataRefreshStopChan closed") + } + + if t.goroutineWG != nil { + done := make(chan struct{}) + go func() { + t.goroutineWG.Wait() + close(done) + }() + + select { + case <-done: + t.safeLogDebug("All background goroutines stopped gracefully") + case <-time.After(10 * time.Second): + t.safeLogError("Timeout waiting for background goroutines to stop") + } + } else { + t.safeLogDebug("No goroutineWG to wait for (likely in test)") + } + + if t.httpClient != nil { + if transport, ok := t.httpClient.Transport.(*http.Transport); ok { + transport.CloseIdleConnections() + t.safeLogDebug("HTTP client idle connections closed") + } + } + + if t.tokenHTTPClient != nil { + if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok { + transport.CloseIdleConnections() + t.safeLogDebug("Token HTTP client idle connections closed") + } + if t.tokenHTTPClient.Transport != t.httpClient.Transport { + if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok { + transport.CloseIdleConnections() + t.safeLogDebug("Token HTTP client transport closed (separate from main)") + } + } + } + + if t.tokenBlacklist != nil { + t.tokenBlacklist.Close() + t.safeLogDebug("tokenBlacklist closed") + } + if t.metadataCache != nil { + t.metadataCache.Close() + t.safeLogDebug("metadataCache closed") + } + if t.tokenCache != nil { + t.tokenCache.Close() + t.safeLogDebug("tokenCache closed") + } + + if t.jwkCache != nil { + t.jwkCache.Close() + t.safeLogDebug("t.jwkCache.Close() called as per original instruction.") + } + + // Shutdown session manager and its background cleanup routines + if t.sessionManager != nil { + if err := t.sessionManager.Shutdown(); err != nil { + t.safeLogErrorf("Error shutting down session manager: %v", err) + } else { + t.safeLogDebug("sessionManager shutdown completed") + } + } + + // Clean up error recovery manager + if t.errorRecoveryManager != nil && t.errorRecoveryManager.gracefulDegradation != nil { + t.errorRecoveryManager.gracefulDegradation.Close() + t.safeLogDebug("Error recovery manager graceful degradation closed") + } + + // Stop all global background tasks + taskRegistry := GetGlobalTaskRegistry() + taskRegistry.StopAllTasks() + t.safeLogDebug("All global background tasks stopped") + + CleanupGlobalMemoryPools() + t.safeLogDebug("Global memory pools cleaned up") + + // Force garbage collection to help with memory cleanup after shutdown + runtime.GC() + t.safeLogDebug("Forced garbage collection after shutdown") + + t.safeLogDebug("TraefikOidc plugin instance closed successfully.") + }) + return closeErr +}