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 }