Files
traefikoidc/csrf_session_test.go
lukaszraczylo 9cbca4c4fb fix(refresh): honor userIdentifierClaim in token refresh path (#132)
patch-release

The refresh path in token_manager.go hardcoded the "email" claim when
extracting the user identifier from a refreshed ID token, ignoring the
configured userIdentifierClaim. Keycloak users without an email claim
(using sub or another identifier) were kicked out on refresh even
though their initial login worked.

The callback path (auth_flow.go:226-239) already honored
userIdentifierClaim with "sub" fallback; PR #100 (commit a316a98)
added that support but missed the refresh path.

Mirror the callback logic in refreshToken so both paths behave the same.

Cleanup: rename Get/SetEmail to Get/SetUserIdentifier on SessionData
to match the actual semantics. The slot already stored the configured
identifier (email, sub, oid, upn, preferred_username), only the API
name was misleading. Storage key "email" → "user_identifier" and
combinedSessionPayload field E (json:"e") → Ui (json:"ui").

Compat note: existing user sessions invalidate on upgrade — every active
user re-authenticates once after deploying this change.
2026-05-07 09:21:41 +01:00

477 lines
15 KiB
Go

package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCSRFTokenSessionManagement tests the session management changes that fix the login loop
func TestCSRFTokenSessionManagement(t *testing.T) {
// Test that CSRF tokens persist through the authentication flow
t.Run("CSRF_Token_Persists_After_Selective_Clear", func(t *testing.T) {
// Create a session manager
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Create initial request
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set initial values
csrfToken := "critical-csrf-token"
session.SetCSRF(csrfToken)
session.SetNonce("test-nonce")
session.SetAuthenticated(true)
session.SetUserIdentifier("user@example.com")
session.SetAccessToken("old-access-token")
session.SetRefreshToken("old-refresh-token")
session.SetIDToken("old-id-token")
// Save session
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Get cookies
cookies := rec.Result().Cookies()
// Create new request with cookies (simulating redirect back)
req2 := httptest.NewRequest("GET", "http://example.com/test2", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
// Get session again
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// Verify all values are there
assert.Equal(t, csrfToken, session2.GetCSRF())
assert.Equal(t, "test-nonce", session2.GetNonce())
assert.True(t, session2.GetAuthenticated())
// Now perform selective clearing (as done in the fix)
session2.SetAuthenticated(false)
session2.SetUserIdentifier("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
// Clear OIDC flow values from previous attempts
session2.SetNonce("")
session2.SetCodeVerifier("")
// CRITICAL: CSRF token should still be there
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF token must persist after selective clearing")
// Save again
rec2 := httptest.NewRecorder()
err = session2.Save(req2, rec2)
require.NoError(t, err)
// Verify CSRF token persists in new session
req3 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range rec2.Result().Cookies() {
req3.AddCookie(cookie)
}
session3, err := sessionManager.GetSession(req3)
require.NoError(t, err)
assert.Equal(t, csrfToken, session3.GetCSRF(), "CSRF token must persist across saves")
})
// Test that marking session as dirty forces save
t.Run("Mark_Dirty_Forces_Session_Save", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF token
csrfToken := "test-csrf-token"
session.SetCSRF(csrfToken)
// Mark as dirty explicitly
session.MarkDirty()
// Save should work even if no apparent changes
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookie was set
cookies := rec.Result().Cookies()
assert.NotEmpty(t, cookies, "Cookies should be set after save")
// Find main session cookie
var mainCookie *http.Cookie
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainCookie = cookie
break
}
}
require.NotNil(t, mainCookie, "Main session cookie should be set")
})
// Test Azure-specific session handling
t.Run("Azure_Session_Cookie_Configuration", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Simulate Azure callback scenario
req := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state=test-csrf", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set values as would happen in auth flow
session.SetCSRF("test-csrf")
session.SetNonce("test-nonce")
// Save with proper cookie settings
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Check cookie attributes
cookies := rec.Result().Cookies()
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
// Azure requires SameSite=Lax for cross-site redirects
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite, "SameSite should be Lax for Azure compatibility")
assert.Equal(t, "/", cookie.Path, "Path should be root")
assert.True(t, cookie.HttpOnly, "Cookie should be HttpOnly")
// In production, Secure would be true, but false in test
}
}
})
// Test session continuity through auth flow
t.Run("Session_Continuity_Through_Auth_Flow", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Step 1: Initial request
req1 := httptest.NewRequest("GET", "http://example.com/protected", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
// Simulate auth initiation
csrfToken := "auth-flow-csrf-token"
nonce := "auth-flow-nonce"
session1.SetCSRF(csrfToken)
session1.SetNonce(nonce)
session1.SetIncomingPath("/protected")
// Force save
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
cookies := rec1.Result().Cookies()
require.NotEmpty(t, cookies)
// Step 2: Callback request with same cookies
req2 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state="+csrfToken, nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// Verify session continuity
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF token should be maintained")
assert.Equal(t, nonce, session2.GetNonce(), "Nonce should be maintained")
assert.Equal(t, "/protected", session2.GetIncomingPath(), "Incoming path should be maintained")
})
// Test large token handling doesn't affect CSRF
t.Run("Large_Tokens_Dont_Affect_CSRF", func(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF first
csrfToken := "important-csrf"
session.SetCSRF(csrfToken)
// Add large tokens that might cause chunking
largeToken := generateMockJWT(5000)
session.SetIDToken(largeToken)
session.SetAccessToken(largeToken)
// Save
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Count cookies
cookies := rec.Result().Cookies()
mainFound := false
chunkCount := 0
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
mainFound = true
}
if strings.Contains(cookie.Name, "_oidc_raczylo_") && strings.Contains(cookie.Name, "_") {
chunkCount++
}
}
assert.True(t, mainFound, "Main session cookie must exist")
t.Logf("Total chunks created: %d", chunkCount)
// Verify CSRF is still accessible
req2 := httptest.NewRequest("GET", "http://example.com/test2", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF must be preserved with large tokens")
})
}
// TestAuthFlowWithoutExternalDependencies tests the auth flow without external dependencies
func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
plugin := CreateConfig()
plugin.ProviderURL = "https://login.microsoftonline.com/test-tenant/v2.0"
plugin.ClientID = "test-client-id"
plugin.ClientSecret = "test-client-secret"
plugin.CallbackURL = "http://example.com/oidc/callback"
plugin.SessionEncryptionKey = "test-encryption-key-32-characters"
plugin.LogLevel = "debug"
// Variables removed as they're not used in this test
// We can't fully initialize TraefikOidc without network access,
// but we can test the session management directly
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", "", 0, NewLogger(plugin.LogLevel))
require.NoError(t, err)
t.Run("Session_Created_On_Protected_Request", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/protected", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Session should be new
assert.False(t, session.GetAuthenticated())
// Set auth flow values
session.SetCSRF("test-csrf-token")
session.SetNonce("test-nonce")
session.SetIncomingPath("/protected")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Should have set cookies
cookies := rec.Result().Cookies()
assert.NotEmpty(t, cookies)
})
}
// TestRegressionLoginLoop specifically tests the fix for issue #53
func TestRegressionLoginLoop(t *testing.T) {
// This test verifies that the specific changes made to fix the login loop work correctly
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
// Simulate the exact flow that was causing the login loop
t.Run("Fix_Session_Clear_Timing", func(t *testing.T) {
// Initial request
req := httptest.NewRequest("GET", "http://example.com/protected", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set initial session data
session.SetAuthenticated(true)
session.SetUserIdentifier("old@example.com")
session.SetAccessToken("old-token")
session.SetCSRF("existing-csrf")
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
cookies := rec.Result().Cookies()
// New request with existing session (user hits protected resource again)
req2 := httptest.NewRequest("GET", "http://example.com/protected", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
// OLD BEHAVIOR: session.Clear() would have been called here, losing CSRF
// NEW BEHAVIOR: Selective clearing
session2.SetAuthenticated(false)
session2.SetUserIdentifier("")
session2.SetAccessToken("")
session2.SetRefreshToken("")
session2.SetIDToken("")
session2.SetNonce("")
session2.SetCodeVerifier("")
// CSRF should still exist
existingCSRF := session2.GetCSRF()
assert.Equal(t, "existing-csrf", existingCSRF, "CSRF should persist through selective clear")
// Set new auth flow values
newCSRF := "new-csrf-for-auth"
session2.SetCSRF(newCSRF)
session2.SetNonce("new-nonce")
// Force save
session2.MarkDirty()
rec2 := httptest.NewRecorder()
err = session2.Save(req2, rec2)
require.NoError(t, err)
// Simulate callback
cookies2 := rec2.Result().Cookies()
req3 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=test&state="+newCSRF, nil)
for _, cookie := range cookies2 {
req3.AddCookie(cookie)
}
session3, err := sessionManager.GetSession(req3)
require.NoError(t, err)
// CSRF should match
assert.Equal(t, newCSRF, session3.GetCSRF(), "CSRF token should be available in callback")
})
t.Run("Fix_Force_Session_Save", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/test", nil)
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
// Set CSRF but don't change authenticated status
session.SetCSRF("important-csrf")
// Without MarkDirty(), the session might not save if the session manager
// doesn't detect the change. The fix ensures we call MarkDirty()
session.MarkDirty()
rec := httptest.NewRecorder()
err = session.Save(req, rec)
require.NoError(t, err)
// Verify cookie was actually set
cookies := rec.Result().Cookies()
found := false
for _, cookie := range cookies {
if cookie.Name == "_oidc_raczylo_m" {
found = true
assert.NotEmpty(t, cookie.Value, "Cookie should have value")
}
}
assert.True(t, found, "Main session cookie must be set after MarkDirty")
})
}
// TestCSRFValidationTiming tests timing-sensitive CSRF validation scenarios
func TestCSRFValidationTiming(t *testing.T) {
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug"))
require.NoError(t, err)
t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) {
// Simulate rapid redirect (no delay between auth init and callback)
req1 := httptest.NewRequest("GET", "http://example.com/auth", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
csrfToken := "rapid-redirect-csrf"
session1.SetCSRF(csrfToken)
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
// Immediate callback (no delay)
cookies := rec1.Result().Cookies()
req2 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF())
})
t.Run("Delayed_Redirect_Maintains_CSRF", func(t *testing.T) {
// Simulate delayed redirect (user takes time at provider)
req1 := httptest.NewRequest("GET", "http://example.com/auth", nil)
session1, err := sessionManager.GetSession(req1)
require.NoError(t, err)
csrfToken := "delayed-redirect-csrf"
session1.SetCSRF(csrfToken)
session1.MarkDirty()
rec1 := httptest.NewRecorder()
err = session1.Save(req1, rec1)
require.NoError(t, err)
// Simulate delay
time.Sleep(500 * time.Millisecond)
// Callback after delay
cookies := rec1.Result().Cookies()
req2 := httptest.NewRequest("GET", "http://example.com/callback", nil)
for _, cookie := range cookies {
req2.AddCookie(cookie)
}
session2, err := sessionManager.GetSession(req2)
require.NoError(t, err)
assert.Equal(t, csrfToken, session2.GetCSRF(), "CSRF should persist even with delay")
})
}
// Helper function to generate a mock JWT of specified size
func generateMockJWT(targetSize int) string {
header := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
signature := "signature"
// Calculate payload size needed
overhead := len(header) + len(signature) + 2 // 2 dots
payloadSize := targetSize - overhead
// Create payload with padding
payload := map[string]interface{}{
"sub": "1234567890",
"name": "Test User",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"padding": strings.Repeat("x", payloadSize-100), // Leave room for JSON structure
}
payloadJSON, _ := json.Marshal(payload)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
return header + "." + payloadB64 + "." + signature
}