mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
9cbca4c4fb
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.
477 lines
15 KiB
Go
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
|
|
}
|