mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-04 22:39:34 +00:00
fix issue with logout url (#112)
* fix(logout): handle logout requests before OIDC initialization - [x] Add debug logging to logout handler entry point - [x] Move logout path check before OIDC initialization to enable logout when provider unavailable - [x] Move excluded URL and SSE checks before initialization wait - [x] Add debug logging for initialization wait to diagnose hanging requests - [x] Add test for logout functionality without OIDC provider availability * feat(logout): implement OIDC backchannel and front-channel logout - [x] Add logout token validation and backchannel logout handler - [x] Add front-channel logout handler with iframe support - [x] Implement session invalidation cache for distributed deployments - [x] Add comprehensive logout token claim verification (issuer, audience, events, iat, sid/sub) - [x] Integrate session invalidation checks into authorization flow - [x] Add configuration options for enabling backchannel/front-channel logout - [x] Add extensive test coverage for logout flows and edge cases - [x] Update documentation with logout configuration examples - [x] Add middleware routing for logout endpoints - [x] Extend cache manager with session invalidation cache support Resolves #110 * fixup! feat(logout): implement OIDC backchannel and front-channel logout * fixup! Merge branch 'main' into fix-issue-with-logout-url
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
docker/
|
||||
.claude/*.out
|
||||
*.test
|
||||
.leann/
|
||||
|
||||
@@ -1021,6 +1021,79 @@ configuration:
|
||||
See: https://github.com/lukaszraczylo/traefikoidc/issues/64
|
||||
required: false
|
||||
|
||||
enableBackchannelLogout:
|
||||
type: boolean
|
||||
description: |
|
||||
Enable OIDC Back-Channel Logout (IdP-initiated logout via server-to-server POST).
|
||||
|
||||
When enabled, the middleware accepts logout tokens at the configured backchannelLogoutURL.
|
||||
The IdP sends a signed JWT (logout_token) to notify the application that a user's session
|
||||
should be terminated.
|
||||
|
||||
This implements the OIDC Back-Channel Logout 1.0 specification.
|
||||
See: https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||
|
||||
Requirements:
|
||||
- backchannelLogoutURL must be configured
|
||||
- The IdP must be configured to send logout tokens to your backchannel URL
|
||||
- Logout tokens are validated using the IdP's JWKS
|
||||
|
||||
Default: false
|
||||
required: false
|
||||
|
||||
backchannelLogoutURL:
|
||||
type: string
|
||||
description: |
|
||||
Path for receiving backchannel logout tokens from the IdP.
|
||||
|
||||
This endpoint receives POST requests with a logout_token JWT in the request body.
|
||||
The token is validated against the IdP's JWKS and contains the session ID (sid)
|
||||
and/or subject (sub) to invalidate.
|
||||
|
||||
Example: /backchannel-logout
|
||||
|
||||
The full URL to configure in your IdP would be:
|
||||
https://your-app.example.com/backchannel-logout
|
||||
|
||||
Note: This path should be unique and not conflict with your application routes.
|
||||
required: false
|
||||
|
||||
enableFrontchannelLogout:
|
||||
type: boolean
|
||||
description: |
|
||||
Enable OIDC Front-Channel Logout (IdP-initiated logout via iframe).
|
||||
|
||||
When enabled, the middleware accepts logout requests at the configured frontchannelLogoutURL.
|
||||
The IdP embeds an iframe pointing to this URL when the user logs out, allowing the
|
||||
application to clear the user's session.
|
||||
|
||||
This implements the OIDC Front-Channel Logout 1.0 specification.
|
||||
See: https://openid.net/specs/openid-connect-frontchannel-1_0.html
|
||||
|
||||
Requirements:
|
||||
- frontchannelLogoutURL must be configured
|
||||
- The IdP must be configured with your front-channel logout URL
|
||||
- Your CSP headers must allow being embedded in an iframe from the IdP
|
||||
|
||||
Default: false
|
||||
required: false
|
||||
|
||||
frontchannelLogoutURL:
|
||||
type: string
|
||||
description: |
|
||||
Path for receiving front-channel logout requests from the IdP.
|
||||
|
||||
This endpoint receives GET requests with optional sid (session ID) and iss (issuer)
|
||||
query parameters. When called, it invalidates the user's session.
|
||||
|
||||
Example: /frontchannel-logout
|
||||
|
||||
The full URL to configure in your IdP would be:
|
||||
https://your-app.example.com/frontchannel-logout
|
||||
|
||||
Note: This path should be unique and not conflict with your application routes.
|
||||
required: false
|
||||
|
||||
headers:
|
||||
type: array
|
||||
description: |
|
||||
|
||||
@@ -154,6 +154,10 @@ The middleware supports the following configuration options:
|
||||
| `disableReplayDetection` | Disable JTI-based replay attack detection for multi-replica deployments | `false` | `true` |
|
||||
| `allowPrivateIPAddresses` | Allow private IP addresses in provider URLs (for internal networks with Keycloak, etc.) | `false` | `true` |
|
||||
| `minimalHeaders` | Reduce forwarded headers to prevent "431 Request Header Fields Too Large" errors | `false` | `true` |
|
||||
| `enableBackchannelLogout` | Enable OIDC Back-Channel Logout (IdP-initiated logout via server-to-server POST) | `false` | `true` |
|
||||
| `backchannelLogoutURL` | The path for receiving backchannel logout tokens from the IdP | none | `/backchannel-logout` |
|
||||
| `enableFrontchannelLogout` | Enable OIDC Front-Channel Logout (IdP-initiated logout via iframe) | `false` | `true` |
|
||||
| `frontchannelLogoutURL` | The path for receiving front-channel logout requests from the IdP | none | `/frontchannel-logout` |
|
||||
| `redis` | Redis cache configuration for distributed deployments | disabled | See "Redis Cache" section |
|
||||
|
||||
> **⚠️ IMPORTANT - TLS Termination at Load Balancer:**
|
||||
@@ -1148,6 +1152,50 @@ spec:
|
||||
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
|
||||
```
|
||||
|
||||
### With IdP-Initiated Logout (Backchannel & Front-Channel)
|
||||
|
||||
This plugin supports [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) and [OIDC Front-Channel Logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html) for IdP-initiated single logout.
|
||||
|
||||
**Backchannel Logout** (recommended): The IdP sends a server-to-server POST request with a signed `logout_token` JWT when a user logs out.
|
||||
|
||||
**Front-Channel Logout**: The IdP loads an iframe with the logout URL to invalidate the session in the browser.
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-with-idp-logout
|
||||
namespace: traefik
|
||||
spec:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: https://auth.example.com
|
||||
clientID: your-client-id
|
||||
clientSecret: your-client-secret
|
||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
||||
callbackURL: /oauth2/callback
|
||||
logoutURL: /oauth2/logout # RP-initiated logout
|
||||
|
||||
# Backchannel Logout (server-to-server)
|
||||
enableBackchannelLogout: true
|
||||
backchannelLogoutURL: /backchannel-logout
|
||||
|
||||
# Front-Channel Logout (iframe-based)
|
||||
enableFrontchannelLogout: true
|
||||
frontchannelLogoutURL: /frontchannel-logout
|
||||
|
||||
# For multi-replica deployments, use Redis to share session invalidations
|
||||
redis:
|
||||
enabled: true
|
||||
address: redis:6379
|
||||
```
|
||||
|
||||
> **Note**: For multi-replica deployments, you **must** enable Redis to share session invalidation state across all instances. Otherwise, a logout on one instance won't invalidate sessions on other instances.
|
||||
|
||||
**IdP Configuration**: Configure your IdP to send logout requests to:
|
||||
- **Backchannel**: `https://your-app.example.com/backchannel-logout` (POST with `logout_token`)
|
||||
- **Front-Channel**: `https://your-app.example.com/frontchannel-logout?sid=SESSION_ID&iss=ISSUER` (GET in iframe)
|
||||
|
||||
### With Templated Headers
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -104,6 +104,14 @@ func (cm *CacheManager) GetSharedTokenTypeCache() CacheInterface {
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetTokenTypeCache(), managed: true}
|
||||
}
|
||||
|
||||
// GetSharedSessionInvalidationCache returns the shared session invalidation cache
|
||||
// for backchannel and front-channel logout (IdP-initiated logout)
|
||||
func (cm *CacheManager) GetSharedSessionInvalidationCache() CacheInterface {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return &CacheInterfaceWrapper{cache: cm.manager.GetSessionInvalidationCache(), managed: true}
|
||||
}
|
||||
|
||||
// Close gracefully shuts down all cache components
|
||||
func (cm *CacheManager) Close() error {
|
||||
cm.mu.Lock()
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
|
||||
<a href="#deployment" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Deployment</a>
|
||||
<a href="#security" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Security</a>
|
||||
<a href="#logout" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Logout</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
|
||||
@@ -114,6 +115,7 @@
|
||||
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
|
||||
<a href="#deployment" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Deployment</a>
|
||||
<a href="#security" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Security</a>
|
||||
<a href="#logout" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1219,6 +1221,71 @@ spec:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- IdP-Initiated Logout Section -->
|
||||
<section id="logout" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">IdP-Initiated Logout</h2>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Support for OIDC Back-Channel and Front-Channel Logout specifications</p>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-server mr-2 text-blue-500"></i>
|
||||
Back-Channel Logout
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Server-to-server logout notification. The IdP sends a signed JWT (logout_token) directly to your application when a user logs out.
|
||||
</p>
|
||||
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
|
||||
<li>• Signed JWT logout tokens</li>
|
||||
<li>• Session ID (sid) based invalidation</li>
|
||||
<li>• Subject (sub) based invalidation</li>
|
||||
<li>• Works behind firewalls</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-browser mr-2 text-purple-500"></i>
|
||||
Front-Channel Logout
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Browser-based logout via iframe. The IdP embeds an iframe pointing to your logout endpoint during user logout.
|
||||
</p>
|
||||
<ul class="text-gray-600 dark:text-gray-400 space-y-2 text-sm">
|
||||
<li>• Iframe-based session termination</li>
|
||||
<li>• Immediate cookie invalidation</li>
|
||||
<li>• Simple GET request handling</li>
|
||||
<li>• Issuer validation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-xl">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuration Example</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>http:
|
||||
middlewares:
|
||||
oidc-auth:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
# ... other OIDC configuration ...
|
||||
|
||||
# Back-Channel Logout (server-to-server)
|
||||
enableBackchannelLogout: true
|
||||
backchannelLogoutURL: "/backchannel-logout"
|
||||
|
||||
# Front-Channel Logout (browser-based)
|
||||
enableFrontchannelLogout: true
|
||||
frontchannelLogoutURL: "/frontchannel-logout"</code></pre>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mt-4">
|
||||
Configure your IdP with the full URLs (e.g., <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">https://your-app.example.com/backchannel-logout</code>).
|
||||
When a user logs out from the IdP, all their sessions across your applications will be invalidated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Choose Section -->
|
||||
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
@@ -336,6 +336,7 @@ func createStringMap(keys []string) map[string]struct{} {
|
||||
// and redirects to the provider's logout endpoint or configured post-logout URI.
|
||||
// It handles potential errors during session retrieval or clearing.
|
||||
func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
t.logger.Debug("Processing logout request")
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Error getting session: %v", err)
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
// Package traefikoidc provides OIDC authentication middleware for Traefik.
|
||||
// This file implements OIDC Backchannel Logout (OpenID Connect Back-Channel Logout 1.0)
|
||||
// and Front-Channel Logout (OpenID Connect Front-Channel Logout 1.0) functionality.
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// logoutTokenType is the expected typ claim for logout tokens
|
||||
// #nosec G101 -- This is a JWT type claim value from OIDC spec, not a credential
|
||||
logoutTokenType = "logout+jwt"
|
||||
|
||||
// sessionInvalidationTTL is how long to remember invalidated sessions
|
||||
// Should be at least as long as your session max age
|
||||
sessionInvalidationTTL = 25 * time.Hour
|
||||
)
|
||||
|
||||
// LogoutTokenClaims represents the claims in an OIDC logout token
|
||||
// as defined in OpenID Connect Back-Channel Logout 1.0
|
||||
type LogoutTokenClaims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience interface{} `json:"aud"` // Can be string or []string
|
||||
IssuedAt int64 `json:"iat"`
|
||||
JTI string `json:"jti"`
|
||||
Events map[string]interface{} `json:"events"`
|
||||
SessionID string `json:"sid,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"` // Must NOT be present
|
||||
}
|
||||
|
||||
// handleBackchannelLogout processes OIDC Backchannel Logout requests.
|
||||
// It accepts POST requests with a logout_token parameter containing a JWT
|
||||
// that identifies which session(s) to terminate.
|
||||
//
|
||||
// According to OpenID Connect Back-Channel Logout 1.0:
|
||||
// - The logout_token is a JWT signed by the IdP
|
||||
// - It contains either a 'sid' (session ID) or 'sub' (subject) claim to identify the session
|
||||
// - The RP must validate the token and invalidate the matching session(s)
|
||||
//
|
||||
// Parameters:
|
||||
// - rw: The HTTP response writer
|
||||
// - req: The HTTP request containing the logout_token
|
||||
func (t *TraefikOidc) handleBackchannelLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
t.logger.Debug("Processing backchannel logout request")
|
||||
|
||||
// Backchannel logout must be POST
|
||||
if req.Method != http.MethodPost {
|
||||
t.logger.Errorf("Backchannel logout: invalid method %s, expected POST", req.Method)
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data to get logout_token
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.logger.Errorf("Backchannel logout: failed to parse form: %v", err)
|
||||
http.Error(rw, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logoutToken := req.FormValue("logout_token")
|
||||
if logoutToken == "" {
|
||||
// Also try reading from request body as raw JWT
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 64*1024)) // 64KB limit
|
||||
if err == nil && len(body) > 0 {
|
||||
logoutToken = string(body)
|
||||
}
|
||||
}
|
||||
|
||||
if logoutToken == "" {
|
||||
t.logger.Error("Backchannel logout: missing logout_token")
|
||||
http.Error(rw, "logout_token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate the logout token
|
||||
claims, err := t.validateLogoutToken(logoutToken)
|
||||
if err != nil {
|
||||
t.logger.Errorf("Backchannel logout: token validation failed: %v", err)
|
||||
// Return 400 for invalid token per spec
|
||||
http.Error(rw, "Invalid logout token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate session(s) based on sid or sub
|
||||
if err := t.invalidateSession(claims.SessionID, claims.Subject); err != nil {
|
||||
t.logger.Errorf("Backchannel logout: failed to invalidate session: %v", err)
|
||||
http.Error(rw, "Failed to invalidate session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t.logger.Infof("Backchannel logout: successfully invalidated session (sid=%s, sub=%s)",
|
||||
claims.SessionID, claims.Subject)
|
||||
|
||||
// Return 200 OK with empty body per spec
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleFrontchannelLogout processes OIDC Front-Channel Logout requests.
|
||||
// It accepts GET requests with 'iss' and 'sid' query parameters that identify
|
||||
// which session to terminate. The IdP typically loads this URL in an iframe.
|
||||
//
|
||||
// According to OpenID Connect Front-Channel Logout 1.0:
|
||||
// - The request contains 'iss' (issuer) and optionally 'sid' (session ID)
|
||||
// - The RP should clear the session and return a response (typically empty or image)
|
||||
// - The response must be cacheable to allow the IdP to load it in an iframe
|
||||
//
|
||||
// Parameters:
|
||||
// - rw: The HTTP response writer
|
||||
// - req: The HTTP request containing iss and sid parameters
|
||||
func (t *TraefikOidc) handleFrontchannelLogout(rw http.ResponseWriter, req *http.Request) {
|
||||
t.logger.Debug("Processing front-channel logout request")
|
||||
|
||||
// Front-channel logout should be GET
|
||||
if req.Method != http.MethodGet {
|
||||
t.logger.Errorf("Front-channel logout: invalid method %s, expected GET", req.Method)
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get iss and sid from query parameters
|
||||
iss := req.URL.Query().Get("iss")
|
||||
sid := req.URL.Query().Get("sid")
|
||||
|
||||
// Validate issuer matches our expected issuer
|
||||
t.metadataMu.RLock()
|
||||
expectedIssuer := t.issuerURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
if iss != "" && iss != expectedIssuer {
|
||||
t.logger.Errorf("Front-channel logout: issuer mismatch: got %s, expected %s", iss, expectedIssuer)
|
||||
http.Error(rw, "Invalid issuer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Must have at least sid for front-channel logout
|
||||
if sid == "" {
|
||||
t.logger.Error("Front-channel logout: missing sid parameter")
|
||||
http.Error(rw, "sid parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate the session
|
||||
if err := t.invalidateSession(sid, ""); err != nil {
|
||||
t.logger.Errorf("Front-channel logout: failed to invalidate session: %v", err)
|
||||
http.Error(rw, "Failed to invalidate session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t.logger.Infof("Front-channel logout: successfully invalidated session (sid=%s)", sid)
|
||||
|
||||
// Return a minimal HTML response that's suitable for iframe loading
|
||||
// Set headers to allow embedding and caching
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.Header().Set("Cache-Control", "no-cache, no-store")
|
||||
rw.Header().Set("Pragma", "no-cache")
|
||||
// Allow embedding in iframes from any origin (required for front-channel logout)
|
||||
rw.Header().Del("X-Frame-Options")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = rw.Write([]byte("<!DOCTYPE html><html><head><title>Logged Out</title></head><body></body></html>"))
|
||||
}
|
||||
|
||||
// validateLogoutToken parses and validates a logout token JWT.
|
||||
// It verifies the token signature, issuer, audience, and required claims.
|
||||
//
|
||||
// Parameters:
|
||||
// - tokenString: The raw JWT logout token
|
||||
//
|
||||
// Returns:
|
||||
// - The parsed logout token claims
|
||||
// - An error if validation fails
|
||||
func (t *TraefikOidc) validateLogoutToken(tokenString string) (*LogoutTokenClaims, error) {
|
||||
// Parse the JWT
|
||||
jwt, err := parseJWT(tokenString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse logout token: %w", err)
|
||||
}
|
||||
|
||||
// Check token type if present
|
||||
if typ, ok := jwt.Header["typ"].(string); ok {
|
||||
// The typ should be "logout+jwt" or omitted
|
||||
if typ != "" && typ != logoutTokenType && typ != "JWT" {
|
||||
return nil, fmt.Errorf("invalid token type: %s", typ)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signature only (not standard claims - logout tokens don't have 'exp')
|
||||
if err := t.verifyLogoutTokenSignature(jwt, tokenString); err != nil {
|
||||
return nil, fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims := &LogoutTokenClaims{}
|
||||
claimsJSON, err := json.Marshal(jwt.Claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal claims: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(claimsJSON, claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Validate required claims
|
||||
t.metadataMu.RLock()
|
||||
expectedIssuer := t.issuerURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
// Validate issuer
|
||||
if claims.Issuer != expectedIssuer {
|
||||
return nil, fmt.Errorf("issuer mismatch: got %s, expected %s", claims.Issuer, expectedIssuer)
|
||||
}
|
||||
|
||||
// Validate audience (must contain our client_id)
|
||||
if !t.validateLogoutTokenAudience(claims.Audience) {
|
||||
return nil, fmt.Errorf("audience validation failed")
|
||||
}
|
||||
|
||||
// Validate iat (issued at) - must be present and not too old
|
||||
if claims.IssuedAt == 0 {
|
||||
return nil, fmt.Errorf("missing iat claim")
|
||||
}
|
||||
iatTime := time.Unix(claims.IssuedAt, 0)
|
||||
// Allow up to 5 minutes clock skew and 10 minutes token age
|
||||
if time.Since(iatTime) > 15*time.Minute {
|
||||
return nil, fmt.Errorf("logout token too old: issued at %v", iatTime)
|
||||
}
|
||||
// Token should not be from the future (with 5 min clock skew tolerance)
|
||||
if iatTime.After(time.Now().Add(5 * time.Minute)) {
|
||||
return nil, fmt.Errorf("logout token issued in the future: %v", iatTime)
|
||||
}
|
||||
|
||||
// Validate events claim - must contain the logout event
|
||||
if claims.Events == nil {
|
||||
return nil, fmt.Errorf("missing events claim")
|
||||
}
|
||||
if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
|
||||
return nil, fmt.Errorf("missing backchannel-logout event in events claim")
|
||||
}
|
||||
|
||||
// Validate that nonce is NOT present (per spec)
|
||||
if claims.Nonce != "" {
|
||||
return nil, fmt.Errorf("nonce claim must not be present in logout token")
|
||||
}
|
||||
|
||||
// Must have either sid or sub (or both)
|
||||
if claims.SessionID == "" && claims.Subject == "" {
|
||||
return nil, fmt.Errorf("logout token must contain either sid or sub claim")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// validateLogoutTokenAudience checks if the logout token audience contains our client_id
|
||||
func (t *TraefikOidc) validateLogoutTokenAudience(aud interface{}) bool {
|
||||
switch v := aud.(type) {
|
||||
case string:
|
||||
return v == t.clientID
|
||||
case []interface{}:
|
||||
for _, a := range v {
|
||||
if s, ok := a.(string); ok && s == t.clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, a := range v {
|
||||
if a == t.clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// verifyLogoutTokenSignature verifies only the signature of a logout token.
|
||||
// Unlike VerifyJWTSignatureAndClaims, this does NOT validate standard claims like 'exp'
|
||||
// because logout tokens don't have an expiration claim per OIDC Back-Channel Logout spec.
|
||||
//
|
||||
// Parameters:
|
||||
// - jwt: The parsed JWT structure
|
||||
// - tokenString: The raw token string for signature verification
|
||||
//
|
||||
// Returns:
|
||||
// - An error if signature verification fails
|
||||
func (t *TraefikOidc) verifyLogoutTokenSignature(jwt *JWT, tokenString string) error {
|
||||
t.logger.Debug("Verifying logout token signature")
|
||||
|
||||
// Read jwksURL with RLock
|
||||
t.metadataMu.RLock()
|
||||
jwksURL := t.jwksURL
|
||||
t.metadataMu.RUnlock()
|
||||
|
||||
jwks, err := t.jwkCache.GetJWKS(context.Background(), jwksURL, t.httpClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get JWKS: %w", err)
|
||||
}
|
||||
|
||||
if jwks == nil {
|
||||
return fmt.Errorf("JWKS is nil, cannot verify token")
|
||||
}
|
||||
|
||||
kid, ok := jwt.Header["kid"].(string)
|
||||
if !ok || kid == "" {
|
||||
return fmt.Errorf("missing key ID in token header")
|
||||
}
|
||||
|
||||
alg, ok := jwt.Header["alg"].(string)
|
||||
if !ok || alg == "" {
|
||||
return fmt.Errorf("missing algorithm in token header")
|
||||
}
|
||||
|
||||
// Find the matching key in JWKS
|
||||
var matchingKey *JWK
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
matchingKey = &key
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchingKey == nil {
|
||||
return fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||
}
|
||||
|
||||
publicKeyPEM, err := jwkToPEM(matchingKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert JWK to PEM: %w", err)
|
||||
}
|
||||
|
||||
if err := verifySignature(tokenString, publicKeyPEM, alg); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
t.logger.Debug("Logout token signature verified successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateSession marks a session as invalidated in the session invalidation cache.
|
||||
// It stores entries by both sid and sub if available.
|
||||
//
|
||||
// Parameters:
|
||||
// - sid: The session ID to invalidate (from the 'sid' claim)
|
||||
// - sub: The subject to invalidate (from the 'sub' claim)
|
||||
//
|
||||
// Returns:
|
||||
// - An error if the invalidation fails
|
||||
func (t *TraefikOidc) invalidateSession(sid, sub string) error {
|
||||
if t.sessionInvalidationCache == nil {
|
||||
return fmt.Errorf("session invalidation cache not initialized")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Store by session ID
|
||||
if sid != "" {
|
||||
key := t.buildSessionInvalidationKey("sid", sid)
|
||||
t.sessionInvalidationCache.Set(key, now, sessionInvalidationTTL)
|
||||
t.logger.Debugf("Invalidated session by sid: %s", sid)
|
||||
}
|
||||
|
||||
// Store by subject (invalidates all sessions for this user)
|
||||
if sub != "" {
|
||||
key := t.buildSessionInvalidationKey("sub", sub)
|
||||
t.sessionInvalidationCache.Set(key, now, sessionInvalidationTTL)
|
||||
t.logger.Debugf("Invalidated session by sub: %s", sub)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSessionInvalidated checks if a session has been invalidated via backchannel
|
||||
// or front-channel logout.
|
||||
//
|
||||
// Parameters:
|
||||
// - sid: The session ID to check
|
||||
// - sub: The subject to check
|
||||
// - sessionCreatedAt: When the session was created (to compare against invalidation time)
|
||||
//
|
||||
// Returns:
|
||||
// - true if the session has been invalidated, false otherwise
|
||||
func (t *TraefikOidc) isSessionInvalidated(sid, sub string, sessionCreatedAt time.Time) bool {
|
||||
if t.sessionInvalidationCache == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Truncate session creation time to seconds for fair comparison with Unix timestamps
|
||||
sessionCreatedAtSec := sessionCreatedAt.Truncate(time.Second)
|
||||
|
||||
// Check by session ID first (more specific)
|
||||
if sid != "" {
|
||||
key := t.buildSessionInvalidationKey("sid", sid)
|
||||
if val, found := t.sessionInvalidationCache.Get(key); found {
|
||||
if invalidatedAt, ok := val.(int64); ok {
|
||||
// Session was invalidated at or after it was created
|
||||
invalidationTime := time.Unix(invalidatedAt, 0)
|
||||
if !invalidationTime.Before(sessionCreatedAtSec) {
|
||||
t.logger.Debugf("Session invalidated by sid: %s", sid)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check by subject (all sessions for this user)
|
||||
if sub != "" {
|
||||
key := t.buildSessionInvalidationKey("sub", sub)
|
||||
if val, found := t.sessionInvalidationCache.Get(key); found {
|
||||
if invalidatedAt, ok := val.(int64); ok {
|
||||
// Sessions for this subject created at or before invalidation are invalid
|
||||
invalidationTime := time.Unix(invalidatedAt, 0)
|
||||
if !invalidationTime.Before(sessionCreatedAtSec) {
|
||||
t.logger.Debugf("Session invalidated by sub: %s", sub)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSessionInvalidationKey creates a cache key for session invalidation
|
||||
func (t *TraefikOidc) buildSessionInvalidationKey(keyType, value string) string {
|
||||
return fmt.Sprintf("session_invalidation:%s:%s", keyType, value)
|
||||
}
|
||||
|
||||
// extractSessionInfo extracts sid and sub from an ID token for session tracking
|
||||
func (t *TraefikOidc) extractSessionInfo(idToken string) (sid, sub string, createdAt time.Time) {
|
||||
if idToken == "" {
|
||||
return "", "", time.Time{}
|
||||
}
|
||||
|
||||
jwt, err := parseJWT(idToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}
|
||||
}
|
||||
|
||||
// Extract sid (session ID)
|
||||
if sidVal, ok := jwt.Claims["sid"].(string); ok {
|
||||
sid = sidVal
|
||||
}
|
||||
|
||||
// Extract sub (subject)
|
||||
if subVal, ok := jwt.Claims["sub"].(string); ok {
|
||||
sub = subVal
|
||||
}
|
||||
|
||||
// Extract iat for session creation time
|
||||
if iatVal, ok := jwt.Claims["iat"].(float64); ok {
|
||||
createdAt = time.Unix(int64(iatVal), 0)
|
||||
} else {
|
||||
// Default to now if iat not present
|
||||
createdAt = time.Now()
|
||||
}
|
||||
|
||||
return sid, sub, createdAt
|
||||
}
|
||||
|
||||
// determineLogoutPath checks if the given path matches any logout URL
|
||||
func (t *TraefikOidc) determineLogoutPath(path string) string {
|
||||
// Check backchannel logout path
|
||||
if t.backchannelLogoutPath != "" && path == t.backchannelLogoutPath {
|
||||
return "backchannel"
|
||||
}
|
||||
|
||||
// Check front-channel logout path
|
||||
if t.frontchannelLogoutPath != "" && path == t.frontchannelLogoutPath {
|
||||
return "frontchannel"
|
||||
}
|
||||
|
||||
// Check regular logout path (for RP-initiated logout)
|
||||
if path == t.logoutURLPath {
|
||||
return "rp"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeLogoutPath ensures logout paths start with / and prevents open redirects
|
||||
func normalizeLogoutPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
// Prevent open redirect: ensure second character is not / or \
|
||||
// This prevents URLs like //example.com or /\example.com from being treated as absolute URLs
|
||||
if len(path) > 1 && (path[1] == '/' || path[1] == '\\') {
|
||||
// Strip leading slashes/backslashes and re-normalize
|
||||
path = strings.TrimLeft(path, "/\\")
|
||||
if path != "" {
|
||||
path = "/" + path
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
+1623
File diff suppressed because it is too large
Load Diff
@@ -212,16 +212,21 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
||||
}
|
||||
return 60 * time.Second
|
||||
}(),
|
||||
tokenCleanupStopChan: make(chan struct{}),
|
||||
metadataRefreshStopChan: make(chan struct{}),
|
||||
ctx: pluginCtx,
|
||||
cancelFunc: cancelFunc,
|
||||
suppressDiagnosticLogs: isTestMode(),
|
||||
securityHeadersApplier: config.GetSecurityHeadersApplier(),
|
||||
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
|
||||
dcrConfig: config.DynamicClientRegistration,
|
||||
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
|
||||
minimalHeaders: config.MinimalHeaders,
|
||||
tokenCleanupStopChan: make(chan struct{}),
|
||||
metadataRefreshStopChan: make(chan struct{}),
|
||||
ctx: pluginCtx,
|
||||
cancelFunc: cancelFunc,
|
||||
suppressDiagnosticLogs: isTestMode(),
|
||||
securityHeadersApplier: config.GetSecurityHeadersApplier(),
|
||||
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
|
||||
dcrConfig: config.DynamicClientRegistration,
|
||||
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
|
||||
minimalHeaders: config.MinimalHeaders,
|
||||
enableBackchannelLogout: config.EnableBackchannelLogout,
|
||||
enableFrontchannelLogout: config.EnableFrontchannelLogout,
|
||||
backchannelLogoutPath: normalizeLogoutPath(config.BackchannelLogoutURL),
|
||||
frontchannelLogoutPath: normalizeLogoutPath(config.FrontchannelLogoutURL),
|
||||
sessionInvalidationCache: cacheManager.GetSharedSessionInvalidationCache(),
|
||||
}
|
||||
|
||||
// Log audience configuration
|
||||
|
||||
+62
-6
@@ -26,6 +26,31 @@ import (
|
||||
// - 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 {
|
||||
@@ -42,6 +67,24 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.firstRequestMutex.Unlock()
|
||||
}
|
||||
|
||||
// Check excluded URLs before waiting for initialization
|
||||
if t.determineExcludedURL(req.URL.Path) {
|
||||
t.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path)
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for SSE requests before waiting for initialization
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
if strings.Contains(acceptHeader, "text/event-stream") {
|
||||
t.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader)
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Log waiting for initialization to help diagnose hanging requests
|
||||
t.logger.Debug("Waiting for OIDC provider initialization...")
|
||||
|
||||
select {
|
||||
case <-t.initComplete:
|
||||
// Read issuerURL with RLock
|
||||
@@ -83,7 +126,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
acceptHeader = req.Header.Get("Accept")
|
||||
if strings.Contains(acceptHeader, "text/event-stream") {
|
||||
t.logger.Debugf("Request accepts text/event-stream (%s), bypassing OIDC", acceptHeader)
|
||||
// Set forwarded user headers from existing session before bypassing
|
||||
@@ -100,7 +143,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
t.next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
t.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
@@ -131,10 +173,6 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
host := utils.DetermineHost(req)
|
||||
redirectURL := buildFullURL(scheme, host, t.redirURLPath)
|
||||
|
||||
if req.URL.Path == t.logoutURLPath {
|
||||
t.handleLogout(rw, req)
|
||||
return
|
||||
}
|
||||
if req.URL.Path == t.redirURLPath {
|
||||
t.handleCallback(rw, req, redirectURL)
|
||||
return
|
||||
@@ -275,6 +313,24 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
|
||||
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", email)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokenForClaims := session.GetIDToken()
|
||||
if tokenForClaims == "" {
|
||||
tokenForClaims = session.GetAccessToken()
|
||||
|
||||
@@ -95,6 +95,38 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogoutWorksWithoutOIDCInitialization tests that logout works even if OIDC provider is unavailable
|
||||
// This is critical for allowing users to clear their session when the provider is down
|
||||
func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestReceived: true,
|
||||
metadataRefreshStarted: true,
|
||||
logoutURLPath: "/logout",
|
||||
postLogoutRedirectURI: "/",
|
||||
forceHTTPS: false,
|
||||
}
|
||||
// Note: initComplete is NOT closed, simulating OIDC provider being unavailable
|
||||
|
||||
req := httptest.NewRequest("GET", "/logout", nil)
|
||||
req.Host = "example.com"
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
// Should redirect to post-logout URI even without OIDC initialization
|
||||
if rw.Code != http.StatusFound {
|
||||
t.Errorf("Expected redirect (302) for logout, got %d", rw.Code)
|
||||
}
|
||||
|
||||
location := rw.Header().Get("Location")
|
||||
if location == "" {
|
||||
t.Error("Expected Location header for logout redirect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareDomainRestrictions tests domain-based access control
|
||||
// NOTE: Currently commented out due to complex session setup requirements
|
||||
// These scenarios are tested indirectly through integration tests
|
||||
|
||||
+4
-114
@@ -65,6 +65,10 @@ type Config struct {
|
||||
ForceHTTPS bool `json:"forceHTTPS"`
|
||||
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
||||
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
||||
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
||||
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
||||
}
|
||||
|
||||
// RedisConfig configures Redis cache backend settings for distributed caching.
|
||||
@@ -744,15 +748,6 @@ func newNoOpLogger() *Logger {
|
||||
// - code: The HTTP status code for the response.
|
||||
// - logger: The Logger instance to use for logging the error.
|
||||
//
|
||||
// handleError writes an HTTP error response with the specified status code and message.
|
||||
// It logs the error and sets appropriate headers before writing the response.
|
||||
//
|
||||
//lint:ignore U1000 Kept for potential future error handling
|
||||
func handleError(w http.ResponseWriter, message string, code int, logger *Logger) {
|
||||
logger.Error("%s", message)
|
||||
http.Error(w, message, code)
|
||||
}
|
||||
|
||||
// GetSecurityHeadersApplier returns a function that applies security headers
|
||||
func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Request) {
|
||||
if c.SecurityHeaders == nil || !c.SecurityHeaders.Enabled {
|
||||
@@ -1058,111 +1053,6 @@ func (rc *RedisConfig) ApplyEnvFallbacks() {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadRedisConfigFromEnv loads Redis configuration from environment variables.
|
||||
// Deprecated: Use RedisConfig.ApplyEnvFallbacks() on an existing config instead.
|
||||
// This function is kept for backward compatibility but should not be used directly.
|
||||
func LoadRedisConfigFromEnv() *RedisConfig {
|
||||
// Check if Redis is enabled
|
||||
enabledStr := os.Getenv("REDIS_ENABLED")
|
||||
if enabledStr == "" || enabledStr == "false" || enabledStr == "0" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := &RedisConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
// Parse numeric values
|
||||
if dbStr := os.Getenv("REDIS_DB"); dbStr != "" {
|
||||
if db, err := strconv.Atoi(dbStr); err == nil {
|
||||
config.DB = db
|
||||
}
|
||||
}
|
||||
|
||||
if poolSizeStr := os.Getenv("REDIS_POOL_SIZE"); poolSizeStr != "" {
|
||||
if poolSize, err := strconv.Atoi(poolSizeStr); err == nil {
|
||||
config.PoolSize = poolSize
|
||||
}
|
||||
}
|
||||
|
||||
if connectTimeoutStr := os.Getenv("REDIS_CONNECT_TIMEOUT"); connectTimeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(connectTimeoutStr); err == nil {
|
||||
config.ConnectTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
if readTimeoutStr := os.Getenv("REDIS_READ_TIMEOUT"); readTimeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(readTimeoutStr); err == nil {
|
||||
config.ReadTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
if writeTimeoutStr := os.Getenv("REDIS_WRITE_TIMEOUT"); writeTimeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(writeTimeoutStr); err == nil {
|
||||
config.WriteTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// Parse boolean values
|
||||
if enableTLSStr := os.Getenv("REDIS_ENABLE_TLS"); enableTLSStr == "true" || enableTLSStr == "1" {
|
||||
config.EnableTLS = true
|
||||
}
|
||||
|
||||
if skipVerifyStr := os.Getenv("REDIS_TLS_SKIP_VERIFY"); skipVerifyStr == "true" || skipVerifyStr == "1" {
|
||||
config.TLSSkipVerify = true
|
||||
}
|
||||
|
||||
// Parse hybrid mode settings
|
||||
if l1SizeStr := os.Getenv("REDIS_HYBRID_L1_SIZE"); l1SizeStr != "" {
|
||||
if size, err := strconv.Atoi(l1SizeStr); err == nil {
|
||||
config.HybridL1Size = size
|
||||
}
|
||||
}
|
||||
|
||||
if l1MemoryStr := os.Getenv("REDIS_HYBRID_L1_MEMORY_MB"); l1MemoryStr != "" {
|
||||
if memory, err := strconv.ParseInt(l1MemoryStr, 10, 64); err == nil {
|
||||
config.HybridL1MemoryMB = memory
|
||||
}
|
||||
}
|
||||
|
||||
// Parse circuit breaker settings
|
||||
if enableCBStr := os.Getenv("REDIS_ENABLE_CIRCUIT_BREAKER"); enableCBStr == "false" || enableCBStr == "0" {
|
||||
config.EnableCircuitBreaker = false
|
||||
} else {
|
||||
config.EnableCircuitBreaker = true // Default to enabled
|
||||
}
|
||||
|
||||
if cbThresholdStr := os.Getenv("REDIS_CIRCUIT_BREAKER_THRESHOLD"); cbThresholdStr != "" {
|
||||
if threshold, err := strconv.Atoi(cbThresholdStr); err == nil {
|
||||
config.CircuitBreakerThreshold = threshold
|
||||
}
|
||||
}
|
||||
|
||||
if cbTimeoutStr := os.Getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT"); cbTimeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(cbTimeoutStr); err == nil {
|
||||
config.CircuitBreakerTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// Parse health check settings
|
||||
if enableHCStr := os.Getenv("REDIS_ENABLE_HEALTH_CHECK"); enableHCStr == "false" || enableHCStr == "0" {
|
||||
config.EnableHealthCheck = false
|
||||
} else {
|
||||
config.EnableHealthCheck = true // Default to enabled
|
||||
}
|
||||
|
||||
if hcIntervalStr := os.Getenv("REDIS_HEALTH_CHECK_INTERVAL"); hcIntervalStr != "" {
|
||||
if interval, err := strconv.Atoi(hcIntervalStr); err == nil {
|
||||
config.HealthCheckInterval = interval
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults after loading from env
|
||||
config.ApplyDefaults()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func isOriginAllowed(origin string, allowedOrigins []string) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if origin == allowed || allowed == "*" {
|
||||
|
||||
@@ -119,6 +119,8 @@ type TraefikOidc struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
registrationURL string
|
||||
backchannelLogoutPath string
|
||||
frontchannelLogoutPath string
|
||||
scopesSupported []string
|
||||
scopes []string
|
||||
refreshGracePeriod time.Duration
|
||||
@@ -126,7 +128,10 @@ type TraefikOidc struct {
|
||||
shutdownOnce sync.Once
|
||||
metadataRetryMutex sync.Mutex
|
||||
firstRequestMutex sync.Mutex
|
||||
sessionInvalidationCache CacheInterface
|
||||
minimalHeaders bool
|
||||
enableBackchannelLogout bool
|
||||
enableFrontchannelLogout bool
|
||||
firstRequestReceived bool
|
||||
requireTokenIntrospection bool
|
||||
metadataRefreshStarted bool
|
||||
|
||||
@@ -720,22 +720,6 @@ func (c *UniversalCache) SetWithMetadata(key string, value interface{}, ttl time
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTyped retrieves a typed value from the cache
|
||||
func GetTyped[T any](c *UniversalCache, key string) (T, bool) {
|
||||
var zero T
|
||||
value, exists := c.Get(key)
|
||||
if !exists {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
typed, ok := value.(T)
|
||||
if !ok {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return typed, true
|
||||
}
|
||||
|
||||
// TokenCacheOperations provides token-specific operations
|
||||
func (c *UniversalCache) BlacklistToken(token string, ttl time.Duration) error {
|
||||
if c.config.Type != CacheTypeToken {
|
||||
|
||||
@@ -13,21 +13,22 @@ import (
|
||||
// It runs a single consolidated cleanup goroutine for all caches, reducing
|
||||
// goroutine count and CPU overhead compared to per-cache cleanup routines.
|
||||
type UniversalCacheManager struct {
|
||||
sharedBackend backends.CacheBackend
|
||||
ctx context.Context
|
||||
tokenTypeCache *UniversalCache
|
||||
jwkCache *UniversalCache
|
||||
sessionCache *UniversalCache
|
||||
introspectionCache *UniversalCache
|
||||
tokenCache *UniversalCache
|
||||
metadataCache *UniversalCache
|
||||
dcrCredentialsCache *UniversalCache // DCR credentials storage for distributed environments
|
||||
logger *Logger
|
||||
blacklistCache *UniversalCache
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
cleanupStarted bool
|
||||
sharedBackend backends.CacheBackend
|
||||
ctx context.Context
|
||||
tokenTypeCache *UniversalCache
|
||||
jwkCache *UniversalCache
|
||||
sessionCache *UniversalCache
|
||||
introspectionCache *UniversalCache
|
||||
tokenCache *UniversalCache
|
||||
metadataCache *UniversalCache
|
||||
dcrCredentialsCache *UniversalCache // DCR credentials storage for distributed environments
|
||||
sessionInvalidationCache *UniversalCache // Session invalidation cache for backchannel/front-channel logout
|
||||
logger *Logger
|
||||
blacklistCache *UniversalCache
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
cleanupStarted bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -170,6 +171,16 @@ func initializeDefaultCaches(manager *UniversalCacheManager, logger *Logger) {
|
||||
Logger: logger,
|
||||
SkipAutoCleanup: true, // Managed cleanup
|
||||
})
|
||||
|
||||
// Initialize session invalidation cache for backchannel/front-channel logout
|
||||
// This cache stores invalidated session IDs and subjects to revoke sessions
|
||||
manager.sessionInvalidationCache = NewUniversalCache(UniversalCacheConfig{
|
||||
Type: CacheTypeSession,
|
||||
MaxSize: 5000, // Support many concurrent invalidations
|
||||
DefaultTTL: 25 * time.Hour, // Slightly longer than session max age (24h)
|
||||
Logger: logger,
|
||||
SkipAutoCleanup: true, // Managed cleanup
|
||||
})
|
||||
}
|
||||
|
||||
// initializeCachesWithRedis initializes caches with Redis/Hybrid backends based on configuration
|
||||
@@ -363,6 +374,19 @@ func initializeCachesWithRedis(manager *UniversalCacheManager, logger *Logger, r
|
||||
createBackend("dcr"),
|
||||
)
|
||||
|
||||
// Session invalidation cache - CRITICAL for distributed backchannel/front-channel logout
|
||||
// Uses Redis backend to share session invalidations across all Traefik replicas
|
||||
manager.sessionInvalidationCache = NewUniversalCacheWithBackend(
|
||||
UniversalCacheConfig{
|
||||
Type: CacheTypeSession,
|
||||
MaxSize: 5000, // Support many concurrent invalidations
|
||||
DefaultTTL: 25 * time.Hour, // Slightly longer than session max age (24h)
|
||||
Logger: logger,
|
||||
SkipAutoCleanup: true, // Managed cleanup
|
||||
},
|
||||
createBackend("session_invalidation"),
|
||||
)
|
||||
|
||||
logger.Infof("Cache manager initialized with %s backend configuration", redisConfig.CacheMode)
|
||||
}
|
||||
|
||||
@@ -411,6 +435,7 @@ func (m *UniversalCacheManager) performConsolidatedCleanup() {
|
||||
m.introspectionCache,
|
||||
m.tokenTypeCache,
|
||||
m.dcrCredentialsCache,
|
||||
m.sessionInvalidationCache,
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
@@ -452,13 +477,6 @@ func (m *UniversalCacheManager) GetJWKCache() *UniversalCache {
|
||||
return m.jwkCache
|
||||
}
|
||||
|
||||
// GetSessionCache returns the session cache
|
||||
func (m *UniversalCacheManager) GetSessionCache() *UniversalCache {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sessionCache
|
||||
}
|
||||
|
||||
// GetIntrospectionCache returns the token introspection cache
|
||||
func (m *UniversalCacheManager) GetIntrospectionCache() *UniversalCache {
|
||||
m.mu.RLock()
|
||||
@@ -473,6 +491,13 @@ func (m *UniversalCacheManager) GetTokenTypeCache() *UniversalCache {
|
||||
return m.tokenTypeCache
|
||||
}
|
||||
|
||||
// GetSessionInvalidationCache returns the session invalidation cache for backchannel/front-channel logout
|
||||
func (m *UniversalCacheManager) GetSessionInvalidationCache() *UniversalCache {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sessionInvalidationCache
|
||||
}
|
||||
|
||||
// GetDCRCredentialsCache returns the DCR credentials cache for distributed storage
|
||||
func (m *UniversalCacheManager) GetDCRCredentialsCache() *UniversalCache {
|
||||
m.mu.RLock()
|
||||
@@ -495,7 +520,7 @@ func (m *UniversalCacheManager) Close() error {
|
||||
|
||||
// Close all caches first (they won't close the shared backend)
|
||||
for _, cache := range []*UniversalCache{
|
||||
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache, m.dcrCredentialsCache,
|
||||
m.tokenCache, m.blacklistCache, m.metadataCache, m.jwkCache, m.sessionCache, m.introspectionCache, m.tokenTypeCache, m.dcrCredentialsCache, m.sessionInvalidationCache,
|
||||
} {
|
||||
if cache != nil {
|
||||
_ = cache.Close() // Safe to ignore: best effort cache cleanup
|
||||
@@ -516,35 +541,6 @@ func (m *UniversalCacheManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitializeCacheManagerFromConfig initializes the cache manager with configuration
|
||||
// This should be called early in the application startup with the loaded configuration
|
||||
func InitializeCacheManagerFromConfig(config *Config) *UniversalCacheManager {
|
||||
logger := NewLogger(config.LogLevel)
|
||||
|
||||
// Initialize Redis config if not present
|
||||
if config.Redis == nil {
|
||||
config.Redis = &RedisConfig{}
|
||||
}
|
||||
|
||||
// Apply environment variable fallbacks for fields not set in config
|
||||
// This allows env vars to be used as optional overrides only when
|
||||
// the config field is not explicitly set through Traefik
|
||||
config.Redis.ApplyEnvFallbacks()
|
||||
|
||||
// Apply defaults after env fallbacks
|
||||
config.Redis.ApplyDefaults()
|
||||
|
||||
// Log cache backend selection
|
||||
if config.Redis != nil && config.Redis.Enabled {
|
||||
logger.Infof("Initializing cache backend with Redis: mode=%s, address=%s",
|
||||
config.Redis.CacheMode, config.Redis.Address)
|
||||
} else {
|
||||
logger.Info("Initializing cache backend with memory-only mode")
|
||||
}
|
||||
|
||||
return GetUniversalCacheManagerWithConfig(logger, config.Redis)
|
||||
}
|
||||
|
||||
// ResetUniversalCacheManagerForTesting resets the singleton for testing purposes only
|
||||
// This should only be called in test code to ensure proper cleanup between tests
|
||||
func ResetUniversalCacheManagerForTesting() {
|
||||
|
||||
Reference in New Issue
Block a user