diff --git a/.traefik.yml b/.traefik.yml index 8aa5a1d..197c594 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -110,6 +110,7 @@ testData: 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) cookiePrefix: "" # Custom prefix for cookie names (e.g., "_oidc_myapp_" for session isolation between middleware instances) + sessionMaxAge: 86400 # Maximum session age in seconds (default: 86400 = 24 hours, 0 = use default) overrideScopes: false # When true, replaces default scopes instead of appending (default: false) refreshGracePeriodSeconds: 60 # Seconds before token expiry to attempt proactive refresh (default: 60) @@ -693,6 +694,45 @@ configuration: See: https://github.com/lukaszraczylo/traefikoidc/issues/87 required: false + sessionMaxAge: + type: integer + description: | + Maximum session age in seconds before requiring re-authentication. + + This setting controls how long a user's authentication session remains valid + before they must authenticate again through the OIDC provider. The session + age is tracked from the initial authentication time (created_at). + + When a session exceeds this age: + - The session is cleared and invalidated + - The user is redirected to re-authenticate + - All session cookies are removed + + Use Cases: + - High-security applications: Use shorter durations (e.g., 3600 = 1 hour) + - Standard applications: Default 24 hours balances security and UX + - Long-lived sessions: Extend for applications accessed infrequently + (e.g., 604800 = 7 days, 2592000 = 30 days) + + Security Considerations: + - Shorter sessions provide better security but require more frequent logins + - Longer sessions improve user experience but increase security risk + - Consider your application's security requirements and user access patterns + - This is independent of token refresh - tokens can be refreshed during the session + + Common Values: + - 3600 (1 hour) - High security applications + - 28800 (8 hours) - Working day session + - 86400 (24 hours) - Default, balances security and convenience + - 604800 (7 days) - Weekly session for less frequently accessed apps + - 2592000 (30 days) - Monthly session for infrequently used applications + + Default: 86400 (24 hours) + Minimum: 0 (uses default of 24 hours) + + See: https://github.com/lukaszraczylo/traefikoidc/issues/91 + required: false + overrideScopes: type: boolean description: | diff --git a/README.md b/README.md index 77d08f1..180439a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ The middleware supports the following configuration options: | `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` | | `cookiePrefix` | Custom prefix for session cookie names (for isolating multiple middleware instances) | `_oidc_raczylo_` | `_oidc_userauth_`, `_oidc_admin_` | +| `sessionMaxAge` | Maximum session age in seconds before requiring re-authentication | `86400` (24 hours) | `3600` (1 hour), `604800` (7 days) | | `audience` | Custom audience for access token validation (for Auth0 custom APIs, etc.) | `clientID` | `https://my-api.example.com` | | `strictAudienceValidation` | Reject sessions with access token audience mismatch (prevents token confusion attacks) | `false` | `true` | | `allowOpaqueTokens` | Enable opaque (non-JWT) access token support via RFC 7662 introspection | `false` | `true` | @@ -934,6 +935,39 @@ spec: This configuration prevents authorization bypass issues where a user authenticated via the general middleware could access admin-protected routes. See [issue #87](https://github.com/lukaszraczylo/traefikoidc/issues/87) for more details. +### With Extended Session Duration + +For applications that users access infrequently (weekly or monthly), you can extend the session duration beyond the default 24 hours to reduce authentication friction: + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oidc-long-session + namespace: traefik +spec: + plugin: + traefikoidc: + providerURL: https://auth.example.com + clientID: your-client-id + clientSecret: your-client-secret + sessionEncryptionKey: your-key-at-least-32-bytes-long + callbackURL: /oauth2/callback + sessionMaxAge: 604800 # 7 days (in seconds) + # Other common values: + # 259200 - 3 days + # 604800 - 7 days + # 1209600 - 14 days + # 2592000 - 30 days +``` + +**Security Note**: Longer session durations improve user experience but increase security risk. Consider your application's security requirements: +- **High-security apps**: Use shorter sessions (3600 = 1 hour) +- **Standard apps**: Default 24 hours balances security and UX +- **Low-frequency access apps**: Extend to 7-30 days for better UX + +See [issue #91](https://github.com/lukaszraczylo/traefikoidc/issues/91) for more details. + ### With Custom Logging and Rate Limiting ```yaml diff --git a/audience_validation_test.go b/audience_validation_test.go index 7f445c1..8e07184 100644 --- a/audience_validation_test.go +++ b/audience_validation_test.go @@ -838,7 +838,7 @@ func TestAudienceEndToEndScenario(t *testing.T) { } logger := NewLogger("debug") - sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", "", logger) + sm, err := NewSessionManager(strings.Repeat("a", MinSessionEncryptionKeyLength), false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/azure_oidc_test.go b/azure_oidc_test.go index 34ed476..f511fb8 100644 --- a/azure_oidc_test.go +++ b/azure_oidc_test.go @@ -79,7 +79,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, "", "", 0, mockLogger) tOidc.sessionManager = sessionManager // Mock the JWT verification to avoid JWKS lookup issues diff --git a/csrf_session_test.go b/csrf_session_test.go index 790afd2..4125187 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, 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, "", "", 0, NewLogger("debug")) require.NoError(t, err) t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) { diff --git a/main.go b/main.go index ac2d862..3f53408 100644 --- a/main.go +++ b/main.go @@ -226,7 +226,9 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name t.logger.Debugf("No custom audience specified, using clientID as audience: %s", t.clientID) } - t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, config.CookiePrefix, t.logger) // Safe to ignore: session manager creation with fallback to defaults + // Convert sessionMaxAge from seconds to duration (0 will use default 24 hours) + sessionMaxAge := time.Duration(config.SessionMaxAge) * time.Second + t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, config.CookiePrefix, sessionMaxAge, t.logger) // Safe to ignore: session manager creation with fallback to defaults t.errorRecoveryManager = NewErrorRecoveryManager(t.logger) // Initialize token resilience manager with default configuration diff --git a/main_servehttp_test.go b/main_servehttp_test.go index 1bf54a9..bb7a82f 100644 --- a/main_servehttp_test.go +++ b/main_servehttp_test.go @@ -539,7 +539,7 @@ func (m *MockSessionData) Clear(r *http.Request, w http.ResponseWriter) error { // Helper function to create a test session manager func createTestSessionManager(t *testing.T) *SessionManager { - sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", NewLogger("debug")) + sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("debug")) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/main_test.go b/main_test.go index 8828f84..ced79fc 100644 --- a/main_test.go +++ b/main_test.go @@ -104,7 +104,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, "", "", 0, logger) // Create WaitGroup for the OIDC instance goroutineWG := &sync.WaitGroup{} @@ -1274,7 +1274,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, "", "", 0, logger) // Create a new instance for each test to avoid state carryover instanceExtractClaimsFunc := tc.extractClaimsFunc @@ -1663,7 +1663,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, "", "", 0, logger) tOidc := &TraefikOidc{ revocationURL: mockRevocationServer.URL, endSessionURL: tc.endSessionURL, @@ -1966,7 +1966,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, "", "", 0, logger) tOidc := &TraefikOidc{ sessionManager: sessionManager, diff --git a/memory_leak_consolidated_test.go b/memory_leak_consolidated_test.go index 2141249..d090749 100644 --- a/memory_leak_consolidated_test.go +++ b/memory_leak_consolidated_test.go @@ -254,6 +254,7 @@ func TestMemoryLeakConsolidated(t *testing.T) { false, "", "", + 0, tf.logger, ) if err != nil { @@ -295,6 +296,7 @@ func TestMemoryLeakConsolidated(t *testing.T) { false, "", "", + 0, tf.logger, ) return err @@ -698,6 +700,7 @@ func BenchmarkMemoryUsage(b *testing.B) { false, "", "", + 0, NewLogger("error"), ) // No Cleanup method, defer not needed @@ -778,6 +781,7 @@ func TestGoroutineLeaks(t *testing.T) { false, "", "", + 0, NewLogger("error"), ) require.NoError(t, err) @@ -868,6 +872,7 @@ func TestMemoryThresholds(t *testing.T) { false, "", "", + 0, NewLogger("error"), ) diff --git a/profiling_test.go b/profiling_test.go index b915cf8..c01781b 100644 --- a/profiling_test.go +++ b/profiling_test.go @@ -65,7 +65,7 @@ func TestMemoryTestOrchestrator(t *testing.T) { mto := NewMemoryTestOrchestrator(config, logger) // Test registering a component - sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", logger) + sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -111,7 +111,7 @@ func TestComponentProfilers(t *testing.T) { logger := NewLogger("debug") // Test Session Pool Profiler - sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", logger) + sessionManager, err := NewSessionManager("test-key-32-chars-long-for-testing", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/regression/regression_test.go b/regression/regression_test.go index 1b77e5b..7ad36b8 100644 --- a/regression/regression_test.go +++ b/regression/regression_test.go @@ -30,7 +30,7 @@ func testIssue53CSRFRegression(t *testing.T) { // 3. Session cookies must be properly configured for HTTPS // 4. CSRF token must persist through the OAuth flow - sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", traefikoidc.NewLogger("debug")) + sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug")) require.NoError(t, err) // Step 1: Initial request to protected resource @@ -116,7 +116,7 @@ func testIssue53CSRFRegression(t *testing.T) { // testIssue53ReverseProxyHTTPS tests HTTPS detection in reverse proxy setups func testIssue53ReverseProxyHTTPS(t *testing.T) { - sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", traefikoidc.NewLogger("debug")) + sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug")) require.NoError(t, err) // Create authenticated session with Azure tokens @@ -200,7 +200,7 @@ func testIssue53SameSiteCookies(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", traefikoidc.NewLogger("debug")) + sessionManager, err := traefikoidc.NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, traefikoidc.NewLogger("debug")) require.NoError(t, err) req := httptest.NewRequest("GET", "http://internal/test", nil) diff --git a/security_edge_cases_test.go b/security_edge_cases_test.go index 2b1a55f..d967c3e 100644 --- a/security_edge_cases_test.go +++ b/security_edge_cases_test.go @@ -468,7 +468,7 @@ func TestSessionFixationAttack(t *testing.T) { tc := newTestCleanup(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, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -625,7 +625,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, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/session.go b/session.go index 987601d..47904eb 100644 --- a/session.go +++ b/session.go @@ -237,7 +237,8 @@ type SessionManager struct { logger *Logger chunkManager *ChunkManager cookieDomain string - cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_") + cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_") + sessionMaxAge time.Duration // Maximum session age (default: 24 hours) cleanupMutex sync.RWMutex forceHTTPS bool cleanupDone bool @@ -258,12 +259,13 @@ type SessionManager struct { // - forceHTTPS: Whether to force HTTPS-only cookies regardless of request scheme. // - cookieDomain: The domain for session cookies (empty for auto-detection). // - cookiePrefix: Prefix for session cookie names (empty for default "_oidc_raczylo_"). +// - sessionMaxAge: Maximum session age duration (0 for default 24 hours). // - logger: Logger instance for debug and error logging. // // Returns: // - The configured SessionManager instance. // - An error if the encryption key does not meet minimum length requirements. -func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, logger *Logger) (*SessionManager, error) { +func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger *Logger) (*SessionManager, error) { if len(encryptionKey) < minEncryptionKeyLength { return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength) } @@ -273,17 +275,23 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain strin cookiePrefix = "_oidc_raczylo_" } + // Set default session max age if not provided (24 hours for backward compatibility) + if sessionMaxAge == 0 { + sessionMaxAge = absoluteSessionTimeout + } + ctx, cancel := context.WithCancel(context.Background()) sm := &SessionManager{ - store: sessions.NewCookieStore([]byte(encryptionKey)), - forceHTTPS: forceHTTPS, - cookieDomain: cookieDomain, - cookiePrefix: cookiePrefix, - logger: logger, - chunkManager: NewChunkManager(logger), - ctx: ctx, - cancel: cancel, + store: sessions.NewCookieStore([]byte(encryptionKey)), + forceHTTPS: forceHTTPS, + cookieDomain: cookieDomain, + cookiePrefix: cookiePrefix, + sessionMaxAge: sessionMaxAge, + logger: logger, + chunkManager: NewChunkManager(logger), + ctx: ctx, + cancel: cancel, } // Initialize global memory monitoring (singleton) @@ -615,7 +623,7 @@ func (sm *SessionManager) GetSessionMetrics() map[string]interface{} { metrics := make(map[string]interface{}) metrics["session_manager_type"] = "CookieStore" metrics["force_https"] = sm.forceHTTPS - metrics["absolute_timeout_hours"] = absoluteSessionTimeout.Hours() + metrics["absolute_timeout_hours"] = sm.sessionMaxAge.Hours() metrics["max_cookie_size"] = maxCookieSize metrics["max_browser_cookie_size"] = maxBrowserCookieSize @@ -649,7 +657,7 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h userAgent := r.Header.Get("User-Agent") if userAgent == "" { sm.logger.Debugf("Request from %s missing User-Agent header", r.RemoteAddr) - options.MaxAge = int((absoluteSessionTimeout / 2).Seconds()) + options.MaxAge = int((sm.sessionMaxAge / 2).Seconds()) } if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS { @@ -699,7 +707,7 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options { HttpOnly: true, Secure: isSecure || sm.forceHTTPS, SameSite: http.SameSiteLaxMode, - MaxAge: int(absoluteSessionTimeout.Seconds()), + MaxAge: int(sm.sessionMaxAge.Seconds()), Path: "/", Domain: sm.cookieDomain, } @@ -829,7 +837,7 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) { } if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok { - if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout { + if time.Since(time.Unix(createdAt, 0)) > sm.sessionMaxAge { _ = sessionData.Clear(r, nil) // Safe to ignore: session is being invalidated return handleError(fmt.Errorf("session timeout"), "session expired") } @@ -1138,7 +1146,7 @@ func (sd *SessionData) getAuthenticatedUnsafe() bool { if !ok { return false } - return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout + return time.Since(time.Unix(createdAt, 0)) <= sd.manager.sessionMaxAge } // SetAuthenticated sets the authentication status and manages session security. diff --git a/session/core/cookie_prefix_test.go b/session/core/cookie_prefix_test.go index b751e30..94ba69f 100644 --- a/session/core/cookie_prefix_test.go +++ b/session/core/cookie_prefix_test.go @@ -50,6 +50,7 @@ func TestCookiePrefix(t *testing.T) { false, "", tt.cookiePrefix, + 0, logger, chunkManager, ) @@ -87,6 +88,7 @@ func TestMultipleInstancesWithDifferentPrefixes(t *testing.T) { false, "example.com", "_oidc_app1_", + 0, logger, chunkManager1, ) @@ -99,6 +101,7 @@ func TestMultipleInstancesWithDifferentPrefixes(t *testing.T) { false, "example.com", "_oidc_app2_", + 0, logger, chunkManager2, ) diff --git a/session/core/session_manager.go b/session/core/session_manager.go index 64be200..ae8cdea 100644 --- a/session/core/session_manager.go +++ b/session/core/session_manager.go @@ -18,15 +18,16 @@ const ( // SessionManager handles session creation, management and cleanup type SessionManager struct { - sessionPool sync.Pool - store sessions.Store - logger Logger - chunkManager ChunkManager - cookieDomain string - cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_") - cleanupMutex sync.RWMutex - forceHTTPS bool - cleanupDone bool + sessionPool sync.Pool + store sessions.Store + logger Logger + chunkManager ChunkManager + cookieDomain string + cookiePrefix string // Prefix for cookie names (default: "_oidc_raczylo_") + sessionMaxAge time.Duration // Maximum session age (default: 24 hours) + cleanupMutex sync.RWMutex + forceHTTPS bool + cleanupDone bool } // Logger interface for dependency injection @@ -70,7 +71,7 @@ type SessionData interface { // NewSessionManager creates a new SessionManager instance with secure defaults. // It initializes the cookie store with encryption, sets up session pooling, // and configures chunk management for large tokens. -func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, logger Logger, chunkManager ChunkManager) (*SessionManager, error) { +func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, cookiePrefix string, sessionMaxAge time.Duration, logger Logger, chunkManager ChunkManager) (*SessionManager, error) { if len(encryptionKey) < minEncryptionKeyLength { return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength) } @@ -80,13 +81,19 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain strin cookiePrefix = "_oidc_raczylo_" } + // Set default session max age if not provided (24 hours for backward compatibility) + if sessionMaxAge == 0 { + sessionMaxAge = absoluteSessionTimeout + } + sm := &SessionManager{ - store: sessions.NewCookieStore([]byte(encryptionKey)), - forceHTTPS: forceHTTPS, - cookieDomain: cookieDomain, - cookiePrefix: cookiePrefix, - logger: logger, - chunkManager: chunkManager, + store: sessions.NewCookieStore([]byte(encryptionKey)), + forceHTTPS: forceHTTPS, + cookieDomain: cookieDomain, + cookiePrefix: cookiePrefix, + sessionMaxAge: sessionMaxAge, + logger: logger, + chunkManager: chunkManager, } sm.sessionPool.New = func() interface{} { @@ -322,7 +329,7 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options { return &sessions.Options{ Path: "/", Domain: sm.cookieDomain, - MaxAge: int(absoluteSessionTimeout.Seconds()), + MaxAge: int(sm.sessionMaxAge.Seconds()), Secure: isSecure, HttpOnly: true, SameSite: http.SameSiteLaxMode, diff --git a/session/core/session_manager_test.go b/session/core/session_manager_test.go index b4433dc..c6d7d49 100644 --- a/session/core/session_manager_test.go +++ b/session/core/session_manager_test.go @@ -165,7 +165,7 @@ func TestSessionManagerCreation(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager(tt.encryptionKey, false, "", "", logger, chunkManager) + sm, err := NewSessionManager(tt.encryptionKey, false, "", "", 0, logger, chunkManager) if tt.expectError { if err == nil { @@ -200,7 +200,7 @@ func TestSessionManagerCreation(t *testing.T) { func TestSessionManagerPoolBehavior(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -291,7 +291,7 @@ func TestSessionManagerPoolBehavior(t *testing.T) { func TestSessionManagerErrorHandling(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -390,7 +390,7 @@ func TestSessionManagerCleanup(t *testing.T) { logger := &MockLogger{} mockChunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, mockChunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, mockChunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -458,7 +458,7 @@ func TestSessionManagerHTTPSBehavior(t *testing.T) { chunkManager := &MockChunkManager{} sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", - tt.forceHTTPS, "", "", logger, chunkManager) + tt.forceHTTPS, "", "", 0, logger, chunkManager) if tt.expectError { if err == nil { @@ -520,7 +520,7 @@ func TestSessionManagerCookieDomain(t *testing.T) { chunkManager := &MockChunkManager{} sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", - false, tt.cookieDomain, "", logger, chunkManager) + false, tt.cookieDomain, "", 0, logger, chunkManager) if err != nil { t.Errorf("Unexpected error for %s: %v", tt.description, err) @@ -549,7 +549,7 @@ func BenchmarkSessionManagerCreation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - sm, err := NewSessionManager(encryptionKey, false, "", "", logger, chunkManager) + sm, err := NewSessionManager(encryptionKey, false, "", "", 0, logger, chunkManager) if err != nil { b.Fatalf("Failed to create session manager: %v", err) } @@ -561,7 +561,7 @@ func BenchmarkSessionManagerCreation(b *testing.B) { func BenchmarkSessionManagerGetSession(b *testing.B) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { b.Fatalf("Failed to create session manager: %v", err) } @@ -599,7 +599,7 @@ func minInt(a, b int) int { func TestValidateSessionHealth(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -660,7 +660,7 @@ func TestValidateSessionHealth(t *testing.T) { func TestValidateTokenFormat(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -727,7 +727,7 @@ func TestValidateTokenFormat(t *testing.T) { func TestDetectSessionTampering(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger, chunkManager) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -812,7 +812,7 @@ func TestGetSessionMetrics(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", - tt.forceHTTPS, tt.cookieDomain, "", logger, chunkManager) + tt.forceHTTPS, tt.cookieDomain, "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -898,7 +898,7 @@ func TestShouldUseSecureCookies(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", - tt.forceHTTPS, "", "", logger, chunkManager) + tt.forceHTTPS, "", "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -940,7 +940,7 @@ func TestGetSessionOptions(t *testing.T) { logger := &MockLogger{} chunkManager := &MockChunkManager{} sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", - false, tt.cookieDomain, "", logger, chunkManager) + false, tt.cookieDomain, "", 0, logger, chunkManager) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/session_helpers_test.go b/session_helpers_test.go index e1cc345..f45f549 100644 --- a/session_helpers_test.go +++ b/session_helpers_test.go @@ -11,7 +11,7 @@ import ( // TestSetCodeVerifier_NoChange tests the branch where the code verifier value doesn't change func TestSetCodeVerifier_NoChange(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -52,7 +52,7 @@ func TestSetCodeVerifier_NoChange(t *testing.T) { // TestClearTokenChunks_EmptyChunks tests the branch where the chunks map is empty func TestClearTokenChunks_EmptyChunks(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -90,7 +90,7 @@ func TestClearTokenChunks_EmptyChunks(t *testing.T) { // TestClearTokenChunks_WithSessions tests the branch where the chunks map contains actual sessions func TestClearTokenChunks_WithSessions(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/session_test.go b/session_test.go index e8b42e4..69952f3 100644 --- a/session_test.go +++ b/session_test.go @@ -84,7 +84,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) { } logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -107,7 +107,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) { session.ReturnToPool() case "Error path in GetSession": - badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", "", logger) + badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", "", 0, logger) _, err = badSM.GetSession(req) if err == nil { t.Log("Note: Expected error when using mismatched encryption keys") @@ -172,7 +172,7 @@ func TestSessionErrorHandling(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -226,7 +226,7 @@ func TestSessionClearAlwaysReturnsToPool(t *testing.T) { Timeout: 30 * time.Second, Operation: func() error { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { return fmt.Errorf("failed to create session manager: %w", err) } @@ -264,7 +264,7 @@ func TestSessionClearAlwaysReturnsToPool(t *testing.T) { // Additional verification test t.Run("Verify pool still works after errors", func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -324,7 +324,7 @@ func TestSessionObjectTracking(t *testing.T) { } logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -574,7 +574,7 @@ func TestTokenChunkingIntegrity(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -681,7 +681,7 @@ func TestTokenChunkingCorruptionResistance(t *testing.T) { for _, test := range corruptionTests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -772,7 +772,7 @@ func TestTokenSizeLimits(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -837,7 +837,7 @@ func TestConcurrentTokenOperations(t *testing.T) { Timeout: 60 * time.Second, Operation: func() error { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { return fmt.Errorf("failed to create session manager: %w", err) } @@ -925,7 +925,7 @@ func TestSessionValidationAndCleanup(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -1010,7 +1010,7 @@ func TestLargeIDTokenChunking(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -1115,7 +1115,7 @@ func BenchmarkSessionOperations(b *testing.B) { perfHelper := NewPerformanceTestHelper() logger := NewLogger("error") // Reduce logging for benchmarks - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { b.Fatalf("Failed to create session manager: %v", err) } @@ -1256,7 +1256,7 @@ func TestSessionStatePreservationWithExpiredTokens(t *testing.T) { t.Log("Testing session state preservation with expired tokens - this test demonstrates BROKEN BEHAVIOR") logger := NewLogger("debug") - sm, err := NewSessionManager("test-session-key-32-bytes-long-12345", false, "", "", logger) + sm, err := NewSessionManager("test-session-key-32-bytes-long-12345", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -1452,7 +1452,7 @@ func TestSessionExpiryVsTokenExpiry(t *testing.T) { t.Log("Testing session expiry vs token expiry distinction - validating proper session and token lifetime management") logger := NewLogger("debug") - sm, err := NewSessionManager("session-vs-token-test-key-32-bytes", false, "", "", logger) + sm, err := NewSessionManager("session-vs-token-test-key-32-bytes", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -1591,7 +1591,7 @@ func TestSessionCleanupOnTokenExpiry(t *testing.T) { t.Log("Testing session cleanup on token expiry - validating proper session data management") logger := NewLogger("debug") - sm, err := NewSessionManager("cleanup-test-key-32-bytes-long-123", false, "", "", logger) + sm, err := NewSessionManager("cleanup-test-key-32-bytes-long-123", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } diff --git a/settings.go b/settings.go index 04b8400..2ebe110 100644 --- a/settings.go +++ b/settings.go @@ -30,7 +30,8 @@ type Config struct { HTTPClient *http.Client `json:"-"` OIDCEndSessionURL string `json:"oidcEndSessionURL"` CookieDomain string `json:"cookieDomain"` - CookiePrefix string `json:"cookiePrefix"` // Prefix for session cookie names (default: "_oidc_raczylo_") + CookiePrefix string `json:"cookiePrefix"` // Prefix for session cookie names (default: "_oidc_raczylo_") + SessionMaxAge int `json:"sessionMaxAge"` // Maximum session age in seconds (default: 86400 = 24 hours) CallbackURL string `json:"callbackURL"` LogoutURL string `json:"logoutURL"` ClientID string `json:"clientID"` diff --git a/test_framework_test.go b/test_framework_test.go index 344bb02..ad83488 100644 --- a/test_framework_test.go +++ b/test_framework_test.go @@ -280,6 +280,7 @@ func (tf *TestFramework) CreateAuthenticatedRequest(method, path string) (*http. false, "", "", + 0, tf.oidc.logger, ) if err != nil { @@ -325,6 +326,7 @@ func (tf *TestFramework) CreateCallbackRequest() *http.Request { false, "", "", + 0, tf.oidc.logger, ) diff --git a/test_helpers_adapter_test.go b/test_helpers_adapter_test.go index e911c7a..b327b54 100644 --- a/test_helpers_adapter_test.go +++ b/test_helpers_adapter_test.go @@ -350,7 +350,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, "", "", 0, logger) // Create a test request req := httptest.NewRequest("GET", "/", nil) diff --git a/token_consolidated_test.go b/token_consolidated_test.go index 25a6243..7838c48 100644 --- a/token_consolidated_test.go +++ b/token_consolidated_test.go @@ -164,7 +164,7 @@ func TestTokenTypes(t *testing.T) { func TestTokenCorruption(t *testing.T) { t.Run("TokenCorruptionScenario", func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -291,7 +291,7 @@ func TestTokenCorruption(t *testing.T) { func TestTokenResilience(t *testing.T) { t.Run("ConcurrentTokenAccess", func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) } @@ -337,7 +337,7 @@ func TestTokenResilience(t *testing.T) { t.Run("TokenSizeHandling", func(t *testing.T) { logger := NewLogger("debug") - sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", logger) + sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", "", 0, logger) if err != nil { t.Fatalf("Failed to create session manager: %v", err) }