// 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 ) // 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 } 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("Logged Out")) } // 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 }