mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
277 lines
9.3 KiB
Go
277 lines
9.3 KiB
Go
// Package handlers provides HTTP request handlers for the OIDC middleware.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// OAuthHandler handles OAuth callback requests
|
|
type OAuthHandler struct {
|
|
logger Logger
|
|
sessionManager SessionManager
|
|
tokenExchanger TokenExchanger
|
|
tokenVerifier TokenVerifier
|
|
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
|
isAllowedDomainFunc func(email string) bool
|
|
redirURLPath string
|
|
sendErrorResponseFunc func(rw http.ResponseWriter, req *http.Request, message string, code int)
|
|
}
|
|
|
|
// Logger interface for dependency injection
|
|
type Logger interface {
|
|
Debugf(format string, args ...interface{})
|
|
Errorf(format string, args ...interface{})
|
|
Error(msg string)
|
|
}
|
|
|
|
// SessionManager interface for session operations
|
|
type SessionManager interface {
|
|
GetSession(req *http.Request) (SessionData, error)
|
|
}
|
|
|
|
// SessionData interface for session data operations
|
|
type SessionData interface {
|
|
GetCSRF() string
|
|
GetNonce() string
|
|
GetCodeVerifier() string
|
|
GetIncomingPath() string
|
|
GetAuthenticated() bool
|
|
SetAuthenticated(bool) error
|
|
SetEmail(string)
|
|
SetIDToken(string)
|
|
SetAccessToken(string)
|
|
SetRefreshToken(string)
|
|
SetCSRF(string)
|
|
SetNonce(string)
|
|
SetCodeVerifier(string)
|
|
SetIncomingPath(string)
|
|
ResetRedirectCount()
|
|
Save(req *http.Request, rw http.ResponseWriter) error
|
|
returnToPoolSafely()
|
|
}
|
|
|
|
// TokenExchanger interface for token operations
|
|
type TokenExchanger interface {
|
|
ExchangeCodeForToken(ctx context.Context, grantType string, codeOrToken string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
|
}
|
|
|
|
// TokenVerifier interface for token verification
|
|
type TokenVerifier interface {
|
|
VerifyToken(token string) error
|
|
}
|
|
|
|
// TokenResponse represents the response from token exchange
|
|
type TokenResponse struct {
|
|
IDToken string
|
|
AccessToken string
|
|
RefreshToken string
|
|
}
|
|
|
|
// NewOAuthHandler creates a new OAuth handler
|
|
func NewOAuthHandler(logger Logger, sessionManager SessionManager, tokenExchanger TokenExchanger,
|
|
tokenVerifier TokenVerifier, extractClaimsFunc func(string) (map[string]interface{}, error),
|
|
isAllowedDomainFunc func(string) bool, redirURLPath string,
|
|
sendErrorResponseFunc func(http.ResponseWriter, *http.Request, string, int)) *OAuthHandler {
|
|
|
|
return &OAuthHandler{
|
|
logger: logger,
|
|
sessionManager: sessionManager,
|
|
tokenExchanger: tokenExchanger,
|
|
tokenVerifier: tokenVerifier,
|
|
extractClaimsFunc: extractClaimsFunc,
|
|
isAllowedDomainFunc: isAllowedDomainFunc,
|
|
redirURLPath: redirURLPath,
|
|
sendErrorResponseFunc: sendErrorResponseFunc,
|
|
}
|
|
}
|
|
|
|
// HandleCallback handles OAuth callback requests
|
|
func (h *OAuthHandler) HandleCallback(rw http.ResponseWriter, req *http.Request, redirectURL string) {
|
|
session, err := h.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
h.logger.Errorf("Session error during callback: %v", err)
|
|
h.sendErrorResponseFunc(rw, req, "Session error during callback", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer session.returnToPoolSafely()
|
|
|
|
h.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")
|
|
}
|
|
h.logger.Errorf("Authentication error from provider during callback: %s - %s", req.URL.Query().Get("error"), errorDescription)
|
|
h.sendErrorResponseFunc(rw, req, fmt.Sprintf("Authentication error from provider: %s", errorDescription), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
state := req.URL.Query().Get("state")
|
|
if state == "" {
|
|
h.logger.Error("No state in callback")
|
|
h.sendErrorResponseFunc(rw, req, "State parameter missing in callback", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
csrfToken := session.GetCSRF()
|
|
if csrfToken == "" {
|
|
h.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 {
|
|
h.logger.Errorf("Main session cookie not found in request: %v", err)
|
|
} else {
|
|
h.logger.Errorf("Main session cookie exists but CSRF token is empty. Cookie value length: %d", len(cookie.Value))
|
|
}
|
|
|
|
h.sendErrorResponseFunc(rw, req, "CSRF token missing in session", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if state != csrfToken {
|
|
h.logger.Error("State parameter does not match CSRF token in session during callback")
|
|
h.sendErrorResponseFunc(rw, req, "Invalid state parameter (CSRF mismatch)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := req.URL.Query().Get("code")
|
|
if code == "" {
|
|
h.logger.Error("No code in callback")
|
|
h.sendErrorResponseFunc(rw, req, "No authorization code received in callback", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
codeVerifier := session.GetCodeVerifier()
|
|
|
|
tokenResponse, err := h.tokenExchanger.ExchangeCodeForToken(req.Context(), "authorization_code", code, redirectURL, codeVerifier)
|
|
if err != nil {
|
|
h.logger.Errorf("Failed to exchange code for token during callback: %v", err)
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not exchange code for token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err = h.tokenVerifier.VerifyToken(tokenResponse.IDToken); err != nil {
|
|
h.logger.Errorf("Failed to verify id_token during callback: %v", err)
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not verify ID token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
claims, err := h.extractClaimsFunc(tokenResponse.IDToken)
|
|
if err != nil {
|
|
h.logger.Errorf("Failed to extract claims during callback: %v", err)
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Could not extract claims from token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
nonceClaim, ok := claims["nonce"].(string)
|
|
if !ok || nonceClaim == "" {
|
|
h.logger.Error("Nonce claim missing in id_token during callback")
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sessionNonce := session.GetNonce()
|
|
if sessionNonce == "" {
|
|
h.logger.Error("Nonce not found in session during callback")
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce missing in session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if nonceClaim != sessionNonce {
|
|
h.logger.Error("Nonce claim does not match session nonce during callback")
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Nonce mismatch", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
email, _ := claims["email"].(string)
|
|
if email == "" {
|
|
h.logger.Errorf("Email claim missing or empty in token during callback")
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email missing in token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !h.isAllowedDomainFunc(email) {
|
|
h.logger.Errorf("Disallowed email domain during callback: %s", email)
|
|
h.sendErrorResponseFunc(rw, req, "Authentication failed: Email domain not allowed", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := session.SetAuthenticated(true); err != nil {
|
|
h.logger.Errorf("Failed to set authenticated state and regenerate session ID: %v", err)
|
|
h.sendErrorResponseFunc(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 != h.redirURLPath {
|
|
redirectPath = incomingPath
|
|
}
|
|
session.SetIncomingPath("")
|
|
|
|
if err := session.Save(req, rw); err != nil {
|
|
h.logger.Errorf("Failed to save session after callback: %v", err)
|
|
h.sendErrorResponseFunc(rw, req, "Failed to save session after callback", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Debugf("Callback successful, redirecting to %s", redirectPath)
|
|
http.Redirect(rw, req, redirectPath, http.StatusFound)
|
|
}
|
|
|
|
// URLHelper provides utility methods for URL operations
|
|
type URLHelper struct {
|
|
logger Logger
|
|
}
|
|
|
|
// NewURLHelper creates a new URL helper
|
|
func NewURLHelper(logger Logger) *URLHelper {
|
|
return &URLHelper{logger: logger}
|
|
}
|
|
|
|
// DetermineExcludedURL checks if a URL path should bypass OIDC authentication.
|
|
// It compares the request path against configured excluded URL prefixes.
|
|
func (h *URLHelper) DetermineExcludedURL(currentRequest string, excludedURLs map[string]struct{}) bool {
|
|
for excludedURL := range excludedURLs {
|
|
if strings.HasPrefix(currentRequest, excludedURL) {
|
|
h.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.
|
|
func (h *URLHelper) 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.
|
|
func (h *URLHelper) DetermineHost(req *http.Request) string {
|
|
if host := req.Header.Get("X-Forwarded-Host"); host != "" {
|
|
return host
|
|
}
|
|
return req.Host
|
|
}
|