Files
traefikoidc/logout.go
T
lukaszraczylo 2cb42aa485 perf(logout): parse the ID token once for the invalidation check
With backchannel or frontchannel logout on, every authenticated request
parsed the ID token twice to read sid, sub and iat. One shared parse now
returns the same values. A test pins equality with the two separate
calls for valid, partial and malformed tokens. The gain is one parseJWT
call fewer per request. I did not measure it under yaegi.
2026-11-03 01:01:52 +00:00

729 lines
27 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
}
// invalidationInputs returns sid, sub and the creation time for the IdP-logout
// check from one parseJWT call, with the same results as extractSessionInfo
// and sessionCreatedAtForInvalidation called separately.
func (t *TraefikOidc) invalidationInputs(idToken string, session *SessionData) (sid, sub string, createdAt time.Time) {
jwt, err := parseJWT(idToken)
if err != nil {
return "", "", t.sessionCreatedAtForInvalidation("", session)
}
sid, _ = jwt.Claims["sid"].(string)
sub, _ = jwt.Claims["sub"].(string)
if iat, ok := jwt.Claims["iat"].(float64); ok {
return sid, sub, time.Unix(int64(iat), 0)
}
return sid, sub, t.sessionCreatedAtForInvalidation("", session)
}
// 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
}