Sarah Ting

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.

  1. Write a new PusherBroadcaster overriding the auth() method (I just put this in app\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);
        }
    }
    
  2. 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);
            });
            ...
        }
    }
    
  3. 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();
        }
    }
    
  4. 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 🤔