diff --git a/README.md b/README.md
index beeae82e..113f6264 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,65 @@ 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, you should also authorize and/or authenticate subscriptions.
+
+#### 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.can_access? streamable
+ end
+ end
+end
+```
+
### Testing Turbo Stream Broadcasts
Receiving server-generated Turbo Broadcasts requires a connected Web Socket.
@@ -182,7 +241,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_channel.rb b/app/channels/turbo/streams_channel.rb
index adb614b4..b70a9556 100644
--- a/app/channels/turbo/streams_channel.rb
+++ b/app/channels/turbo/streams_channel.rb
@@ -5,41 +5,46 @@
# 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:
+# Stream names may leak, which is why it's highly recommended to authenticate your connections and authorize your subscriptions.
+# See the README for more details.
#
-# 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
+# Subscribe to custom channels by passing the :channel option to turbo_stream_from:
+# <%= turbo_stream_from "room", channel: CustomChannel %>
#
-# 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.
+class Turbo::StreamsChannel < Turbo.base_stream_channel_class.constantize
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
def subscribed
- if stream_name = verified_stream_name_from_params
+ if subscription_allowed?
stream_from stream_name
else
reject
end
end
+
+ private
+ def subscription_allowed?
+ stream_name && authorized?
+ end
+
+ def stream_name
+ @stream_name ||= verified_stream_name_from_params
+ end
+
+ # Override this method to match your authorization rules in config.turbo.base_stream_channel_class e.g:
+ # current_user.can_access? streamable. current_user should match your
+ # ApplicationCable::Connection identified_by accessor.
+ def authorized?
+ defined?(super) ? super : true
+ end
+
+ # Helpful for implementing domain specific authorization rules when overriding authorized?.
+ def streamable
+ @streamable ||= GlobalID::Locator.locate(stream_name)
+ 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