mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
2072 lines
70 KiB
Go
2072 lines
70 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"text/template"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// TestTokenTypeDistinction tests that AccessToken and IDToken are correctly distinguished in templates
|
|
func TestTokenTypeDistinction(t *testing.T) {
|
|
// Define test data where AccessToken and IDToken are deliberately different
|
|
type templateData struct {
|
|
Claims map[string]interface{}
|
|
AccessToken string
|
|
IDToken string
|
|
RefreshToken string
|
|
}
|
|
|
|
testData := templateData{
|
|
AccessToken: "test-access-token-abc123",
|
|
IDToken: "test-id-token-xyz789",
|
|
RefreshToken: "test-refresh-token",
|
|
Claims: map[string]interface{}{
|
|
"sub": "test-subject",
|
|
"email": "[email protected]",
|
|
},
|
|
}
|
|
|
|
// Test cases
|
|
tests := []struct {
|
|
name string
|
|
templateText string
|
|
expectedValue string
|
|
}{
|
|
{
|
|
name: "Access Token Only",
|
|
templateText: "Bearer {{.AccessToken}}",
|
|
expectedValue: "Bearer test-access-token-abc123",
|
|
},
|
|
{
|
|
name: "ID Token Only",
|
|
templateText: "ID: {{.IDToken}}",
|
|
expectedValue: "ID: test-id-token-xyz789",
|
|
},
|
|
{
|
|
name: "Both Tokens",
|
|
templateText: "Access: {{.AccessToken}} ID: {{.IDToken}}",
|
|
expectedValue: "Access: test-access-token-abc123 ID: test-id-token-xyz789",
|
|
},
|
|
{
|
|
name: "Both Tokens in Authorization Format",
|
|
templateText: "Bearer {{.AccessToken}} and Bearer {{.IDToken}}",
|
|
expectedValue: "Bearer test-access-token-abc123 and Bearer test-id-token-xyz789",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tmpl, err := template.New("test").Parse(tc.templateText)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse template: %v", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = tmpl.Execute(&buf, testData)
|
|
if err != nil {
|
|
t.Fatalf("Failed to execute template: %v", err)
|
|
}
|
|
|
|
result := buf.String()
|
|
if result != tc.expectedValue {
|
|
t.Errorf("Expected template output %q, got %q", tc.expectedValue, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTokenTypeIntegration tests the integration of ID and access tokens with the middleware
|
|
func TestTokenTypeIntegration(t *testing.T) {
|
|
// Create a TestSuite to use its helper methods and fields
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Create different tokens for ID and access tokens
|
|
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"exp": float64(3000000000),
|
|
"iat": float64(1000000000),
|
|
"nbf": float64(1000000000),
|
|
"sub": "test-subject",
|
|
"nonce": "test-nonce",
|
|
"jti": generateRandomString(16),
|
|
"token_type": "id_token",
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test ID JWT: %v", err)
|
|
}
|
|
|
|
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"exp": float64(3000000000),
|
|
"iat": float64(1000000000),
|
|
"nbf": float64(1000000000),
|
|
"sub": "test-subject",
|
|
"jti": generateRandomString(16),
|
|
"token_type": "access_token",
|
|
"scope": "openid profile email",
|
|
"email": "[email protected]", // Add email to access token so it's available in claims
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test access JWT: %v", err)
|
|
}
|
|
|
|
// Define test headers that use both token types
|
|
headers := []TemplatedHeader{
|
|
{Name: "X-ID-Token", Value: "{{.IDToken}}"},
|
|
{Name: "X-Access-Token", Value: "{{.AccessToken}}"},
|
|
{Name: "Authorization", Value: "Bearer {{.AccessToken}}"},
|
|
{Name: "X-Email-From-Claims", Value: "{{.Claims.email}}"},
|
|
}
|
|
|
|
// Store intercepted headers for verification
|
|
interceptedHeaders := make(map[string]string)
|
|
|
|
// Create a test next handler that captures the headers
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Capture headers for verification
|
|
for _, header := range headers {
|
|
if value := r.Header.Get(header.Name); value != "" {
|
|
interceptedHeaders[header.Name] = value
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Create the TraefikOidc instance
|
|
tOidc := &TraefikOidc{
|
|
next: nextHandler,
|
|
name: "test",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/callback/logout",
|
|
issuerURL: "https://test-issuer.com",
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
jwkCache: ts.mockJWKCache,
|
|
jwksURL: "https://test-jwks-url.com",
|
|
tokenBlacklist: ts.tOidc.tokenBlacklist,
|
|
tokenCache: ts.tOidc.tokenCache,
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
logger: NewLogger("debug"),
|
|
allowedUserDomains: map[string]struct{}{"example.com": {}},
|
|
excludedURLs: map[string]struct{}{"/favicon": {}},
|
|
httpClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
initComplete: make(chan struct{}),
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
headerTemplates: make(map[string]*template.Template),
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
|
|
// Initialize and parse header templates
|
|
for _, header := range headers {
|
|
tmpl, err := template.New(header.Name).Parse(header.Value)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse header template for %s: %v", header.Name, err)
|
|
}
|
|
tOidc.headerTemplates[header.Name] = tmpl
|
|
}
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Create a test request
|
|
req := httptest.NewRequest("GET", "/protected", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Create a session
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
// Setup the session with authentication data
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail("[email protected]")
|
|
session.SetIDToken(idToken) // Set the ID token
|
|
session.SetAccessToken(accessToken) // Set the access token
|
|
session.SetRefreshToken("test-refresh-token")
|
|
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Add session cookies to the request
|
|
for _, cookie := range rr.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
// Reset the response recorder for the main test
|
|
rr = httptest.NewRecorder()
|
|
|
|
// Process the request
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// Check status code
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
|
|
}
|
|
|
|
// Verify headers were set correctly
|
|
expectedHeaders := map[string]string{
|
|
"X-ID-Token": idToken,
|
|
"X-Access-Token": accessToken,
|
|
"Authorization": "Bearer " + accessToken,
|
|
"X-Email-From-Claims": "[email protected]",
|
|
}
|
|
|
|
for name, expectedValue := range expectedHeaders {
|
|
if value, exists := interceptedHeaders[name]; !exists {
|
|
t.Errorf("Expected header %s was not set", name)
|
|
} else if value != expectedValue {
|
|
t.Errorf("Header %s expected value %q, got %q", name, expectedValue, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionIDTokenAccessToken tests that the SessionData correctly stores and retrieves
|
|
// both ID tokens and access tokens separately
|
|
func TestSessionIDTokenAccessToken(t *testing.T) {
|
|
// Create a logger for the session manager
|
|
logger := NewLogger("debug")
|
|
|
|
// Create a session manager
|
|
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, "", logger)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create session manager: %v", err)
|
|
}
|
|
|
|
// Create a test request
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Get a session
|
|
session, err := sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
// Set test tokens using standardized tokens
|
|
idToken := ValidIDToken
|
|
accessToken := ValidAccessToken
|
|
refreshToken := ValidRefreshToken
|
|
|
|
// Store tokens in session
|
|
session.SetIDToken(idToken)
|
|
session.SetAccessToken(accessToken)
|
|
session.SetRefreshToken(refreshToken)
|
|
|
|
// Save the session
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Get cookies from response
|
|
cookies := rr.Result().Cookies()
|
|
|
|
// Create a new request with those cookies
|
|
req2 := httptest.NewRequest("GET", "/test", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
|
|
// Get the session again
|
|
session2, err := sessionManager.GetSession(req2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session from request with cookies: %v", err)
|
|
}
|
|
|
|
// Verify that the tokens were correctly stored and retrieved
|
|
retrievedIDToken := session2.GetIDToken()
|
|
retrievedAccessToken := session2.GetAccessToken()
|
|
retrievedRefreshToken := session2.GetRefreshToken()
|
|
|
|
if retrievedIDToken != idToken {
|
|
t.Errorf("ID token mismatch: expected %q, got %q", idToken, retrievedIDToken)
|
|
}
|
|
|
|
if retrievedAccessToken != accessToken {
|
|
t.Errorf("Access token mismatch: expected %q, got %q", accessToken, retrievedAccessToken)
|
|
}
|
|
|
|
if retrievedRefreshToken != refreshToken {
|
|
t.Errorf("Refresh token mismatch: expected %q, got %q", refreshToken, retrievedRefreshToken)
|
|
}
|
|
|
|
// Verify that the tokens are distinct
|
|
if retrievedIDToken == retrievedAccessToken {
|
|
t.Errorf("ID token and Access token should be different, but both are %q", retrievedIDToken)
|
|
}
|
|
}
|
|
|
|
// TestTokenCorruptionIntegrationFlows tests the complete token handling flow with corruption scenarios
|
|
func TestTokenCorruptionIntegrationFlows(t *testing.T) {
|
|
logger := NewLogger("debug")
|
|
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create session manager: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
corruptAction func(*SessionData)
|
|
name string
|
|
accessToken string
|
|
refreshToken string
|
|
idToken string
|
|
expectSuccess bool
|
|
}{
|
|
{
|
|
name: "Normal flow - small tokens",
|
|
accessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.access_signature_data_here",
|
|
refreshToken: "refresh_token_12345",
|
|
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "Normal flow - large tokens (chunked)",
|
|
accessToken: createLargeValidJWT(5000),
|
|
refreshToken: createLargeRefreshToken(3000),
|
|
idToken: createLargeValidJWT(2000),
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "Corrupted access token compression",
|
|
accessToken: createLargeValidJWT(3000),
|
|
refreshToken: "refresh_token_12345",
|
|
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
|
|
expectSuccess: false,
|
|
corruptAction: func(session *SessionData) {
|
|
// Corrupt compressed access token
|
|
if session.accessSession != nil {
|
|
session.accessSession.Values["token"] = "corrupted_compressed_data_!@#"
|
|
session.accessSession.Values["compressed"] = true
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "Corrupted chunk in large token",
|
|
accessToken: createLargeValidJWT(15000), // Force chunking with larger size
|
|
refreshToken: "refresh_token_12345",
|
|
idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.id_token_signature_data_here",
|
|
expectSuccess: false,
|
|
corruptAction: func(session *SessionData) {
|
|
// Corrupt first chunk if chunked, otherwise corrupt single token
|
|
if len(session.accessTokenChunks) > 0 {
|
|
if chunk, exists := session.accessTokenChunks[0]; exists {
|
|
chunk.Values["token_chunk"] = "__CORRUPTED_CHUNK_DATA__"
|
|
}
|
|
} else {
|
|
// Token is stored as single compressed token - corrupt it
|
|
if session.accessSession != nil {
|
|
session.accessSession.Values["token"] = "__CORRUPTED_CHUNK_DATA__"
|
|
}
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Get session
|
|
session, err := sm.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
defer session.ReturnToPool()
|
|
|
|
// Store tokens
|
|
session.SetAccessToken(tt.accessToken)
|
|
session.SetRefreshToken(tt.refreshToken)
|
|
session.SetIDToken(tt.idToken)
|
|
session.SetAuthenticated(true)
|
|
|
|
// Save session
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Apply corruption if specified
|
|
if tt.corruptAction != nil {
|
|
tt.corruptAction(session)
|
|
}
|
|
|
|
// Test token retrieval after corruption
|
|
retrievedAccess := session.GetAccessToken()
|
|
retrievedRefresh := session.GetRefreshToken()
|
|
retrievedID := session.GetIDToken()
|
|
|
|
if tt.expectSuccess {
|
|
if retrievedAccess != tt.accessToken {
|
|
t.Errorf("Access token corruption: expected %q, got %q", tt.accessToken, retrievedAccess)
|
|
}
|
|
if retrievedRefresh != tt.refreshToken {
|
|
t.Errorf("Refresh token corruption: expected %q, got %q", tt.refreshToken, retrievedRefresh)
|
|
}
|
|
if retrievedID != tt.idToken {
|
|
t.Errorf("ID token corruption: expected %q, got %q", tt.idToken, retrievedID)
|
|
}
|
|
} else {
|
|
// For corruption scenarios, access token should be empty (graceful failure)
|
|
if retrievedAccess != "" {
|
|
t.Errorf("Expected corrupted access token to return empty, got: %q", retrievedAccess)
|
|
}
|
|
// Other tokens should still work
|
|
if retrievedRefresh != tt.refreshToken {
|
|
t.Errorf("Refresh token should not be affected by access token corruption: expected %q, got %q",
|
|
tt.refreshToken, retrievedRefresh)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSessionPersistenceWithCorruption tests that session corruption is handled across requests
|
|
func TestSessionPersistenceWithCorruption(t *testing.T) {
|
|
logger := NewLogger("debug")
|
|
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create session manager: %v", err)
|
|
}
|
|
|
|
// First request - store tokens
|
|
req1 := httptest.NewRequest("GET", "/test", nil)
|
|
rr1 := httptest.NewRecorder()
|
|
|
|
session1, err := sm.GetSession(req1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
// Use a smaller token that's less likely to accidentally contain corruption markers
|
|
largeToken := createLargeValidJWT(2000)
|
|
session1.SetAccessToken(largeToken)
|
|
session1.SetAuthenticated(true)
|
|
|
|
if err := session1.Save(req1, rr1); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Get cookies from first response
|
|
cookies := rr1.Result().Cookies()
|
|
session1.ReturnToPool()
|
|
|
|
// Second request - retrieve tokens with cookies
|
|
req2 := httptest.NewRequest("GET", "/test", nil)
|
|
for _, cookie := range cookies {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
|
|
session2, err := sm.GetSession(req2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session from cookies: %v", err)
|
|
}
|
|
defer session2.ReturnToPool()
|
|
|
|
// Verify token can be retrieved initially
|
|
retrieved := session2.GetAccessToken()
|
|
if retrieved != largeToken {
|
|
t.Errorf("Token persistence failed: expected valid token, got empty token")
|
|
}
|
|
|
|
// Simulate corruption by modifying chunks
|
|
if len(session2.accessTokenChunks) > 0 {
|
|
// Corrupt a middle chunk with a unique corruption marker
|
|
chunkIndex := len(session2.accessTokenChunks) / 2
|
|
if chunk, exists := session2.accessTokenChunks[chunkIndex]; exists {
|
|
chunk.Values["token_chunk"] = "__CORRUPTION_MARKER_TEST__"
|
|
}
|
|
|
|
// Try to retrieve again - should detect corruption and return empty
|
|
retrievedAfterCorruption := session2.GetAccessToken()
|
|
if retrievedAfterCorruption != "" {
|
|
t.Errorf("Expected corruption to be detected, but got token: %q", retrievedAfterCorruption)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrentTokenOperationsWithCorruption tests concurrent access with intentional corruption
|
|
func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
|
|
logger := NewLogger("debug")
|
|
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create session manager: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
session, err := sm.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
defer session.ReturnToPool()
|
|
|
|
const numGoroutines = 10
|
|
const numOperations = 20
|
|
|
|
done := make(chan bool, numGoroutines)
|
|
errorChan := make(chan error, numGoroutines*numOperations)
|
|
|
|
// Start concurrent operations
|
|
for i := 0; i < numGoroutines; i++ {
|
|
go func(goroutineID int) {
|
|
defer func() { done <- true }()
|
|
|
|
for j := 0; j < numOperations; j++ {
|
|
// Create a unique valid token for each operation
|
|
token := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwib3AiOiIxMjMifQ.sig_%d_%d",
|
|
goroutineID, j)
|
|
|
|
// Store token
|
|
session.SetAccessToken(token)
|
|
|
|
// Retrieve token
|
|
retrieved := session.GetAccessToken()
|
|
|
|
// Validate retrieved token format
|
|
if retrieved != "" {
|
|
if strings.Count(retrieved, ".") != 2 {
|
|
errorChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format: %q",
|
|
goroutineID, j, retrieved)
|
|
continue
|
|
}
|
|
|
|
// Check if it's a reasonable length
|
|
if len(retrieved) < 10 || len(retrieved) > 100000 {
|
|
errorChan <- fmt.Errorf("goroutine %d, op %d: suspicious token length %d: %q",
|
|
goroutineID, j, len(retrieved), retrieved)
|
|
}
|
|
}
|
|
|
|
// Occasionally simulate corruption to test error handling
|
|
if j%5 == 0 && len(session.accessTokenChunks) > 0 {
|
|
// Intentionally corrupt a random chunk
|
|
for chunkID, chunk := range session.accessTokenChunks {
|
|
if chunkID%2 == 0 {
|
|
chunk.Values["token_chunk"] = "__CORRUPTION_MARKER_TEST__"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines to complete
|
|
for i := 0; i < numGoroutines; i++ {
|
|
<-done
|
|
}
|
|
close(errorChan)
|
|
|
|
// Check for any unexpected errors
|
|
errorCount := 0
|
|
for err := range errorChan {
|
|
t.Logf("Concurrent operation error: %v", err)
|
|
errorCount++
|
|
}
|
|
|
|
// We expect some corruption-related "errors" due to intentional corruption,
|
|
// but not format-related errors which would indicate actual corruption bugs
|
|
if errorCount > numGoroutines*numOperations/4 { // Allow up to 25% corruption-related issues
|
|
t.Errorf("Too many errors during concurrent operations: %d", errorCount)
|
|
}
|
|
}
|
|
|
|
// TestTokenValidationEdgeCases tests edge cases in token validation
|
|
func TestTokenValidationEdgeCases(t *testing.T) {
|
|
logger := NewLogger("debug")
|
|
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create session manager: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/test", nil)
|
|
session, err := sm.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
defer session.ReturnToPool()
|
|
|
|
// Use standardized test tokens
|
|
testTokens := NewTestTokens()
|
|
edgeCases := testTokens.TokenValidationTestCases()
|
|
|
|
for _, ec := range edgeCases {
|
|
t.Run(ec.Name, func(t *testing.T) {
|
|
// Clear any previous token
|
|
session.SetAccessToken("")
|
|
|
|
// Store the test token
|
|
originalToken := session.GetAccessToken()
|
|
session.SetAccessToken(ec.Token)
|
|
afterStoreToken := session.GetAccessToken()
|
|
|
|
if ec.ExpectStored {
|
|
if afterStoreToken != ec.Token {
|
|
t.Errorf("Expected token to be stored, but got different value")
|
|
}
|
|
} else {
|
|
if afterStoreToken != originalToken {
|
|
t.Errorf("Expected invalid token to be rejected, but it was stored")
|
|
}
|
|
}
|
|
|
|
// Test retrieval
|
|
finalToken := session.GetAccessToken()
|
|
if ec.ExpectRetrieved {
|
|
if finalToken != ec.Token {
|
|
t.Errorf("Expected token to be retrievable: %q, got: %q", ec.Token, finalToken)
|
|
}
|
|
} else {
|
|
if finalToken != "" {
|
|
t.Errorf("Expected empty token due to invalid format, got: %q", finalToken)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper functions for test data creation
|
|
|
|
// createLargeValidJWT creates a JWT of approximately the specified size
|
|
func createLargeValidJWT(targetSize int) string {
|
|
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
// Create a valid base64url signature
|
|
signatureBytes := make([]byte, 32)
|
|
rand.Read(signatureBytes)
|
|
signature := base64.RawURLEncoding.EncodeToString(signatureBytes)
|
|
|
|
// Calculate required payload size
|
|
usedSize := len(header) + len(signature) + 2 // account for dots
|
|
payloadSize := targetSize - usedSize
|
|
if payloadSize < 50 {
|
|
payloadSize = 50
|
|
}
|
|
|
|
// Create a payload with realistic JWT claims, using safe content
|
|
claims := map[string]interface{}{
|
|
"sub": "user123",
|
|
"iss": "https://example.com",
|
|
"aud": "client123",
|
|
"exp": 9999999999,
|
|
"iat": 1000000000,
|
|
"data": strings.Repeat("abcdef0123456789", (payloadSize-100)/16), // Safe repeating pattern
|
|
}
|
|
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
|
|
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
|
|
}
|
|
|
|
// createLargeRefreshToken creates a refresh token of approximately the specified size
|
|
func createLargeRefreshToken(targetSize int) string {
|
|
baseToken := "refresh_token_"
|
|
padding := generateRandomString(targetSize - len(baseToken))
|
|
return baseToken + padding
|
|
}
|
|
|
|
// ====== COMPREHENSIVE TOKEN EXPIRY TESTS ======
|
|
// These tests demonstrate the current broken behavior with 6-hour token expiry
|
|
// and other critical token handling scenarios
|
|
|
|
// TestSixHourTokenExpiryScenario tests the exact 6-hour browser inactivity scenario
|
|
// This test SHOULD FAIL with current implementation - it demonstrates the broken behavior
|
|
func TestSixHourTokenExpiryScenario(t *testing.T) {
|
|
t.Log("Testing 6-hour token expiry scenario - this test demonstrates the CURRENT BROKEN BEHAVIOR")
|
|
|
|
// Create test suite with proper setup
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Mock current time to simulate exactly 6 hours of inactivity
|
|
sixHoursAgo := time.Now().Add(-6 * time.Hour)
|
|
|
|
// Create tokens that expired exactly 6 hours ago (simulating browser inactivity)
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(sixHoursAgo.Unix()), // Expired 6 hours ago
|
|
"iat": float64(sixHoursAgo.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired access token: %v", err)
|
|
}
|
|
|
|
expiredIDToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(sixHoursAgo.Unix()), // Also expired 6 hours ago
|
|
"iat": float64(sixHoursAgo.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
"nonce": "test-nonce",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired ID token: %v", err)
|
|
}
|
|
|
|
// Valid refresh token (should still be valid for renewal)
|
|
validRefreshToken := "valid-refresh-token-for-renewal-12345"
|
|
|
|
// Set up the middleware with token refresh capability
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// This should be reached after successful token renewal
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("SUCCESS: Token renewed and request processed"))
|
|
}),
|
|
name: "test-6hour",
|
|
redirURLPath: "/callback",
|
|
logoutURLPath: "/callback/logout",
|
|
issuerURL: "https://test-issuer.com",
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
jwkCache: ts.mockJWKCache,
|
|
jwksURL: "https://test-jwks-url.com",
|
|
tokenBlacklist: ts.tOidc.tokenBlacklist,
|
|
tokenCache: ts.tOidc.tokenCache,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
refreshGracePeriod: 60 * time.Second, // 60 second grace period
|
|
logger: NewLogger("debug"),
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
initComplete: make(chan struct{}),
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Mock the token refresh endpoint to return new valid tokens
|
|
refreshCount := 0
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
refreshCount++
|
|
t.Logf("Token refresh request #%d received", refreshCount)
|
|
|
|
// Create new valid tokens with future expiry
|
|
newAccessToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()), // Valid for 1 hour
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
|
|
newIDToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
"nonce": "test-nonce",
|
|
})
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": newAccessToken,
|
|
"id_token": newIDToken,
|
|
"refresh_token": "new-refresh-token-12345",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}))
|
|
defer tokenServer.Close()
|
|
tOidc.tokenURL = tokenServer.URL
|
|
|
|
// Create a session with expired tokens
|
|
req := httptest.NewRequest("GET", "/protected-resource", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Create and populate session with expired tokens
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail("[email protected]")
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetIDToken(expiredIDToken)
|
|
session.SetRefreshToken(validRefreshToken)
|
|
|
|
// Save session to establish cookies
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Add cookies to request to simulate browser with expired session
|
|
for _, cookie := range rr.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
session.ReturnToPool()
|
|
|
|
// Reset response recorder for actual test
|
|
rr = httptest.NewRecorder()
|
|
|
|
t.Log("Making request with 6-hour expired tokens - expecting automatic renewal")
|
|
|
|
// This is where the middleware should automatically refresh tokens
|
|
// but currently it redirects to /unknown-session instead
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// ==== ASSERTIONS THAT DEMONSTRATE THE BUG ====
|
|
|
|
// Current broken behavior - this is what happens now:
|
|
if rr.Code == http.StatusTemporaryRedirect && strings.Contains(rr.Header().Get("Location"), "/unknown-session") {
|
|
t.Errorf("BUG DEMONSTRATED: Got redirect to /unknown-session instead of automatic token renewal")
|
|
t.Errorf("Response code: %d", rr.Code)
|
|
t.Errorf("Location header: %s", rr.Header().Get("Location"))
|
|
t.Errorf("This proves the 6-hour expiry bug exists!")
|
|
|
|
// Log what should happen instead
|
|
t.Log("EXPECTED BEHAVIOR: Should have automatically refreshed tokens and returned 200 OK")
|
|
t.Log("EXPECTED: Token refresh should have been called")
|
|
t.Log("EXPECTED: User should not see redirect to /unknown-session")
|
|
|
|
return // Test fails as expected - demonstrates the bug
|
|
}
|
|
|
|
// This is what SHOULD happen but doesn't currently work:
|
|
if rr.Code == http.StatusOK {
|
|
t.Log("SUCCESS: Automatic token renewal worked correctly")
|
|
t.Logf("Token refresh was called %d times", refreshCount)
|
|
|
|
if refreshCount == 0 {
|
|
t.Error("Expected token refresh to be called, but it wasn't")
|
|
}
|
|
|
|
// Verify response indicates successful token renewal
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "SUCCESS") {
|
|
t.Errorf("Expected success response, got: %s", body)
|
|
}
|
|
} else {
|
|
t.Errorf("Unexpected response code: %d", rr.Code)
|
|
t.Errorf("Response body: %s", rr.Body.String())
|
|
t.Errorf("Response headers: %v", rr.Header())
|
|
}
|
|
}
|
|
|
|
// TestAutomaticRenewalFlow tests the complete automatic token renewal flow
|
|
// This test SHOULD FAIL - it demonstrates broken automatic renewal behavior
|
|
func TestAutomaticRenewalFlow(t *testing.T) {
|
|
t.Log("Testing automatic token renewal flow - this test demonstrates BROKEN BEHAVIOR")
|
|
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Create tokens that expired 30 minutes ago
|
|
expiredTime := time.Now().Add(-30 * time.Minute)
|
|
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(expiredTime.Unix()),
|
|
"iat": float64(expiredTime.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired access token: %v", err)
|
|
}
|
|
|
|
validRefreshToken := "valid-refresh-token-67890"
|
|
|
|
// Track renewal attempts
|
|
renewalAttempts := 0
|
|
renewalErrors := []string{}
|
|
|
|
// Mock token server for automatic renewal
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
renewalAttempts++
|
|
t.Logf("Automatic renewal attempt #%d", renewalAttempts)
|
|
|
|
// Verify this is a proper refresh token request
|
|
if err := r.ParseForm(); err != nil {
|
|
renewalErrors = append(renewalErrors, fmt.Sprintf("Failed to parse form: %v", err))
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if grantType := r.Form.Get("grant_type"); grantType != "refresh_token" {
|
|
renewalErrors = append(renewalErrors, fmt.Sprintf("Wrong grant type: %s", grantType))
|
|
http.Error(w, "Invalid grant type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if refreshToken := r.Form.Get("refresh_token"); refreshToken != validRefreshToken {
|
|
renewalErrors = append(renewalErrors, fmt.Sprintf("Wrong refresh token: %s", refreshToken))
|
|
http.Error(w, "Invalid refresh token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create new valid tokens
|
|
newAccessToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
|
|
// Create new ID token too (required for proper validation)
|
|
newIDToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-123",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
"nonce": "test-nonce",
|
|
})
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": newAccessToken,
|
|
"id_token": newIDToken,
|
|
"refresh_token": "new-refresh-token-67890",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Set up middleware that should perform automatic renewal
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("Request processed after automatic renewal"))
|
|
}),
|
|
name: "test-auto-renewal",
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
issuerURL: "https://test-issuer.com",
|
|
tokenURL: tokenServer.URL,
|
|
jwkCache: ts.mockJWKCache,
|
|
jwksURL: "https://test-jwks-url.com",
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport},
|
|
tokenBlacklist: ts.tOidc.tokenBlacklist,
|
|
tokenCache: ts.tOidc.tokenCache,
|
|
logger: NewLogger("debug"),
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
initComplete: make(chan struct{}),
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Create request with expired tokens
|
|
req := httptest.NewRequest("GET", "/api/protected", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Set up session with expired tokens
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetRefreshToken(validRefreshToken)
|
|
session.SetEmail("[email protected]")
|
|
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Add cookies to request
|
|
for _, cookie := range rr.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
session.ReturnToPool()
|
|
|
|
rr = httptest.NewRecorder()
|
|
|
|
t.Log("Making request with expired token - should trigger automatic renewal")
|
|
|
|
// This should automatically renew the token without user interaction
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// ==== ASSERTIONS TO DEMONSTRATE THE BROKEN BEHAVIOR ====
|
|
|
|
t.Logf("Renewal attempts: %d", renewalAttempts)
|
|
t.Logf("Renewal errors: %v", renewalErrors)
|
|
t.Logf("Response code: %d", rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
|
|
// Current broken behavior - automatic renewal doesn't happen
|
|
if renewalAttempts == 0 {
|
|
t.Error("BUG DEMONSTRATED: Automatic token renewal was not attempted")
|
|
t.Error("Expected: middleware should detect expired token and automatically refresh")
|
|
t.Error("Actual: no renewal attempt was made")
|
|
|
|
if rr.Code == http.StatusTemporaryRedirect {
|
|
location := rr.Header().Get("Location")
|
|
t.Errorf("Got redirect to: %s", location)
|
|
if strings.Contains(location, "/unknown-session") {
|
|
t.Error("Redirected to /unknown-session instead of automatic renewal")
|
|
}
|
|
}
|
|
return // Test fails as expected - demonstrates the bug
|
|
}
|
|
|
|
// This is what SHOULD happen:
|
|
if renewalAttempts > 0 && rr.Code == http.StatusOK {
|
|
t.Log("SUCCESS: Automatic token renewal worked correctly")
|
|
|
|
if len(renewalErrors) > 0 {
|
|
t.Errorf("Renewal had errors: %v", renewalErrors)
|
|
}
|
|
|
|
// Verify the request was processed after renewal
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "processed after automatic renewal") {
|
|
t.Errorf("Expected success message, got: %s", body)
|
|
}
|
|
} else {
|
|
t.Errorf("Unexpected response after renewal attempt - Code: %d, Body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBrowserStatePreservationDuringRenewal tests that cookies/session state is preserved
|
|
// This test SHOULD FAIL - it demonstrates broken state preservation behavior
|
|
func TestBrowserStatePreservationDuringRenewal(t *testing.T) {
|
|
t.Log("Testing browser state preservation during token renewal - this test demonstrates BROKEN BEHAVIOR")
|
|
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Create expired tokens
|
|
expiredTime := time.Now().Add(-2 * time.Hour)
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-456",
|
|
"exp": float64(expiredTime.Unix()),
|
|
"iat": float64(expiredTime.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired access token: %v", err)
|
|
}
|
|
|
|
validRefreshToken := "state-preservation-refresh-token"
|
|
|
|
// Track original session state
|
|
originalUserEmail := "[email protected]"
|
|
|
|
// Mock token server that provides new tokens
|
|
renewalCount := 0
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
renewalCount++
|
|
t.Logf("State preservation test - renewal #%d", renewalCount)
|
|
|
|
// Return new valid tokens
|
|
newAccessToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-456",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": originalUserEmail, // Should preserve email
|
|
})
|
|
|
|
newIDToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-456",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": originalUserEmail,
|
|
"nonce": "test-nonce",
|
|
})
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": newAccessToken,
|
|
"id_token": newIDToken,
|
|
"refresh_token": "new-state-preservation-refresh-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Set up middleware
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify that session state is preserved after renewal
|
|
session, err := ts.sessionManager.GetSession(r)
|
|
if err != nil {
|
|
t.Errorf("Failed to get session in next handler: %v", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer session.ReturnToPool()
|
|
|
|
// Check that original session data is preserved
|
|
if session.GetEmail() != originalUserEmail {
|
|
t.Errorf("Session email not preserved: expected %s, got %s", originalUserEmail, session.GetEmail())
|
|
}
|
|
|
|
if !session.GetAuthenticated() {
|
|
t.Error("Session authentication state not preserved")
|
|
}
|
|
|
|
// Check that tokens were actually renewed (not the same expired ones)
|
|
currentAccessToken := session.GetAccessToken()
|
|
if currentAccessToken == expiredAccessToken {
|
|
t.Error("Access token was not renewed - still has expired token")
|
|
}
|
|
|
|
if len(currentAccessToken) == 0 {
|
|
t.Error("Access token is empty after renewal")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fmt.Sprintf("State preserved - Email: %s, Auth: %t", session.GetEmail(), session.GetAuthenticated())))
|
|
}),
|
|
name: "test-state-preservation",
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
issuerURL: "https://test-issuer.com",
|
|
tokenURL: tokenServer.URL,
|
|
jwkCache: ts.mockJWKCache,
|
|
jwksURL: "https://test-jwks-url.com",
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
logger: NewLogger("debug"),
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
initComplete: make(chan struct{}),
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenCache: NewTokenCache(),
|
|
tokenBlacklist: NewCache(),
|
|
redirURLPath: "/oauth/callback",
|
|
refreshGracePeriod: 1 * time.Minute,
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Create initial request and establish session
|
|
req := httptest.NewRequest("GET", "/protected", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
// Set up session with expired token but preserve user state
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail(originalUserEmail)
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetRefreshToken(validRefreshToken)
|
|
|
|
// Simulate additional custom session data that should be preserved
|
|
// (This represents user preferences, shopping cart, etc.)
|
|
// Store as individual values to avoid gob encoding issues with maps
|
|
session.mainSession.Values["custom_data_preserved"] = true
|
|
session.mainSession.Values["user_theme"] = "dark"
|
|
session.mainSession.Values["user_lang"] = "en-US"
|
|
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save initial session: %v", err)
|
|
}
|
|
|
|
// Get the cookies to simulate browser state
|
|
cookies := rr.Result().Cookies()
|
|
for _, cookie := range cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
session.ReturnToPool()
|
|
|
|
rr = httptest.NewRecorder()
|
|
|
|
t.Log("Making request with expired token - should preserve session state during renewal")
|
|
|
|
// This should renew tokens while preserving all session state
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// ==== ASSERTIONS TO VERIFY STATE PRESERVATION ====
|
|
|
|
t.Logf("Renewal count: %d", renewalCount)
|
|
t.Logf("Response code: %d", rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
|
|
// Check for current broken behavior
|
|
if renewalCount == 0 {
|
|
t.Error("BUG DEMONSTRATED: Token renewal was not attempted - state preservation test cannot proceed")
|
|
t.Error("Expected: automatic renewal should occur with state preservation")
|
|
|
|
if rr.Code == http.StatusTemporaryRedirect {
|
|
location := rr.Header().Get("Location")
|
|
t.Errorf("Got redirect instead of renewal: %s", location)
|
|
if strings.Contains(location, "/unknown-session") {
|
|
t.Error("Lost session state - redirected to /unknown-session")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// If renewal happened, verify state preservation
|
|
if rr.Code == http.StatusOK {
|
|
t.Log("Token renewal occurred - checking state preservation")
|
|
|
|
body := rr.Body.String()
|
|
|
|
// Verify email was preserved
|
|
if !strings.Contains(body, originalUserEmail) {
|
|
t.Errorf("BROKEN STATE PRESERVATION: Original email %s not preserved in response: %s", originalUserEmail, body)
|
|
}
|
|
|
|
// Verify authentication state was preserved
|
|
if !strings.Contains(body, "Auth: true") {
|
|
t.Error("BROKEN STATE PRESERVATION: Authentication state not preserved")
|
|
}
|
|
|
|
// Verify session cookies are still set for continued access
|
|
setCookieHeaders := rr.Header().Values("Set-Cookie")
|
|
if len(setCookieHeaders) == 0 {
|
|
t.Error("BROKEN STATE PRESERVATION: No session cookies set after renewal")
|
|
} else {
|
|
t.Logf("Session cookies preserved: %d cookies", len(setCookieHeaders))
|
|
}
|
|
|
|
// Test that renewed session can be used for subsequent requests
|
|
req2 := httptest.NewRequest("GET", "/protected-2", nil)
|
|
req2.Header.Set("X-Forwarded-Proto", "https")
|
|
req2.Header.Set("X-Forwarded-Host", "example.com")
|
|
|
|
// Add cookies from renewal response
|
|
for _, cookieHeader := range setCookieHeaders {
|
|
// Parse and add cookies (simplified for test)
|
|
if strings.Contains(cookieHeader, "_oidc_raczylo") {
|
|
req2.Header.Add("Cookie", strings.Split(cookieHeader, ";")[0])
|
|
}
|
|
}
|
|
|
|
rr2 := httptest.NewRecorder()
|
|
tOidc.ServeHTTP(rr2, req2)
|
|
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("BROKEN STATE PRESERVATION: Subsequent request failed with code %d", rr2.Code)
|
|
t.Errorf("This indicates session state was not properly preserved after renewal")
|
|
} else {
|
|
t.Log("SUCCESS: Session state preserved - subsequent request succeeded")
|
|
}
|
|
|
|
} else {
|
|
t.Errorf("Unexpected response after state preservation test - Code: %d, Body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestConcurrentRequestsWithExpiredTokens tests handling of multiple concurrent requests with expired tokens
|
|
// This test SHOULD FAIL - it demonstrates broken concurrent request handling
|
|
func TestConcurrentRequestsWithExpiredTokens(t *testing.T) {
|
|
t.Log("Testing concurrent requests with expired tokens - this test demonstrates BROKEN BEHAVIOR")
|
|
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Create expired tokens
|
|
expiredTime := time.Now().Add(-1 * time.Hour)
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-concurrent",
|
|
"exp": float64(expiredTime.Unix()),
|
|
"iat": float64(expiredTime.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired access token: %v", err)
|
|
}
|
|
|
|
validRefreshToken := "concurrent-refresh-token"
|
|
|
|
// Track concurrent renewal attempts
|
|
var renewalMutex sync.Mutex
|
|
renewalAttempts := 0
|
|
concurrentRenewals := 0
|
|
maxConcurrentRenewals := 0
|
|
|
|
// Mock token server that tracks concurrent requests
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
renewalMutex.Lock()
|
|
renewalAttempts++
|
|
concurrentRenewals++
|
|
if concurrentRenewals > maxConcurrentRenewals {
|
|
maxConcurrentRenewals = concurrentRenewals
|
|
}
|
|
currentAttempt := renewalAttempts
|
|
renewalMutex.Unlock()
|
|
|
|
t.Logf("Concurrent renewal attempt #%d started", currentAttempt)
|
|
|
|
// Simulate some processing time
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Create new valid tokens
|
|
newAccessToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-concurrent",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
|
|
newIDToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-concurrent",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
"nonce": "test-nonce",
|
|
})
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": newAccessToken,
|
|
"id_token": newIDToken,
|
|
"refresh_token": fmt.Sprintf("new-concurrent-refresh-token-%d", currentAttempt),
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
renewalMutex.Lock()
|
|
concurrentRenewals--
|
|
renewalMutex.Unlock()
|
|
|
|
t.Logf("Concurrent renewal attempt #%d completed", currentAttempt)
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Set up middleware
|
|
var requestCounter int64
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestNum := atomic.AddInt64(&requestCounter, 1)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fmt.Sprintf("Request #%d processed successfully", requestNum)))
|
|
}),
|
|
name: "test-concurrent",
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
issuerURL: "https://test-issuer.com",
|
|
tokenURL: tokenServer.URL,
|
|
jwkCache: ts.mockJWKCache,
|
|
jwksURL: "https://test-jwks-url.com",
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
logger: NewLogger("debug"),
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
initComplete: make(chan struct{}),
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenCache: NewTokenCache(),
|
|
tokenBlacklist: NewCache(),
|
|
redirURLPath: "/oauth/callback",
|
|
refreshGracePeriod: 1 * time.Minute,
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Create a shared session setup
|
|
setupReq := httptest.NewRequest("GET", "/setup", nil)
|
|
setupRr := httptest.NewRecorder()
|
|
|
|
session, err := tOidc.sessionManager.GetSession(setupReq)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session for setup: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail("[email protected]")
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetRefreshToken(validRefreshToken)
|
|
|
|
if err := session.Save(setupReq, setupRr); err != nil {
|
|
t.Fatalf("Failed to save setup session: %v", err)
|
|
}
|
|
|
|
// Get cookies for concurrent requests
|
|
cookies := setupRr.Result().Cookies()
|
|
session.ReturnToPool()
|
|
|
|
// Launch concurrent requests
|
|
const numConcurrentRequests = 10
|
|
var wg sync.WaitGroup
|
|
results := make([]struct {
|
|
statusCode int
|
|
body string
|
|
err error
|
|
}, numConcurrentRequests)
|
|
|
|
t.Logf("Launching %d concurrent requests with expired tokens", numConcurrentRequests)
|
|
|
|
for i := 0; i < numConcurrentRequests; i++ {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
|
|
// Create request with expired token session
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/concurrent-test-%d", index), nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
|
|
// Add cookies from setup
|
|
for _, cookie := range cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
// This should handle the expired token appropriately
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
results[index] = struct {
|
|
statusCode int
|
|
body string
|
|
err error
|
|
}{
|
|
statusCode: rr.Code,
|
|
body: rr.Body.String(),
|
|
err: nil,
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all concurrent requests to complete
|
|
wg.Wait()
|
|
|
|
// ==== ANALYZE CONCURRENT BEHAVIOR ====
|
|
|
|
t.Logf("Total renewal attempts: %d", renewalAttempts)
|
|
t.Logf("Maximum concurrent renewals: %d", maxConcurrentRenewals)
|
|
|
|
// Count successful vs failed requests
|
|
successCount := 0
|
|
redirectCount := 0
|
|
errorCount := 0
|
|
unknownSessionRedirects := 0
|
|
|
|
for i, result := range results {
|
|
t.Logf("Request #%d: Status=%d, Body=%s", i, result.statusCode, result.body)
|
|
|
|
switch result.statusCode {
|
|
case http.StatusOK:
|
|
successCount++
|
|
case http.StatusTemporaryRedirect:
|
|
redirectCount++
|
|
// Check if redirected to unknown-session (bug indicator)
|
|
if strings.Contains(result.body, "/unknown-session") {
|
|
unknownSessionRedirects++
|
|
}
|
|
default:
|
|
errorCount++
|
|
}
|
|
}
|
|
|
|
t.Logf("Results: Success=%d, Redirects=%d, Errors=%d, UnknownSession=%d",
|
|
successCount, redirectCount, errorCount, unknownSessionRedirects)
|
|
|
|
// ==== ASSERTIONS FOR CONCURRENT BEHAVIOR ====
|
|
|
|
// Check for broken behavior patterns
|
|
if renewalAttempts == 0 {
|
|
t.Error("BUG DEMONSTRATED: No token renewal attempts despite expired tokens")
|
|
t.Error("Expected: At least one renewal attempt should occur")
|
|
|
|
if unknownSessionRedirects > 0 {
|
|
t.Errorf("BUG: %d requests redirected to /unknown-session", unknownSessionRedirects)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check for excessive renewal attempts (allow for current implementation behavior)
|
|
if renewalAttempts > numConcurrentRequests {
|
|
t.Errorf("EFFICIENCY ISSUE: More renewal attempts (%d) than concurrent requests (%d)", renewalAttempts, numConcurrentRequests)
|
|
t.Error("Expected: At most one renewal per request should be needed")
|
|
t.Log("Current implementation may lack proper concurrent renewal coordination")
|
|
} else {
|
|
t.Logf("INFO: Renewal attempts (%d) within acceptable range for %d concurrent requests", renewalAttempts, numConcurrentRequests)
|
|
}
|
|
|
|
// Check for excessive concurrent renewals
|
|
if maxConcurrentRenewals > numConcurrentRequests {
|
|
t.Errorf("RACE CONDITION: Up to %d concurrent renewals detected (more than %d requests)", maxConcurrentRenewals, numConcurrentRequests)
|
|
t.Error("Expected: Concurrent renewals should not exceed concurrent requests")
|
|
t.Log("This indicates lack of proper renewal synchronization")
|
|
} else {
|
|
t.Logf("INFO: Maximum concurrent renewals (%d) is within acceptable range for %d concurrent requests", maxConcurrentRenewals, numConcurrentRequests)
|
|
if maxConcurrentRenewals == 1 {
|
|
t.Log("SUCCESS: Perfect synchronization - only one renewal at a time")
|
|
}
|
|
}
|
|
|
|
// Check overall success rate
|
|
if successCount < numConcurrentRequests/2 {
|
|
t.Errorf("LOW SUCCESS RATE: Only %d/%d requests succeeded", successCount, numConcurrentRequests)
|
|
t.Error("Expected: Most requests should succeed after renewal")
|
|
}
|
|
|
|
// Check for /unknown-session redirects (major bug)
|
|
if unknownSessionRedirects > 0 {
|
|
t.Errorf("CRITICAL BUG: %d requests redirected to /unknown-session", unknownSessionRedirects)
|
|
t.Error("This indicates complete failure to handle expired tokens")
|
|
}
|
|
|
|
if successCount == numConcurrentRequests && renewalAttempts <= 2 && maxConcurrentRenewals <= 1 {
|
|
t.Log("SUCCESS: Concurrent requests handled efficiently with proper renewal coordination")
|
|
} else {
|
|
t.Log("ISSUES DETECTED: Concurrent request handling needs improvement")
|
|
}
|
|
}
|
|
|
|
// TestRefreshTokenExpiryScenarios tests scenarios where tokens are expired and validates secure server-side handling
|
|
func TestRefreshTokenExpiryScenarios(t *testing.T) {
|
|
t.Log("Testing refresh token expiry scenarios - validating secure server-side token validation")
|
|
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Test multiple scenarios around refresh token expiry
|
|
scenarios := []struct {
|
|
name string
|
|
accessTokenExpiry time.Duration
|
|
refreshTokenExpiry time.Duration
|
|
expectedBehavior string
|
|
shouldAttemptRefresh bool
|
|
shouldRedirectToAuth bool
|
|
}{
|
|
{
|
|
name: "Access expired, refresh valid",
|
|
accessTokenExpiry: -2 * time.Hour, // Expired 2 hours ago
|
|
refreshTokenExpiry: +24 * time.Hour, // Valid for 24 more hours
|
|
expectedBehavior: "Should refresh access token successfully",
|
|
shouldAttemptRefresh: true,
|
|
shouldRedirectToAuth: false,
|
|
},
|
|
{
|
|
name: "Both access and refresh expired",
|
|
accessTokenExpiry: -6 * time.Hour, // Expired 6 hours ago
|
|
refreshTokenExpiry: -1 * time.Hour, // Also expired 1 hour ago
|
|
expectedBehavior: "Should attempt refresh, handle server error, then redirect to re-authenticate",
|
|
shouldAttemptRefresh: true,
|
|
shouldRedirectToAuth: true,
|
|
},
|
|
{
|
|
name: "Access expired recently, refresh expired long ago",
|
|
accessTokenExpiry: -30 * time.Minute, // Expired 30 min ago
|
|
refreshTokenExpiry: -12 * time.Hour, // Expired 12 hours ago
|
|
expectedBehavior: "Should attempt refresh, handle server error, then redirect to re-authenticate",
|
|
shouldAttemptRefresh: true,
|
|
shouldRedirectToAuth: true,
|
|
},
|
|
{
|
|
name: "Both tokens expired at same time (6 hours ago)",
|
|
accessTokenExpiry: -6 * time.Hour,
|
|
refreshTokenExpiry: -6 * time.Hour,
|
|
expectedBehavior: "Should attempt refresh, handle server error, then redirect to auth",
|
|
shouldAttemptRefresh: true,
|
|
shouldRedirectToAuth: true,
|
|
},
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
t.Run(scenario.name, func(t *testing.T) {
|
|
t.Logf("Testing scenario: %s", scenario.expectedBehavior)
|
|
|
|
// Calculate expiry times
|
|
accessExpTime := time.Now().Add(scenario.accessTokenExpiry)
|
|
refreshExpTime := time.Now().Add(scenario.refreshTokenExpiry)
|
|
|
|
// Create access token with specified expiry
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-refresh-expiry",
|
|
"exp": float64(accessExpTime.Unix()),
|
|
"iat": float64(accessExpTime.Add(-1 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create access token: %v", err)
|
|
}
|
|
|
|
// Create refresh token with specified expiry (simulate JWT refresh token with expiry)
|
|
expiredRefreshToken := fmt.Sprintf("refresh_token_expires_%d", refreshExpTime.Unix())
|
|
|
|
// Track refresh attempts
|
|
refreshAttempts := 0
|
|
refreshErrors := []string{}
|
|
|
|
// Mock token server
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
refreshAttempts++
|
|
t.Logf("Refresh attempt #%d for scenario: %s", refreshAttempts, scenario.name)
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
refreshErrors = append(refreshErrors, "Failed to parse form")
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_ = r.Form.Get("refresh_token") // Ignore unused for now
|
|
|
|
// Simulate checking refresh token expiry
|
|
if scenario.refreshTokenExpiry < 0 {
|
|
// Refresh token is expired - return error
|
|
refreshErrors = append(refreshErrors, "Refresh token expired")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "invalid_grant",
|
|
"error_description": "refresh token expired",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Refresh token is valid - return new tokens
|
|
newAccessToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-refresh-expiry",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
|
|
newIDToken, _ := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-refresh-expiry",
|
|
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Unix()),
|
|
"email": "[email protected]",
|
|
"nonce": "test-nonce",
|
|
})
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": newAccessToken,
|
|
"id_token": newIDToken,
|
|
"refresh_token": fmt.Sprintf("new_refresh_token_%d", time.Now().Unix()),
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Set up middleware
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fmt.Sprintf("Success for scenario: %s", scenario.name)))
|
|
}),
|
|
name: fmt.Sprintf("test-refresh-expiry-%s", scenario.name),
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
tokenURL: tokenServer.URL,
|
|
jwkCache: ts.mockJWKCache,
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
logger: NewLogger("debug"),
|
|
redirURLPath: "/oauth/callback",
|
|
issuerURL: "https://test-issuer.com",
|
|
initComplete: make(chan struct{}),
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second},
|
|
limiter: rate.NewLimiter(rate.Every(100*time.Millisecond), 10),
|
|
tokenCache: NewTokenCache(),
|
|
tokenBlacklist: NewCache(),
|
|
jwksURL: "https://test-jwks-url.com",
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Set up request and session
|
|
req := httptest.NewRequest("GET", "/protected", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail("[email protected]")
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetRefreshToken(expiredRefreshToken)
|
|
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
// Add cookies to request
|
|
for _, cookie := range rr.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
session.ReturnToPool()
|
|
|
|
rr = httptest.NewRecorder()
|
|
|
|
t.Logf("Making request for scenario: %s", scenario.name)
|
|
|
|
// Execute the request
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// ==== ASSERTIONS FOR DIFFERENT SCENARIOS ====
|
|
|
|
t.Logf("Scenario: %s", scenario.name)
|
|
t.Logf("Refresh attempts: %d", refreshAttempts)
|
|
t.Logf("Refresh errors: %v", refreshErrors)
|
|
t.Logf("Response code: %d", rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
|
|
if scenario.shouldRedirectToAuth {
|
|
// Scenario where tokens are expired - should attempt refresh and handle server error securely
|
|
if scenario.shouldAttemptRefresh {
|
|
if refreshAttempts == 0 {
|
|
t.Errorf("Expected refresh attempt for scenario '%s' but none occurred", scenario.name)
|
|
t.Error("Secure behavior: Should attempt refresh with potentially valid tokens")
|
|
} else {
|
|
t.Logf("Secure behavior: Attempted refresh for scenario '%s' and handled server error gracefully", scenario.name)
|
|
if len(refreshErrors) > 0 {
|
|
t.Logf("Server properly rejected expired token with error: %v", refreshErrors[0])
|
|
}
|
|
}
|
|
} else {
|
|
if refreshAttempts > 0 {
|
|
t.Errorf("Unexpected refresh attempt for scenario '%s'", scenario.name)
|
|
}
|
|
}
|
|
|
|
if rr.Code == http.StatusFound || rr.Code == http.StatusTemporaryRedirect {
|
|
location := rr.Header().Get("Location")
|
|
t.Logf("Correctly redirected to: %s", location)
|
|
|
|
// Should redirect to auth, not unknown-session
|
|
if strings.Contains(location, "/unknown-session") {
|
|
t.Errorf("BUG: Redirected to /unknown-session instead of proper auth URL")
|
|
t.Error("Expected: Redirect to OAuth provider for re-authentication")
|
|
t.Error("Actual: Generic /unknown-session redirect")
|
|
} else if strings.Contains(location, "oauth") || strings.Contains(location, "auth") {
|
|
t.Logf("SUCCESS: Properly redirected to auth for scenario '%s'", scenario.name)
|
|
} else {
|
|
t.Errorf("Unexpected redirect location for expired tokens: %s", location)
|
|
}
|
|
} else if rr.Code == http.StatusOK {
|
|
t.Errorf("BUG: Request succeeded despite both tokens being expired")
|
|
t.Error("This indicates broken token expiry detection")
|
|
} else {
|
|
t.Errorf("Unexpected response code %d for scenario '%s'", rr.Code, scenario.name)
|
|
}
|
|
} else if scenario.shouldAttemptRefresh {
|
|
// Scenario where refresh should work and succeed
|
|
if refreshAttempts == 0 {
|
|
t.Errorf("Expected refresh attempt for scenario '%s', but none occurred", scenario.name)
|
|
t.Error("This indicates the middleware is not detecting expired access tokens properly")
|
|
} else {
|
|
// Refresh was attempted - check if it succeeded
|
|
if rr.Code == http.StatusOK {
|
|
t.Logf("SUCCESS: Refresh worked for scenario '%s'", scenario.name)
|
|
if !strings.Contains(rr.Body.String(), scenario.name) {
|
|
t.Errorf("Expected success response to contain scenario name")
|
|
}
|
|
} else {
|
|
t.Errorf("Refresh was attempted but failed - Code: %d", rr.Code)
|
|
t.Errorf("Refresh errors: %v", refreshErrors)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRefreshTokenValidityCheck tests validation of refresh token before attempting refresh
|
|
// TestRefreshTokenValidityCheck validates that malformed tokens are rejected securely client-side
|
|
func TestRefreshTokenValidityCheck(t *testing.T) {
|
|
t.Log("Testing refresh token validity check - validating secure client-side rejection of malformed tokens")
|
|
|
|
ts := NewTestSuite(t)
|
|
ts.Setup()
|
|
|
|
// Create expired access token
|
|
expiredAccessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
|
"iss": "https://test-issuer.com",
|
|
"aud": "test-client-id",
|
|
"sub": "test-user-validity",
|
|
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()),
|
|
"iat": float64(time.Now().Add(-2 * time.Hour).Unix()),
|
|
"email": "[email protected]",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create expired access token: %v", err)
|
|
}
|
|
|
|
invalidRefreshTokenScenarios := []struct {
|
|
name string
|
|
refreshToken string
|
|
expectedBehavior string
|
|
shouldAttemptRefresh bool
|
|
}{
|
|
{
|
|
name: "Empty refresh token",
|
|
refreshToken: "",
|
|
expectedBehavior: "Should not attempt refresh with empty token",
|
|
shouldAttemptRefresh: false,
|
|
},
|
|
{
|
|
name: "Malformed refresh token",
|
|
refreshToken: "invalid-malformed-token-###",
|
|
expectedBehavior: "Should reject malformed token client-side for security",
|
|
shouldAttemptRefresh: false,
|
|
},
|
|
{
|
|
name: "Very short refresh token",
|
|
refreshToken: "abc",
|
|
expectedBehavior: "Should reject short token client-side for security",
|
|
shouldAttemptRefresh: false,
|
|
},
|
|
{
|
|
name: "Token with null bytes",
|
|
refreshToken: "valid-token\x00with-null",
|
|
expectedBehavior: "Should reject token with null bytes for security",
|
|
shouldAttemptRefresh: false,
|
|
},
|
|
}
|
|
|
|
for _, scenario := range invalidRefreshTokenScenarios {
|
|
t.Run(scenario.name, func(t *testing.T) {
|
|
t.Logf("Testing: %s", scenario.expectedBehavior)
|
|
|
|
refreshAttempts := 0
|
|
serverErrors := []string{}
|
|
|
|
// Mock token server that validates refresh token format
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
refreshAttempts++
|
|
t.Logf("Refresh attempt with invalid token: %s", scenario.name)
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
serverErrors = append(serverErrors, "Form parse error")
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
receivedToken := r.Form.Get("refresh_token")
|
|
t.Logf("Received refresh token: %q", receivedToken)
|
|
|
|
// Simulate server-side validation failures
|
|
if receivedToken == "" {
|
|
serverErrors = append(serverErrors, "Empty refresh token")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "invalid_request",
|
|
"error_description": "refresh token is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(receivedToken) < 10 {
|
|
serverErrors = append(serverErrors, "Refresh token too short")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "invalid_grant",
|
|
"error_description": "invalid refresh token format",
|
|
})
|
|
return
|
|
}
|
|
|
|
if strings.Contains(receivedToken, "\x00") {
|
|
serverErrors = append(serverErrors, "Refresh token contains null bytes")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "invalid_grant",
|
|
"error_description": "invalid refresh token encoding",
|
|
})
|
|
return
|
|
}
|
|
|
|
// If we get here, return an invalid_grant error for the malformed token
|
|
serverErrors = append(serverErrors, "Invalid refresh token")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": "invalid_grant",
|
|
"error_description": "refresh token is invalid or expired",
|
|
})
|
|
}))
|
|
defer tokenServer.Close()
|
|
|
|
// Set up middleware
|
|
tOidc := &TraefikOidc{
|
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("Should not reach here with invalid refresh token"))
|
|
}),
|
|
name: fmt.Sprintf("test-validity-%s", scenario.name),
|
|
clientID: "test-client-id",
|
|
clientSecret: "test-client-secret",
|
|
tokenURL: tokenServer.URL,
|
|
jwkCache: ts.mockJWKCache,
|
|
sessionManager: ts.sessionManager,
|
|
extractClaimsFunc: extractClaims,
|
|
logger: NewLogger("debug"),
|
|
redirURLPath: "/oauth/callback",
|
|
issuerURL: "https://test-issuer.com",
|
|
initComplete: make(chan struct{}),
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenHTTPClient: &http.Client{Timeout: 10 * time.Second},
|
|
tokenCache: NewTokenCache(),
|
|
tokenBlacklist: NewCache(),
|
|
jwksURL: "https://test-jwks-url.com",
|
|
refreshGracePeriod: 1 * time.Minute,
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
|
|
}
|
|
tOidc.tokenVerifier = tOidc
|
|
tOidc.jwtVerifier = tOidc
|
|
tOidc.tokenExchanger = tOidc
|
|
|
|
// Close the initComplete channel to bypass the waiting
|
|
close(tOidc.initComplete)
|
|
|
|
// Set up request
|
|
req := httptest.NewRequest("GET", "/protected", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "example.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
session, err := tOidc.sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get session: %v", err)
|
|
}
|
|
|
|
session.SetAuthenticated(true)
|
|
session.SetEmail("[email protected]")
|
|
session.SetAccessToken(expiredAccessToken)
|
|
session.SetRefreshToken(scenario.refreshToken) // Use the invalid refresh token
|
|
|
|
if err := session.Save(req, rr); err != nil {
|
|
t.Fatalf("Failed to save session: %v", err)
|
|
}
|
|
|
|
for _, cookie := range rr.Result().Cookies() {
|
|
req.AddCookie(cookie)
|
|
}
|
|
session.ReturnToPool()
|
|
|
|
rr = httptest.NewRecorder()
|
|
|
|
// Execute the request
|
|
tOidc.ServeHTTP(rr, req)
|
|
|
|
// ==== ASSERTIONS ====
|
|
|
|
t.Logf("Scenario: %s", scenario.name)
|
|
t.Logf("Refresh attempts: %d", refreshAttempts)
|
|
t.Logf("Server errors: %v", serverErrors)
|
|
t.Logf("Response code: %d", rr.Code)
|
|
|
|
if scenario.shouldAttemptRefresh {
|
|
if refreshAttempts == 0 {
|
|
t.Errorf("Expected refresh attempt for '%s', but none occurred", scenario.name)
|
|
t.Error("Should attempt refresh with valid tokens")
|
|
} else {
|
|
t.Logf("Correctly attempted refresh for '%s'", scenario.name)
|
|
|
|
// After failed refresh, should redirect to auth
|
|
if rr.Code == http.StatusTemporaryRedirect {
|
|
location := rr.Header().Get("Location")
|
|
if strings.Contains(location, "/unknown-session") {
|
|
t.Errorf("BUG: After refresh failure, redirected to /unknown-session")
|
|
t.Error("Expected: Redirect to proper auth after refresh failure")
|
|
} else {
|
|
t.Logf("After refresh failure, correctly redirected to: %s", location)
|
|
}
|
|
} else if rr.Code == http.StatusOK {
|
|
t.Error("BUG: Request succeeded despite invalid refresh token")
|
|
}
|
|
}
|
|
} else {
|
|
// Should not attempt refresh - this is the secure behavior
|
|
if refreshAttempts > 0 {
|
|
t.Errorf("Security issue: Attempted refresh with %s, potentially exposing malformed token to server", scenario.name)
|
|
t.Error("Expected: Should detect invalid refresh token client-side and reject it for security")
|
|
} else {
|
|
t.Logf("Security validated: Correctly rejected malformed token '%s' client-side", scenario.name)
|
|
t.Logf("This prevents potentially malicious tokens from reaching the server")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|