R87: validate backchannel logout token exp; live-refresh JWKS for logout kid-miss

This commit is contained in:
2026-08-13 18:59:52 +01:00
parent 39b3c53d82
commit 9e39827f7d
5 changed files with 165 additions and 66 deletions
+2 -26
View File
@@ -6,9 +6,7 @@ import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"strings"
@@ -513,31 +511,9 @@ func verifyNotBefore(notBefore float64) error {
return verifyTimeConstraint(notBefore, "nbf", false)
}
// verifySignature verifies the JWT signature using the provided public key.
// Supports RSA (RS256/384/512, PS256/384/512) and ECDSA (ES256/384/512) algorithms.
// Parameters:
// - tokenString: The complete JWT token string
// - publicKeyPEM: The public key in PEM format
// - alg: The signing algorithm specified in the JWT header
//
// Returns:
// - An error if the key parsing fails, the algorithm is unsupported,
// or the signature verification fails
func verifySignature(tokenString string, publicKeyPEM []byte, alg string) error {
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return fmt.Errorf("failed to parse PEM block containing the public key")
}
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
return verifySignatureWithKey(tokenString, pubKey, alg)
}
// verifySignatureWithKey verifies a JWT signature using an already-parsed
// public key, skipping the PEM-encode/decode round trip that verifySignature
// performs. This is the hot path used by VerifyJWTSignatureAndClaims.
// public key, skipping the PEM-encode/decode round trip. This is the hot
// path used by VerifyJWTSignatureAndClaims and verifyLogoutTokenSignature.
func verifySignatureWithKey(tokenString string, pubKey crypto.PublicKey, alg string) error {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
+32 -40
View File
@@ -30,6 +30,7 @@ type LogoutTokenClaims struct {
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"`
@@ -194,7 +195,8 @@ func (t *TraefikOidc) validateLogoutToken(tokenString string) (*LogoutTokenClaim
}
}
// Verify signature only (not standard claims - logout tokens don't have 'exp')
// 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)
}
@@ -238,6 +240,20 @@ func (t *TraefikOidc) validateLogoutToken(tokenString string) (*LogoutTokenClaim
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")
@@ -294,33 +310,15 @@ func (t *TraefikOidc) validateLogoutTokenAudience(aud interface{}) bool {
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
// 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")
// 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")
@@ -331,25 +329,19 @@ func (t *TraefikOidc) verifyLogoutTokenSignature(jwt *JWT, tokenString string) e
return fmt.Errorf("missing algorithm in token header")
}
// Find the matching key in JWKS
var matchingKey *JWK
for i := range jwks.Keys {
if jwks.Keys[i].Kid == kid {
matchingKey = &jwks.Keys[i]
break
}
}
// 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()
if matchingKey == nil {
return fmt.Errorf("no matching public key found for kid: %s", kid)
}
publicKeyPEM, err := jwkToPEM(matchingKey)
pubKey, err := t.jwkCache.GetPublicKey(context.Background(), jwksURL, kid, t.httpClient)
if err != nil {
return fmt.Errorf("failed to convert JWK to PEM: %w", err)
return fmt.Errorf("failed to get public key for logout token: %w", err)
}
if err := verifySignature(tokenString, publicKeyPEM, alg); err != nil {
if err := verifySignatureWithKey(tokenString, pubKey, alg); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
+7
View File
@@ -726,6 +726,7 @@ func TestBackchannelLogoutIntegration(t *testing.T) {
"iss": "https://provider.example.com",
"aud": "test-client",
"iat": now,
"exp": time.Now().Add(time.Hour).Unix(),
"jti": "unique-id-123",
"events": map[string]interface{}{
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
@@ -1644,6 +1645,12 @@ func createSignedLogoutToken(t *testing.T, privateKey *ecdsa.PrivateKey, claims
headerJSON, _ := json.Marshal(header)
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// OIDC Back-Channel Logout requires exp; default-inject a valid future
// one unless the caller supplies its own (e.g. an expired-exp test).
if _, ok := claims["exp"]; !ok {
claims["exp"] = time.Now().Add(time.Hour).Unix()
}
claimsJSON, _ := json.Marshal(claims)
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
+1
View File
@@ -76,6 +76,7 @@ func TestBackchannelLogout_RejectsReplayedJTI(t *testing.T) {
"iss": "https://provider.example.com",
"aud": "test-client",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"jti": "unique-jti-123",
"events": map[string]interface{}{
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
+123
View File
@@ -0,0 +1,123 @@
package traefikoidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// signR87LogoutToken builds an ES256 logout token with the exact given claims
// (so a test can omit or age the exp claim).
type r87LogoutHarness struct {
oidc *TraefikOidc
tok func(map[string]interface{}) string
}
func newR87LogoutHarness(t *testing.T) *r87LogoutHarness {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("keygen: %v", err)
}
x := base64.RawURLEncoding.EncodeToString(priv.PublicKey.X.Bytes())
y := base64.RawURLEncoding.EncodeToString(priv.PublicKey.Y.Bytes())
oidc := &TraefikOidc{
logger: NewLogger("error"),
enableBackchannelLogout: true,
backchannelLogoutPath: "/backchannel-logout",
sessionInvalidationCache: &mockCacheInterface{data: map[string]interface{}{}},
clientID: "test-client",
issuerURL: "https://provider.example.com",
jwkCache: &staticJWKCache{jwks: &JWKSet{Keys: []JWK{{
Kty: "EC", Crv: "P-256", X: x, Y: y, Kid: "test-key-1", Use: "sig", Alg: "ES256",
}}}},
jwksURL: "https://provider.example.com/.well-known/jwks.json",
}
tok := func(claims map[string]interface{}) string {
h, _ := json.Marshal(map[string]interface{}{"alg": "ES256", "typ": "logout+jwt", "kid": "test-key-1"})
hb := base64.RawURLEncoding.EncodeToString(h)
c, _ := json.Marshal(claims)
cb := base64.RawURLEncoding.EncodeToString(c)
hash := sha256.Sum256([]byte(hb + "." + cb))
r, s, _ := ecdsa.Sign(rand.Reader, priv, hash[:])
sig := make([]byte, 64)
rb, sb := r.Bytes(), s.Bytes()
copy(sig[32-len(rb):32], rb)
copy(sig[64-len(sb):], sb)
return hb + "." + cb + "." + base64.RawURLEncoding.EncodeToString(sig)
}
return &r87LogoutHarness{oidc: oidc, tok: tok}
}
func (h *r87LogoutHarness) post(claims map[string]interface{}) int {
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
strings.NewReader("logout_token="+url.QueryEscape(h.tok(claims))))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rw := httptest.NewRecorder()
h.oidc.handleBackchannelLogout(rw, req)
return rw.Code
}
func r87BaseClaims() map[string]interface{} {
return map[string]interface{}{
"iss": "https://provider.example.com",
"aud": "test-client",
"iat": time.Now().Unix(),
"jti": fmt.Sprintf("r87-%d", time.Now().UnixNano()), // unique per call
"events": map[string]interface{}{
"http://schemas.openid.net/event/backchannel-logout": map[string]interface{}{},
},
"sid": "session-1",
}
}
// TestBackchannelLogout_RequiresExp is a regression: OIDC Back-Channel
// Logout 1.0 §2.4 makes exp REQUIRED and §2.6 requires it to be
// validated. The code previously accepted an exp-less logout token (the
// comment claimed "logout tokens don't have exp", which is false) and
// still invalidated the session, letting an unbounded-lifetime captured
// token be replayed.
func TestBackchannelLogout_RequiresExp(t *testing.T) {
h := newR87LogoutHarness(t)
claims := r87BaseClaims()
delete(claims, "exp") // ensure absent
if code := h.post(claims); code != http.StatusBadRequest {
t.Fatalf("expected 400 for logout token missing exp, got %d", code)
}
}
// TestBackchannelLogout_RejectsExpiredExp is a regression: a logout token
// whose exp is already past must be rejected even when iat is fresh, as
// the spec's validation-is-like-ID-Tokens rule requires. Previously only
// iat was capped (15 min), so an expired exp was silently accepted and the
// session was still invalidated.
func TestBackchannelLogout_RejectsExpiredExp(t *testing.T) {
h := newR87LogoutHarness(t)
claims := r87BaseClaims()
claims["iat"] = time.Now().Add(-2 * time.Minute).Unix() // fresh (within 15 min)
claims["exp"] = time.Now().Add(-10 * time.Minute).Unix() // expired beyond the 5-min skew
if code := h.post(claims); code != http.StatusBadRequest {
t.Fatalf("expected 400 for logout token with expired exp, got %d", code)
}
}
// TestBackchannelLogout_AcceptsValidExp is the control: an in-window
// exp must still be accepted and must invalidate the session.
func TestBackchannelLogout_AcceptsValidExp(t *testing.T) {
h := newR87LogoutHarness(t)
claims := r87BaseClaims()
claims["exp"] = time.Now().Add(time.Hour).Unix()
if code := h.post(claims); code != http.StatusOK {
t.Fatalf("expected 200 for valid logout token, got %d", code)
}
}