mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
a548665edb
* docs: bearer-token auth design spec * docs: harden bearer-auth spec with security review findings * feat(bearer): opt-in M2M bearer-token authentication Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine clients. Replaces and supersedes the broken approach in PR #93 (synthetic-session that omitted user_identifier and skipped ID-token rejection / replay-protection-semantics / kid-pinning / etc.). Design Two auth entrypoints feed one shared post-auth pipeline: cookie path ─┐ ├── forwardAuthorized(rw, req, *principal) bearer path ─┘ (roles/groups, header injection, security headers, cookie strip, forward) buildPrincipalFromSession and buildPrincipalFromBearerToken produce the same `principal` value type. forwardAuthorized is session-agnostic and runs the existing post-auth work; processAuthorizedRequest now wraps it with the session-specific concerns (backchannel-logout, dirty/Save). The cookie path's behaviour is byte-identical to before this PR; the existing test suite passes unmodified. Security hardening baked into the bearer path - Audience MANDATORY. Startup fails when EnableBearerAuth=true and Audience is empty. - BearerIdentifierClaim defaults to "sub"; "email" is rejected at startup to avoid the unverified-email spoofing footgun. Cookie path's UserIdentifierClaim is unaffected and still defaults to "email". - ID tokens explicitly rejected via the existing detectTokenType helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID heuristics); belt-and-braces nonce/token_use=id rejection on top. - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE JWKS fetch, blocking alg=none and alg=HS* probes from amplifying into upstream calls. - kid length capped at 256 bytes and charset-restricted before JWKS fetch, blocking pathological-kid JWKS amplification. - Multi-audience tokens require azp == clientID. - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock- manipulation and forever-token abuse. - Identifier sanitization: length cap, control-char + bidi-override + delimiter (, ; =) rejection. - Per-IP failure throttle: configurable threshold/window/penalty; returns 429 + Retry-After. Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. - JTI replay marking suppressed via new internal verifyOpts {skipReplayMarking} so the same bearer can be reused until exp; the blacklist Get stays active so RevokeToken still terminates a bearer token immediately. The existing exported VerifyToken interface is unchanged so all mocks continue to work. - Cookie wins by default when both bearer and cookie are present (safer against browser/extension/proxy bearer injection). Operator can flip via BearerOverridesCookie. - Authorization header stripped on forward by default; also stripped on excluded URLs so the token can't leak into health/metrics downstream logs. - Optional RFC 7662 introspection via existing requireTokenIntrospection. Introspection-endpoint failure returns 503 (distinguishes infra from token rejection). - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure reason is logged at debug; raw tokens are never logged. Implementation - principal.go: pure-data principal type and buildPrincipalFromSession. - bearer_auth.go: alg/kid pin, classifier, identifier sanitization, multi-aud azp gate, iat age check, per-IP failure tracker, handleBearerRequest, buildPrincipalFromBearerToken. - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts that accepts internal-only verifyOpts. Existing callers, the TokenVerifier interface, and all mocks unchanged. - middleware.go: extracted forwardAuthorized from processAuthorizedRequest; wired bearer detection after init wait + after bypass; excluded-URL Authorization strip when bearer enabled. - settings.go: ten new config fields with defaults applied in CreateConfig. - main.go: startup validation for audience + identifier-claim guard; bearer failure tracker init. Tests - bearer_auth_test.go: table-driven helper tests for every new component (parseBearerJOSEHeader, sanitizeBearerIdentifier, resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge, bearerFailureTracker, detectBearerToken). Integration tests through ServeHTTP covering happy path, ID-token rejection, alg=none rejection, oversized kid, multi-aud with/without azp, iat-too-old, bidi identifier, replay (100x reuse), 429 throttle trip, excluded-URL strip, roles gate, cookie-wins precedence, BearerOverridesCookie, oversized token, malformed JWT, feature-off pass-through. Startup validation for audience- required and email-identifier-rejected. - All existing tests pass unmodified (cookie-path regression). - go vet clean. golangci-lint clean (0 issues). Race detector clean on bearer tests. Documentation - README.md: bearer auth section with security highlights and config snippet; doc link in the index. - .traefik.yml: commented config block exposing every bearer knob. - docs/CONFIGURATION.md: new subsection with full parameter table. - docs/BEARER_AUTH.md: threat model, hardening matrix, failure response table, operational guidance, known follow-ups. - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md: design spec + security-review hardening history. * fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging) CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug. Cache callers (token cache, blacklist, introspection cache) pass raw access / refresh / id tokens as cache keys, so any debug-enabled deployment would write them to log streams. Pre-existing issue. CodeQL started flagging it on this PR because the new bearer-auth path adds a data-flow source (req.Header.Get("Authorization")) that reaches the existing logging sinks via the same cache. The cookie path had the same risk but wasn't tracked as taint by CodeQL. Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the bearer-auth logger uses for principal identifiers (spec §13). Doesn't change cache semantics — same key still produces the same hash, so debug correlation across log lines is preserved without exposing the raw value. Touches both affected packages: - internal/cache/cache.go (2 sites: Set + LRU eviction) - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback) New helper `redactKey` colocated with each package (unexported, package-local) keeps the change blast radius narrow. Tests green; lint clean. * docs(bearer): how to obtain bearer tokens from the OIDC provider Adds a section walking operators through the OAuth 2.0 client_credentials flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523), with a worked Auth0-shape curl example, a per-provider quick reference (Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational notes (token TTL, caching, JWKS rotation, revocation, scope vs audience, secret hygiene), and a three-line validation loop. Most common operator confusion: "I enabled the feature but tokens get 401'd" — almost always missing or wrong audience. The new section makes the audience-matching requirement loud, with per-provider parameter names so people don't have to dig through IdP docs. Locations: - docs/BEARER_AUTH.md — full section under "Quick start" - README.md — short snippet + deep link
675 lines
26 KiB
Go
675 lines
26 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 (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 side-effects
|
|
// (propagating the user header from an existing session) should run.
|
|
const (
|
|
bypassReasonExcluded = "excluded-url"
|
|
bypassReasonSSE = "sse"
|
|
bypassReasonWebSocket = "websocket"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
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.
|
|
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) bool {
|
|
if t.sessionManager == nil {
|
|
return false
|
|
}
|
|
|
|
session, err := t.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.logger.Debugf("%s bypass: unable to load session: %v", reason, err)
|
|
return false
|
|
}
|
|
defer session.returnToPoolSafely()
|
|
|
|
if !session.GetAuthenticated() {
|
|
t.logger.Debugf("%s bypass: rejecting request without authenticated session", reason)
|
|
return false
|
|
}
|
|
|
|
userIdentifier := session.GetUserIdentifier()
|
|
if userIdentifier == "" {
|
|
t.logger.Debugf("%s bypass: rejecting request, session has no user identifier", reason)
|
|
return false
|
|
}
|
|
|
|
req.Header.Set("X-Forwarded-User", userIdentifier)
|
|
if !t.minimalHeaders {
|
|
req.Header.Set("X-Auth-Request-User", userIdentifier)
|
|
}
|
|
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, userIdentifier)
|
|
return true
|
|
}
|
|
|
|
// ServeHTTP implements the main middleware logic for processing HTTP requests.
|
|
// It handles the complete OIDC authentication flow including:
|
|
// - Excluded URL bypass
|
|
// - Session validation and management
|
|
// - Authentication callback processing
|
|
// - Logout handling
|
|
// - Token verification and refresh
|
|
// - Header injection for authenticated requests
|
|
//
|
|
// Parameters:
|
|
// - rw: The HTTP response writer.
|
|
// - req: The incoming HTTP request.
|
|
func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|
// 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") {
|
|
t.firstRequestMutex.Lock()
|
|
if !t.firstRequestReceived {
|
|
t.firstRequestReceived = true
|
|
t.logger.Debug("Starting background tasks on first request")
|
|
t.startTokenCleanup()
|
|
|
|
if !t.metadataRefreshStarted && t.providerURL != "" {
|
|
t.metadataRefreshStarted = true
|
|
// Metadata refresh is handled by singleton resource manager
|
|
t.startMetadataRefresh(t.providerURL)
|
|
}
|
|
}
|
|
t.firstRequestMutex.Unlock()
|
|
}
|
|
|
|
// 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.
|
|
if t.enableBearerAuth {
|
|
req.Header.Del("Authorization")
|
|
}
|
|
switch reason {
|
|
case bypassReasonExcluded:
|
|
// Operator-declared excluded URLs forward unconditionally.
|
|
t.next.ServeHTTP(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 !t.applyBypassUserHeaders(req, reason) {
|
|
t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
t.next.ServeHTTP(rw, req)
|
|
default:
|
|
t.next.ServeHTTP(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 with RLock
|
|
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 when provider comes back online
|
|
t.metadataRetryMutex.Lock()
|
|
shouldRetry := time.Since(t.lastMetadataRetryTime) >= 30*time.Second
|
|
if shouldRetry {
|
|
t.lastMetadataRetryTime = time.Now()
|
|
}
|
|
t.metadataRetryMutex.Unlock()
|
|
|
|
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 {
|
|
defer session.returnToPoolSafely()
|
|
if clearErr := session.Clear(cleanReq, rw); clearErr != nil {
|
|
t.logger.Errorf("Error clearing potentially corrupted session: %v", clearErr)
|
|
}
|
|
} else {
|
|
t.logger.Error("Critical session error: Failed to get even a new session.")
|
|
t.sendErrorResponse(rw, req, "Critical session error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// 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
|
|
}
|
|
|
|
defer session.returnToPoolSafely()
|
|
|
|
scheme := utils.DetermineScheme(req, t.forceHTTPS)
|
|
host := utils.DetermineHost(req)
|
|
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
|
|
|
// 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)
|
|
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)
|
|
|
|
authenticated, needsRefresh, expired := t.isUserAuthenticated(session)
|
|
|
|
if expired {
|
|
t.logger.Debug("Session token is definitively expired or invalid, initiating re-auth")
|
|
t.handleExpiredToken(rw, req, session, redirectURL)
|
|
return
|
|
}
|
|
|
|
userIdentifier := session.GetUserIdentifier()
|
|
// 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.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
return
|
|
}
|
|
|
|
refreshTokenPresent := session.GetRefreshToken() != ""
|
|
|
|
// 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 {
|
|
idToken := session.GetIDToken()
|
|
if idToken != "" {
|
|
jwt, err := parseJWT(idToken)
|
|
if err == nil {
|
|
claims := jwt.Claims
|
|
if expClaim, ok := claims["exp"].(float64); ok {
|
|
expTime := int64(expClaim)
|
|
expTimeObj := time.Unix(expTime, 0)
|
|
refreshThreshold := time.Now().Add(t.refreshGracePeriod)
|
|
|
|
if !expTimeObj.Before(refreshThreshold) {
|
|
t.logger.Debug("Token is valid and outside grace period, skipping refresh")
|
|
t.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
return
|
|
}
|
|
} else {
|
|
t.logger.Debug("Could not extract 'exp' claim for grace period check, proceeding with refresh")
|
|
}
|
|
}
|
|
}
|
|
|
|
if needsRefresh && authenticated {
|
|
t.logger.Debug("Session token needs proactive refresh, attempting refresh")
|
|
} else if needsRefresh && !authenticated {
|
|
t.logger.Debug("ID token invalid/expired, but refresh token found. Attempting refresh.")
|
|
}
|
|
|
|
refreshed := t.refreshToken(rw, req, session)
|
|
if refreshed {
|
|
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.
|
|
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, createdAt := t.extractSessionInfo(idToken)
|
|
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)
|
|
}
|
|
session.ResetRedirectCount()
|
|
t.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
|
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.
|
|
func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Request, p *principal) {
|
|
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 {
|
|
if len(groups) > 0 {
|
|
req.Header.Set("X-User-Groups", strings.Join(groups, ","))
|
|
}
|
|
if len(roles) > 0 {
|
|
req.Header.Set("X-User-Roles", strings.Join(roles, ","))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(t.allowedRolesAndGroups) > 0 {
|
|
allowed := false
|
|
for _, roleOrGroup := range append(groups, roles...) {
|
|
if _, ok := t.allowedRolesAndGroups[roleOrGroup]; ok {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
t.logger.Infof("User %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
|
|
}
|
|
}
|
|
|
|
req.Header.Set("X-Forwarded-User", p.Identifier)
|
|
|
|
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
|
|
if !t.minimalHeaders {
|
|
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
|
|
req.Header.Set("X-Auth-Request-User", p.Identifier)
|
|
if p.IDToken != "" {
|
|
req.Header.Set("X-Auth-Request-Token", p.IDToken)
|
|
}
|
|
}
|
|
|
|
if len(t.headerTemplates) > 0 {
|
|
// 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,
|
|
"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()
|
|
req.Header.Set(headerName, headerValue)
|
|
t.logger.Debugf("Set templated header %s = %s", headerName, headerValue)
|
|
}
|
|
}
|
|
|
|
// Apply security headers if configured
|
|
if t.securityHeadersApplier != nil {
|
|
t.securityHeadersApplier(rw, req)
|
|
} else {
|
|
// Fallback to basic security headers
|
|
rw.Header().Set("X-Frame-Options", "DENY")
|
|
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
|
rw.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
rw.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Bearer source: strip the Authorization header to keep the raw token
|
|
// out of downstream service logs. Off-by-config for operators who chain
|
|
// services that each re-verify the bearer.
|
|
if p.Source == sourceBearer && t.stripAuthorizationHeader {
|
|
req.Header.Del("Authorization")
|
|
}
|
|
|
|
t.logger.Debugf("Request authorized for user %s (source=%d), forwarding to next handler", p.Identifier, p.Source)
|
|
|
|
t.next.ServeHTTP(rw, req)
|
|
}
|