mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
R87: validate backchannel logout token exp; live-refresh JWKS for logout kid-miss
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user