mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
e64fc7f730
* Add redis support for distributed caching * Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * fixup! fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi. * ... and another all nighter. * fixup! ... and another all nighter. * fixup! fixup! ... and another all nighter. * fixup! fixup! fixup! ... and another all nighter. * Resolve issue #85 by adding ability to set custom claims in JWT tokens * Remove redundant validation in auth middleware ( issue #89 ) * Add ability to set cookie prefix for session cookies ( #87 ) * fixup! Add ability to set cookie prefix for session cookies ( #87 ) * Add ability to set cookie max age - issue #91 * Potential fix for code scanning alert no. 10: Size computation for allocation may overflow Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fixup! Merge main into 0.8.0-redis: resolve conflicts --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
685 lines
15 KiB
Go
685 lines
15 KiB
Go
//go:build !yaegi
|
|
|
|
package token
|
|
|
|
import (
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Mock implementations for validator tests
|
|
type mockTokenCache struct {
|
|
data map[string]map[string]interface{}
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func newMockTokenCache() *mockTokenCache {
|
|
return &mockTokenCache{
|
|
data: make(map[string]map[string]interface{}),
|
|
}
|
|
}
|
|
|
|
func (m *mockTokenCache) CacheToken(token string, claims map[string]interface{}) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.data[token] = claims
|
|
}
|
|
|
|
func (m *mockTokenCache) GetCachedToken(token string) (map[string]interface{}, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
claims, exists := m.data[token]
|
|
return claims, exists
|
|
}
|
|
|
|
func (m *mockTokenCache) InvalidateToken(token string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.data, token)
|
|
}
|
|
|
|
func (m *mockTokenCache) StartCleanup(interval time.Duration) {
|
|
// No-op for tests
|
|
}
|
|
|
|
func (m *mockTokenCache) StopCleanup() {
|
|
// No-op for tests
|
|
}
|
|
|
|
// Validator tests
|
|
func TestNewValidator(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
Audience: "test-audience",
|
|
IssuerURL: "https://issuer.example.com",
|
|
JwksURL: "https://issuer.example.com/jwks",
|
|
TokenCache: newMockTokenCache(),
|
|
TokenBlacklist: newMockCache(),
|
|
TokenTypeCache: newMockCache(),
|
|
HTTPClient: &http.Client{},
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
if validator == nil {
|
|
t.Fatal("Expected NewValidator to return non-nil")
|
|
}
|
|
|
|
if validator.clientID != "test-client" {
|
|
t.Error("Expected clientID to be set")
|
|
}
|
|
|
|
if validator.audience != "test-audience" {
|
|
t.Error("Expected audience to be set")
|
|
}
|
|
|
|
if validator.issuerURL != "https://issuer.example.com" {
|
|
t.Error("Expected issuerURL to be set")
|
|
}
|
|
}
|
|
|
|
func TestNewValidator_NilMetadataMu(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
// MetadataMu is nil
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
if validator.metadataMu != nil {
|
|
t.Error("Expected metadataMu to be nil when not provided")
|
|
}
|
|
}
|
|
|
|
func TestValidator_VerifyToken_EmptyToken(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenCache: newMockTokenCache(),
|
|
TokenBlacklist: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
err := validator.VerifyToken("")
|
|
if err == nil {
|
|
t.Error("Expected error for empty token")
|
|
}
|
|
|
|
if err.Error() != "invalid JWT format: token is empty" {
|
|
t.Errorf("Expected empty token error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidator_VerifyToken_InvalidFormat(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenCache: newMockTokenCache(),
|
|
TokenBlacklist: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
// Token with only 2 parts (missing 3rd part)
|
|
err := validator.VerifyToken("header.payload")
|
|
if err == nil {
|
|
t.Error("Expected error for invalid token format")
|
|
}
|
|
|
|
// Token with too many parts
|
|
err = validator.VerifyToken("part1.part2.part3.part4")
|
|
if err == nil {
|
|
t.Error("Expected error for token with too many parts")
|
|
}
|
|
}
|
|
|
|
func TestValidator_VerifyToken_TooShort(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenCache: newMockTokenCache(),
|
|
TokenBlacklist: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
err := validator.VerifyToken("ab.cd.ef")
|
|
if err == nil {
|
|
t.Error("Expected error for too short token")
|
|
}
|
|
|
|
if err.Error() != "token too short to be valid JWT" {
|
|
t.Errorf("Expected too short error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetermineTokenType(t *testing.T) {
|
|
// Test ID token
|
|
configID := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
validatorID := NewValidator(configID)
|
|
|
|
jwtID := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"nonce": "test-nonce",
|
|
},
|
|
}
|
|
|
|
tokenType := validatorID.determineTokenType(jwtID)
|
|
if tokenType != TokenTypeID {
|
|
t.Errorf("Expected ID token type, got: %s", tokenType)
|
|
}
|
|
|
|
// Test access token with separate validator to avoid cache interference
|
|
configAccess := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
validatorAccess := NewValidator(configAccess)
|
|
|
|
jwtAccess := &JWT{
|
|
Header: map[string]interface{}{
|
|
"typ": "at+jwt",
|
|
},
|
|
Claims: map[string]interface{}{},
|
|
}
|
|
|
|
tokenType = validatorAccess.determineTokenType(jwtAccess)
|
|
if tokenType != TokenTypeAccess {
|
|
t.Errorf("Expected access token type, got: %s", tokenType)
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_Nonce(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"nonce": "test-nonce-123",
|
|
},
|
|
}
|
|
|
|
isIDToken := validator.detectTokenType(jwt, "test-token")
|
|
if !isIDToken {
|
|
t.Error("Expected nonce to indicate ID token")
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_AtJwt(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Header: map[string]interface{}{
|
|
"typ": "at+jwt",
|
|
},
|
|
Claims: map[string]interface{}{},
|
|
}
|
|
|
|
isIDToken := validator.detectTokenType(jwt, "test-token")
|
|
if isIDToken {
|
|
t.Error("Expected at+jwt type to indicate access token")
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_TokenUse(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
// ID token
|
|
jwtID := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"token_use": "id",
|
|
},
|
|
}
|
|
|
|
if !validator.detectTokenType(jwtID, "test-token-id") {
|
|
t.Error("Expected token_use=id to indicate ID token")
|
|
}
|
|
|
|
// Access token
|
|
jwtAccess := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"token_use": "access",
|
|
},
|
|
}
|
|
|
|
if validator.detectTokenType(jwtAccess, "test-token-access") {
|
|
t.Error("Expected token_use=access to indicate access token")
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_Scope(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"scope": "openid profile email",
|
|
},
|
|
}
|
|
|
|
isIDToken := validator.detectTokenType(jwt, "test-token")
|
|
if isIDToken {
|
|
t.Error("Expected scope claim to indicate access token")
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_AudienceMatching(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client-id",
|
|
TokenTypeCache: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
// Single audience matching client ID
|
|
jwtSingleAud := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"aud": "test-client-id",
|
|
},
|
|
}
|
|
|
|
if !validator.detectTokenType(jwtSingleAud, "test-token-1") {
|
|
t.Error("Expected matching audience to indicate ID token")
|
|
}
|
|
|
|
// Array audience with matching client ID
|
|
jwtArrayAud := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"aud": []interface{}{"test-client-id"},
|
|
},
|
|
}
|
|
|
|
if !validator.detectTokenType(jwtArrayAud, "test-token-2") {
|
|
t.Error("Expected matching audience array to indicate ID token")
|
|
}
|
|
|
|
// Non-matching audience
|
|
jwtNoMatch := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"aud": "different-audience",
|
|
},
|
|
}
|
|
|
|
if validator.detectTokenType(jwtNoMatch, "test-token-3") {
|
|
t.Error("Expected non-matching audience to indicate access token")
|
|
}
|
|
}
|
|
|
|
func TestValidator_DetectTokenType_Caching(t *testing.T) {
|
|
cache := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: cache,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"nonce": "test",
|
|
},
|
|
}
|
|
|
|
// First call - should cache
|
|
isIDToken := validator.detectTokenType(jwt, token)
|
|
if !isIDToken {
|
|
t.Error("Expected ID token")
|
|
}
|
|
|
|
// Verify cache was populated
|
|
cacheKey := token[:32]
|
|
cached, exists := cache.Get(cacheKey)
|
|
if !exists {
|
|
t.Error("Expected token type to be cached")
|
|
}
|
|
|
|
if isID, ok := cached["is_id_token"].(bool); !ok || !isID {
|
|
t.Error("Expected cached value to be true for ID token")
|
|
}
|
|
|
|
// Modify JWT but use cached value
|
|
jwt.Claims = map[string]interface{}{
|
|
"scope": "openid", // Would indicate access token
|
|
}
|
|
|
|
// Should still return cached ID token result
|
|
isIDToken = validator.detectTokenType(jwt, token)
|
|
if !isIDToken {
|
|
t.Error("Expected cached ID token result")
|
|
}
|
|
}
|
|
|
|
func TestValidator_CheckJTIBlacklist_Disabled(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
DisableReplayDetection: true,
|
|
TokenBlacklist: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"jti": "blacklisted-jti",
|
|
},
|
|
}
|
|
|
|
// Should not check blacklist when disabled
|
|
err := validator.checkJTIBlacklist(jwt, "test-token")
|
|
if err != nil {
|
|
t.Errorf("Expected no error when replay detection disabled, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidator_CheckJTIBlacklist_NoJTI(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenBlacklist: newMockCache(),
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
// No JTI claim
|
|
},
|
|
}
|
|
|
|
err := validator.checkJTIBlacklist(jwt, "test-token")
|
|
if err != nil {
|
|
t.Errorf("Expected no error when JTI missing, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidator_AddJTIToBlacklist(t *testing.T) {
|
|
blacklist := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenBlacklist: blacklist,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"jti": "test-jti-123",
|
|
},
|
|
}
|
|
|
|
validator.addJTIToBlacklist(jwt)
|
|
|
|
// Verify JTI was blacklisted
|
|
data, exists := blacklist.Get("test-jti-123")
|
|
if !exists {
|
|
t.Error("Expected JTI to be blacklisted")
|
|
}
|
|
|
|
if reason, ok := data["reason"].(string); !ok || reason != "jti_replay_prevention" {
|
|
t.Error("Expected JTI blacklist reason to be jti_replay_prevention")
|
|
}
|
|
}
|
|
|
|
func TestValidator_AddJTIToBlacklist_Disabled(t *testing.T) {
|
|
blacklist := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
DisableReplayDetection: true,
|
|
TokenBlacklist: blacklist,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"jti": "test-jti",
|
|
},
|
|
}
|
|
|
|
validator.addJTIToBlacklist(jwt)
|
|
|
|
// Should not blacklist when disabled
|
|
_, exists := blacklist.Get("test-jti")
|
|
if exists {
|
|
t.Error("Expected JTI not to be blacklisted when replay detection disabled")
|
|
}
|
|
}
|
|
|
|
func TestValidator_AddJTIToBlacklist_NoJTI(t *testing.T) {
|
|
blacklist := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenBlacklist: blacklist,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
// No JTI
|
|
},
|
|
}
|
|
|
|
validator.addJTIToBlacklist(jwt)
|
|
|
|
// Should handle gracefully
|
|
if len(blacklist.data) != 0 {
|
|
t.Error("Expected no entries in blacklist when JTI missing")
|
|
}
|
|
}
|
|
|
|
func TestValidator_CacheTokenType(t *testing.T) {
|
|
cache := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: cache,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
validator.cacheTokenType("cache-key-123", true)
|
|
|
|
data, exists := cache.Get("cache-key-123")
|
|
if !exists {
|
|
t.Error("Expected token type to be cached")
|
|
}
|
|
|
|
if isID, ok := data["is_id_token"].(bool); !ok || !isID {
|
|
t.Error("Expected is_id_token to be true")
|
|
}
|
|
|
|
if _, ok := data["cached_at"].(int64); !ok {
|
|
t.Error("Expected cached_at timestamp")
|
|
}
|
|
}
|
|
|
|
func TestValidator_CacheVerifiedToken(t *testing.T) {
|
|
tokenCache := newMockTokenCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenCache: tokenCache,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
claims := map[string]interface{}{
|
|
"sub": "user123",
|
|
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
|
}
|
|
|
|
validator.cacheVerifiedToken("test-token", claims)
|
|
|
|
cached, exists := tokenCache.GetCachedToken("test-token")
|
|
if !exists {
|
|
t.Error("Expected token to be cached")
|
|
}
|
|
|
|
if cached["sub"] != "user123" {
|
|
t.Error("Expected cached claims to match")
|
|
}
|
|
}
|
|
|
|
func TestValidator_CheckRateLimit(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
// Default implementation returns true
|
|
if !validator.checkRateLimit() {
|
|
t.Error("Expected checkRateLimit to return true by default")
|
|
}
|
|
}
|
|
|
|
func TestValidator_FindMatchingKey(t *testing.T) {
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
jwks := &JWKS{
|
|
Keys: []JWK{
|
|
{Kid: "key-1", Kty: "RSA"},
|
|
{Kid: "key-2", Kty: "RSA"},
|
|
{Kid: "key-3", Kty: "RSA"},
|
|
},
|
|
}
|
|
|
|
key := validator.findMatchingKey(jwks, "key-2")
|
|
if key == nil {
|
|
t.Fatal("Expected to find matching key")
|
|
}
|
|
|
|
if key.Kid != "key-2" {
|
|
t.Errorf("Expected kid 'key-2', got '%s'", key.Kid)
|
|
}
|
|
|
|
// Test non-existent key
|
|
key = validator.findMatchingKey(jwks, "key-999")
|
|
if key != nil {
|
|
t.Error("Expected nil for non-existent key")
|
|
}
|
|
|
|
// Test nil JWKS
|
|
key = validator.findMatchingKey(nil, "key-1")
|
|
if key != nil {
|
|
t.Error("Expected nil for nil JWKS")
|
|
}
|
|
}
|
|
|
|
// Race condition tests
|
|
func TestValidator_ConcurrentTokenTypeDetection(t *testing.T) {
|
|
cache := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenTypeCache: cache,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
var wg sync.WaitGroup
|
|
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-concurrent"
|
|
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"nonce": "test",
|
|
},
|
|
}
|
|
|
|
// Concurrent token type detection
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = validator.detectTokenType(jwt, token)
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Should have cached the result
|
|
cacheKey := token[:32]
|
|
if _, exists := cache.Get(cacheKey); !exists {
|
|
t.Error("Expected token type to be cached after concurrent access")
|
|
}
|
|
}
|
|
|
|
func TestValidator_ConcurrentJTIBlacklisting(t *testing.T) {
|
|
blacklist := newMockCache()
|
|
config := ValidatorConfig{
|
|
ClientID: "test-client",
|
|
TokenBlacklist: blacklist,
|
|
MetadataMu: &sync.RWMutex{},
|
|
}
|
|
|
|
validator := NewValidator(config)
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent JTI blacklisting
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
jwt := &JWT{
|
|
Claims: map[string]interface{}{
|
|
"jti": string(rune('A' + idx%26)),
|
|
},
|
|
}
|
|
validator.addJTIToBlacklist(jwt)
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Should have multiple JTIs blacklisted
|
|
if len(blacklist.data) == 0 {
|
|
t.Error("Expected JTIs to be blacklisted")
|
|
}
|
|
}
|