mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
Seven findings are fixed. Each fix has a regression test. - Compare X-Forwarded-Host and the serving host case-insensitively, so a case variant cannot widen the session cookie Domain. - Reject a future iat on the opaque introspection path. - Serve the cached parsed JWKS inside the signature-refresh cooldown. - Release the half-open circuit slot when a probe panics. - Cap requests that wait for provider initialisation at 512. Extra requests receive 503 with Retry-After. - Cap cookie deletion headers at 32 cookie names per response. - Cap front-channel sid markers at 1000, so unauthenticated requests cannot evict backchannel or jti replay markers.
713 lines
26 KiB
Go
713 lines
26 KiB
Go
// 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"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
|
)
|
|
|
|
// backchannelLogoutJTIMu serializes the FALLBACK check-and-set inside
|
|
// checkAndMarkLogoutJTIProcessed: used when sessionInvalidationCache does
|
|
// not implement AtomicSetIfAbsentCache, AND when it does but SetIfAbsent
|
|
// itself returns an error — including errSetIfAbsentUnsupported, which
|
|
// UniversalCache.SetIfAbsent now reports rather than silently degrading to
|
|
// a process-local check when its distributed backend does not implement
|
|
// the atomic SetNX primitive (FIX-17 round-2; this closed the gap where a
|
|
// Redis backend wrapped by the circuit-breaker or health-check decorator
|
|
// made every SetIfAbsent call silently local-only). One SetIfAbsent error
|
|
// does NOT fall through to this mutex: backends.ErrSetNXAmbiguous (a lost
|
|
// SET NX reply — the write may have already reached the shared backend) is
|
|
// accepted outright instead, because this fallback's Get could otherwise
|
|
// see that same call's own possible write and misreport a first-ever token
|
|
// as a replay. It guards against two logout tokens sharing a jti (retried
|
|
// delivery, or a captured token replayed by an attacker) both observing
|
|
// "not yet processed" within THIS PROCESS. This mutex is process-local: it
|
|
// does not coordinate across Traefik replicas — so this fallback path
|
|
// itself gives only a per-process guarantee, same as before FIX-17.
|
|
// The primary path (a cache that implements AtomicSetIfAbsentCache, which
|
|
// CacheInterfaceWrapper — what sessionInvalidationCache actually is at
|
|
// runtime — does, and whose SetIfAbsent call succeeds) does not use this
|
|
// mutex at all: SetIfAbsent's own atomicity, backed by Redis SET NX PX
|
|
// (directly, or forwarded through the circuit-breaker/health-check
|
|
// wrapper) when the cache is Redis-backed, closes the cross-replica gap
|
|
// this mutex could never cover (FIX-17, R36 correction).
|
|
var backchannelLogoutJTIMu sync.Mutex
|
|
|
|
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
|
|
|
|
// maxFrontchannelMarkers caps the sid markers the unauthenticated
|
|
// front-channel endpoint may keep in the shared invalidation cache, so
|
|
// a flood cannot LRU-evict signed backchannel and jti replay markers.
|
|
maxFrontchannelMarkers = 1000
|
|
)
|
|
|
|
// markerQuota is a FIFO of cache keys written on behalf of one untrusted
|
|
// source. The zero value is ready to use.
|
|
type markerQuota struct {
|
|
set map[string]struct{}
|
|
keys []string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// admit records key and returns the oldest key to drop once more than limit
|
|
// keys are held, or "" when none must go. Re-admitting a held key is a no-op.
|
|
func (q *markerQuota) admit(key string, limit int) string {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
if q.set == nil {
|
|
q.set = make(map[string]struct{})
|
|
}
|
|
if _, ok := q.set[key]; ok {
|
|
return ""
|
|
}
|
|
q.set[key] = struct{}{}
|
|
q.keys = append(q.keys, key)
|
|
if len(q.keys) <= limit {
|
|
return ""
|
|
}
|
|
oldest := q.keys[0]
|
|
q.keys = q.keys[1:]
|
|
delete(q.set, oldest)
|
|
return oldest
|
|
}
|
|
|
|
// 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"`
|
|
ExpiresAt int64 `json:"exp,omitempty"`
|
|
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; mark it non-cacheable so a
|
|
// proxy cannot replay a stale logout acknowledgement (R147 contract).
|
|
rw.Header().Set("Cache-Control", "no-store")
|
|
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
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()
|
|
|
|
// Require a matching issuer. An empty iss must be rejected too: accepting a
|
|
// missing issuer would let an unauthenticated attacker force-logout any
|
|
// session whose sid is known by simply omitting iss.
|
|
if iss == "" || iss != expectedIssuer {
|
|
t.logger.Errorf("Front-channel logout: issuer validation failed: got %q, expected %q", 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
|
|
}
|
|
|
|
if evicted := t.frontchannelMarkers.admit(t.buildSessionInvalidationKey("sid", sid), maxFrontchannelMarkers); evicted != "" {
|
|
t.sessionInvalidationCache.Delete(evicted)
|
|
}
|
|
|
|
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 (standard logout-token claims like exp are validated
|
|
// below; exp IS present per OIDC Back-Channel Logout 1.0 §2.4).
|
|
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 exp. OIDC Back-Channel Logout 1.0 §2.4 marks exp REQUIRED
|
|
// and §2.6 REQUIREDs validating it the same way as ID Tokens; the
|
|
// spec's security note also recommends short exp precisely so a
|
|
// captured logout token can't be replayed later. Without this an
|
|
// expired logout token was still accepted and still invalidated live
|
|
// sessions.
|
|
if claims.ExpiresAt == 0 {
|
|
return nil, fmt.Errorf("missing exp claim")
|
|
}
|
|
expTime := time.Unix(claims.ExpiresAt, 0)
|
|
if time.Now().After(expTime.Add(5 * time.Minute)) {
|
|
return nil, fmt.Errorf("logout token expired at %v", expTime)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// OIDC Back-Channel Logout 1.0 §2.5: the RP MUST record the logout
|
|
// token's jti and reject any replayed token with the same jti.
|
|
// Without this a captured token could be re-applied to a session the
|
|
// user re-established after the genuine logout.
|
|
if err := t.checkAndMarkLogoutJTIProcessed(claims.JTI, claims.IssuedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// checkAndMarkLogoutJTIProcessed enforces OIDC Back-Channel Logout 1.0
|
|
// §2.5's jti replay check: it records jti as processed and rejects a
|
|
// second call with the same jti. Only enforced when a cache is available;
|
|
// the jti is stored under its own namespace so it never collides with
|
|
// sid/sub invalidation entries.
|
|
//
|
|
// The check-and-set is one atomic operation, not a separate Get followed
|
|
// by a Set (FIX-17, R36 correction): when sessionInvalidationCache
|
|
// implements AtomicSetIfAbsentCache (CacheInterfaceWrapper, what it
|
|
// actually is at runtime, does) AND its SetIfAbsent call succeeds, that
|
|
// call is the sole check, and its own atomicity — backed by Redis SET NX
|
|
// PX when the cache is Redis-backed, including through the
|
|
// circuit-breaker/health-check wrapper's SetNX passthrough (FIX-17
|
|
// round-2) — holds across every Traefik replica sharing that cache, not
|
|
// just within this process. A cache that does not provide the atomic
|
|
// primitive at all, or whose SetIfAbsent call errors (including
|
|
// errSetIfAbsentUnsupported for a distributed backend that itself lacks
|
|
// the atomic SetNX primitive), falls back to a
|
|
// backchannelLogoutJTIMu-guarded Get then Set, correct only within this
|
|
// process; see that mutex's comment.
|
|
func (t *TraefikOidc) checkAndMarkLogoutJTIProcessed(jti string, issuedAt int64) error {
|
|
if jti == "" || t.sessionInvalidationCache == nil {
|
|
return nil
|
|
}
|
|
key := t.buildSessionInvalidationKey("jti", jti)
|
|
|
|
if atomicCache, ok := t.sessionInvalidationCache.(AtomicSetIfAbsentCache); ok {
|
|
claimed, err := atomicCache.SetIfAbsent(key, issuedAt, sessionInvalidationTTL)
|
|
if err == nil {
|
|
if !claimed {
|
|
return fmt.Errorf("logout token replay: jti %s already processed", jti)
|
|
}
|
|
return nil
|
|
}
|
|
if err == backends.ErrSetNXAmbiguous {
|
|
// The distributed SET NX write reached Redis but its reply was
|
|
// lost, so we don't know whether THIS call claimed the key
|
|
// (FIX-17 round-2). Falling through to the Get below would be
|
|
// wrong either way it turns out: if the write applied, Get
|
|
// finds our own key and misreports this first-ever token as a
|
|
// replay; treating an ambiguous outcome as a hard failure would
|
|
// also needlessly reject a valid token on a lost TCP ACK.
|
|
// Accept it — a genuine second delivery of the same jti is
|
|
// still caught by the next call once the ambiguity is over,
|
|
// same as any other momentary cache hiccup a replay check must
|
|
// tolerate.
|
|
return nil
|
|
}
|
|
// SetIfAbsent already logged the backend failure itself. Fall
|
|
// through to the mutex-guarded Get+Set below rather than reject an
|
|
// otherwise-valid logout token outright on a transient cache error.
|
|
}
|
|
|
|
backchannelLogoutJTIMu.Lock()
|
|
defer backchannelLogoutJTIMu.Unlock()
|
|
|
|
if _, found := t.sessionInvalidationCache.Get(key); found {
|
|
return fmt.Errorf("logout token replay: jti %s already processed", jti)
|
|
}
|
|
t.sessionInvalidationCache.Set(key, issuedAt, sessionInvalidationTTL)
|
|
return nil
|
|
}
|
|
|
|
// validateLogoutTokenAudience checks if the logout token audience contains our client_id
|
|
func (t *TraefikOidc) validateLogoutTokenAudience(aud interface{}) bool {
|
|
// t.clientID is mutated at runtime by the DCR path
|
|
// (performDynamicClientRegistration, under metadataMu). Read it under
|
|
// the same lock as every other credential field (issuer, jwksURL) so a
|
|
// backchannel-logout token validated concurrent with client re-issuance
|
|
// does not race the write.
|
|
t.metadataMu.RLock()
|
|
clientID := t.clientID
|
|
t.metadataMu.RUnlock()
|
|
|
|
switch v := aud.(type) {
|
|
case string:
|
|
return v == clientID
|
|
case []interface{}:
|
|
for _, a := range v {
|
|
if s, ok := a.(string); ok && s == clientID {
|
|
return true
|
|
}
|
|
}
|
|
case []string:
|
|
for _, a := range v {
|
|
if a == clientID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// verifyLogoutTokenSignature verifies the signature of a logout token.
|
|
// Like every other token path it resolves the key via GetPublicKey,
|
|
// which on a kid miss performs a bounded live JWKS refresh - so a logout
|
|
// token signed with a freshly rotated key is accepted immediately
|
|
// instead of failing for up to the 1h JWKS TTL (which would leave the
|
|
// user's session alive after the IdP logged them out).
|
|
func (t *TraefikOidc) verifyLogoutTokenSignature(jwt *JWT, tokenString string) error {
|
|
t.logger.Debug("Verifying logout token signature")
|
|
|
|
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")
|
|
}
|
|
|
|
// GetPublicKey bounds a live JWKS refresh when kid is absent from the
|
|
// cached keyset, matching the normal token-verification path and
|
|
// keeping logout recovery robust across key rotation.
|
|
t.metadataMu.RLock()
|
|
jwksURL := t.jwksURL
|
|
t.metadataMu.RUnlock()
|
|
|
|
pubKey, err := t.jwkCache.GetPublicKey(context.Background(), jwksURL, kid, t.httpClient)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get public key for logout token: %w", err)
|
|
}
|
|
|
|
if err := verifySignatureWithKey(tokenString, pubKey, alg); err != nil {
|
|
// The signature failed against the (possibly stale) cached key. The
|
|
// provider may have rotated its signing keys in place, reusing the
|
|
// same kid (R109): refresh the JWKS once and retry before failing.
|
|
// Without this a logout token signed with an in-place-rotated key is
|
|
// rejected for up to the JWKS cache TTL, leaving the user's session
|
|
// alive after the IdP logged them out (R130).
|
|
if freshKey, ferr := t.jwkCache.getPublicKeyFresh(context.Background(), jwksURL, kid, t.httpClient); ferr == nil {
|
|
if verr := verifySignatureWithKey(tokenString, freshKey, alg); verr == nil {
|
|
return 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()
|
|
|
|
// The default 25h invalidation lives longer than the default 24h
|
|
// session, but a configured sessionMaxAge longer than 25h would let
|
|
// the invalidation expire while a still-valid old cookie survives, so a
|
|
// replayed dead cookie is accepted again. Derive the TTL from the
|
|
// session's actual max age (with margin) when it exceeds the default.
|
|
ttl := sessionInvalidationTTL
|
|
if t.sessionManager != nil && t.sessionManager.sessionMaxAge > ttl {
|
|
ttl = t.sessionManager.sessionMaxAge + time.Hour
|
|
}
|
|
|
|
// Store by session ID
|
|
if sid != "" {
|
|
key := t.buildSessionInvalidationKey("sid", sid)
|
|
t.sessionInvalidationCache.Set(key, now, ttl)
|
|
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, ttl)
|
|
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
|
|
//
|
|
// sessionInvalidationTime extracts the invalidation timestamp from a cache
|
|
// value regardless of backend storage semantics. The in-memory cache returns
|
|
// the raw int64 we stored; serializing backends (e.g. Redis) JSON round-trip
|
|
// numbers, so Get yields a float64. Accepting both keeps backchannel/front-
|
|
// channel logout functional in multi-instance (distributed) deployments;
|
|
// previously the strict int64 assertion silently returned false there.
|
|
func sessionInvalidationTime(val interface{}) (int64, bool) {
|
|
switch v := val.(type) {
|
|
case int64:
|
|
return v, true
|
|
case float64:
|
|
return int64(v), true
|
|
case json.Number:
|
|
n, err := v.Int64()
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
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 := sessionInvalidationTime(val); 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 := sessionInvalidationTime(val); 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)
|
|
}
|
|
|
|
// sessionCreatedAtForInvalidation returns the session creation time to use
|
|
// for the backchannel/front-channel logout invalidation check. It prefers
|
|
// the ID token's iat claim; when iat is absent, it falls back to the
|
|
// session's own created_at (recorded at login) rather than time.Now().
|
|
// Using time.Now() here meant a session whose ID token lacks iat was
|
|
// always treated as created "now" (after any logout), so invalidation
|
|
// never matched and IdP-initiated logout silently had no effect (R98).
|
|
func (t *TraefikOidc) sessionCreatedAtForInvalidation(idToken string, session *SessionData) time.Time {
|
|
if jwt, err := parseJWT(idToken); err == nil {
|
|
if iat, ok := jwt.Claims["iat"].(float64); ok {
|
|
return time.Unix(int64(iat), 0)
|
|
}
|
|
}
|
|
if ca := session.getCreatedAtUnsafe(); ca > 0 {
|
|
return time.Unix(ca, 0)
|
|
}
|
|
return time.Time{} // zero: treated as invalidated -> safe re-auth
|
|
}
|
|
|
|
// 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
|
|
}
|