Laravel & Pusher: Public private channels
For a recent project, I had to figure out how to support a private broadcasting Pusher channel that does not require user authentication. However, Laravel does not permit non-authenticated users to access private channels.
The requirement was Pusher authentication for a chat room which is only available to users who have purchased access or guests who have scanned a QR code. We still wanted to use a private channel, but didn’t want to force these specific guests to log in to read chat messages.
Investigation
Logged out users will always receive a 401 on the broadcasting auth endpoint for private channels regardless of what you put in the channel class’s authentication method. Checking out the Laravel source code makes this obvious —
// PusherBroadcaster.php::75-88
public function auth($request)
{
$channelName = $this->normalizeChannelName($request->channel_name);
if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}
return parent::verifyUserCanAccessChannel(
$request, $channelName
);
}
All private channels immediately throw an AccessDeniedHttpException
if a user is not logged in. It’s also not possible to attach an authorization method to a public channel, as these do not use an authentication endpoint at all to connect.
Solution
I couldn’t figure out a better way to do this outside of overriding Laravel’s Pusher broadcaster or just writing our own Pusher authentication implementation from scratch.
I opted for the former this time since it seemed like it might be more straightforward and wouldn’t require rewriting all of our channel classes. Now that I’m done, I’m a little uncomfortable with how brittle it is to future changes from Laravel’s end, but here’s the solution regardless.
-
Write a new
PusherBroadcaster
overriding theauth()
method (I just put this inapp\Broadcasting\AcmePusherBroadcaster.php
next to the channel classes) —<?php class AcmePusherBroadcaster extends PusherBroadcaster { // all this does is copies down the auth() function from PusherBroadcaster and removes the requirement for the user to be logged in public function auth($request) { $channelName = $this->normalizeChannelName($request->channel_name); if (empty($request->channel_name)) { throw new AccessDeniedHttpException; } return parent::verifyUserCanAccessChannel($request, $channelName); } }
-
Register the custom broadcaster in the
BroadcastServiceProvider
—class BroadcastServiceProvider extends ServiceProvider { public function boot() { // copied down from BroadcastManager::createPusherDriver Broadcast::extend('pusher', function ($app, array $config): AcmePusherBroadcaster { $pusher = new Pusher( $config['key'], $config['secret'], $config['app_id'], $config['options'] ?? [] ); if ($config['log'] ?? false) { $pusher->setLogger($this->app->make(LoggerInterface::class)); } return new AcmePusherBroadcaster($pusher); }); ... } }
-
Write the channel class/route as usual, except
User
will now be nullable. An example might look like this —class ChatChannel { public function join(?User $user, Chat $chat) { if (Request::hasValidSignature()) { return true; } return $user && $chat->members()->where('user_id', $user->id)->exists(); } }
-
On the client side, pass any necessary authorization details through to the auth endpoint. We happened to already have this set up, but a generic one might look something like this —
const pusher = new Pusher(MY_PUSHER_KEY, { channelAuthorization: { customHandler: (params: ChannelAuthorizationRequestParams, callback: ChannelAuthorizationCallback): void => { axios.post( MY_AUTH_ENDPOINT, // add a signature or other query parameters here { socket_id: params.socketId, channel_name: params.channelName }, { withCredentials: true, headers: { 'Content-Type': 'application/json', Accept: 'application/json', ... MY_AUTH_HEADERS, // authentication headers }, } ) .then(response => { callback(null, response.data); }) .catch(error => { callback(error, null); }); }, }, });
Feels hacky but it does the job 🤔