mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
fixup! fixup! ... and another all nighter.
This commit is contained in:
@@ -134,12 +134,15 @@ func (tc *TokenCache) StartCleanup(interval time.Duration) {
|
||||
return // Already running
|
||||
}
|
||||
|
||||
// Create fresh stop channel for this cleanup session
|
||||
tc.cleanupStop = make(chan bool, 1)
|
||||
tc.cleanupTicker = time.NewTicker(interval)
|
||||
tickerChan := tc.cleanupTicker.C // Capture channel before goroutine starts
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-tc.cleanupTicker.C:
|
||||
case <-tickerChan:
|
||||
tc.cleanupExpiredTokens()
|
||||
case <-tc.cleanupStop:
|
||||
return
|
||||
@@ -157,9 +160,11 @@ func (tc *TokenCache) StopCleanup() {
|
||||
|
||||
if tc.cleanupTicker != nil {
|
||||
tc.cleanupTicker.Stop()
|
||||
select {
|
||||
case tc.cleanupStop <- true: // Signal stop
|
||||
default: // Channel might be full or goroutine already stopped
|
||||
}
|
||||
tc.cleanupTicker = nil
|
||||
close(tc.cleanupStop)
|
||||
tc.cleanupStop = make(chan bool)
|
||||
tc.logger.Logf("Stopped token cache cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations
|
||||
type mockCache struct {
|
||||
data map[string]map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newMockCache() *mockCache {
|
||||
return &mockCache{
|
||||
data: make(map[string]map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCache) Get(key string) (map[string]interface{}, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
val, exists := m.data[key]
|
||||
return val, exists
|
||||
}
|
||||
|
||||
func (m *mockCache) Set(key string, value map[string]interface{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
func (m *mockCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
type mockLogger struct{}
|
||||
|
||||
func (m *mockLogger) Logf(format string, args ...interface{}) {}
|
||||
func (m *mockLogger) ErrorLogf(format string, args ...interface{}) {}
|
||||
|
||||
type mockMetrics struct{}
|
||||
|
||||
func (m *mockMetrics) RecordTokenRefresh() {}
|
||||
func (m *mockMetrics) RecordTokenRefreshError() {}
|
||||
|
||||
// TokenCache tests
|
||||
func TestNewTokenCache(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
metrics := &mockMetrics{}
|
||||
|
||||
tokenCache := NewTokenCache(cache, blacklist, logger, metrics, 5*time.Minute)
|
||||
|
||||
if tokenCache == nil {
|
||||
t.Fatal("Expected NewTokenCache to return non-nil")
|
||||
}
|
||||
|
||||
if tokenCache.cache == nil {
|
||||
t.Error("Expected cache to be set")
|
||||
}
|
||||
|
||||
if tokenCache.maxTTL != 5*time.Minute {
|
||||
t.Error("Expected maxTTL to be 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
metrics := &mockMetrics{}
|
||||
tokenCache := NewTokenCache(cache, blacklist, logger, metrics, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("test-token", claims)
|
||||
|
||||
// Verify it was cached with metadata
|
||||
stored, exists := cache.Get("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected token to be cached")
|
||||
}
|
||||
|
||||
if stored["sub"] != "user123" {
|
||||
t.Error("Expected sub claim to be preserved")
|
||||
}
|
||||
|
||||
if _, ok := stored["_cached_at"]; !ok {
|
||||
t.Error("Expected _cached_at metadata to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken_EmptyToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{"sub": "user"}
|
||||
|
||||
// Should not cache empty token
|
||||
tokenCache.CacheToken("", claims)
|
||||
|
||||
if len(cache.data) != 0 {
|
||||
t.Error("Expected empty token not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_CacheToken_EmptyClaims(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Should not cache with empty claims
|
||||
tokenCache.CacheToken("test-token", map[string]interface{}{})
|
||||
|
||||
if len(cache.data) != 0 {
|
||||
t.Error("Expected token with empty claims not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("test-token", claims)
|
||||
|
||||
// Retrieve token
|
||||
retrieved, exists := tokenCache.GetCachedToken("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected cached token to be found")
|
||||
}
|
||||
|
||||
if retrieved["sub"] != "user123" {
|
||||
t.Error("Expected sub claim to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_Expired(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Add expired token
|
||||
expiredClaims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("expired-token", expiredClaims)
|
||||
|
||||
// Should not return expired token
|
||||
_, exists := tokenCache.GetCachedToken("expired-token")
|
||||
if exists {
|
||||
t.Error("Expected expired token not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_ExceedsMaxTTL(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 1*time.Millisecond)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
"_cached_at": time.Now().Add(-10 * time.Minute).Unix(),
|
||||
}
|
||||
|
||||
cache.Set("old-token", claims)
|
||||
|
||||
// Should not return token that exceeds maxTTL
|
||||
_, exists := tokenCache.GetCachedToken("old-token")
|
||||
if exists {
|
||||
t.Error("Expected token exceeding maxTTL not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_GetCachedToken_Blacklisted(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("token", claims)
|
||||
|
||||
// Blacklist the token
|
||||
blacklist.Set("token", map[string]interface{}{"reason": "test"})
|
||||
|
||||
// Should not return blacklisted token
|
||||
_, exists := tokenCache.GetCachedToken("token")
|
||||
if exists {
|
||||
t.Error("Expected blacklisted token not to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_InvalidateToken(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
blacklist := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, blacklist, &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user",
|
||||
}
|
||||
|
||||
tokenCache.CacheToken("token", claims)
|
||||
|
||||
// Invalidate
|
||||
tokenCache.InvalidateToken("token")
|
||||
|
||||
// Should be removed from cache
|
||||
_, exists := cache.Get("token")
|
||||
if exists {
|
||||
t.Error("Expected token to be removed from cache")
|
||||
}
|
||||
|
||||
// Should be in blacklist
|
||||
_, blacklisted := blacklist.Get("token")
|
||||
if !blacklisted {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_StartStopCleanup(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
|
||||
// Verify ticker is set
|
||||
if tokenCache.cleanupTicker == nil {
|
||||
t.Error("Expected cleanup ticker to be started")
|
||||
}
|
||||
|
||||
// Stop cleanup
|
||||
tokenCache.StopCleanup()
|
||||
|
||||
// Wait briefly for cleanup to stop
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Ticker should be nil after stop
|
||||
if tokenCache.cleanupTicker != nil {
|
||||
t.Error("Expected cleanup ticker to be stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCache_StartCleanup_AlreadyRunning(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
ticker1 := tokenCache.cleanupTicker
|
||||
|
||||
// Start again (should not create new ticker)
|
||||
tokenCache.StartCleanup(100 * time.Millisecond)
|
||||
ticker2 := tokenCache.cleanupTicker
|
||||
|
||||
if ticker1 != ticker2 {
|
||||
t.Error("Expected same ticker when starting cleanup while already running")
|
||||
}
|
||||
|
||||
tokenCache.StopCleanup()
|
||||
}
|
||||
|
||||
// TokenBlacklist tests
|
||||
func TestNewTokenBlacklist(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
logger := &mockLogger{}
|
||||
|
||||
tb := NewTokenBlacklist(blacklist, logger)
|
||||
|
||||
if tb == nil {
|
||||
t.Fatal("Expected NewTokenBlacklist to return non-nil")
|
||||
}
|
||||
|
||||
if tb.blacklist == nil {
|
||||
t.Error("Expected blacklist to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_Add(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.Add("test-token", "test_reason")
|
||||
|
||||
// Verify token was blacklisted
|
||||
data, exists := blacklist.Get("test-token")
|
||||
if !exists {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
|
||||
if data["reason"] != "test_reason" {
|
||||
t.Error("Expected reason to be stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_AddJTI(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.AddJTI("jti-123")
|
||||
|
||||
// Verify JTI was blacklisted
|
||||
data, exists := blacklist.Get("jti-123")
|
||||
if !exists {
|
||||
t.Error("Expected JTI to be blacklisted")
|
||||
}
|
||||
|
||||
if data["reason"] != "jti_replay_detection" {
|
||||
t.Error("Expected replay detection reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_IsBlacklisted(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.Add("blacklisted-token", "test")
|
||||
|
||||
if !tb.IsBlacklisted("blacklisted-token") {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
|
||||
if tb.IsBlacklisted("not-blacklisted") {
|
||||
t.Error("Expected token not to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_IsJTIBlacklisted(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
tb.AddJTI("jti-123")
|
||||
|
||||
if !tb.IsJTIBlacklisted("jti-123") {
|
||||
t.Error("Expected JTI to be blacklisted")
|
||||
}
|
||||
|
||||
if tb.IsJTIBlacklisted("jti-456") {
|
||||
t.Error("Expected JTI not to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TokenRevocationManager tests
|
||||
func TestNewTokenRevocationManager(t *testing.T) {
|
||||
blacklist := NewTokenBlacklist(newMockCache(), &mockLogger{})
|
||||
httpClient := &http.Client{}
|
||||
|
||||
trm := NewTokenRevocationManager("client-id", "secret", "https://revoke.url", httpClient, &mockLogger{}, blacklist)
|
||||
|
||||
if trm == nil {
|
||||
t.Fatal("Expected NewTokenRevocationManager to return non-nil")
|
||||
}
|
||||
|
||||
if trm.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRevocationManager_RevokeToken(t *testing.T) {
|
||||
blacklist := NewTokenBlacklist(newMockCache(), &mockLogger{})
|
||||
trm := NewTokenRevocationManager("client-id", "secret", "https://revoke.url", &http.Client{}, &mockLogger{}, blacklist)
|
||||
|
||||
err := trm.RevokeToken("test-token", "access_token", false)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Token should be in blacklist
|
||||
if !blacklist.IsBlacklisted("test-token") {
|
||||
t.Error("Expected token to be blacklisted")
|
||||
}
|
||||
}
|
||||
|
||||
// Race condition tests
|
||||
func TestTokenCache_ConcurrentAccess(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
iterations := 100
|
||||
|
||||
// Concurrent cache operations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
claims := map[string]interface{}{
|
||||
"sub": idx,
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
token := string(rune('A' + idx%26))
|
||||
tokenCache.CacheToken(token, claims)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent retrieve operations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := string(rune('A' + idx%26))
|
||||
_, _ = tokenCache.GetCachedToken(token)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent invalidations
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := string(rune('A' + idx%26))
|
||||
tokenCache.InvalidateToken(token)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestTokenBlacklist_ConcurrentAccess(t *testing.T) {
|
||||
blacklist := newMockCache()
|
||||
tb := NewTokenBlacklist(blacklist, &mockLogger{})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent adds
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
tb.Add(string(rune('A'+idx%26)), "test")
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent checks
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_ = tb.IsBlacklisted(string(rune('A' + idx%26)))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestTokenCache_CleanupWithConcurrentOperations(t *testing.T) {
|
||||
cache := newMockCache()
|
||||
tokenCache := NewTokenCache(cache, newMockCache(), &mockLogger{}, &mockMetrics{}, 5*time.Minute)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stopFlag := atomic.Bool{}
|
||||
|
||||
// Start cleanup
|
||||
tokenCache.StartCleanup(50 * time.Millisecond)
|
||||
|
||||
// Goroutine adding tokens
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; !stopFlag.Load() && i < 50; i++ {
|
||||
claims := map[string]interface{}{
|
||||
"sub": i,
|
||||
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
||||
}
|
||||
tokenCache.CacheToken(string(rune('A'+i%26)), claims)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// Goroutine invalidating tokens
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; !stopFlag.Load() && i < 30; i++ {
|
||||
tokenCache.InvalidateToken(string(rune('A' + i%26)))
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// Let it run for a bit
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
stopFlag.Store(true)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Stop cleanup
|
||||
tokenCache.StopCleanup()
|
||||
|
||||
// Should not have panicked
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Introspector tests
|
||||
func TestNewIntrospector(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/introspect",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
if introspector == nil {
|
||||
t.Fatal("Expected NewIntrospector to return non-nil")
|
||||
}
|
||||
|
||||
if introspector.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
|
||||
if introspector.clientSecret != "client-secret" {
|
||||
t.Error("Expected clientSecret to be set")
|
||||
}
|
||||
|
||||
if introspector.introspectionURL != "https://provider.example.com/introspect" {
|
||||
t.Error("Expected introspectionURL to be set")
|
||||
}
|
||||
|
||||
if len(introspector.groupsClaimPath) != 1 || introspector.groupsClaimPath[0] != "groups" {
|
||||
t.Error("Expected groupsClaimPath to be set")
|
||||
}
|
||||
|
||||
if len(introspector.rolesClaimPath) != 1 || introspector.rolesClaimPath[0] != "roles" {
|
||||
t.Error("Expected rolesClaimPath to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_NoEndpoint(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"", // No introspection endpoint
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error when introspection endpoint not configured")
|
||||
}
|
||||
|
||||
if err.Error() != "introspection endpoint not configured" {
|
||||
t.Errorf("Expected configuration error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_Success(t *testing.T) {
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify parameters
|
||||
if r.FormValue("token") != "test-token" {
|
||||
t.Error("Expected token parameter")
|
||||
}
|
||||
|
||||
if r.FormValue("token_type_hint") != "access_token" {
|
||||
t.Error("Expected token_type_hint parameter")
|
||||
}
|
||||
|
||||
if r.FormValue("client_id") != "test-client" {
|
||||
t.Error("Expected client_id parameter")
|
||||
}
|
||||
|
||||
// Return valid introspection response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{
|
||||
"active": true,
|
||||
"scope": "openid profile email",
|
||||
"client_id": "test-client",
|
||||
"username": "testuser",
|
||||
"token_type": "Bearer",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567800,
|
||||
"sub": "user123",
|
||||
"aud": "test-audience",
|
||||
"iss": "https://issuer.example.com",
|
||||
"custom_claim": "custom_value"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"test-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
resp, err := introspector.IntrospectToken("test-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Active {
|
||||
t.Error("Expected token to be active")
|
||||
}
|
||||
|
||||
if resp.Scope != "openid profile email" {
|
||||
t.Errorf("Expected scope 'openid profile email', got '%s'", resp.Scope)
|
||||
}
|
||||
|
||||
if resp.ClientID != "test-client" {
|
||||
t.Errorf("Expected client_id 'test-client', got '%s'", resp.ClientID)
|
||||
}
|
||||
|
||||
if resp.Username != "testuser" {
|
||||
t.Errorf("Expected username 'testuser', got '%s'", resp.Username)
|
||||
}
|
||||
|
||||
if resp.TokenType != "Bearer" {
|
||||
t.Errorf("Expected token_type 'Bearer', got '%s'", resp.TokenType)
|
||||
}
|
||||
|
||||
// Check extra fields
|
||||
if resp.Extra == nil {
|
||||
t.Fatal("Expected Extra map to be populated")
|
||||
}
|
||||
|
||||
if val, ok := resp.Extra["custom_claim"]; !ok || val != "custom_value" {
|
||||
t.Error("Expected custom_claim in Extra fields")
|
||||
}
|
||||
|
||||
// Standard fields should not be in Extra
|
||||
if _, ok := resp.Extra["active"]; ok {
|
||||
t.Error("Standard field 'active' should not be in Extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_HTTPError(t *testing.T) {
|
||||
// Create a test server that returns an error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid_token"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("bad-token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTP 401 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_InvalidJSON(t *testing.T) {
|
||||
// Create a test server that returns invalid JSON
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{invalid json`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_IntrospectToken_NoTokenTypeHint(t *testing.T) {
|
||||
// Test that token_type_hint is optional
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify token_type_hint is not set when empty
|
||||
if r.FormValue("token_type_hint") != "" {
|
||||
t.Error("Expected no token_type_hint when not provided")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"active":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
introspector := NewIntrospector(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
_, err := introspector.IntrospectToken("token", "") // Empty token type hint
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospector_DetectTokenType_IDToken_AudienceString(t *testing.T) {
|
||||
_ = NewIntrospector(
|
||||
"test-client",
|
||||
"client-secret",
|
||||
"https://introspect.example.com",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
// Mock JWT with audience matching client ID
|
||||
// Note: parseJWT is a package-level function that we can't easily mock,
|
||||
// so this test validates the logic assuming parseJWT works
|
||||
// We'll test the DetectTokenType method indirectly
|
||||
|
||||
// This test would require mocking parseJWT which is complex
|
||||
// Skip for now or implement when parseJWT is mockable
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
|
||||
func TestIntrospector_DetectTokenType_AccessToken_Scope(t *testing.T) {
|
||||
// Similar to above - requires parseJWT mocking
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
|
||||
func TestIntrospector_ExtractGroupsAndRoles(t *testing.T) {
|
||||
// Requires parseJWT mocking
|
||||
t.Skip("Requires parseJWT mocking - tested indirectly through integration")
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock implementations for refresher tests
|
||||
type mockSessionManager struct{}
|
||||
|
||||
func (m *mockSessionManager) GetSession(sessionID string) (SessionDataInterface, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionManager) SaveSession(session SessionDataInterface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockSessionData struct {
|
||||
idToken string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
idExpiry time.Time
|
||||
accessExpiry time.Time
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIDToken() string {
|
||||
return m.idToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAccessToken() string {
|
||||
return m.accessToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetRefreshToken() string {
|
||||
return m.refreshToken
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetIDTokenExpiry() time.Time {
|
||||
return m.idExpiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) GetAccessTokenExpiry() time.Time {
|
||||
return m.accessExpiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetTokens(idToken, accessToken, refreshToken string, idExp, accessExp time.Time) {
|
||||
m.idToken = idToken
|
||||
m.accessToken = accessToken
|
||||
m.refreshToken = refreshToken
|
||||
m.idExpiry = idExp
|
||||
m.accessExpiry = accessExp
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetIDToken(token string, expiry time.Time) {
|
||||
m.idToken = token
|
||||
m.idExpiry = expiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetAccessToken(token string, expiry time.Time) {
|
||||
m.accessToken = token
|
||||
m.accessExpiry = expiry
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SetRefreshToken(token string) {
|
||||
m.refreshToken = token
|
||||
}
|
||||
|
||||
func (m *mockSessionData) SaveToCache() error {
|
||||
return m.saveErr
|
||||
}
|
||||
|
||||
type mockTokenVerifier struct {
|
||||
shouldFail bool
|
||||
}
|
||||
|
||||
func (m *mockTokenVerifier) VerifyToken(token string) error {
|
||||
if m.shouldFail {
|
||||
return fmt.Errorf("token verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresher tests
|
||||
func TestNewRefresher(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
if refresher == nil {
|
||||
t.Fatal("Expected NewRefresher to return non-nil")
|
||||
}
|
||||
|
||||
if refresher.clientID != "client-id" {
|
||||
t.Error("Expected clientID to be set")
|
||||
}
|
||||
|
||||
if refresher.clientSecret != "client-secret" {
|
||||
t.Error("Expected clientSecret to be set")
|
||||
}
|
||||
|
||||
if refresher.tokenURL != "https://provider.example.com/token" {
|
||||
t.Error("Expected tokenURL to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_RefreshToken_NilSession(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
result := refresher.RefreshToken(nil, nil, nil)
|
||||
if result {
|
||||
t.Error("Expected RefreshToken to return false for nil session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_RefreshToken_NoRefreshToken(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
session := &mockSessionData{
|
||||
refreshToken: "", // No refresh token
|
||||
}
|
||||
|
||||
result := refresher.RefreshToken(nil, nil, session)
|
||||
if result {
|
||||
t.Error("Expected RefreshToken to return false when no refresh token available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_UnsupportedGrantType(t *testing.T) {
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/token",
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("unsupported_grant", "token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported grant type")
|
||||
}
|
||||
|
||||
if err.Error() != "unsupported grant type: unsupported_grant" {
|
||||
t.Errorf("Expected unsupported grant type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_RefreshToken_RequestCreation(t *testing.T) {
|
||||
// Test with valid refresh_token grant type but invalid URL to test request creation
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"://invalid-url", // Invalid URL
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "refresh-token-value", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_AuthorizationCode_WithPKCE(t *testing.T) {
|
||||
// Create a test server that verifies the request
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify PKCE parameters are included
|
||||
if r.FormValue("code_verifier") != "test-verifier" {
|
||||
t.Error("Expected code_verifier to be included")
|
||||
}
|
||||
|
||||
if r.FormValue("code") != "auth-code" {
|
||||
t.Error("Expected authorization code to be included")
|
||||
}
|
||||
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
t.Error("Expected grant_type to be authorization_code")
|
||||
}
|
||||
|
||||
// Return valid token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"test-access","id_token":"test-id","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
resp, err := refresher.exchangeToken("authorization_code", "auth-code", "https://callback.example.com", "test-verifier")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if resp.AccessToken != "test-access" {
|
||||
t.Errorf("Expected access token 'test-access', got '%s'", resp.AccessToken)
|
||||
}
|
||||
|
||||
if resp.IDToken != "test-id" {
|
||||
t.Errorf("Expected ID token 'test-id', got '%s'", resp.IDToken)
|
||||
}
|
||||
|
||||
if resp.ExpiresIn != 3600 {
|
||||
t.Errorf("Expected expires_in 3600, got %d", resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_HTTPError(t *testing.T) {
|
||||
// Create a test server that returns an error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid_grant"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "bad-token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTP 401 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_ExchangeToken_InvalidJSON(t *testing.T) {
|
||||
// Create a test server that returns invalid JSON
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{invalid json`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
_, err := refresher.exchangeToken("refresh_token", "token", "", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_GetNewTokenWithRefreshToken(t *testing.T) {
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
refresher := NewRefresher(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
server.URL,
|
||||
&http.Client{},
|
||||
&mockLogger{},
|
||||
&mockMetrics{},
|
||||
&mockSessionManager{},
|
||||
newMockCache(),
|
||||
&mockTokenVerifier{},
|
||||
)
|
||||
|
||||
resp, err := refresher.GetNewTokenWithRefreshToken("old-refresh")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if resp.AccessToken != "new-access" {
|
||||
t.Error("Expected new access token")
|
||||
}
|
||||
|
||||
if resp.RefreshToken != "new-refresh" {
|
||||
t.Error("Expected new refresh token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//go:build !yaegi
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Helper function to create a simple JWT token for testing
|
||||
func createTestJWT(header, claims map[string]interface{}) string {
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Use fake signature
|
||||
return headerB64 + "." + claimsB64 + ".fake-signature"
|
||||
}
|
||||
|
||||
// parseJWT Tests
|
||||
func TestParseJWT_Valid(t *testing.T) {
|
||||
header := map[string]interface{}{"alg": "RS256", "typ": "JWT"}
|
||||
claims := map[string]interface{}{"sub": "user123", "aud": "client-id"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
jwt, err := parseJWT(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
t.Fatal("Expected non-nil JWT")
|
||||
}
|
||||
|
||||
if jwt.Header["alg"] != "RS256" {
|
||||
t.Error("Expected alg to be RS256")
|
||||
}
|
||||
|
||||
if jwt.Claims["sub"] != "user123" {
|
||||
t.Error("Expected sub to be user123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWT_InvalidFormat(t *testing.T) {
|
||||
// Token with wrong number of parts
|
||||
_, err := parseJWT("invalid.token")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token format")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "expected 3 parts") {
|
||||
t.Errorf("Expected error about parts, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWT_InvalidBase64(t *testing.T) {
|
||||
// Token with invalid base64
|
||||
_, err := parseJWT("!@#$%^.invalid.base64")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSegment Tests
|
||||
func TestDecodeSegment_Valid(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"field1": "value1",
|
||||
"field2": 123,
|
||||
}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
encoded := base64.RawURLEncoding.EncodeToString(jsonData)
|
||||
|
||||
result, err := decodeSegment(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if result["field1"] != "value1" {
|
||||
t.Error("Expected field1 to be value1")
|
||||
}
|
||||
|
||||
if result["field2"].(float64) != 123 {
|
||||
t.Error("Expected field2 to be 123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_WithPadding(t *testing.T) {
|
||||
// Create data that needs padding
|
||||
data := map[string]interface{}{"test": "value"}
|
||||
jsonData, _ := json.Marshal(data)
|
||||
// Use standard encoding to get padded version
|
||||
encoded := base64.URLEncoding.EncodeToString(jsonData)
|
||||
// Remove padding to test the function adds it back
|
||||
encoded = strings.TrimRight(encoded, "=")
|
||||
|
||||
result, err := decodeSegment(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with unpadded segment, got: %v", err)
|
||||
}
|
||||
|
||||
if result["test"] != "value" {
|
||||
t.Error("Expected test to be value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_InvalidBase64(t *testing.T) {
|
||||
_, err := decodeSegment("!@#$%^&*()")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSegment_InvalidJSON(t *testing.T) {
|
||||
// Valid base64 but invalid JSON
|
||||
invalid := base64.RawURLEncoding.EncodeToString([]byte("{invalid json"))
|
||||
_, err := decodeSegment(invalid)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTokenType Tests
|
||||
func TestDetectTokenType_IDToken_StringAudience(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"https://introspect.example.com",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"aud": "test-client", // Matches clientID
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_IDToken_ArrayAudience(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"aud": []interface{}{"test-client", "other-client"},
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_Scope(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"scope": "openid profile email",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_IDToken_TokenUse(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"token_use": "id",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "id_token" {
|
||||
t.Errorf("Expected 'id_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_TokenUse(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"token_use": "access",
|
||||
"sub": "user123",
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_AccessToken_TypHeader(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256", "typ": "at+jwt"}
|
||||
claims := map[string]interface{}{"sub": "user123"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "access_token" {
|
||||
t.Errorf("Expected 'access_token', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTokenType_Unknown(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
nil,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{"sub": "user123"}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
tokenType, err := introspector.DetectTokenType(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if tokenType != "unknown" {
|
||||
t.Errorf("Expected 'unknown', got '%s'", tokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractGroupsAndRoles Tests
|
||||
func TestExtractGroupsAndRoles_SimpleArrays(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"groups": []interface{}{"group1", "group2", "group3"},
|
||||
"roles": []interface{}{"role1", "role2"},
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
groups, roles, err := introspector.ExtractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 3 {
|
||||
t.Errorf("Expected 3 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if len(roles) != 2 {
|
||||
t.Errorf("Expected 2 roles, got %d", len(roles))
|
||||
}
|
||||
|
||||
if groups[0] != "group1" {
|
||||
t.Errorf("Expected first group to be 'group1', got '%s'", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsAndRoles_NestedClaims(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"resource_access", "account", "roles"},
|
||||
[]string{"realm_access", "roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
header := map[string]interface{}{"alg": "RS256"}
|
||||
claims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"resource_access": map[string]interface{}{
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"manage-account", "view-profile"},
|
||||
},
|
||||
},
|
||||
"realm_access": map[string]interface{}{
|
||||
"roles": []interface{}{"admin", "user"},
|
||||
},
|
||||
}
|
||||
token := createTestJWT(header, claims)
|
||||
|
||||
groups, roles, err := introspector.ExtractGroupsAndRoles(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(groups) != 2 {
|
||||
t.Errorf("Expected 2 groups, got %d", len(groups))
|
||||
}
|
||||
|
||||
if len(roles) != 2 {
|
||||
t.Errorf("Expected 2 roles, got %d", len(roles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsAndRoles_InvalidToken(t *testing.T) {
|
||||
introspector := NewIntrospector(
|
||||
"test-client",
|
||||
"secret",
|
||||
"",
|
||||
nil,
|
||||
&mockLogger{},
|
||||
[]string{"groups"},
|
||||
[]string{"roles"},
|
||||
"",
|
||||
)
|
||||
|
||||
_, _, err := introspector.ExtractGroupsAndRoles("invalid.token")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
// extractStringSlice Tests (indirect via Introspector)
|
||||
func TestExtractStringSlice_StringArray(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
val := []interface{}{"value1", "value2", "value3"}
|
||||
result := introspector.extractStringSlice(val)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "value1" {
|
||||
t.Errorf("Expected 'value1', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_StringSlice(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
val := []string{"a", "b", "c"}
|
||||
result := introspector.extractStringSlice(val)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_SingleString(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("single-value")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 value, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "single-value" {
|
||||
t.Errorf("Expected 'single-value', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_CommaSeparated(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("value1,value2,value3")
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0] != "value1" {
|
||||
t.Errorf("Expected 'value1', got '%s'", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_EmptyString(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice("")
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for empty string, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStringSlice_InvalidType(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
result := introspector.extractStringSlice(12345)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for invalid type, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// extractClaimValues Tests (indirect via Introspector)
|
||||
func TestExtractClaimValues_SimplePath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"roles": []interface{}{"admin", "user"},
|
||||
}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"roles"})
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_NestedPath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"resource": map[string]interface{}{
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"role1", "role2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"resource", "account", "roles"})
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 values, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_EmptyPath(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{"roles": []interface{}{"admin"}}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{})
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for empty path, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimValues_PathNotFound(t *testing.T) {
|
||||
introspector := NewIntrospector("", "", "", nil, &mockLogger{}, nil, nil, "")
|
||||
|
||||
claims := map[string]interface{}{"other": "value"}
|
||||
|
||||
result := introspector.extractClaimValues(claims, []string{"roles"})
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected 0 values for missing path, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TokenRevocationManager revokeWithProvider test
|
||||
func TestTokenRevocationManager_RevokeWithProvider(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
cache := newMockCache()
|
||||
blacklist := NewTokenBlacklist(cache, logger)
|
||||
trm := NewTokenRevocationManager(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"https://provider.example.com/revoke",
|
||||
nil, // http client
|
||||
logger,
|
||||
blacklist,
|
||||
)
|
||||
|
||||
// This function is a simplified placeholder that just logs
|
||||
err := trm.revokeWithProvider("test-token", "access_token")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// Just verify it doesn't panic - mockLogger doesn't track logs
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
//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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user