diff --git a/.traefik.yml b/.traefik.yml index 8eb5fce..5527c14 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -77,6 +77,7 @@ testData: revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security + cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups) # --- Provider Specific Configuration Examples --- # @@ -373,6 +374,31 @@ configuration: Default: false required: false + cookieDomain: + type: string + description: | + Explicit domain for session cookies. This is important for multi-subdomain setups + and reverse proxy deployments to ensure consistent cookie handling. + + When set, all session cookies will use this domain. When not set, the domain + is auto-detected from the request headers (X-Forwarded-Host or Host). + + Use a leading dot for subdomain-wide cookies (e.g., ".example.com" allows + cookies to be shared between app.example.com, api.example.com, etc.). + + Use a specific domain for host-only cookies (e.g., "app.example.com" restricts + cookies to that exact domain). + + This setting is crucial to prevent authentication issues like "CSRF token missing + in session" errors that can occur when cookies are created with inconsistent domains. + + Examples: + - ".example.com" - Allows all subdomains to share cookies + - "app.example.com" - Restricts cookies to this specific host + + Default: "" (auto-detected from request headers) + required: false + headers: type: array description: | diff --git a/README.md b/README.md index bcbf487..9822e78 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ The middleware supports the following configuration options: | `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` | | `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` | | `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` | +| `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` | | `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section | ## Scope Configuration @@ -311,6 +312,30 @@ spec: - developer ``` +### With Cookie Domain Configuration (Multi-Subdomain Setup) + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-multi-subdomain + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: https://accounts.google.com + clientID: 1234567890.apps.googleusercontent.com + clientSecret: your-client-secret + sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long + callbackURL: /oauth2/callback + logoutURL: /oauth2/logout + cookieDomain: .example.com # Allows cookies to be shared across all subdomains + scopes: + - roles # Appended to defaults: ["openid", "profile", "email", "roles"] +``` + +**Important**: The `cookieDomain` parameter is crucial when running behind a reverse proxy or when your application serves multiple subdomains. Without it, cookies may be created with inconsistent domains, leading to authentication issues like "CSRF token missing in session" errors. + ### With Custom Logging and Rate Limiting ```yaml diff --git a/azure_oauth_callback_test.go b/azure_oauth_callback_test.go index ab1a8fb..fc7eb1b 100644 --- a/azure_oauth_callback_test.go +++ b/azure_oauth_callback_test.go @@ -14,7 +14,7 @@ import ( // during the callback from Azure AD func TestAzureOAuthCallbackScenario(t *testing.T) { t.Run("Azure_OAuth_Complete_Flow", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Step 1: User visits https://app.example.com/protected @@ -110,7 +110,7 @@ func TestAzureOAuthCallbackScenario(t *testing.T) { t.Run("Cookie_Not_Sent_With_Wrong_Domain", func(t *testing.T) { // This test verifies that cookies with wrong domain won't be sent - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Initial request sets cookie for app.example.com @@ -148,7 +148,7 @@ func TestAzureOAuthCallbackScenario(t *testing.T) { // This test demonstrates why we can't use SameSite=Strict for OAuth // With Strict, cookies wouldn't be sent when redirecting from Azure to our app - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // If we had SameSite=Strict (which we don't anymore), the browser would: diff --git a/azure_oidc_test.go b/azure_oidc_test.go index 277250b..a295da7 100644 --- a/azure_oidc_test.go +++ b/azure_oidc_test.go @@ -71,7 +71,7 @@ func TestAzureOIDCRegression(t *testing.T) { tOidc := &mockTraefikOidc{TraefikOidc: baseOidc} // Initialize session manager - sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, mockLogger) + sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, "", mockLogger) tOidc.sessionManager = sessionManager // Mock the JWT verification to avoid JWKS lookup issues diff --git a/cookie_cleanup_test.go b/cookie_cleanup_test.go new file mode 100644 index 0000000..7d4476a --- /dev/null +++ b/cookie_cleanup_test.go @@ -0,0 +1,218 @@ +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestCookieCleanupBehavior tests the cookie cleanup function's behavior +func TestCookieCleanupBehavior(t *testing.T) { + logger := NewLogger("debug") + + // Create session manager with a specific domain + sm, err := NewSessionManager("test-encryption-key-32-characters", false, "app.example.com", logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + // Create request from app.example.com + req := httptest.NewRequest("GET", "http://app.example.com/test", nil) + + // Add some OIDC cookies (browsers don't send domain info) + cookies := []*http.Cookie{ + { + Name: "_oidc_raczylo_m", + Value: "test-value-1", + }, + { + Name: "_oidc_raczylo_a", + Value: "test-value-2", + }, + { + Name: "access_token_chunk_0", + Value: "chunk-value", + }, + } + + for _, cookie := range cookies { + req.AddCookie(cookie) + } + + // Create response recorder to capture Set-Cookie headers + rr := httptest.NewRecorder() + + // Run cleanup - it should attempt to delete cookies with various domains + sm.CleanupOldCookies(rr, req) + + // Check Set-Cookie headers + setCookies := rr.Result().Cookies() + + // The cleanup should have attempted to delete cookies with various domain variations + // We should see deletion attempts for: + // - app.example.com + // - .app.example.com + // - example.com + // - .example.com + var deletionAttempts int + domainsAttempted := make(map[string]bool) + + for _, cookie := range setCookies { + if cookie.MaxAge == -1 { + deletionAttempts++ + domainsAttempted[cookie.Domain] = true + } + } + + // We should see deletion attempts for various domain variations + // Note: The exact number depends on the implementation, but we should see multiple attempts + if deletionAttempts == 0 { + t.Error("Expected cleanup to attempt cookie deletions, but none were found") + } + + // Log the domains attempted for debugging + t.Logf("Deletion attempts: %d", deletionAttempts) + for domain := range domainsAttempted { + t.Logf("Attempted deletion for domain: %q", domain) + } +} + +// TestConfiguredDomainPersistence tests that configured domain is consistently used +func TestConfiguredDomainPersistence(t *testing.T) { + logger := NewLogger("debug") + + // Create session manager with explicit domain configuration + configuredDomain := ".example.com" + sm, err := NewSessionManager("test-encryption-key-32-characters", false, configuredDomain, logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + // Create requests from different subdomains + tests := []struct { + requestHost string + name string + }{ + { + name: "Request from main domain", + requestHost: "example.com", + }, + { + name: "Request from subdomain", + requestHost: "app.example.com", + }, + { + name: "Request from nested subdomain", + requestHost: "api.app.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil) + rr := httptest.NewRecorder() + + // Get session and set a value + session, err := sm.GetSession(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + defer session.ReturnToPool() + + session.SetNonce("test-nonce") + + // Save session + err = session.Save(req, rr) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + // Check that all cookies use the configured domain + cookies := rr.Result().Cookies() + for _, cookie := range cookies { + if strings.HasPrefix(cookie.Name, "_oidc_") { + // The domain should match the configured domain (with possible normalization) + expectedDomains := []string{configuredDomain, strings.TrimPrefix(configuredDomain, ".")} + domainMatches := false + for _, expected := range expectedDomains { + if cookie.Domain == expected || cookie.Domain == "" { + domainMatches = true + break + } + } + if !domainMatches { + t.Errorf("Cookie %s has unexpected domain %q, expected one of %v", + cookie.Name, cookie.Domain, expectedDomains) + } + } + } + }) + } +} + +// TestDomainMigration simulates migrating from no configured domain to explicit domain +func TestDomainMigration(t *testing.T) { + logger := NewLogger("debug") + + // Step 1: Create session without configured domain (auto-detection) + sm1, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + req1 := httptest.NewRequest("GET", "http://app.example.com/test", nil) + rr1 := httptest.NewRecorder() + + session1, err := sm1.GetSession(req1) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + + session1.SetNonce("test-nonce") + session1.Save(req1, rr1) + session1.ReturnToPool() + + // The cookies will have auto-detected domain + oldCookies := rr1.Result().Cookies() + t.Logf("Old cookies count: %d", len(oldCookies)) + + // Step 2: Create new session manager with explicit domain configuration + sm2, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + // Step 3: Make request with old cookies + req2 := httptest.NewRequest("GET", "http://app.example.com/test", nil) + + // Add the old cookies to the new request + for _, cookie := range oldCookies { + // Simulate browser behavior - don't include domain in Cookie header + simpleCookie := &http.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + } + req2.AddCookie(simpleCookie) + } + + rr2 := httptest.NewRecorder() + + // Run cleanup - should attempt to delete old cookies + sm2.CleanupOldCookies(rr2, req2) + + // Check that deletion cookies were sent + newCookies := rr2.Result().Cookies() + deletionCount := 0 + for _, cookie := range newCookies { + if cookie.MaxAge == -1 { + deletionCount++ + } + } + + if deletionCount == 0 { + t.Error("Expected cleanup to send deletion cookies during migration, but none were found") + } + + t.Logf("Sent %d deletion cookies during migration", deletionCount) +} diff --git a/cookie_domain_test.go b/cookie_domain_test.go new file mode 100644 index 0000000..9bbbf83 --- /dev/null +++ b/cookie_domain_test.go @@ -0,0 +1,205 @@ +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestCookieDomainConfiguration tests that the cookie domain configuration is properly applied +func TestCookieDomainConfiguration(t *testing.T) { + tests := []struct { + configDomain string + requestHost string + forwardedHost string + expectedDomain string + name string + }{ + { + name: "Configured domain takes precedence", + configDomain: ".example.com", + requestHost: "app.example.com", + expectedDomain: ".example.com", + }, + { + name: "Auto-detection when no domain configured", + configDomain: "", + requestHost: "app.example.com", + expectedDomain: "app.example.com", + }, + { + name: "X-Forwarded-Host used for auto-detection", + configDomain: "", + requestHost: "internal.local", + forwardedHost: "public.example.com", + expectedDomain: "public.example.com", + }, + { + name: "No domain for localhost", + configDomain: "", + requestHost: "localhost:8080", + expectedDomain: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create session manager with configured domain + logger := NewLogger("debug") + sm, err := NewSessionManager("test-encryption-key-32-characters", false, tt.configDomain, logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + // Create request + req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil) + if tt.forwardedHost != "" { + req.Header.Set("X-Forwarded-Host", tt.forwardedHost) + } + + // Create a dummy response writer to test getCookieOptions behavior + // We'll examine the session options domain instead + options := &http.Cookie{ + Domain: sm.cookieDomain, + } + // If no configured domain, simulate auto-detection + if sm.cookieDomain == "" && req != nil { + host := req.Host + if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" { + host = forwardedHost + } + if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") { + if colonIndex := strings.Index(host, ":"); colonIndex != -1 { + host = host[:colonIndex] + } + options.Domain = host + } + } + + // Check domain + if options.Domain != tt.expectedDomain { + t.Errorf("Expected domain %q, got %q", tt.expectedDomain, options.Domain) + } + }) + } +} + +// TestCookieDomainConsistency tests that all session cookies use the same domain +func TestCookieDomainConsistency(t *testing.T) { + logger := NewLogger("debug") + + // Test with configured domain + sm, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + req := httptest.NewRequest("GET", "http://app.example.com/test", nil) + rr := httptest.NewRecorder() + + // Get session and set some values + session, err := sm.GetSession(req) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + defer session.ReturnToPool() + + // Set various session values including ID token + session.SetNonce("test-nonce") + session.SetAccessToken("test-access-token") + session.SetRefreshToken("test-refresh-token") + // Set a valid JWT-like ID token (needs 2 dots) + session.SetIDToken("header.payload.signature") + + // Save session + err = session.Save(req, rr) + if err != nil { + t.Fatalf("Failed to save session: %v", err) + } + + // Check all cookies have the same domain + cookies := rr.Result().Cookies() + var seenDomain string + + for _, cookie := range cookies { + // Only check our OIDC cookies + if strings.HasPrefix(cookie.Name, "_oidc_") || + strings.HasPrefix(cookie.Name, "access_token_chunk_") || + strings.HasPrefix(cookie.Name, "refresh_token_chunk_") { + + // Normalize domain for comparison (handle leading dot and empty domain) + normalizedDomain := cookie.Domain + if normalizedDomain == "" { + // Empty domain means host-only cookie, should match configured domain + normalizedDomain = "example.com" + } else if strings.HasPrefix(normalizedDomain, ".") { + normalizedDomain = normalizedDomain[1:] + } + + if seenDomain == "" { + seenDomain = normalizedDomain + } else if normalizedDomain != seenDomain { + t.Errorf("Inconsistent cookie domains: %q vs %q for cookie %s", + seenDomain, normalizedDomain, cookie.Name) + } + + // Verify it matches configured domain (browsers may normalize by removing leading dot) + // Empty domain is also acceptable for host-only cookies + if cookie.Domain != "" && cookie.Domain != ".example.com" && cookie.Domain != "example.com" { + t.Errorf("Cookie %s has domain %q, expected %q, %q, or empty", + cookie.Name, cookie.Domain, ".example.com", "example.com") + } + } + } +} + +// TestCookieDomainWithReverseProxy simulates a reverse proxy scenario +func TestCookieDomainWithReverseProxy(t *testing.T) { + logger := NewLogger("debug") + + // No configured domain, should auto-detect from X-Forwarded-Host + sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger) + if err != nil { + t.Fatalf("Failed to create session manager: %v", err) + } + + // Simulate reverse proxy request + req := httptest.NewRequest("GET", "http://internal.local:8080/test", nil) + req.Header.Set("X-Forwarded-Host", "public.example.com") + req.Header.Set("X-Forwarded-Proto", "https") + + // Test the domain configuration + // Since getCookieOptions is private, we'll check the configured domain directly + options := &http.Cookie{ + Domain: sm.cookieDomain, + } + // If no configured domain, simulate auto-detection + if sm.cookieDomain == "" && req != nil { + host := req.Host + if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" { + host = forwardedHost + } + if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") { + if colonIndex := strings.Index(host, ":"); colonIndex != -1 { + host = host[:colonIndex] + } + options.Domain = host + } + } + + // Check secure flag based on X-Forwarded-Proto + isSecure := req.Header.Get("X-Forwarded-Proto") == "https" || req.TLS != nil + options.Secure = isSecure // Note: forceHTTPS is private so we can't access it in test + + // Should use the forwarded host + if options.Domain != "public.example.com" { + t.Errorf("Expected domain from X-Forwarded-Host %q, got %q", + "public.example.com", options.Domain) + } + + // Should be secure due to X-Forwarded-Proto + if !options.Secure { + t.Error("Expected Secure flag to be true with X-Forwarded-Proto: https") + } +} diff --git a/csrf_session_test.go b/csrf_session_test.go index e924ea5..8030072 100644 --- a/csrf_session_test.go +++ b/csrf_session_test.go @@ -18,7 +18,7 @@ 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, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Create initial request @@ -90,7 +90,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) { // 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, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "http://example.com/test", nil) @@ -126,7 +126,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) { // Test Azure-specific session handling t.Run("Azure_Session_Cookie_Configuration", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Simulate Azure callback scenario @@ -158,7 +158,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) { // 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, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Step 1: Initial request @@ -199,7 +199,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) { // 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, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "http://example.com/test", nil) @@ -262,7 +262,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) { // We can't fully initialize TraefikOidc without network access, // but we can test the session management directly - sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, NewLogger(plugin.LogLevel)) + sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", NewLogger(plugin.LogLevel)) require.NoError(t, err) t.Run("Session_Created_On_Protected_Request", func(t *testing.T) { @@ -291,7 +291,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) { // 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, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Simulate the exact flow that was causing the login loop @@ -392,7 +392,7 @@ func TestRegressionLoginLoop(t *testing.T) { // TestCSRFValidationTiming tests timing-sensitive CSRF validation scenarios func TestCSRFValidationTiming(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) { diff --git a/google_session_test.go b/google_session_test.go index 208f28d..3a64f4d 100644 --- a/google_session_test.go +++ b/google_session_test.go @@ -43,7 +43,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) { } // Create a session manager - sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger) + sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger) tOidc.sessionManager = sessionManager t.Run("Google provider detection adds required parameters", func(t *testing.T) { @@ -367,7 +367,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) { } // Create a session manager - sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger) + sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger) googleTOidc.sessionManager = sessionManager // Create a mock token verifier diff --git a/issue53_regression_test.go b/issue53_regression_test.go index 2469712..8c9e3e6 100644 --- a/issue53_regression_test.go +++ b/issue53_regression_test.go @@ -20,7 +20,7 @@ func TestIssue53Regression(t *testing.T) { // 3. Session cookies must be properly configured for HTTPS // 4. CSRF token must persist through the OAuth flow - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Step 1: Initial request to protected resource @@ -108,7 +108,7 @@ func TestIssue53Regression(t *testing.T) { // This test ensures that once the session is properly maintained, // token signature verification works correctly for Azure tokens - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Create authenticated session with Azure tokens @@ -154,7 +154,7 @@ func TestIssue53Regression(t *testing.T) { // This test verifies the redirect loop prevention mechanism // that was added to handle authentication failures gracefully - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "http://internal/protected", nil) @@ -183,7 +183,7 @@ func TestIssue53Regression(t *testing.T) { // in different reverse proxy scenarios func TestReverseProxySameSiteHandling(t *testing.T) { t.Run("SameSite_Lax_For_HTTPS_OAuth", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // HTTPS request via proxy @@ -211,7 +211,7 @@ func TestReverseProxySameSiteHandling(t *testing.T) { }) t.Run("SameSite_Lax_For_HTTP", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Plain HTTP request (no proxy headers) diff --git a/main.go b/main.go index df3a5cd..8134893 100644 --- a/main.go +++ b/main.go @@ -756,7 +756,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h suppressDiagnosticLogs: isTestMode(), // Suppress diagnostic logs during tests } - t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger) + t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, t.logger) t.errorRecoveryManager = NewErrorRecoveryManager(t.logger) t.extractClaimsFunc = extractClaims // t.exchangeCodeForTokenFunc = t.exchangeCodeForToken // Removed, using interface now @@ -1087,6 +1087,10 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } + // Clean up any old cookies with different domains before processing + // This prevents CSRF token mismatch errors when cookie domain configuration changes + t.sessionManager.CleanupOldCookies(rw, req) + // Retrieve or create session for request session, err := t.sessionManager.GetSession(req) if err != nil { diff --git a/main_test.go b/main_test.go index 046fd89..99478c2 100644 --- a/main_test.go +++ b/main_test.go @@ -90,7 +90,7 @@ func (ts *TestSuite) Setup() { } logger := NewLogger("info") - ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) // Common TraefikOidc instance ts.tOidc = &TraefikOidc{ @@ -1173,7 +1173,7 @@ func TestHandleCallback(t *testing.T) { ts.tOidc.tokenBlacklist = NewCache() // Use generic cache for blacklist logger := NewLogger("info") - sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) // Create a new instance for each test to avoid state carryover instanceExtractClaimsFunc := tc.extractClaimsFunc @@ -1561,7 +1561,7 @@ func TestHandleLogout(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { logger := NewLogger("info") - sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) tOidc := &TraefikOidc{ revocationURL: mockRevocationServer.URL, endSessionURL: tc.endSessionURL, @@ -1863,7 +1863,7 @@ func TestHandleExpiredToken(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { logger := NewLogger("info") - sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) tOidc := &TraefikOidc{ sessionManager: sessionManager, diff --git a/memory_leak_test.go b/memory_leak_test.go index 23eaa51..5de819f 100644 --- a/memory_leak_test.go +++ b/memory_leak_test.go @@ -72,7 +72,7 @@ func TestMemoryLeakFixes(t *testing.T) { t.Run("Session pool returns properly", func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("test-encryption-key-that-is-long-enough-32bytes", false, logger) + sm, err := NewSessionManager("test-encryption-key-that-is-long-enough-32bytes", false, "", logger) if err != nil { t.Fatal(err) } diff --git a/reverse_proxy_test.go b/reverse_proxy_test.go index d1ece3c..31c8634 100644 --- a/reverse_proxy_test.go +++ b/reverse_proxy_test.go @@ -12,7 +12,7 @@ import ( // TestReverseProxyHTTPSDetection tests that HTTPS is properly detected in reverse proxy environments func TestReverseProxyHTTPSDetection(t *testing.T) { t.Run("HTTPS_Detection_With_X_Forwarded_Proto", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Simulate request from reverse proxy (Traefik/nginx) @@ -48,7 +48,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) { }) t.Run("HTTPS_Detection_Without_Headers", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Request without reverse proxy headers (direct HTTP) @@ -79,7 +79,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) { t.Run("HTTPS_Detection_With_ForceHTTPS", func(t *testing.T) { // Test with forceHTTPS enabled - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug")) require.NoError(t, err) // Even without headers, forceHTTPS should make cookies secure @@ -110,7 +110,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) { // TestCSRFPersistenceInReverseProxy tests CSRF token persistence in reverse proxy setups func TestCSRFPersistenceInReverseProxy(t *testing.T) { t.Run("CSRF_Persists_Through_OAuth_Flow_With_Proxy", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Step 1: Initial request to protected resource (HTTPS via proxy) @@ -156,7 +156,7 @@ func TestCSRFPersistenceInReverseProxy(t *testing.T) { }) t.Run("Session_Cookie_Domain_With_Proxy_Headers", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Test with X-Forwarded-Host header @@ -196,7 +196,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) { // This test verifies Azure-specific provider detection and configuration // without making actual network calls - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Step 1: Test session setup for Azure OAuth flow @@ -254,7 +254,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) { }) t.Run("Mixed_HTTP_HTTPS_Requests", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Simulate a scenario where some requests come via HTTPS proxy and some don't @@ -299,7 +299,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) { // TestEnhanceSessionSecurity verifies the security enhancement function func TestEnhanceSessionSecurity(t *testing.T) { t.Run("Security_Enhancement_For_AJAX_Requests", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // AJAX request via HTTPS proxy @@ -327,7 +327,7 @@ func TestEnhanceSessionSecurity(t *testing.T) { }) t.Run("Security_Enhancement_Missing_User_Agent", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Request without User-Agent (potential bot/attack) diff --git a/robustness_test.go b/robustness_test.go index c3cfe6f..9a78b05 100644 --- a/robustness_test.go +++ b/robustness_test.go @@ -197,7 +197,7 @@ func TestCacheMemoryExhaustion(t *testing.T) { // TestSessionConcurrencyProtection tests session safety under concurrent access func TestSessionConcurrencyProtection(t *testing.T) { logger := NewLogger("debug") - sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/security_edge_cases_test.go b/security_edge_cases_test.go index ab9dfaa..5ee5f1b 100644 --- a/security_edge_cases_test.go +++ b/security_edge_cases_test.go @@ -461,7 +461,7 @@ func TestMissingClaims(t *testing.T) { // TestSessionFixationAttack tests the plugin's resistance to session fixation attacks func TestSessionFixationAttack(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -615,7 +615,7 @@ func TestSessionFixationAttack(t *testing.T) { // TestCSRFProtection tests CSRF protection in POST requests func TestCSRFProtection(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger) + sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/session.go b/session.go index 9f65ff4..5019c5e 100644 --- a/session.go +++ b/session.go @@ -243,17 +243,20 @@ type SessionManager struct { store sessions.Store logger *Logger forceHTTPS bool + cookieDomain string chunkManager *ChunkManager + cleanupDone bool // Track if we've attempted cookie cleanup } // NewSessionManager creates a new session manager with the specified configuration. // Parameters: // - encryptionKey: Key used to encrypt session data (must be at least 32 bytes) // - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme +// - cookieDomain: Explicit domain for cookies (e.g., ".example.com" for subdomain access) // - logger: Logger instance for recording session-related events // // Returns an error if the encryption key does not meet minimum length requirements. -func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*SessionManager, error) { +func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, logger *Logger) (*SessionManager, error) { // Validate encryption key length. if len(encryptionKey) < minEncryptionKeyLength { return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength) @@ -262,6 +265,7 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (* sm := &SessionManager{ store: sessions.NewCookieStore([]byte(encryptionKey)), forceHTTPS: forceHTTPS, + cookieDomain: cookieDomain, logger: logger, chunkManager: NewChunkManager(logger), } @@ -512,8 +516,13 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h // Always enforce security best practices options.HttpOnly = true - // Set secure Domain attribute for production - if options.Domain == "" && r != nil { + // Set cookie domain from configuration if specified + if sm.cookieDomain != "" { + // Use the explicitly configured domain + options.Domain = sm.cookieDomain + sm.logger.Debugf("Using configured cookie domain: %s", sm.cookieDomain) + } else if options.Domain == "" && r != nil { + // Fall back to auto-detection only if no domain is configured // In reverse proxy setups, use the original host if available // This ensures cookies work correctly with the public domain host := r.Host @@ -529,10 +538,10 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h if colonIndex := strings.Index(host, ":"); colonIndex != -1 { host = host[:colonIndex] // Remove port } - // For cookie domain, we might want to use the base domain - // to allow cookies to work across subdomains - // But for now, use the exact host for security + // Use the exact host (without leading dot) for host-only cookies + // This prevents subdomain access but ensures consistency options.Domain = host + sm.logger.Debugf("Auto-detected cookie domain: %s", host) } } @@ -555,10 +564,101 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options { SameSite: http.SameSiteLaxMode, MaxAge: int(absoluteSessionTimeout.Seconds()), Path: "/", + Domain: sm.cookieDomain, // Use configured domain if set } return baseOptions } +// CleanupOldCookies attempts to remove cookies with potentially mismatched domains. +// Since browsers don't send domain info with cookies, this function tries to delete +// cookies with various domain variations to ensure cleanup after configuration changes. +func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Request) { + // Get all cookies from the request + cookies := r.Cookies() + + // Get the current configured domain + currentDomain := sm.cookieDomain + host := r.Host + if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { + host = forwardedHost + } + // Remove port if present + if colonIndex := strings.Index(host, ":"); colonIndex != -1 { + host = host[:colonIndex] + } + + // Build list of domains to attempt deletion for + // This ensures we clean up cookies from various possible domains + var domainsToClean []string + + // Add variations of the current host + if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") { + domainsToClean = append(domainsToClean, + host, // exact host + "."+host, // with leading dot + ) + + // Add parent domain variations if host has subdomains + parts := strings.Split(host, ".") + if len(parts) > 2 { + // For "sub.example.com", also try "example.com" and ".example.com" + parentDomain := strings.Join(parts[len(parts)-2:], ".") + domainsToClean = append(domainsToClean, + parentDomain, + "."+parentDomain, + ) + } + } + + // Track which cookies we're processing + processedCookies := make(map[string]bool) + + // Check each cookie + for _, cookie := range cookies { + // Only process our OIDC session cookies + if strings.HasPrefix(cookie.Name, mainCookieName) || + strings.HasPrefix(cookie.Name, accessTokenCookie) || + strings.HasPrefix(cookie.Name, refreshTokenCookie) || + strings.HasPrefix(cookie.Name, "_oidc_raczylo_id") || + strings.HasPrefix(cookie.Name, "access_token_chunk_") || + strings.HasPrefix(cookie.Name, "refresh_token_chunk_") { + + // Mark this cookie as seen + processedCookies[cookie.Name] = true + + // If we have a configured domain and this is the first request after config change, + // attempt to delete the cookie with various domain variations to ensure cleanup + if currentDomain != "" && !sm.cleanupDone { + for _, domain := range domainsToClean { + // Skip the current configured domain + if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain { + continue + } + + // Create a deletion cookie for this domain variation + deleteCookie := &http.Cookie{ + Name: cookie.Name, + Value: "", + Path: "/", + Domain: domain, + MaxAge: -1, + HttpOnly: true, + Secure: r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS, + SameSite: http.SameSiteLaxMode, + } + http.SetCookie(w, deleteCookie) + sm.logger.Debugf("Attempting to clean up cookie %s with domain %s", cookie.Name, domain) + } + } + } + } + + // Mark cleanup as done for this session manager instance + if !sm.cleanupDone && len(processedCookies) > 0 { + sm.cleanupDone = true + } +} + // GetSession retrieves all session data for the current request. // It loads the main session and token sessions, including any chunked token data, // and combines them into a single SessionData structure for easy access. diff --git a/session_compatibility_test.go b/session_compatibility_test.go index 6c274e2..a8c8b3f 100644 --- a/session_compatibility_test.go +++ b/session_compatibility_test.go @@ -14,7 +14,7 @@ import ( func TestSessionCompatibilityAfterChanges(t *testing.T) { t.Run("Plain_HTTP_Without_Proxy_Headers", func(t *testing.T) { // Test that plain HTTP requests without proxy headers still work - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "http://localhost:8080/test", nil) @@ -67,7 +67,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) { t.Run("HTTPS_With_TLS_Field", func(t *testing.T) { // Test direct HTTPS connection (not proxied) - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "https://example.com/test", nil) @@ -101,7 +101,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) { t.Run("ForceHTTPS_Setting", func(t *testing.T) { // Test forceHTTPS setting works regardless of request - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug")) require.NoError(t, err) // Plain HTTP request @@ -132,7 +132,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) { }) t.Run("AJAX_Request_Gets_Strict_SameSite", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // AJAX request @@ -165,7 +165,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) { }) t.Run("Missing_UserAgent_Gets_Reduced_Timeout", func(t *testing.T) { - sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug")) + sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug")) require.NoError(t, err) // Request without User-Agent (suspicious) diff --git a/session_test.go b/session_test.go index 5bc7965..7f900f7 100644 --- a/session_test.go +++ b/session_test.go @@ -16,7 +16,7 @@ import ( func TestSessionPoolMemoryLeak(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -44,7 +44,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) { // Test 3: Error path in GetSession // Modify the session store to force an error - use a different encryption key - badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, logger) + badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", logger) // Get session using mismatched manager/request to force error _, err = badSM.GetSession(req) @@ -68,7 +68,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) { func TestSessionErrorHandling(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -95,7 +95,7 @@ func TestSessionErrorHandling(t *testing.T) { func TestSessionClearAlwaysReturnsToPool(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -181,7 +181,7 @@ func getPooledObjects(sm *SessionManager) int { // returned to the pool in various scenarios including normal usage and error paths func TestSessionObjectTracking(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -344,7 +344,7 @@ func TestTokenCompressionCorruptionDetection(t *testing.T) { // TestTokenChunkingIntegrity tests that large tokens are properly chunked and reassembled func TestTokenChunkingIntegrity(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -419,7 +419,7 @@ func TestTokenChunkingIntegrity(t *testing.T) { // TestTokenChunkingCorruptionResistance tests handling of corrupted chunks func TestTokenChunkingCorruptionResistance(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -525,7 +525,7 @@ func TestTokenChunkingCorruptionResistance(t *testing.T) { // TestTokenSizeLimits tests that token size limits are enforced func TestTokenSizeLimits(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -592,7 +592,7 @@ func TestTokenSizeLimits(t *testing.T) { // TestConcurrentTokenOperations tests thread safety of token operations func TestConcurrentTokenOperations(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -646,7 +646,7 @@ func TestConcurrentTokenOperations(t *testing.T) { // TestSessionValidationAndCleanup tests session validation and orphan cleanup func TestSessionValidationAndCleanup(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -694,7 +694,7 @@ func TestSessionValidationAndCleanup(t *testing.T) { // TestLargeIDTokenChunking tests that large ID tokens are properly chunked across multiple cookies func TestLargeIDTokenChunking(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/settings.go b/settings.go index c1f520b..313ce1a 100644 --- a/settings.go +++ b/settings.go @@ -43,6 +43,7 @@ type Config struct { AllowedUsers []string `json:"allowedUsers"` Scopes []string `json:"scopes"` Headers []TemplatedHeader `json:"headers"` + CookieDomain string `json:"cookieDomain"` RateLimit int `json:"rateLimit"` RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"` ForceHTTPS bool `json:"forceHTTPS"` diff --git a/test_helpers_adapter.go b/test_helpers_adapter.go index c89c535..29ba762 100644 --- a/test_helpers_adapter.go +++ b/test_helpers_adapter.go @@ -85,7 +85,7 @@ func setupTestOIDCMiddleware(t *testing.T, config *Config) (*TraefikOidc, *httpt logInfo: log.New(&testWriter{t}, "INFO: ", 0), logDebug: log.New(&testWriter{t}, "DEBUG: ", 0), } - sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, logger) + sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", logger) // Create next handler nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -217,7 +217,7 @@ func createMockJWT(t *testing.T, sub, email string) string { func createTestSession() *SessionData { // Create a minimal session manager for testing logger := newNoOpLogger() - sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, logger) + sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", logger) // Create a test request req := httptest.NewRequest("GET", "/", nil) diff --git a/token_corruption_test.go b/token_corruption_test.go index e983c5e..97133c3 100644 --- a/token_corruption_test.go +++ b/token_corruption_test.go @@ -17,7 +17,7 @@ import ( // Token verified successfully multiple times, then fails with "signature verification failed" func TestTokenCorruptionScenario(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -180,7 +180,7 @@ func TestCompressionIntegrityFailure(t *testing.T) { // TestChunkReassemblyEdgeCases tests edge cases in chunk reassembly that could cause corruption func TestChunkReassemblyEdgeCases(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -296,7 +296,7 @@ func TestChunkReassemblyEdgeCases(t *testing.T) { // TestRaceConditionProtection tests that concurrent access doesn't cause corruption func TestRaceConditionProtection(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -432,7 +432,7 @@ func TestMemoryExhaustionProtection(t *testing.T) { // TestBackwardCompatibility ensures that sessions created before the fixes still work func TestBackwardCompatibility(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/token_handling_test.go b/token_handling_test.go index 8f06836..4be9008 100644 --- a/token_handling_test.go +++ b/token_handling_test.go @@ -247,7 +247,7 @@ func TestSessionIDTokenAccessToken(t *testing.T) { logger := NewLogger("debug") // Create a session manager - sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, logger) + 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) } @@ -318,7 +318,7 @@ func TestSessionIDTokenAccessToken(t *testing.T) { // TestTokenCorruptionIntegrationFlows tests the complete token handling flow with corruption scenarios func TestTokenCorruptionIntegrationFlows(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -442,7 +442,7 @@ func TestTokenCorruptionIntegrationFlows(t *testing.T) { // TestSessionPersistenceWithCorruption tests that session corruption is handled across requests func TestSessionPersistenceWithCorruption(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -506,7 +506,7 @@ func TestSessionPersistenceWithCorruption(t *testing.T) { // TestConcurrentTokenOperationsWithCorruption tests concurrent access with intentional corruption func TestConcurrentTokenOperationsWithCorruption(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -592,7 +592,7 @@ func TestConcurrentTokenOperationsWithCorruption(t *testing.T) { // TestTokenValidationEdgeCases tests edge cases in token validation func TestTokenValidationEdgeCases(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) }