From 9dd19d0c957239a4d5220051548d04524c2a1ba4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 18:21:16 -0700 Subject: [PATCH] Close live Action Cable connections on sign out (#268) Action Cable authorizes a Connection once at the WebSocket handshake and never re-checks it. Destroying the session record refuses future handshakes and HTTP requests bearing the cookie, but a socket opened before sign out keeps its handshake-time current_user and keeps authorizing new subscriptions and delivering frames as the signed-out user. Reset the user's remote connections when the session is terminated. Clients tear down and reconnect: the signed-out device carries a destroyed session and cleared cookie and is rejected at the fresh handshake, while the user's other devices with still-valid sessions reconnect and stay live. This reuses the existing reset_remote_connections primitive already used on membership removal, for the same reason. Run the disconnect last and best-effort, after the session record and cookie are already gone, so sign out completes even when the realtime service is unreachable. --- app/controllers/concerns/authentication.rb | 7 ++++++ test/controllers/sessions_controller_test.rb | 24 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index db23800..e609d33 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -71,6 +71,13 @@ module Authentication Current.session&.destroy! reset_session remove_authentication_cookie + disconnect_remote_connections + end + + def disconnect_remote_connections + Current.user&.reset_remote_connections + rescue => error + Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}" end def authenticated_as(session) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 92b860f..4b20dd9 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -56,6 +56,30 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_nil Session.find_by(id: session.id) end + test "destroy closes the signed-out user's live connections" do + sign_in :david + + remote_connections = mock + remote_connections.expects(:disconnect).with(reconnect: true) + ActionCable.server.stubs(:remote_connections).returns(mock.tap { |m| m.expects(:where).with(current_user: users(:david)).returns(remote_connections) }) + + delete session_url + + assert_redirected_to root_url + end + + test "destroy still signs out when the realtime service is unreachable" do + sign_in :david + session = users(:david).sessions.last + ActionCable.server.stubs(:remote_connections).raises(RuntimeError.new("cable down")) + + delete session_url + + assert_redirected_to root_url + assert_not cookies[:session_token].present? + assert_nil Session.find_by(id: session.id) + end + test "destroy removes the push subscription for the device" do sign_in :david