require "test_helper" class SessionSecurityTest < ActionDispatch::IntegrationTest # ==================== # ACCOUNT DEACTIVATION TESTS # ==================== test "TOTP verification rejects a user disabled mid-flow" do user = User.create!(email_address: "midflow_totp@example.com", password: "password123") user.enable_totp! code = ROTP::TOTP.new(user.totp_secret).now # Phase A: password step stashes the pending 2FA user post signin_path, params: {email_address: "midflow_totp@example.com", password: "password123"} assert_redirected_to totp_verification_path # Admin disables the account while the user is on the 2FA screen user.update!(status: :disabled) # Phase B: completing TOTP must NOT create a session post totp_verification_path, params: {code: code} assert_redirected_to signin_path assert_equal 0, user.reload.sessions.count user.destroy end test "an existing session stops authenticating once the user is disabled" do user = User.create!(email_address: "disabled_session@example.com", password: "password123") sign_in_as(user) get root_path assert_response :success # Disable bypassing the destroy callback to isolate the request-time lookup # guard (find_session_by_cookie filtering on active users). user.update_column(:status, User.statuses[:disabled]) get root_path assert_response :redirect assert_match %r{/signin}, response.location user.sessions.delete_all user.destroy end # ==================== # SESSION TIMEOUT TESTS # ==================== test "session expires after inactivity" do user = User.create!(email_address: "session_test@example.com", password: "password123") # Sign in post signin_path, params: {email_address: "session_test@example.com", password: "password123"} assert_response :redirect follow_redirect! assert_response :success # Create a session that expires in 1 hour session_record = user.sessions.create!( ip_address: "127.0.0.1", user_agent: "TestAgent", last_activity_at: Time.current, expires_at: 1.hour.from_now ) # Session should be active assert session_record.active? # Simulate session expiration by traveling past the expiry time travel 2.hours do session_record.reload assert_not session_record.active? end user.sessions.delete_all user.destroy end test "active sessions are tracked correctly" do user = User.create!(email_address: "multi_session_test@example.com", password: "password123") # Create multiple sessions session1 = user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: 10.minutes.ago ) session2 = user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", last_activity_at: 5.minutes.ago ) # Check that both sessions are active assert_equal 2, user.sessions.active.count # Revoke one session session2.update!(expires_at: 1.minute.ago) # Only one session should remain active assert_equal 1, user.sessions.active.count assert_equal session1.id, user.sessions.active.first.id user.sessions.delete_all user.destroy end # ==================== # SESSION FIXATION PREVENTION TESTS # ==================== test "session_id changes after authentication" do user = User.create!(email_address: "session_fixation_test@example.com", password: "password123") # Sign in creates a new session post signin_path, params: {email_address: "session_fixation_test@example.com", password: "password123"} assert_response :redirect # User should be authenticated after sign in assert_redirected_to root_path user.destroy end # ==================== # CONCURRENT SESSION HANDLING TESTS # ==================== test "user can have multiple concurrent sessions" do user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123") # Create multiple sessions from different devices user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: Time.current ) user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", last_activity_at: Time.current ) user.sessions.create!( ip_address: "192.168.1.3", user_agent: "Mozilla/5.0 (Macintosh)", device_name: "MacBook", last_activity_at: Time.current ) # All three sessions should be active assert_equal 3, user.sessions.active.count user.sessions.delete_all user.destroy end test "revoking one session does not affect other sessions" do user = User.create!(email_address: "revoke_session_test@example.com", password: "password123") # Create two sessions session1 = user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: Time.current ) session2 = user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", last_activity_at: Time.current ) # Revoke session1 session1.update!(expires_at: 1.minute.ago) # Session2 should still be active assert_equal 1, user.sessions.active.count assert_equal session2.id, user.sessions.active.first.id user.sessions.delete_all user.destroy end # ==================== # LOGOUT INVALIDATES SESSIONS TESTS # ==================== test "logout invalidates current session" do user = User.create!(email_address: "logout_test@example.com", password: "password123") # Create multiple sessions user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: Time.current ) user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", last_activity_at: Time.current ) # Sign in (creates a new session via the sign-in flow) post signin_path, params: {email_address: "logout_test@example.com", password: "password123"} assert_response :redirect # Should have 3 sessions now assert_equal 3, user.sessions.count # Sign out (only terminates the current session) delete signout_path assert_response :redirect follow_redirect! assert_response :success # The 2 manually created sessions should still be active # The sign-in session was terminated assert_equal 2, user.sessions.active.count user.sessions.delete_all user.destroy end test "logout sends backchannel logout notifications" do user = User.create!(email_address: "logout_notification_test@example.com", password: "password123") application = Application.create!( name: "Logout Test App", slug: "logout-test-app", app_type: "oidc", redirect_uris: ["http://localhost:4000/callback"].to_json, backchannel_logout_uri: "https://rp.example.com/backchannel-logout", active: true ) # Create consent with backchannel logout enabled OidcUserConsent.create!( user: user, application: application, scopes_granted: "openid profile", sid: "test-session-id-123" ) # Sign in post signin_path, params: {email_address: "logout_notification_test@example.com", password: "password123"} assert_response :redirect # Sign out assert_enqueued_jobs 1 do delete signout_path assert_response :redirect end # Verify backchannel logout job was enqueued assert_equal BackchannelLogoutJob, ActiveJob::Base.queue_adapter.enqueued_jobs.first[:job] user.sessions.delete_all user.destroy application.destroy end # ==================== # SESSION HIJACKING PREVENTION TESTS # ==================== test "session includes IP address and user agent tracking" do user = User.create!(email_address: "hijacking_test@example.com", password: "password123") # Sign in post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"}, headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"} assert_response :redirect # Check that session includes IP and user agent session = user.sessions.active.first assert_not_nil session.ip_address assert_not_nil session.user_agent user.sessions.delete_all user.destroy end test "session activity is tracked" do user = User.create!(email_address: "activity_test@example.com", password: "password123") # Create session session = user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0", device_name: "Test Device", last_activity_at: 1.hour.ago ) # Simulate activity update session.update!(last_activity_at: Time.current) # Session should still be active assert session.active? user.sessions.delete_all user.destroy end # ==================== # FORWARD AUTH SESSION TESTS # ==================== test "forward auth validates session correctly" do user = User.create!(email_address: "forward_auth_test@example.com", password: "password123") application = Application.create!( name: "Forward Auth Test", slug: "forward-auth-test-#{SecureRandom.hex(4)}", app_type: "forward_auth", domain_pattern: "test.example.com", redirect_uris: ["https://test.example.com"].to_json, active: true ) # Create session user_session = user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0", last_activity_at: Time.current ) # Test forward auth endpoint with valid session get api_verify_path(rd: "https://test.example.com/protected"), headers: {cookie: "_session_id=#{user_session.id}"} # Should accept the request and redirect back assert_response :redirect user.sessions.delete_all user.destroy application.destroy end end