How to authorize Broadcasting route with Vue 3 SPA + Laravel Sanctum

97 Views Asked by At

I'm trying to set up private-channel broadcasting with my vue 3 spa + laravel sanctum backend. The broadcasting works if used without authorization (non-private channels) but when I try to access a sanctum-protected route (required for private-channels), a 401 is returned to the client.

This happens with both the plain pusher-js library as well as laravel-echo. I've been trying to debug this for some time now without any progress.

Backend setup:

/routes/api.php:

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/broadcasting/auth', function (Request $request) {
        // 401 prevents this code from running

        \Log::info('hello');

        response()->json('hello');
    });
});

/app/Events/ChatMessageSent.php:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ChatMessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $chatSessionId;
    public $chatMessage;

    public function __construct($chatSessionId, $chatMessage)
    {
        $this->chatSessionId = $chatSessionId;
        $this->chatMessage = $chatMessage;
    }

    public function broadcastOn()
    {
        return new PrivateChannel('chat.'.$this->chatSessionId);
    }
}

Used inside ChatController.php like so:

event(new ChatMessageSent($session->id, $message));

BroadcastServiceProvider.php:

<?php

namespace App\Providers;

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;

class BroadcastServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Broadcast::routes(['middleware' => ['api', 'auth:sanctum']]);
        require base_path('routes/channels.php');
    }
}

/routes/channels.php:

<?php

use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
use App\Models\ChatSession;

Broadcast::channel('chat.{chatSessionId}', function ($user, $chatSessionId) {
    $chatSession = ChatSession::find($chatSessionId);
    return $chatSession ? $chatSession->hasParticipant($user) : false;
});

VerifyCsrfToken.php:

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        '/api/pusher/auth',
        '/api/broadcasting/auth',
    ];
}

I've commented App\Providers\BroadcastServiceProvider::class in in /config/app.php.

Frontend setup:

login.vue:

const signIn = () => {
  axios.get('/sanctum/csrf-cookie').then((res) => {
    store.authToken = res.config.headers['X-XSRF-TOKEN']
    console.log(store.authToken)
    axios
      .post('/login', form)
      .then(() => {
        store.auth = sessionStorage.auth = 1
        store.signInModal = false
      })
      .catch((er) => {
        state.errors = er.response.data.errors
      })
  })
}

returns the XSRF-Token:

eyJpdiI6Imp0dEc5ck9NU1BZQUlqbUlxYVRoY3c9PSIsInZhbHVlIjoiSUJBNXNJVTg3OTFTN0c2VXluQzJ0Vk5sVGxnenU2aVhNWGE1V2tEMGFBNVBabmZDOFpqRWF6MFN0N0VLUDVRVmtXVFlmOTQ1MlcySFppWUM5eGZRcU1pSDlsN0grVCtRV2ZjYW1ONjFEdDQzT0JOUW4xalkwOGhaWGVXQi8rbHgiLCJtYWMiOiIyZjUyNGEzZTY5MmRiZDgyYzVhZDMyNGJhMjQ3NGFmYmE5ZDZhNDc0NDZiYTY3NTQ4MzFkYWZmMjU1YWNjNTkwIiwidGFnIjoiIn0=

chat.vue (call broadcasting-route):

import initEcho from '@/lib/laravel-echo.js'

const inquireChatSession = () => {
  axios
    .get(`/api/chat/${props.id}`)
    .then((res) => {
      state.chatSessionId = res.data.id
      state.messages = res.data.messages
      state.loadingSession = false

      const echo = initEcho(store.authToken) // create laravel-echo instance with authToken, which tries to call authEndpoint (/api/broadcasting/auth) and authorize, throws 401

      echo
        .private(`private-chat.${state.chatSessionId}`)
        .listen('ChatMessageSent', (data) => {
          state.messages.push(data.chatMessage)

          if (state.focusBrowserTab) {
            state.messages.forEach((message) => {
              if (message.sender_id !== store.user.id) {
                message.read_status = 1
              }
            })
          }
        })
    })
    .catch((er) => {
      state.errors = er.response?.data.errors
      state.loadingSession = false
    })
}

laravel-echo.js:

import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

window.Pusher = Pusher

export default (token) => {
  return (window.Echo = new Echo({
    broadcaster: 'pusher',
    key: 'bf29be46d8eb2ea8ccd4',
    cluster: 'eu',
    forceTLS: true,
    wsPort: 443,
    wssPort: 443,
    enabledTransports: ['ws', 'wss'],
    withCredentials: true,
    authEndpoint: 'http://localhost:8000/api/broadcasting/auth',
    auth: {
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        Authorization: `Bearer ${token}`,
      },
    },
  }))
}

axios.js:

import axios from 'axios'

axios.defaults.withCredentials = true

if (import.meta.env.DEV) {
  axios.defaults.baseURL = 'http://localhost:8000'
}
1

There are 1 best solutions below

0
Artur Müller Romanov On

I had to use a custom authorizor in my laravel-echo.js init file:

import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

window.Pusher = Pusher

export default (token) => {
  return (window.Echo = new Echo({
    broadcaster: 'pusher',
    key: 'bf29be46d8eb2ea8ccd4',
    cluster: 'eu',
    forceTLS: true,
    withCredentials: true,
    authEndpoint: 'http://localhost:8000/api/broadcasting/auth',
    auth: {
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        Authorization: `Bearer ${token}`,
      },
    },
    authorizer: (channel, options) => {
      return {
        authorize: (socketId, callback) => {
          axios
            .post('/api/broadcasting/auth', {
              socket_id: socketId,
              channel_name: channel.name,
            })
            .then((response) => {
              callback(null, response.data)
            })
            .catch((error) => {
              callback(error)
            })
        },
      }
    },
  }))
}

This made the /api/broadcasting/auth route work.