diff --git a/README.md b/README.md
index beeae82e..c0d41be6 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,68 @@ This gem provides a `turbo_stream_from` helper to create a turbo stream.
<%# Rest of show here %>
```
+### Security
+
+#### Signed Stream Names
+
+Turbo stream names are cryptographically signed, which ensures that they cannot be guessed or altered.
+
+Stream names do not expire and are rendered into the HTML. If you're broadcasting private data, additional security measurements are recommended.
+
+#### Authentication
+
+It is recommended to authenticate connections in `ApplicationCable::Connection`. Without authentication, a leaked stream name could be used to subscribe without a valid application session.
+
+```rb
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user
+
+ def connect
+ self.current_user = find_verified_user
+ end
+
+ private
+ def find_verified_user
+ if verified_session = Session.find_by(id: cookies.signed[:session_id])
+ verified_session.user
+ else
+ reject_unauthorized_connection
+ end
+ end
+ end
+end
+```
+
+#### Authorization
+
+In multi-tenant applications, it’s often crucial to authorize subscriptions. Without authorization, someone with prior access could continue to subscribe as another tenant.
+
+```rb
+# config/application.rb
+
+config.turbo.base_stream_channel_class = "ApplicationCable::Channel"
+```
+
+This allows you to define domain-specific authorization logic that `Turbo::StreamsChannel` and any other channels inheriting from `ApplicationCable::Channel` will use."
+
+```rb
+# app/channels/application_cable/channel.rb
+
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ private
+
+ def authorized?
+ # `current_user` - from `ApplicationCable::Connection.identified_by`
+ # `locate_streamable` - from `Turbo::StreamsChannel` or by including `Turbo::Streams::LocatableName`
+ # `can_access?` - is yours to implement according to your domain specific needs.
+ current_user.can_access? locate_streamable
+ end
+ end
+end
+```
+
### Testing Turbo Stream Broadcasts
Receiving server-generated Turbo Broadcasts requires a connected Web Socket.
@@ -182,7 +244,7 @@ import "@hotwired/turbo-rails"
You can watch [the video introduction to Hotwire](https://hotwired.dev/#screencast), which focuses extensively on demonstrating Turbo in a Rails demo. Then you should familiarize yourself with [Turbo handbook](https://turbo.hotwired.dev/handbook/introduction) to understand Drive, Frames, and Streams in-depth. Finally, dive into the code documentation by starting with [`Turbo::FramesHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/frames_helper.rb), [`Turbo::StreamsHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/streams_helper.rb), [`Turbo::Streams::TagBuilder`](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/streams/tag_builder.rb), and [`Turbo::Broadcastable`](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb).
-Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear.
+Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear.
### RubyDoc Documentation
diff --git a/app/channels/turbo/streams/locatable_name.rb b/app/channels/turbo/streams/locatable_name.rb
new file mode 100644
index 00000000..1c438aef
--- /dev/null
+++ b/app/channels/turbo/streams/locatable_name.rb
@@ -0,0 +1,17 @@
+# When streaming from a model instance using turbo_stream_from @post, it can be useful to locate the instance
+# in config.turbo.base_stream_channel_class. These helper methods are available as a convenience for applications
+# to implement custom logic such as authorization.
+module Turbo::Streams::LocatableName
+ # Locate a single streamable. Useful when subscribing with turbo_stream_from @post. It can be used e.g to
+ # implement application-specific authorization, ex: current_user.can_access? locate_streamable
+ def locate_streamable
+ @locate_streamable ||= GlobalID::Locator.locate(verified_stream_name_from_params)
+ end
+
+ # Locate multiple streamables. Useful when subscribing with turbo_stream_from @post1, @post2. It can be
+ # used e.g to implement application-specific authorization, ex:
+ # locate_streamables.present? && locate_streamables.all? { |streamable| current_user.can_access?(streamable) }
+ def locate_streamables
+ @locate_streamables ||= GlobalID::Locator.locate_many(verified_stream_name_parts_from_params)
+ end
+end
diff --git a/app/channels/turbo/streams/stream_name.rb b/app/channels/turbo/streams/stream_name.rb
index 37d80b18..3b3be957 100644
--- a/app/channels/turbo/streams/stream_name.rb
+++ b/app/channels/turbo/streams/stream_name.rb
@@ -2,7 +2,12 @@
# Turbo::StreamsChannel, but each with their own subscription. Since stream names are exposed directly to the user
# via the HTML stream subscription tags, we need to ensure that the name isn't tampered with, so the names are signed
# upon generation and verified upon receipt. All verification happens through the Turbo.signed_stream_verifier.
+#
+# Signed stream names do not expire. To prevent unauthorized access through leaked stream names it is recommended to
+# authorize subscriptions and/or authenticate connections based on your needs.
module Turbo::Streams::StreamName
+ STREAMABLE_SEPARATOR = ":"
+
# Used by Turbo::StreamsChannel to verify a signed stream name.
def verified_stream_name(signed_stream_name)
Turbo.signed_stream_verifier.verified signed_stream_name
@@ -14,16 +19,23 @@ def signed_stream_name(streamables)
end
module ClassMethods
- # Can be used by custom turbo stream channels to obtain signed stream name from params
+ # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name
+ # from params.
def verified_stream_name_from_params
self.class.verified_stream_name(params[:signed_stream_name])
end
+
+ # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name
+ # parts from params.
+ def verified_stream_name_parts_from_params
+ verified_stream_name_from_params.split STREAMABLE_SEPARATOR
+ end
end
private
def stream_name_from(streamables)
if streamables.is_a?(Array)
- streamables.map { |streamable| stream_name_from(streamable) }.join(":")
+ streamables.map { |streamable| stream_name_from(streamable) }.join(STREAMABLE_SEPARATOR)
else
streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
end
diff --git a/app/channels/turbo/streams_channel.rb b/app/channels/turbo/streams_channel.rb
index adb614b4..a58b9a94 100644
--- a/app/channels/turbo/streams_channel.rb
+++ b/app/channels/turbo/streams_channel.rb
@@ -5,41 +5,30 @@
# using the view helper Turbo::StreamsHelper#turbo_stream_from(*streamables).
# If the signed stream name cannot be verified, the subscription is rejected.
#
-# In case if custom behavior is desired, one can create their own channel and re-use some of the primitives from
-# helper modules like Turbo::Streams::StreamName:
+# Subscribe to custom channels by passing the :channel option to turbo_stream_from:
+# <%= turbo_stream_from "room", channel: CustomChannel %>
#
-# class CustomChannel < ActionCable::Channel::Base
-# extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
-# include Turbo::Streams::StreamName::ClassMethods
-#
-# def subscribed
-# if (stream_name = verified_stream_name_from_params).present? &&
-# subscription_allowed?
-# stream_from stream_name
-# else
-# reject
-# end
-# end
-#
-# def subscription_allowed?
-# # ...
-# end
-# end
-#
-# This channel can be connected to a web page using :channel option in
-# turbo_stream_from helper:
-#
-# <%= turbo_stream_from 'room', channel: CustomChannel %>
-#
-class Turbo::StreamsChannel < ActionCable::Channel::Base
+# Any channel that listens to a Turbo::Broadcastable-compatible stream name (e.g., verified_stream_name_from_params)
+# can also be subscribed to via Turbo::StreamsChannel. Never use the turbo_stream_from :channel option
+# to implement authorization. Authorizing subscriptions is often recommended, see the README for details.
+class Turbo::StreamsChannel < Turbo.base_stream_channel_class.constantize
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
- include Turbo::Streams::StreamName::ClassMethods
+ include Turbo::Streams::LocatableName, Turbo::Streams::StreamName::ClassMethods
def subscribed
- if stream_name = verified_stream_name_from_params
+ if (stream_name = verified_stream_name_from_params) && authorized?
stream_from stream_name
else
reject
end
end
+
+ private
+ # Override this method to define custom authorization rules in config.turbo.base_stream_channel_class.
+ # Refer to Turbo::Streams::LocatableName for details on locating streamables.
+ #
+ # By default, no authorization is performed.
+ def authorized?
+ defined?(super) ? super : true
+ end
end
diff --git a/lib/turbo-rails.rb b/lib/turbo-rails.rb
index ee81e814..50beca2e 100644
--- a/lib/turbo-rails.rb
+++ b/lib/turbo-rails.rb
@@ -5,6 +5,7 @@ module Turbo
extend ActiveSupport::Autoload
mattr_accessor :draw_routes, default: true
+ mattr_accessor :base_stream_channel_class, default: "ActionCable::Channel::Base"
thread_mattr_accessor :current_request_id
diff --git a/lib/turbo/engine.rb b/lib/turbo/engine.rb
index ecfd82e0..19250fe0 100644
--- a/lib/turbo/engine.rb
+++ b/lib/turbo/engine.rb
@@ -78,6 +78,12 @@ class Engine < Rails::Engine
end
end
+ initializer "turbo.configure" do |app|
+ if base_class = app.config.turbo&.base_stream_channel_class
+ Turbo.base_stream_channel_class = base_class
+ end
+ end
+
initializer "turbo.helpers", before: :load_config_initializers do
ActiveSupport.on_load(:action_controller_base) do
include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
diff --git a/test/streams/streams_channel_test.rb b/test/streams/streams_channel_test.rb
index 63ae7af2..018ce163 100644
--- a/test/streams/streams_channel_test.rb
+++ b/test/streams/streams_channel_test.rb
@@ -337,4 +337,72 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase
end
end
end
+
+ test "locates single streamable" do
+ record = Message.create!
+ subscribe signed_stream_name: signed_stream_name(record)
+
+ assert_equal record, subscription.locate_streamable
+ end
+
+ test "raises if streamable can't be found" do
+ record = Message.create!
+ subscribe signed_stream_name: signed_stream_name(record)
+ record.destroy
+
+ assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamable }
+ end
+
+ test "locates multiple streamables" do
+ record1, record2 = Message.create!, Message.create!
+ subscribe signed_stream_name: signed_stream_name([ record1, record2 ])
+
+ assert_equal [ record1, record2 ], subscription.locate_streamables
+ end
+
+ test "raises unless all streamables can be found" do
+ record1, record2 = Message.create!, Message.create!
+ subscribe signed_stream_name: signed_stream_name([ record1, record2 ])
+ record1.destroy
+
+ assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamables }
+ end
+
+ test "confirms subscription when unauthenticated by default" do
+ subscribe signed_stream_name: Turbo.signed_stream_verifier.generate("stream")
+
+ assert subscription.confirmed?
+ assert_has_stream "stream"
+ end
+
+ test "confirms subscription when succeeding authorization" do
+ authorizing do |record|
+ Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable == record }
+ subscribe signed_stream_name: signed_stream_name(record)
+
+ assert subscription.confirmed?
+ assert_has_stream record.to_gid_param
+ end
+ end
+
+ test "rejects subscription when failing authorization" do
+ authorizing do |record|
+ Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable != record }
+ subscribe signed_stream_name: signed_stream_name(record)
+
+ assert subscription.rejected?
+ assert_no_streams
+ end
+ end
+
+ private
+ def authorizing
+ original_authorized = Turbo::StreamsChannel.instance_method(:authorized?)
+ Turbo::StreamsChannel.remove_method :authorized?
+
+ yield Message.create!
+ ensure
+ Turbo::StreamsChannel.remove_method :authorized?
+ Turbo::StreamsChannel.define_method :authorized?, original_authorized
+ end
end