Files
traefikoidc/review_r133_test.go
T
lukaszraczylo 6d96e8869b test: add R-round regression and security suite
- fail-on-old regression tests for every shipped fix (R-rounds)
- fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards
- coverage-closing contract tests (health-check backend, TLS-posture reuse)
- r154 ID-token fixture built from parts to avoid false-positive secret scan
2026-08-14 16:22:39 +01:00

167 lines
5.4 KiB
Go

package traefikoidc
// R133 regressions.
//
// F1 — cacheVerifiedToken must not re-populate the verified-token cache
// with a token (or jti) that a concurrent RevokeToken has blacklisted,
// otherwise a revoked token can serve a positive verification verdict
// until exp (the cache was only guarded by check-order at the top of
// verifyTokenWithOpts).
//
// F2 — validateTokenExpiryRS is the final authenticated gate; it must
// reject a blacklisted-but-still-cached token independently rather than
// relying on every caller happening to call verifyToken first.
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/sessions"
)
func TestCacheVerifiedToken_DoesNotReAddRevokedToken(t *testing.T) {
blacklist := NewCache()
token := "R133-revoked-token-aaaa-zzzz"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
claims := map[string]interface{}{"exp": float64(time.Now().Add(time.Hour).Unix())}
oidc.cacheVerifiedToken(token, claims)
if _, found := oidc.tokenCache.Get(token); found {
t.Fatalf("cacheVerifiedToken re-added a blacklisted token; expected no cache entry")
}
}
func TestCacheVerifiedToken_DoesNotReAddRevokedJTI(t *testing.T) {
blacklist := NewCache()
token := "R133-revoked-jti-token-aaaa-zzzz"
jti := "R133-revoked-jti-0001"
blacklist.Set(jti, true, time.Hour) // RevokeToken marks by jti too
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
claims := map[string]interface{}{
"jti": jti,
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
oidc.cacheVerifiedToken(token, claims)
if _, found := oidc.tokenCache.Get(token); found {
t.Fatalf("cacheVerifiedToken re-added a token whose jti is blacklisted; expected no cache entry")
}
}
func TestValidateTokenExpiryRS_BlacklistedToken_WithRefresh(t *testing.T) {
blacklist := NewCache()
token := "R133-blacklisted-exp-future-aaaa"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
// Seed the verified-cache with UNEXPIRED claims so the pre-fix code,
// which read only the cache, would report authenticated=true.
oidc.tokenCache.Set(token, map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, time.Hour)
rs := &requestState{refreshToken: "refresh-123"}
auth, needsRefresh, expired := oidc.validateTokenExpiryRS(rs, token)
if auth || !needsRefresh || expired {
t.Fatalf("blacklisted-but-cached token must request refresh, got (auth=%v, needsRefresh=%v, expired=%v)", auth, needsRefresh, expired)
}
}
func TestValidateTokenExpiryRS_BlacklistedToken_NoRefresh(t *testing.T) {
blacklist := NewCache()
token := "R133-blacklisted-no-refresh-aaaa"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
oidc.tokenCache.Set(token, map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, time.Hour)
rs := &requestState{} // no refresh token
auth, needsRefresh, expired := oidc.validateTokenExpiryRS(rs, token)
if auth || needsRefresh || !expired {
t.Fatalf("blacklisted-but-cached token without refresh must force re-auth, got (auth=%v, needsRefresh=%v, expired=%v)", auth, needsRefresh, expired)
}
}
// TestExpireLegacyCookies_RefreshChunkExpiresWhenAccessSingle verifies R133 P2-A:
// legacy refresh/id chunk cookies must be expired even when the access token is
// a single (un-chunked) cookie. Previously the shared access-driven loop
// broke all three series at the first absent access chunk, so refresh legacy
// chunk cookies were never expired during combined migration.
func TestExpireLegacyCookies_RefreshChunkExpiresWhenAccessSingle(t *testing.T) {
sm := createTestSessionManager(t)
// Craft ONE legacy refresh chunk cookie at index 0; NO access chunk cookie.
seedReq := httptest.NewRequest("GET", "/", nil)
seedRw := httptest.NewRecorder()
refreshChunk0 := fmt.Sprintf("%s_0", sm.refreshTokenCookieName())
rs, err := sm.store.Get(seedReq, refreshChunk0)
if err != nil {
t.Fatalf("store.Get refresh chunk: %v", err)
}
rs.Values["d"] = "some-chunk-data"
rs.Options = &sessions.Options{Path: "/", MaxAge: 3600}
if err := rs.Save(seedReq, seedRw); err != nil {
t.Fatalf("save legacy refresh chunk: %v", err)
}
chunkVal := ""
for _, c := range seedRw.Result().Cookies() {
if c.Name == refreshChunk0 {
chunkVal = c.Value
}
}
if chunkVal == "" {
t.Fatalf("failed to craft legacy refresh chunk cookie %s", refreshChunk0)
}
// A FRESH request carrying only that cookie (the gorilla per-request
// registry must treat it as an existing, non-new session).
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: refreshChunk0, Value: chunkVal, Path: "/"})
sd, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
rw := httptest.NewRecorder()
sd.expireLegacyCookies(req, rw, &sessions.Options{Path: "/", MaxAge: 86400})
var refreshExpired bool
for _, c := range rw.Result().Cookies() {
if c.Name == refreshChunk0 && c.MaxAge < 0 {
refreshExpired = true
}
}
if !refreshExpired {
t.Fatalf("legacy refresh chunk %s was not expired when access is a single (un-chunked) cookie", refreshChunk0)
}
}