mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
review fixes apr 2026 (#130)
* Multiple fixes - refresh coordinator dedup + memory pressure wire - middleware sse consolidation + timer leak + claim cache - universal cache sync backfill + isDebug gate - lazy background task race - memory monitor stw cached + refresh() api * fix(auth): suppress OIDC redirects on non-navigation requests - [x] Add isNonNavigationRequest using Sec-Fetch-Mode and Accept headers - [x] Add comprehensive TestIsNonNavigationRequest - [x] Update ServeHTTP to 401 non-navigation and AJAX requests Fixes #129 * feat(config): add custom CA and insecure skip verify for OIDC TLS - [x] Add CACertPath, CACertPEM, InsecureSkipVerify to Config - [x] Implement loadCACertPool for CA bundle loading - [x] Update HTTPClientConfig with RootCAs and InsecureSkipVerify - [x] Apply CA pool and skip verify to pooled HTTP clients - [x] Enhance configKey to distinguish TLS configs - [x] Add comprehensive ca_cert_test.go Fixes #125 * feat(oidc): add custom CA certificate support for private OIDC providers - [x] Add caCertPath, caCertPEM, insecureSkipVerify config options - [x] Update traefik.yml with new OIDC client config fields - [x] Add configuration schema descriptions for new options - [x] Update README table and add Custom CA Certificates section * Fix the documentation. * test(redis): add oversized argument rejection test - [x] Add TestRedisConn_RejectOversizedArgumentBytes - [x] Import strings package * Dependencies cleanup
This commit is contained in:
+91
-37
@@ -13,6 +13,63 @@ import (
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/utils"
|
||||
)
|
||||
|
||||
// bypassReason describes why a request is being forwarded without OIDC auth.
|
||||
// It is only used for logging and to decide whether extra SSE-specific work
|
||||
// (propagating the user header from an existing session) should run.
|
||||
const (
|
||||
bypassReasonExcluded = "excluded-url"
|
||||
bypassReasonSSE = "sse"
|
||||
)
|
||||
|
||||
// shouldBypassAuth decides whether a request must skip OIDC authentication
|
||||
// entirely. It returns (true, reason) when either the request path matches a
|
||||
// configured excluded URL or the Accept header asks for a text/event-stream
|
||||
// response (SSE). The reason lets ServeHTTP apply any side-effects that are
|
||||
// unique to the bypass kind (e.g. propagating user headers for SSE).
|
||||
//
|
||||
// This must be called BEFORE waiting on t.initComplete so excluded and SSE
|
||||
// traffic is never blocked by a slow/broken provider.
|
||||
func (t *TraefikOidc) shouldBypassAuth(req *http.Request) (bool, string) {
|
||||
if t.determineExcludedURL(req.URL.Path) {
|
||||
return true, bypassReasonExcluded
|
||||
}
|
||||
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
|
||||
return true, bypassReasonSSE
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// applySSEUserHeaders attempts to copy the authenticated user's identity from
|
||||
// an existing session onto the outgoing SSE request so downstream services
|
||||
// can still see who the user is. Failures are logged (not silenced) because
|
||||
// they indicate either a corrupt cookie or a misconfigured session manager
|
||||
// and are useful for debugging, but they never block the bypass itself.
|
||||
func (t *TraefikOidc) applySSEUserHeaders(req *http.Request) {
|
||||
if t.sessionManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
// Intentionally not fatal: SSE requests bypass auth, we just lose the
|
||||
// forwarded-user header for this request.
|
||||
t.logger.Debugf("SSE bypass: unable to load session for user header propagation: %v", err)
|
||||
return
|
||||
}
|
||||
defer session.returnToPoolSafely()
|
||||
|
||||
email := session.GetEmail()
|
||||
if email == "" {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("X-Forwarded-User", email)
|
||||
if !t.minimalHeaders {
|
||||
req.Header.Set("X-Auth-Request-User", email)
|
||||
}
|
||||
t.logger.Debugf("SSE bypass: forwarded user %s from session", email)
|
||||
}
|
||||
|
||||
// ServeHTTP implements the main middleware logic for processing HTTP requests.
|
||||
// It handles the complete OIDC authentication flow including:
|
||||
// - Excluded URL bypass
|
||||
@@ -67,17 +124,15 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.firstRequestMutex.Unlock()
|
||||
}
|
||||
|
||||
// Check excluded URLs before waiting for initialization
|
||||
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
|
||||
}
|
||||
|
||||
// Check for SSE requests before waiting for initialization
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
if strings.Contains(acceptHeader, "text/event-stream") {
|
||||
t.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader)
|
||||
// Evaluate auth-bypass once, before waiting for initialization. Excluded URLs
|
||||
// and SSE requests must not block on provider init. For SSE we additionally
|
||||
// attempt to forward the user identity from an existing session (best
|
||||
// effort) so downstream handlers still see X-Forwarded-User.
|
||||
if bypass, reason := t.shouldBypassAuth(req); bypass {
|
||||
t.logger.Debugf("Bypassing OIDC for %s (%s)", req.URL.Path, reason)
|
||||
if reason == bypassReasonSSE {
|
||||
t.applySSEUserHeaders(req)
|
||||
}
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
@@ -85,6 +140,11 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
// Log waiting for initialization to help diagnose hanging requests
|
||||
t.logger.Debug("Waiting for OIDC provider initialization...")
|
||||
|
||||
// time.NewTimer + Stop avoids leaking a goroutine+channel for 30s on every
|
||||
// request when initComplete fires quickly (would happen with time.After).
|
||||
initTimer := time.NewTimer(30 * time.Second)
|
||||
defer initTimer.Stop()
|
||||
|
||||
select {
|
||||
case <-t.initComplete:
|
||||
// Read issuerURL with RLock
|
||||
@@ -115,34 +175,13 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.logger.Debug("Request canceled while waiting for OIDC initialization")
|
||||
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-initTimer.C:
|
||||
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)
|
||||
// Set forwarded user headers from existing session before bypassing
|
||||
if session, err := t.sessionManager.GetSession(req); err == nil {
|
||||
defer session.returnToPoolSafely()
|
||||
if email := session.GetEmail(); email != "" {
|
||||
req.Header.Set("X-Forwarded-User", email)
|
||||
if !t.minimalHeaders {
|
||||
req.Header.Set("X-Auth-Request-User", email)
|
||||
}
|
||||
t.logger.Debugf("SSE bypass: forwarded user %s from session", email)
|
||||
}
|
||||
}
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
// Bypass checks already ran before the init wait; no need to repeat them.
|
||||
t.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
@@ -160,6 +199,14 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.sendErrorResponse(rw, req, "Critical session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Sub-resource requests (script/image/fetch/serviceWorker) must not
|
||||
// trigger an OIDC redirect from this path either: they would overwrite
|
||||
// any in-flight CSRF/nonce in the session. Let the next HTML navigation
|
||||
// initiate the flow. See issue #129.
|
||||
if t.isAjaxRequest(req) || t.isNonNavigationRequest(req) {
|
||||
t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
scheme := utils.DetermineScheme(req, t.forceHTTPS)
|
||||
host := utils.DetermineHost(req)
|
||||
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
||||
@@ -213,8 +260,12 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
refreshTokenPresent := session.GetRefreshToken() != ""
|
||||
|
||||
// Check if this is an AJAX request that should receive 401 instead of redirect
|
||||
isAjaxRequest := t.isAjaxRequest(req)
|
||||
// Decide whether to answer with 401 instead of a redirect. AJAX requests
|
||||
// cannot follow a 302 into an IdP, and sub-resource loads (script/image/
|
||||
// fetch/serviceWorker) must not trigger a fresh OIDC flow because parallel
|
||||
// loads would each overwrite the session CSRF/nonce (issue #129). Only
|
||||
// top-level HTML navigations should redirect.
|
||||
isAjaxRequest := t.isAjaxRequest(req) || t.isNonNavigationRequest(req)
|
||||
|
||||
// Check if refresh token is likely expired (older than 6 hours)
|
||||
refreshTokenExpired := refreshTokenPresent && t.isRefreshTokenExpired(session)
|
||||
@@ -397,7 +448,10 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
|
||||
}
|
||||
|
||||
if len(t.headerTemplates) > 0 {
|
||||
claims, err := t.extractClaimsFunc(session.GetIDToken())
|
||||
// Reuse claims parsed earlier in this request if the ID token has not
|
||||
// changed. Saves an unnecessary JWT parse on every authenticated
|
||||
// request that uses headerTemplates.
|
||||
claims, err := session.GetIDTokenClaims(t.extractClaimsFunc)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", err)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user