Files
traefikoidc/middleware.go
T
lukaszraczyloandGitHub f0af6282dc feat(templates): add toJson helper for object claims (#165)
Header templates can now emit an object or array claim as one JSON
header. Example: {{get .Claims "tenant" | toJson}}. The backend parses
the claim, so the middleware does not need to know its schema.

The helper adds no new access path. The validator treats toJson like
default and checks its arguments. A claim must be on the whitelist, and
a dump of the whole claims map is rejected. An absent or empty claim
skips the header. The JSON encoder escapes control characters.

A rendered header value over 8192 bytes is still dropped. The drop is
now logged at error level, with the header name and without the value.
Other sanitizer drops stay at debug level.

Tests cover value types, edge cases, whitelist bypass attempts, token
leaks and header injection. A new yaegi check runs the helper under the
interpreter. README, configuration docs and the plugin manifest show the
new helper.
2026-10-05 00:20:01 +01:00

1546 lines
67 KiB
Go

// 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 (
"bufio"
"bytes"
"fmt"
"net"
"net/http"
"regexp"
"runtime/debug"
"strings"
"sync/atomic"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/utils"
)
// trackingWriter wraps an http.ResponseWriter so the deferred panic handler
// can tell whether a response was already committed. WriteHeader is
// idempotent so calling it again after commit is a no-op, but a later
// Write appends to the committed body - so writing "Internal Server
// Error" after a handler has already produced a valid 200 (or a 302
// redirect) corrupts the response.
//
// The wrapper itself is NEVER handed to t.next.ServeHTTP (see
// downstreamWriter/FIX-06) -- next always gets the real writer ServeHTTP
// received, so Flush/Hijack/Unwrap survive the yaegi interpreted->compiled
// boundary. That means wroteHeader can only observe writes this
// middleware's OWN code makes before forwarding; it says nothing about
// whether the downstream handler committed a response after taking over.
// calledNext records that handoff: once true, the deferred recover in
// ServeHTTP no longer tries to answer a panic itself at all -- it
// re-panics the original value instead, because from here it has no way
// to tell whether next already committed a response before panicking (see
// the recover site in ServeHTTP for why guessing would be worse than
// re-panicking).
type trackingWriter struct {
http.ResponseWriter
wroteHeader bool
calledNext bool
}
// Header is declared explicitly rather than left to the embedded
// http.ResponseWriter's promotion. yaegi v0.16.1 does not count a promoted
// method when statically checking whether *trackingWriter satisfies
// http.ResponseWriter at import time, so without this method yaegi refuses
// to load the plugin at all (`rw = tw` at middleware.go's ServeHTTP: "missing
// Header method"), even though every native (compiled) test passes (FIX-06).
func (w *trackingWriter) Header() http.Header { return w.ResponseWriter.Header() }
func (w *trackingWriter) WriteHeader(code int) {
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
func (w *trackingWriter) Write(b []byte) (int, error) {
w.wroteHeader = true
return w.ResponseWriter.Write(b)
}
func (w *trackingWriter) Flush() {
w.wroteHeader = true
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
w.wroteHeader = true
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, http.ErrNotSupported
}
func (w *trackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// downstreamWriter returns the http.ResponseWriter to hand to the next
// handler in the chain: rw itself, unless it is our own *trackingWriter, in
// which case the writer it wraps (Unwrap()). Traefik plugins run
// interpreted under yaegi v0.16.1, whose stdlib interop layer composes a
// boundary wrapper only for ResponseWriter+Hijacker crossing into compiled
// code, not for Flusher or Unwrap. So a compiled downstream (Traefik's
// ReverseProxy calling http.NewResponseController(rw).Flush() for SSE)
// handed the interpreted trackingWriter gets "feature not supported" in
// production, even though trackingWriter declares Flush/Unwrap and native
// tests (which never cross an interpreter boundary) pass. Every
// t.next.ServeHTTP call site must use this instead of the local rw
// (FIX-06).
//
// It also marks the trackingWriter's calledNext, whether the call site is
// ServeHTTP itself or (via forwardAuthorized, reached through
// processAuthorizedRequest/processAuthorizedRequestRS) a separate function
// sharing the same *trackingWriter -- mutating the field through the
// pointer makes the handoff visible back in ServeHTTP's deferred recover
// regardless of which function actually calls next.
func downstreamWriter(rw http.ResponseWriter) http.ResponseWriter {
if tw, ok := rw.(*trackingWriter); ok {
tw.calledNext = true
return tw.Unwrap()
}
return rw
}
// optionsPreflightWriter wraps the real http.ResponseWriter for the
// bypassReasonOptions forwarding path. When AllowUnauthenticatedPreflight is
// enabled, shouldBypassAuth treats any OPTIONS request carrying both Origin
// and Access-Control-Request-Method as a CORS preflight and forwards it to
// next WITHOUT a session check -- necessary for a real browser preflight,
// which carries no session cookie. But any non-browser client can set those
// same two headers itself, so this only rules out an accidental bare
// OPTIONS, not a deliberate attacker (FIX-02).
// Wrapping the writer so next can set status and headers (answering real
// CORS headers) but never emit a body closes that gap: a browser discards a
// preflight response body anyway, so this is unobservable to a genuine
// preflight, while a forged one can no longer read a protected resource's
// content through the OPTIONS bypass.
//
// Declares Header/Write/WriteHeader explicitly rather than relying on the
// embedded http.ResponseWriter's promoted methods, for the same reason
// trackingWriter does (FIX-06): yaegi v0.16.1 does not count a promoted
// method when statically checking whether a type satisfies
// http.ResponseWriter.
type optionsPreflightWriter struct {
http.ResponseWriter
wroteHeader bool
}
func (w *optionsPreflightWriter) Header() http.Header { return w.ResponseWriter.Header() }
// WriteHeader deletes any Content-Length next set before sending the
// status: no body is ever going to follow, and a stale Content-Length would
// leave the client waiting for bytes that never arrive.
func (w *optionsPreflightWriter) WriteHeader(code int) {
w.wroteHeader = true
w.ResponseWriter.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(code)
}
// Write discards the body. A CORS preflight response must never carry a
// body a non-browser client could read to exfiltrate a protected resource.
//
// A next handler can set Content-Length and call Write directly, with no
// preceding WriteHeader call (net/http's implicit-200 idiom). WriteHeader
// is the only place Content-Length gets deleted, so without this check that
// idiom would leave the real Content-Length header in place while zero body
// bytes are actually sent -- the client waits for bytes that never arrive.
// Committing the header here first (defaulting to 200, matching what
// net/http would send for an implicit commit) keeps the same guarantee
// WriteHeader already gives an explicit caller.
func (w *optionsPreflightWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return len(b), nil
}
// bypassReason describes why a request is being forwarded without OIDC auth.
// It is only used for logging and to decide whether extra side-effects
// (propagating the user header from an existing session) should run.
const (
bypassReasonExcluded = "excluded-url"
bypassReasonSSE = "sse"
bypassReasonWebSocket = "websocket"
bypassReasonOptions = "options"
)
// isWebSocketUpgrade reports whether req is a WebSocket upgrade handshake
// (RFC 6455). The middleware can only see the handshake; once Traefik
// completes the upgrade it forwards frames directly, so we never re-process
// per-frame traffic. We bypass auth on the handshake the same way we do for
// SSE, because browser WebSocket clients cannot follow an OIDC redirect.
func isWebSocketUpgrade(req *http.Request) bool {
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return false
}
for _, token := range strings.Split(req.Header.Get("Connection"), ",") {
if strings.EqualFold(strings.TrimSpace(token), "upgrade") {
return true
}
}
return false
}
// shouldBypassAuth decides whether a request must skip OIDC authentication
// entirely. It returns (true, reason) when either the request path matches a
// configured excluded URL, the Accept header asks for a text/event-stream
// response (SSE), or the request is a WebSocket upgrade handshake. The
// reason lets ServeHTTP apply any side-effects that are unique to the bypass
// kind (e.g. propagating user headers).
//
// This must be called BEFORE waiting on t.initComplete so excluded, SSE and
// WebSocket 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
}
// The released behavior requires authentication for every OPTIONS
// request, preflight or not, like any other method: a 401 with no
// Access-Control-Allow-* headers makes the browser block the
// cross-origin request, which is the correct outcome for a protected
// resource the caller hasn't authenticated to yet. R124 bypassed OPTIONS
// unconditionally to unbreak CORS for backends that answer their own
// preflights; FIX-02 found that bypass matched on the method alone,
// forwarding ANY unauthenticated "OPTIONS <protected-path>" with no
// session check. Restoring the released default here (bypass off)
// closes that gap directly. AllowUnauthenticatedPreflight (default
// false) is the opt-in for deployments that still need the R124
// behavior for a backend that must see its own preflights.
//
// A genuine preflight (RFC "Fetch" CORS protocol) always carries BOTH
// an Origin header and an Access-Control-Request-Method header - the
// browser sends no other OPTIONS request shaped like this. Gating on
// the method alone (the pre-FIX-02 shape) is why the opt-in also
// requires both headers, not method alone: even with the flag on this
// only rules out an accidental bare OPTIONS, not a deliberate attacker
// (see optionsPreflightWriter below).
if t.allowUnauthenticatedPreflight &&
req.Method == http.MethodOptions &&
req.Header.Get("Origin") != "" &&
req.Header.Get("Access-Control-Request-Method") != "" {
return true, bypassReasonOptions
}
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
return true, bypassReasonSSE
}
if isWebSocketUpgrade(req) {
return true, bypassReasonWebSocket
}
return false, ""
}
// applyBypassUserHeaders enforces authentication on SSE / WebSocket bypass
// requests and, on success, copies the authenticated user's identity onto
// the outgoing request so downstream services can see who the user is.
//
// Returns true when the request carries a valid authenticated session and
// the bypass should proceed. Returns false when no usable session is
// present; callers must then reject the request (typically with 401) to
// prevent unauthenticated traffic from reaching the backend just by setting
// `Accept: text/event-stream` or sending a WebSocket upgrade.
//
// The check is cookie-only: the session cookie is sealed by our encryption
// key, so the authenticated flag cannot be forged. We do NOT run full token
// signature verification here so that SSE/WS keeps working when the OIDC
// provider is briefly unavailable for JWK fetches.
// The second return value (0 when forwarding) is the HTTP status to send
// when the request is rejected, letting the caller distinguish a 401
// (unauthenticated) from a 403 (authenticated but not permitted).
// identityHeaders are the headers this middleware owns upstream. It
// unconditionally strips any inbound values with these names before
// forwarding — both on authenticated and bypassed requests — so forged
// values (e.g. X-Forwarded-User: admin on a public path) can never
// reach the backend and be trusted for downstream authorization.
var identityHeaders = []string{
"X-Forwarded-User",
"X-User-Groups",
"X-User-Roles",
"X-Auth-Request-User",
"X-Auth-Request-Redirect",
"X-Auth-Request-Token",
}
// stripIdentityHeaders removes any inbound identity headers from req. The
// middleware re-injects its own authoritative values afterwards where
// appropriate. See identityHeaders.
func stripIdentityHeaders(req *http.Request) {
for _, h := range identityHeaders {
req.Header.Del(h)
}
}
func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.Request, reason string) (bool, int) {
if t.sessionManager == nil {
return false, http.StatusUnauthorized
}
session, err := t.sessionManager.GetSession(req)
if err != nil {
t.logger.Debugf("%s bypass: unable to load session: %v", reason, err)
return false, http.StatusUnauthorized
}
defer session.returnToPoolSafely()
if !session.GetAuthenticated() {
t.logger.Debugf("%s bypass: rejecting request without authenticated session", reason)
return false, http.StatusUnauthorized
}
userIdentifier := session.GetUserIdentifier()
if userIdentifier == "" {
t.logger.Debugf("%s bypass: rejecting request, session has no user identifier", reason)
return false, http.StatusUnauthorized
}
// Opt-in: refresh an expired session so an IdP-revoked grant stops opening
// streams. Only a definitive rejection (refreshToken clears the session on
// invalid_grant) denies; IdP outages keep forwarding as before. The full
// coordinator wait is kept so a rotated refresh token is never dropped.
if t.streamingRefresh && bypassTokenExpired(session) {
if !t.refreshToken(rw, req, session) && !session.GetAuthenticated() {
t.logger.Infof("User %s refresh rejected by IdP (bypass)", userIdentifier)
return false, http.StatusUnauthorized
}
}
// Mirror the normal path's IdP-initiated logout check: this path forwards
// configured headers (often a shared proxy secret), so a logged-out cookie
// must not keep opening streams until the absolute session timeout.
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken := session.GetIDToken(); idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
if t.isSessionInvalidated(sid, sub, t.sessionCreatedAtForInvalidation(idToken, session)) {
t.logger.Infof("User %s session invalidated via IdP-initiated logout (bypass)", userIdentifier)
return false, http.StatusUnauthorized
}
}
}
// Enforce the user allowlist, mirroring the normal path's authorization
// check (isAllowedUser). Without this, an authenticated user not in
// allowedUsers (or whose domain is not in allowedUserDomains) could
// reach the backend just by using the SSE/WebSocket bypass.
if !t.isAllowedUser(userIdentifier) {
t.logger.Infof("User %s is not authorized (bypass)", userIdentifier)
return false, http.StatusForbidden
}
// Enforce the allowedRolesAndGroups gate, mirroring forwardAuthorized:
// an authenticated user without any permitted role/group must not reach
// the SSE/WebSocket backend just by using the streaming bypass. Claims
// come from the session's ID token by preference, falling back to the
// access token when there is no ID token — the same source selection the
// main authorization path (processAuthorizedRequest) uses. Without the
// fallback, opaque-ID-token providers (groups only in the access token)
// were granted on normal requests but 403'd on the streaming bypass
// (R118).
var claims map[string]interface{}
var claimsErr error
if len(t.allowedRolesAndGroups) > 0 || len(t.headerTemplates) > 0 {
claims, claimsErr = t.bypassSessionClaims(session)
}
if len(t.allowedRolesAndGroups) > 0 {
if claimsErr != nil || claims == nil {
t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier)
return false, http.StatusForbidden
}
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(claims)
if extErr != nil {
t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr)
return false, http.StatusForbidden
}
allowed := false
for _, roleOrGroup := range append(groups, roles...) {
if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok {
allowed = true
break
}
}
if !allowed {
t.logger.Infof("User %s does not have any allowed roles or groups (bypass)", userIdentifier)
return false, http.StatusForbidden
}
}
// This middleware fully owns the identity headers it injects upstream, even
// on the SSE/WebSocket bypass path. Unconditionally remove inbound values
// for the same names first: a client can forge X-User-Groups /
// X-Auth-Request-User etc. on a bypassed request, and since this path
// only sets X-Forwarded-User / X-Auth-Request-User (never groups/roles),
// forged group/role headers would otherwise survive to the backend,
// which commonly trusts them for downstream authorization. Must precede
// the sanitize gate so forged values are cleared even when the
// identifier is dropped as unsafe (R100).
stripIdentityHeaders(req)
// Sanitize the claim-derived identifier before it is injected as a
// header, matching forwardAuthorized (which uses safeIdentifier). The
// value originates from an IdP-controlled claim and can contain CRLF
// or delimiter characters that would otherwise inject or confuse
// downstream header parsing. On failure, drop the header but still
// honor the bypass (the identity headers are decoration here).
if safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen()); ok {
req.Header.Set("X-Forwarded-User", safeIdentifier)
if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-User", safeIdentifier)
}
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
} else {
t.logger.Debugf("%s bypass: dropping unsafe user-identifier header: %s", reason, headerClaimValueReason(userIdentifier, t.headerClaimMaxLen()))
}
// Backends that authenticate the proxy via configured headers (shared
// secret, user/groups) need them on the upgrade too (#162). Claims are
// cookie-sealed but not re-verified here; see the doc comment above.
if len(t.headerTemplates) > 0 {
if claimsErr != nil {
t.logger.Debugf("%s bypass: rendering header templates without claims: %v", reason, claimsErr)
}
t.applyHeaderTemplates(req, &principal{
Source: sourceSession,
Identifier: userIdentifier,
AccessToken: session.GetAccessToken(),
IDToken: session.GetIDToken(),
RefreshToken: session.GetRefreshToken(),
Claims: claims,
})
}
return true, 0
}
// bypassTokenExpired reports whether the session's access token, or its ID
// token when the access token carries no readable exp (opaque), has expired.
// exp is read unverified: the cookie is sealed and a verified refresh follows.
func bypassTokenExpired(session *SessionData) bool {
for _, token := range []string{session.GetAccessToken(), session.GetIDToken()} {
if token == "" {
continue
}
jwt, err := parseJWT(token)
if err != nil {
continue
}
if exp, ok := jwt.Claims["exp"].(float64); ok {
return !time.Now().Before(time.Unix(int64(exp), 0))
}
}
return false
}
// bypassSessionClaims returns the session's ID-token claims, falling back to
// access-token claims when there is no ID token, matching the source the
// normal authorization path uses (R118).
func (t *TraefikOidc) bypassSessionClaims(session *SessionData) (map[string]interface{}, error) {
if t.extractClaimsFunc == nil {
return nil, nil
}
// Explicit assignments, not tail-call returns: yaegi can zero
// multi-value tail-call results (see issue151_regression_test.go).
var claims map[string]interface{}
var err error
if session.GetIDToken() != "" {
claims, err = session.GetIDTokenClaims(t.extractClaimsFunc)
} else if accessToken := session.GetAccessToken(); accessToken != "" {
claims, err = t.extractClaimsFunc(accessToken)
}
return claims, err
}
// 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.
//
// shouldSkipGraceRefresh reports whether a proactive refresh can be skipped for
// this request. The token forwarded downstream (forwardAuthorized) is the
// ACCESS token, and needsRefresh (validateStandardTokensRS -> validateTokenExpiryRS)
// is derived from the ACCESS token's expiry whenever it is a verifiable JWT.
// Basing the skip decision on the (possibly longer-lived) ID token's exp would
// forward a stale access token whenever the ID token is still fresh. Base it on
// the access token's expiry, falling back to the ID token only when the access
// token is opaque and unparseable (worst case: refresh when a skip might have
// been possible — the safe direction).
func (t *TraefikOidc) shouldSkipGraceRefresh(session *SessionData) bool {
return skipRefreshForTokens(session.GetAccessToken(), session.GetIDToken(), t.refreshGracePeriod)
}
// skipRefreshForTokens reports whether a proactive refresh can be skipped given
// the session's access and ID tokens.
func skipRefreshForTokens(accessToken, idToken string, grace time.Duration) bool {
graceToken := accessToken
graceJWT, graceErr := parseJWT(graceToken)
if graceErr != nil || graceJWT == nil {
graceToken = idToken
graceJWT, graceErr = parseJWT(graceToken)
}
if graceJWT == nil || graceErr != nil {
return false
}
expClaim, ok := graceJWT.Claims["exp"].(float64)
if !ok {
return false
}
expTimeObj := time.Unix(int64(expClaim), 0)
return !expTimeObj.Before(time.Now().Add(grace))
}
func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Track whether a response has been committed so the panic handler can
// avoid appending an error body to an already-emitted valid response.
tw := &trackingWriter{ResponseWriter: rw}
rw = tw
// Recover from any panic in the handler chain (session decode, token
// validation, header injection, authorize-url build) so a bug answers a
// single clean 500 (and logs a stack) instead of escaping to net/http,
// which closes/truncates the connection. If the response was already
// committed by a handler (200 + body or a redirect), WriteHeader is an
// idempotent no-op and appending the body would corrupt a valid
// response, so only write the body when nothing has been sent yet.
//
// Once t.next.ServeHTTP has been called (tw.calledNext), it received
// the real ResponseWriter, not tw (FIX-06) -- any write it makes
// bypasses wroteHeader entirely, so a false wroteHeader no longer
// proves nothing was sent, and we have no way to tell from here whether
// next already committed a response before panicking. Guessing wrong is
// worse than doing nothing, so once calledNext is true we do neither:
// log the panic below, then re-panic the exact same value instead of
// answering it ourselves. A prior fix here returned normally in this
// state (logging but writing nothing), which silently turned every
// post-next panic into a false success from ServeHTTP's caller's point
// of view: a panic before next wrote anything delivered an empty 200,
// and a panic mid-body delivered a truncated body as a cleanly
// terminated 200 -- the exact defect class http.ErrAbortHandler already
// gets a dedicated re-panic for, just above. Re-panicking instead
// reaches net/http's own top-level recovery (conn.serve) exactly as an
// unrecovered panic always would, and in production reaches Traefik's
// compiled recovery middleware (pkg/middlewares/recovery), which
// answers with its own 500 when nothing was sent yet, or aborts the
// connection when a response was already committed -- the correct
// outcome this plugin cannot compute for itself, and decision (b)'s
// "wraps nothing, sends no second WriteHeader" requirement holds either
// way.
defer func() {
r := recover()
if r == nil {
return
}
if r == http.ErrAbortHandler {
// net/http (and httputil.ReverseProxy, when copyResponse fails
// mid-body) panics with this exact sentinel to mean "abort the
// connection now, write nothing else". Recovering it here and
// returning normally would let net/http finish the response as
// if it were complete, turning a truncated upstream body into a
// clean-looking 200. Re-panic so the outer net/http conn.serve
// recover (which special-cases this sentinel) aborts the
// connection the same way it would with no plugin in front of
// it.
//
// Under yaegi v0.16.1, `r == http.ErrAbortHandler` only matches
// when the sentinel was raised by COMPILED code -- an
// interpreted panic(http.ErrAbortHandler) compares unequal to
// the compiled net/http.ErrAbortHandler value the interpreter
// sees here, so this branch is only reached by the real
// production case (a compiled httputil.ReverseProxy `next`
// aborting mid-copy). Re-panicking that value is also not
// byte-for-byte the original sentinel once it has crossed back
// out through the interpreter boundary, so net/http's
// conn.serve and Traefik's recovery middleware (shouldLogPanic)
// both treat it as an ordinary panic and log it, unlike the
// silent abort a fully-compiled stack gets. The connection is
// still aborted correctly either way; only the log noise
// differs (see cmd/yaegicheck for the check that pins this).
panic(r)
}
t.logger.Errorf("OIDC handler panic recovered: %v\n%s", r, debug.Stack())
if tw.calledNext {
panic(r)
}
if !tw.wroteHeader {
// A panic-induced 500 must not be cached (consistent with
// every other auth-failure response, R101/R172).
rw.Header().Set("Cache-Control", "no-store")
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte("Internal Server Error"))
}
}()
// Apply security headers to EVERY response this middleware authors
// (redirects, error pages, bypass forwards, and authenticated
// forwards), not just the forwardAuthorized tail. Previously they only
// reached successfully-authenticated forwarding, so the initial
// 302-to-IdP, /health, /metrics, error pages and SSE/WS responses
// carried no Strict-Transport-Security / framing / MIME sniffing
// protections (R132).
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")
}
// Log request entry for debugging routing issues
t.logger.Debugf("Incoming request: %s %s", req.Method, req.URL.Path)
// Handle logout requests early - before waiting for OIDC initialization
// This allows users to logout even if the OIDC provider is unavailable
if req.URL.Path == t.logoutURLPath {
t.logger.Debugf("Logout path matched early: %s", req.URL.Path)
t.handleLogout(rw, req)
return
}
// Handle backchannel logout (IdP-initiated POST with logout_token)
if t.enableBackchannelLogout && t.backchannelLogoutPath != "" && req.URL.Path == t.backchannelLogoutPath {
t.logger.Debug("Backchannel logout path matched")
t.handleBackchannelLogout(rw, req)
return
}
// Handle front-channel logout (IdP-initiated GET with sid/iss in iframe)
if t.enableFrontchannelLogout && t.frontchannelLogoutPath != "" && req.URL.Path == t.frontchannelLogoutPath {
t.logger.Debug("Front-channel logout path matched")
t.handleFrontchannelLogout(rw, req)
return
}
if !strings.HasPrefix(req.URL.Path, "/health") {
// Lock-free one-shot bootstrap. The previous firstRequestMutex.Lock()
// fired on EVERY non-health request forever (even after the boolean
// flipped true), which under Yaegi added a per-request serialization
// point. CAS gives single-firing semantics with zero steady-state cost.
if atomic.CompareAndSwapInt32(&t.firstRequestStarted, 0, 1) {
t.logger.Debug("Starting background tasks on first request")
t.startTokenCleanup()
if t.providerURL != "" &&
atomic.CompareAndSwapInt32(&t.metadataRefreshStartedAtomic, 0, 1) {
// Metadata refresh is handled by singleton resource manager
t.startMetadataRefresh(t.providerURL)
}
}
}
// Evaluate auth-bypass once, before waiting for initialization. Excluded
// URLs, SSE and WebSocket upgrade requests must not block on provider
// init. For SSE/WebSocket we ALSO require an authenticated session
// (cookie-only check, no JWK fetch) and otherwise return 401 — clients
// of in-flight streams can't follow an OIDC redirect, so forwarding
// unauthenticated traffic would silently expose the backend.
if bypass, reason := t.shouldBypassAuth(req); bypass {
t.logger.Debugf("Bypassing OIDC for %s (%s)", req.URL.Path, reason)
// When bearer auth is enabled, strip the Authorization header on
// bypassed paths so a bearer token can't leak into health/metrics/
// public endpoint logs via downstream services that don't expect it.
// Excluded URLs are explicitly public; bearer is an artifact of the
// API auth flow that doesn't belong on them.
// Also honor stripAuthorizationHeader (default true) so a raw
// client-supplied Authorization header is not forwarded verbatim on
// the SSE/WebSocket/excluded bypass — the same leak R144 closed for
// the normal path was left open on the streaming bypass (R151).
if t.enableBearerAuth || t.stripAuthorizationHeader {
req.Header.Del("Authorization")
}
switch reason {
case bypassReasonExcluded:
// Operator-declared excluded URLs forward unconditionally.
// These are public paths; still strip any client-forged
// identity headers so X-Forwarded-User / X-User-Groups etc.
// on a public endpoint can't be trusted downstream (R102).
stripIdentityHeaders(req)
t.next.ServeHTTP(downstreamWriter(rw), req)
case bypassReasonSSE, bypassReasonWebSocket:
// Skip the OIDC redirect dance (clients can't follow it
// mid-stream) but still require an authenticated session.
// Otherwise an unauthenticated client could hit the backend
// just by setting Accept: text/event-stream or sending a
// WebSocket upgrade.
if ok, status := t.applyBypassUserHeaders(rw, req, reason); !ok {
msg := "Authentication required"
if status == http.StatusForbidden {
msg = "Access denied"
}
t.sendErrorResponse(rw, req, msg, status)
return
}
t.next.ServeHTTP(downstreamWriter(rw), req)
case bypassReasonOptions:
// Only reached when AllowUnauthenticatedPreflight is true
// (shouldBypassAuth gates it): forward the CORS preflight
// unconditionally (no session needed) so the backend can answer
// with CORS headers. Origin + Access-Control-Request-Method only
// rule out an accidental bare OPTIONS, not a deliberate attacker
// -- any non-browser client can set both itself -- so next must
// not be able to leak a protected resource's body on this
// unauthenticated path (FIX-02). optionsPreflightWriter forwards
// status and headers but discards the body; a browser never
// reads a preflight body, so real CORS answers keep working.
stripIdentityHeaders(req)
t.next.ServeHTTP(&optionsPreflightWriter{ResponseWriter: downstreamWriter(rw)}, req)
default:
t.next.ServeHTTP(downstreamWriter(rw), req)
}
return
}
// 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 via atomic snapshot when available — replaces the
// metadataMu.RLock that previously fired on every non-bypass request.
// Under Yaegi each RLock acquisition costs 1-5ms of interpreter
// dispatch; the snapshot is a single atomic.Value.Load. Falls back
// to the legacy field+RLock for paths that haven't published a
// snapshot yet (notably some test setups that initialize the struct
// fields directly).
var issuerURL string
if snap := t.metadataSnap(); snap != nil {
issuerURL = snap.IssuerURL
} else {
t.metadataMu.RLock()
issuerURL = t.issuerURL
t.metadataMu.RUnlock()
}
if issuerURL == "" {
// Provider metadata initialization failed - try to recover.
// Retry every 30 seconds to allow automatic recovery. Lock-free
// throttle via CAS on lastMetadataRetryNano: one goroutine wins
// the window, others see shouldRetry=false.
nowNano := time.Now().UnixNano()
last := atomic.LoadInt64(&t.lastMetadataRetryNano)
shouldRetry := time.Duration(nowNano-last) >= 30*time.Second &&
atomic.CompareAndSwapInt64(&t.lastMetadataRetryNano, last, nowNano)
if shouldRetry && t.providerURL != "" {
t.logger.Info("Attempting to recover OIDC provider metadata...")
go t.attemptMetadataRecovery()
}
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 canceled while waiting for OIDC initialization")
t.sendErrorResponse(rw, req, "Request canceled", http.StatusRequestTimeout)
return
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
}
// Bypass checks already ran before the init wait; no need to repeat them.
t.sessionManager.CleanupOldCookies(rw, req)
// Bearer-token auth (opt-in). Runs after init (we need issuer+JWKs+aud
// available) and after bypass (excluded URLs always win). Cookie-vs-
// bearer precedence is configurable; the safe default is cookie-wins.
// See bearer_auth.go for the full pipeline.
if t.enableBearerAuth {
if _, hasBearer := detectBearerToken(req); hasBearer {
cookiePresent := t.hasSessionCookie(req)
if !cookiePresent || t.bearerOverridesCookie {
if cookiePresent {
t.logger.Infof("Both Authorization: Bearer and session cookie present on %s; bearer-wins per BearerOverridesCookie=true", req.URL.Path)
}
t.handleBearerRequest(rw, req)
return
}
t.logger.Infof("Both Authorization: Bearer and session cookie present on %s; cookie-wins (default); bearer ignored", req.URL.Path)
}
}
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) // Safe to ignore: error already logged, proceeding with new session
if session != nil {
if clearErr := session.Clear(cleanReq, rw); clearErr != nil {
t.logger.Errorf("Error clearing potentially corrupted session: %v", clearErr)
}
// Clear() returns the session to the object pool, so discard the
// pointer — reusing it would be a use-after-return, since a
// concurrent GetSession may already own the pooled object
// (cross-request session bleed / data race). Obtain a fresh
// owned session in the shared block below. (R108)
session = nil
}
if session == nil {
// Session cookie is expired (past sessionMaxAge): a fresh
// GetSession still re-reads the same expired cookie and returns
// nil, so obtain a clean session and let the shared block below
// re-initiate authentication rather than returning 500 —
// otherwise every request past the session max age is a 500
// with no way to recover (R97).
session = t.sessionManager.newSession(cleanReq)
if session == nil {
t.logger.Error("Critical session error: Failed to get even a new session.")
t.sendErrorResponse(rw, req, "Critical session error", http.StatusInternalServerError)
return
}
defer session.returnToPoolSafely()
t.logger.Infof("Session expired or unreadable; re-initiating authentication")
}
// 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)
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Deferred with a captured generation, not a plain returnToPoolSafely:
// the invalidated-session paths below (processAuthorizedRequestRS /
// processAuthorizedRequest) can session.Clear() this exact object and
// re-acquire a fresh one before this function returns. A plain
// deferred return stays bound to this pointer regardless, so it can
// fire AFTER a concurrent request has since popped the same pooled
// object and release/Reset a session that request still owns (ABA on
// the pool-return CAS; FIX-10). returnToPoolIfOwner is a no-op once
// the object's generation has moved past what was captured here.
sessionGen := session.ownerGeneration()
defer session.returnToPoolIfOwner(sessionGen)
scheme := utils.DetermineScheme(req, t.forceHTTPS)
host := utils.DetermineHost(req)
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
// Capture per-request state: one RLock on sd.sessionMutex covers all the
// getter values the handler chain needs (instead of 5-7 separate
// session.GetX() calls each acquiring their own RLock under Yaegi).
// metadataSnap is also stored once so downstream handlers don't repeat
// the atomic.Value.Load.
rs := (&requestState{
scheme: scheme,
host: host,
redirectURL: redirectURL,
next: t.next,
metadata: t.metadataSnap(),
}).captureSession(session)
// Check if the current request is the OIDC callback
t.logger.Debugf("Checking callback URL match: request_path=%q, configured_callback=%q", req.URL.Path, t.redirURLPath)
if req.URL.Path == t.redirURLPath {
t.logger.Debugf("Callback URL matched, processing OIDC callback (redirect_url=%s)", redirectURL)
// The authorization-code response is delivered to the redirect
// endpoint via HTTP GET (RFC 6749 §3.1.2): handleCallback reads
// code/state/error only from the query string (form_post is not
// supported - a POST would see an empty query and fail with "No
// authorization code" anyway). Reject other methods with 405 so a
// HEAD/non-GET probe doesn't consume the one-time state and code
// and emit a 302 (R181).
if req.Method != http.MethodGet {
t.logger.Debugf("Callback requested with method %s (want GET); returning 405", req.Method)
rw.Header().Set("Cache-Control", "no-store")
rw.WriteHeader(http.StatusMethodNotAllowed)
return
}
t.handleCallback(rw, req, redirectURL)
return
}
t.logger.Debugf("Callback URL did not match (request_path=%q != configured=%q), continuing auth flow", req.URL.Path, t.redirURLPath)
// Token validation reads session via the captured snapshot — saves ~21
// sd.sessionMutex.RLock acquisitions (Yaegi-dispatched, ~1-5ms each)
// across the validation path.
authenticated, needsRefresh, expired := t.isUserAuthenticatedRS(rs)
if expired {
// AJAX / sub-resource requests must not follow a 302 into the IdP:
// parallel loads would each overwrite the shared session's CSRF/nonce
// mid-navigation (issue #129), the same reason the sibling branches
// below return 401 instead of redirecting.
if t.isAjaxRequest(req) || t.isNonNavigationRequest(req) {
t.logger.Debug("Session token expired on AJAX/sub-resource request, sending 401")
t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized)
return
}
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
t.handleExpiredToken(rw, req, session, redirectURL)
return
}
userIdentifier := rs.userIdentifier
// User authorization check
if authenticated && userIdentifier != "" {
if !t.isAllowedUser(userIdentifier) {
t.logger.Infof("User %s is not authorized", userIdentifier)
errorMsg := fmt.Sprintf("Access denied: You are not authorized to access this resource. 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")
// Access token validation is already performed by provider-specific validation
// methods (validateAzureTokens/validateStandardTokens) before reaching this point.
// Redundant validation here was causing issues with Azure AD tokens that have
// JWT format but unverifiable signatures. See issue #89.
t.processAuthorizedRequestRS(rw, req, rs)
return
}
refreshTokenPresent := rs.refreshToken != ""
// 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)
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 {
// Only skip the proactive refresh when the session is genuinely
// authenticated. skipRefreshForTokens only parseJWT's the access
// token and reads the raw exp claim — it does not verify the
// signature or identity, so gating on the authenticated flag keeps
// an unauthenticated session (or one that failed verification for a
// non-expiry reason) from being bare-forwarded past the allowlist
// gate (R142).
if authenticated && t.shouldSkipGraceRefresh(session) {
t.logger.Debug("Token is valid and outside grace period, skipping refresh")
t.processAuthorizedRequest(rw, req, session, redirectURL)
return
}
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 {
userIdentifier = session.GetUserIdentifier()
if userIdentifier != "" && !t.isAllowedUser(userIdentifier) {
t.logger.Infof("User with refreshed token %s is not authorized", userIdentifier)
errorMsg := fmt.Sprintf("Access denied: You are not authorized to access this resource. 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 cookie/session
// users. It performs session-specific checks (identifier presence, backchannel-
// logout invalidation, claims extraction with potential re-auth), persists
// dirty session state, then delegates the post-auth pipeline (roles/groups,
// header injection, security headers, cookie strip, forward) to
// forwardAuthorized.
//
// The bearer-token path uses the same forwardAuthorized helper but takes a
// different route to it (see bearer_auth.go). Keeping forwardAuthorized
// session-agnostic is what lets the two auth methods share one pipeline.
//
// 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.
//
// processAuthorizedRequestRS is the requestState-aware variant of
// processAuthorizedRequest. It reads SessionData fields from the captured
// snapshot in rs instead of calling session.GetX() (each of which acquires
// sd.sessionMutex.RLock — under Yaegi every RLock pays ~1-5ms of interpreter
// dispatch). Only session-mutating operations (Save, ResetRedirectCount,
// Clear, IsDirty) still go through the session pointer because those write
// state and have no snapshot.
func (t *TraefikOidc) processAuthorizedRequestRS(rw http.ResponseWriter, req *http.Request, rs *requestState) {
session := rs.session
redirectURL := rs.redirectURL
userIdentifier := rs.userIdentifier
if userIdentifier == "" {
t.logger.Info("No user identifier found in session during final processing, initiating re-auth")
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Check if session has been invalidated via backchannel or front-channel logout
idToken := rs.idToken
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
if idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
if t.isSessionInvalidated(sid, sub, createdAt) {
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing invalidated session: %v", err)
}
// Clear already returned `session` to the shared pool via
// returnToPoolSafely; re-acquire an owned session so a
// concurrent GetSession can't pop the pooled object while we
// write the re-auth challenge into it.
ns, gerr := t.sessionManager.GetSession(req)
if gerr != nil {
t.logger.Errorf("Error creating session for re-authentication: %v", gerr)
t.sendErrorResponse(rw, req, "Failed to start re-authentication", http.StatusInternalServerError)
return
}
ns.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, ns, redirectURL)
ns.returnToPoolSafely()
return
}
}
}
// Resolve ID-token claims at most once per request. SessionData caches
// the parsed claims keyed on the raw ID token.
var (
idClaims map[string]interface{}
idClaimsErr error
)
if idToken != "" {
idClaims, idClaimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
}
var (
groupClaims map[string]interface{}
groupClaimsErr error
)
if idToken != "" {
groupClaims, groupClaimsErr = idClaims, idClaimsErr
} else if rs.accessToken != "" {
groupClaims, groupClaimsErr = t.extractClaimsFunc(rs.accessToken)
} else if len(t.allowedRolesAndGroups) > 0 {
t.logger.Error("No token available but roles/groups checks are required")
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
if groupClaimsErr != nil && len(t.allowedRolesAndGroups) > 0 {
t.logger.Errorf("Failed to extract claims for roles/groups check: %v", groupClaimsErr)
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Persist any dirty session state BEFORE forwardAuthorized writes the
// response.
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")
}
p := &principal{
Source: sourceSession,
Identifier: userIdentifier,
AccessToken: rs.accessToken,
IDToken: idToken,
RefreshToken: rs.refreshToken,
Claims: groupClaims,
}
t.forwardAuthorized(rw, req, p)
}
func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
userIdentifier := session.GetUserIdentifier()
if userIdentifier == "" {
t.logger.Info("No user identifier 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
}
// Check if session has been invalidated via backchannel or front-channel logout
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
idToken := session.GetIDToken()
if idToken != "" {
sid, sub, _ := t.extractSessionInfo(idToken)
createdAt := t.sessionCreatedAtForInvalidation(idToken, session)
if t.isSessionInvalidated(sid, sub, createdAt) {
t.logger.Infof("Session for user %s has been invalidated via IdP-initiated logout", userIdentifier)
// Clear the session and redirect to login
if err := session.Clear(req, rw); err != nil {
t.logger.Errorf("Error clearing invalidated session: %v", err)
}
// Clear already returned `session` to the shared pool via
// returnToPoolSafely; re-acquire an owned session so a
// concurrent GetSession can't pop the pooled object while we
// write the re-auth challenge into it.
ns, gerr := t.sessionManager.GetSession(req)
if gerr != nil {
t.logger.Errorf("Error creating session for re-authentication: %v", gerr)
t.sendErrorResponse(rw, req, "Failed to start re-authentication", http.StatusInternalServerError)
return
}
ns.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, ns, redirectURL)
ns.returnToPoolSafely()
return
}
}
}
// Resolve ID-token claims at most once per request. SessionData caches
// the parsed claims keyed on the raw ID token, so concurrent dashboard
// panel requests on the same session don't repeatedly base64-decode and
// JSON-unmarshal the same JWT (a real cost under the yaegi interpreter
// that hosts Traefik plugins).
idToken := session.GetIDToken()
var (
idClaims map[string]interface{}
idClaimsErr error
)
if idToken != "" {
idClaims, idClaimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
}
// Choose which claims drive groups/roles extraction. Prefer the ID
// token (cached) and fall back to the access token if there is no ID
// token in the session — matching the prior behavior for opaque
// ID-token providers.
var (
groupClaims map[string]interface{}
groupClaimsErr error
)
if idToken != "" {
groupClaims, groupClaimsErr = idClaims, idClaimsErr
} else if accessToken := session.GetAccessToken(); accessToken != "" {
groupClaims, groupClaimsErr = t.extractClaimsFunc(accessToken)
} else if len(t.allowedRolesAndGroups) > 0 {
t.logger.Error("No token available but roles/groups checks are required")
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
if groupClaimsErr != nil && len(t.allowedRolesAndGroups) > 0 {
// Claims couldn't be extracted but roles checks are required:
// re-authenticate rather than 403 (session may be salvageable on
// re-issue). Bearer path uses 401 for the equivalent failure.
t.logger.Errorf("Failed to extract claims for roles/groups check: %v", groupClaimsErr)
session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return
}
// Persist any dirty session state BEFORE forwardAuthorized writes the
// response. Once next.ServeHTTP fires, Set-Cookie can no longer reach
// the client. The forwardAuthorized pipeline does not mutate session
// state, so saving here is safe.
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")
}
// Build the source-agnostic principal. ID-token claims drive header
// templates and roles when present; otherwise fall back to access-token
// claims (matches prior behavior for opaque-ID-token providers).
p := &principal{
Source: sourceSession,
Identifier: userIdentifier,
AccessToken: session.GetAccessToken(),
IDToken: idToken,
RefreshToken: session.GetRefreshToken(),
Claims: groupClaims,
}
t.forwardAuthorized(rw, req, p)
}
// forwardAuthorized completes the post-authentication pipeline shared by the
// cookie/session path and the bearer-token path. It performs:
//
// 1. Roles/groups extraction from p.Claims (idempotent; existing
// extractGroupsAndRolesFromClaims helper).
// 2. allowedRolesAndGroups gate — writes a 403 and returns if denied.
// 3. Identity-header injection (X-Forwarded-User, X-User-Groups, X-User-Roles,
// plus X-Auth-Request-* when !minimalHeaders).
// 4. Operator-defined header templates.
// 5. Security headers (delegated to t.securityHeadersApplier or fallback).
// 6. OIDC session-cookie strip (stripAuthCookies).
// 7. Authorization header strip on bearer source when stripAuthorizationHeader.
// 8. next.ServeHTTP.
//
// Session persistence is the CALLER's responsibility — it must happen before
// this function so Set-Cookie reaches the response.
// headerTemplateMaxLen bounds the length of a rendered operator-defined header
// template before it is forwarded downstream. Generous enough for an
// "Authorization: Bearer <jwt>" value but small enough to reject obviously
// abusive output. Matches the input-validation default header cap (8KB).
const headerTemplateMaxLen = 8192
// noValueSentinel is Go's text/template placeholder for a missing key in an
// interface-valued map. It is stripped from rendered header values so an
// optional claim the provider does not emit does not leak "<no value>".
const noValueSentinel = "<no value>"
// noValueTokenRe matches "<no value>" only as a whole token (bounded by
// non-alphanumerics or string end), so a legitimate claim value that
// happens to contain the substring survives the strip instead of being
// corrupted (R103).
var noValueTokenRe = regexp.MustCompile(`(^|[^0-9A-Za-z])` + regexp.QuoteMeta(noValueSentinel) + `([^0-9A-Za-z]|$)`)
// stripNoValueSentinels removes text/template's missing-key placeholder
// only when it appears as a whole token.
func stripNoValueSentinels(s string) string {
return noValueTokenRe.ReplaceAllString(s, "${1}${2}")
}
// headerClaimMaxLen returns the maximum accepted length for a claim-derived
// header value (principal identifier, group, role). Reuses the operator-
// configured identifier cap (default 256) so a single setting governs both
// auth paths; falls back to 256 when unset.
func (t *TraefikOidc) headerClaimMaxLen() int {
if t.maxIdentifierLength > 0 {
return t.maxIdentifierLength
}
return 256
}
// sanitizeHeaderClaimList drops any group/role value that fails claim
// sanitization (control chars, bidi-override runes, the , ; = delimiters, or an
// over-long value) and returns the surviving values. Failing closed on a bad
// entry prevents header injection and stops an embedded comma from injecting
// extra entries into the comma-joined header. headerName is used only for
// debug logging — the value is never logged.
func (t *TraefikOidc) sanitizeHeaderClaimList(values []string, headerName string) []string {
if len(values) == 0 {
return nil
}
safe := make([]string, 0, len(values))
for _, v := range values {
if clean, ok := sanitizeHeaderClaimValue(v, t.headerClaimMaxLen()); ok {
safe = append(safe, clean)
} else {
t.logger.Debugf("Dropping %s entry: value failed claim sanitization", headerName)
}
}
return safe
}
// joinBoundedClaimHeader joins sanitized claim values with commas, bounding
// the TOTAL length to headerTemplateMaxLen. A provider can return an
// unbounded number of groups/roles; joining them all into a single header
// would let an oversized value reach a downstream proxy that rejects or
// truncates over-long headers (431), even though each value already passed
// the per-value cap. Accumulates as many leading values as fit, so a
// well-formed list is unaffected; an empty result means nothing fit and the
// caller should drop the header (fail-closed).
func joinBoundedClaimHeader(values []string) string {
if len(values) == 0 {
return ""
}
var b strings.Builder
for _, v := range values {
sep := 0
if b.Len() > 0 {
sep = 1 // leading comma
}
if b.Len()+sep+len(v) > headerTemplateMaxLen {
break
}
if sep > 0 {
b.WriteByte(',')
}
b.WriteString(v)
}
return b.String()
}
// applyHeaderTemplates renders the configured header templates for p onto req.
func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
if len(t.headerTemplates) == 0 {
return
}
// p.Claims may be nil (e.g. session without an ID token). Templates
// referencing .Claims.* will simply produce empty values — matches
// the prior behavior. Bearer-source principals always carry access-
// token claims (post-verifyToken).
templateData := map[string]interface{}{
"AccessToken": p.AccessToken,
"IDToken": p.IDToken,
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
"RefreshToken": p.RefreshToken,
"Claims": p.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()
// Go's text/template renders a missing key in an interface-valued
// map as the literal "<no value>" even with missingkey=zero
// (zero only applies to typed keys). Replace that sentinel so an
// optional claim the provider did not emit (e.g. email) does not
// leak "<no value>" into the downstream header.
if strings.Contains(headerValue, noValueSentinel) {
headerValue = stripNoValueSentinels(headerValue)
}
// Skip an empty render: Setting "" would clobber an identity
// header (X-Forwarded-User / X-Auth-Request-*) the caller already
// injected with the authenticated value, silently
// dropping the user identity at the backend.
if headerValue == "" {
t.logger.Debugf("Skipping templated header %s: rendered value is empty", headerName)
continue
}
// Sanitize the rendered output: template inputs are claim-derived
// and attacker-influenceable, so reject control chars (header
// injection), bidi-override runes, the , ; = delimiters, and an
// over-long value. Fail closed by dropping the header rather than
// forwarding a tainted value. Do not log the value (it commonly
// carries the access token); log only name + reason.
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
if reason == headerReasonTooLong {
t.logger.Errorf("Dropping templated header %s: rendered value exceeds %d bytes", headerName, headerTemplateMaxLen)
} else {
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
}
continue
}
req.Header.Set(headerName, headerValue)
// Do not log the value: templated headers commonly carry the access
// token (e.g. "Authorization: Bearer {{.AccessToken}}"), and logging
// it — even at debug — leaks credentials into logs.
t.logger.Debugf("Set templated header %s (%d bytes)", headerName, len(headerValue))
}
}
func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Request, p *principal) {
// This middleware fully owns the identity headers it injects upstream.
// Unconditionally remove any inbound values with the same names first:
// a request could carry a forged X-User-Groups / X-Auth-Request-User
// etc. that, if the middleware computes no value for one of them
// (e.g. an authenticated user with no groups, an absent ID token, a
// a header-template skip), would otherwise survive through to the
// backend and be trusted for downstream authorization. Clearing here —
// before any conditional Set — makes the values we emit authoritative.
stripIdentityHeaders(req)
var (
groups, roles []string
extractErr error
)
if p.Claims != nil {
groups, roles, extractErr = t.extractGroupsAndRolesFromClaims(p.Claims)
if extractErr != nil && len(t.allowedRolesAndGroups) > 0 {
// Bearer path: 403 (caller already verified the token; principal
// claims are present but malformed for roles purposes).
// Cookie path can't reach here because processAuthorizedRequest
// catches groupClaimsErr earlier.
t.logger.Errorf("Failed to extract groups and roles: %v", extractErr)
t.sendErrorResponse(rw, req, "Access denied", http.StatusForbidden)
return
}
if extractErr == nil {
// Sanitize each group/role before it is joined into a comma-
// delimited header. The cookie/session path does not otherwise
// sanitize claim-derived values (the bearer path sanitizes its
// identifier at construction), so a control char would enable
// header injection and an embedded comma would inject extra
// entries into the comma-joined header. Fail closed: drop any
// value that does not pass.
// X-User-Groups / X-User-Roles are the largest variably-sized
// forward headers; gate them behind minimalHeaders like the
// X-Auth-Request-* family so group-heavy users don't still trip
// HTTP 431 on the backend (the failure minimalHeaders exists to
// prevent, #64). Identity (X-Forwarded-User) stays
// unconditional; the allowedRolesAndGroups gate below still
// evaluates groups regardless of emission.
if !t.minimalHeaders {
if safeGroups := t.sanitizeHeaderClaimList(groups, "X-User-Groups"); len(safeGroups) > 0 {
if gh := joinBoundedClaimHeader(safeGroups); gh != "" {
req.Header.Set("X-User-Groups", gh)
}
}
if safeRoles := t.sanitizeHeaderClaimList(roles, "X-User-Roles"); len(safeRoles) > 0 {
if rh := joinBoundedClaimHeader(safeRoles); rh != "" {
req.Header.Set("X-User-Roles", rh)
}
}
}
}
}
// Bearer (M2M) path: enforce allowedUsers / allowedUserDomains the same
// way every cookie-path entry point does. The bearer path reaches here
// via buildPrincipalFromBearerToken with no earlier isAllowedUser check;
// without this, a valid bearer whose subject is absent from the
// allowlist would still be forwarded, making the restriction a no-op.
if p.Source == sourceBearer && !t.isAllowedUser(p.Identifier) {
t.logger.Infof("Bearer user %s is not in allowed users or domains", p.Identifier)
errorMsg := fmt.Sprintf("Access denied: User not allowed. To log out, visit: %s", t.logoutURLPath)
t.sendErrorResponse(rw, req, errorMsg, http.StatusForbidden)
return
}
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 %s does not have any allowed roles or groups", p.Identifier)
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
}
}
// Sanitize the principal identifier before injecting it into headers. The
// bearer path already sanitizes its identifier at construction; the
// cookie/session path does not, so a claim carrying control chars, bidi-
// override runes, or , ; = could inject or spoof header content. Fail
// closed: drop the identifier header(s) rather than forward a tainted value.
safeIdentifier, identifierOK := sanitizeHeaderClaimValue(p.Identifier, t.headerClaimMaxLen())
if identifierOK {
req.Header.Set("X-Forwarded-User", safeIdentifier)
} else {
t.logger.Debugf("Dropping X-Forwarded-User header: identifier failed claim sanitization")
}
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
if identifierOK {
req.Header.Set("X-Auth-Request-User", safeIdentifier)
} else {
t.logger.Debugf("Dropping X-Auth-Request-User header: identifier failed claim sanitization")
}
if p.IDToken != "" {
// Bound the raw ID token to the same header budget as rendered
// template values so a large token can't push this (and every
// other header on the request) past nginx/traefik 431 limits.
// A truncated JWT is structurally invalid, so drop the header
// instead of forwarding a corrupted value (fail-closed, matching
// the X-Forwarded-User / X-Auth-Request-User pattern above;
// FIX-34).
if len(p.IDToken) > headerTemplateMaxLen {
t.logger.Debugf("Dropping X-Auth-Request-Token header: ID token length %d exceeds %d", len(p.IDToken), headerTemplateMaxLen)
} else {
req.Header.Set("X-Auth-Request-Token", p.IDToken)
}
}
}
// Strip the raw inbound Authorization header for BOTH auth paths when
// configured. The bearer path already stripped it; the cookie/session
// path forwarded a client-supplied Authorization verbatim, letting a
// session user present an arbitrary (and possibly more privileged)
// bearer that a downstream verifier might trust over X-Forwarded-User,
// or leaking a raw token into downstream logs. Kept out of downstream
// logs and make X-Forwarded-User / X-Auth-Request-Token the
// authoritative identity signal. Stripped here, before header-template
// application, so an operator-supplied Authorization template (e.g.
// "Authorization: Bearer {{.AccessToken}}") can re-supply the
// downstream value rather than being immediately deleted.
if t.stripAuthorizationHeader {
req.Header.Del("Authorization")
}
t.applyHeaderTemplates(req, p)
// Strip OIDC session cookies before forwarding to the backend to prevent
// HTTP 431 "Request Header Fields Too Large" errors (GitHub issue #122).
if t.stripAuthCookies && t.sessionManager != nil {
prefix := t.sessionManager.GetCookiePrefix()
filtered := make([]*http.Cookie, 0, len(req.Cookies()))
for _, c := range req.Cookies() {
if !strings.HasPrefix(c.Name, prefix) {
filtered = append(filtered, c)
}
}
req.Header.Del("Cookie")
for _, c := range filtered {
req.AddCookie(c)
}
}
t.logger.Debugf("Request authorized for user %s (source=%d), forwarding to next handler", p.Identifier, p.Source)
t.next.ServeHTTP(downstreamWriter(rw), req)
}