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.
This commit is contained in:
Jeremy Daer
2026-08-31 18:21:16 -07:00
committed by GitHub
parent 79eb9a5516
commit 9dd19d0c95
2 changed files with 31 additions and 0 deletions
@@ -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)
@@ -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