skip to Main Content

I’ve set up a Vue3/Laravel app with a live-chat via Pusher which works over non-private channel chat. In the next step I want to use a private channel but something weird happens. The pusher.subscribe function that tries to send a request to /api/pusher/auth doesn’t seem to handle the sanctum authorization correctly, resulting in:

POST http://localhost:8000/api/pusher/auth 401 (Unauthorized)
ajax @ pusher-js.js?v=1974b27b:676
(anonymous) @ pusher-js.js?v=1974b27b:3548
authorize @ pusher-js.js?v=1974b27b:1860
subscribe @ pusher-js.js?v=1974b27b:1828
subscribe @ pusher-js.js?v=1974b27b:3960
subscribeAll @ pusher-js.js?v=1974b27b:3951
(anonymous) @ pusher-js.js?v=1974b27b:3868
emit @ pusher-js.js?v=1974b27b:1230
updateState @ pusher-js.js?v=1974b27b:2341
connected @ pusher-js.js?v=1974b27b:2281
callback @ pusher-js.js?v=1974b27b:2176
cb @ pusher-js.js?v=1974b27b:2619
tryNextStrategy @ pusher-js.js?v=1974b27b:2459
(anonymous) @ pusher-js.js?v=1974b27b:2507
(anonymous) @ pusher-js.js?v=1974b27b:3399
finish @ pusher-js.js?v=1974b27b:1752
onMessage @ pusher-js.js?v=1974b27b:1729
emit @ pusher-js.js?v=1974b27b:1230
onMessage @ pusher-js.js?v=1974b27b:1327
socket.onmessage @ pusher-js.js?v=1974b27b:1343
Show 20 more frames
Show less
pusher-js.js?v=1974b27b:979 Pusher :  : ["Error: Unable to retrieve auth string from channel-authorization endpoint - received status: 401 from http://localhost:8000/api/pusher/auth. Clients must be authorized to join private or presence channels. See: https://pusher.com/docs/channels/server_api/authorizing-users/"]

This problem is specific to the pusher route, all other api routes work just fine.

Frontend setup

pusher.js:

import Pusher from 'pusher-js'

Pusher.logToConsole = true

const pusher = new Pusher('bf29be46d8eb2ea8ccd4', {
  cluster: 'eu',
  forceTLS: true,
  authEndpoint: 'http://localhost:8000/api/pusher/auth',
  withCredentials: true,
  wsPort: 443,
  wssPort: 443,
  enableStats: false,
  enabledTransports: ['ws', 'wss'],
  auth: {
    headers: {
      'X-Requested-With': 'XMLHttpRequest',
    },
  }
})

export default pusher

axios.js:

import axios from 'axios'

axios.defaults.withCredentials = true

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

the user is authenticated in a login component:

const signIn = () => {
  axios.get('/sanctum/csrf-cookie').then(() => {
    axios
      .post('/login', form)
      .then(() => {
        store.auth = sessionStorage.auth = 1
        store.signInModal = false
      })
      .catch((er) => {
        state.errors = er.response.data.errors
      })
  })
}

and the app tries to subscribe to pusher in a chat component, only accessible to authenticated users:

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

      const channel = pusher.subscribe(`private-chat.${state.chatSessionId}`) // 401 happens here

      channel.bind('App\Events\ChatMessageSent', (data) => {
        state.messages.push(data.chatMessage)
      })
    })
    .catch((er) => {
      state.errors = er.response.data.errors
      state.loadingSession = false
    })
}

Backend setup

/routes/api.php:

Route::post('/pusher/auth', function (Request $request) {
    Log::info('test');

    $user = $request->user();
    if (!$user) {
        abort(403, 'Unauthorized');
    }

    $pusher = new Pusher(
        env('PUSHER_APP_KEY'),
        env('PUSHER_APP_SECRET'),
        env('PUSHER_APP_ID'),
        ['cluster' => env('PUSHER_APP_CLUSTER')]
    );

    $channelName = $request->channel_name;
    $socketId = $request->socket_id;

    $auth = $pusher->socket_auth($channelName, $socketId);

    return response()->json(['auth' => $auth]);
})->middleware('auth:sanctum');

pusher is trying to connect to this route but the authorization fails, resulting in 'test' not being logged and returning aforementioned error back to the client. This seems to be a pusher + sanctum problem because connecting to this diagnostic route works:

Route::post('/pusher/auth', function (Request $request) {
    Log::info(var_export($request, true));
    Log::info('Request headers: ', $request->header());
    Log::info('Request cookies: ', $request->cookies->all());
    Log::info('Session data: ', $request->session()->all());
    Log::info('User: ', $request->user());
})

but $request->cookies->all() is empty and $request->user() is null. For some reason no auth cookies are arriving in the pusher route. To check if sanctum works by itself, connecting to the following route, returns the authorized user:

Route::middleware('auth:sanctum')->get('/test-auth', function (Request $request) {
    return $request->user();
});

relevant .env entries:

APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
SANCTUM_STATEFUL_DOMAINS=localhost:3000
SESSION_DOMAIN=localhost

PUSHER_APP_ID=1728518
PUSHER_APP_KEY=bf29be46d8eb2ea8ccd4
PUSHER_APP_SECRET=...
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=eu

like I said, the entire authentication of the app works fine except the pusher route.

Browser Network Tab

auth request #1:

General:

Request URL:       http://localhost:8000/api/pusher/auth
Request Method:    OPTIONS
Status Code:       204 No Content
Remote Address:    127.0.0.1:8000
Referrer Policy:   strict-origin-when-cross-origin

Response Headers:

Access-Control-Allow-Credentials:    true
Access-Control-Allow-Headers:        x-requested-with
Access-Control-Allow-Methods:        POST
Access-Control-Allow-Origin:         http://localhost:3000
Access-Control-Max-Age:              0
Cache-Control:                       no-cache, private
Connection:                          close
Content-Type:                        text/html; charset=UTF-8
Date:                                Wed, 27 Dec 2023 02:25:13 GMT
Host:                                localhost:8000
Vary:                                Access-Control-Request-Method, Access-Control-Request-Headers
X-Powered-By:                        PHP/8.3.1

Request Headers:

Accept:                            */*
Accept-Encoding:                   gzip, deflate, br
Accept-Language:                   en-GB,en;q=0.9,de;q=0.8
Access-Control-Request-Headers:    x-requested-with
Access-Control-Request-Method:     POST
Cache-Control:                     no-cache
Connection:                        keep-alive
Host:                              localhost:8000
Origin:                            http://localhost:3000
Pragma:                            no-cache
Referer:                           http://localhost:3000/
Sec-Fetch-Dest:                    empty
Sec-Fetch-Mode:                    cors
Sec-Fetch-Site:                    same-site
User-Agent:                        Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36

followed by auth request #2:

General:

Request URL:        http://localhost:8000/api/pusher/auth
Request Method:     POST
Status Code:        401 Unauthorized
Remote Address:     127.0.0.1:8000
Referrer Policy:    strict-origin-when-cross-origin

Response Headers:

Access-Control-Allow-Credentials:    true
Access-Control-Allow-Origin:         http://localhost:3000
Cache-Control:                       no-cache, private
Connection:                          close
Content-Type:                        application/json
Date:                                Wed, 27 Dec 2023 02:25:13 GMT
Host:                                localhost:8000
Set-Cookie:                          XSRF-TOKEN=eyJpdiI6Inl5T2ZLbndpZG1OUTV5MmxNdDlNNWc9PSIsInZhbHVlIjoiUzdJYVkzZzJvM3FnaUlIUGxVWFBDTTZYeHQveTBWOWoxSEsvcThGM00wVDh6WExmK2RYWVBldTNxK2xKS1RrV1JSTHA2b0NEMVFtQzlzSmxyVVVRbmlrSmNRdmJQaW00cWpIQVFyZkhYM0RwampuMDZWVzJsV3NUZjVJZ1kxaG0iLCJtYWMiOiI4MzFlZjBjYWZkNDZkZDBhMGYxZDgwMDQ5YTgzY2ExNDg1NDMyNjFlNTNmZDg5NGJmZTI4MDMxNzAzMjVlNjZjIiwidGFnIjoiIn0%3D; expires=Wed, 27 Dec 2023 04:25:13 GMT; Max-Age=7200; path=/; domain=localhost; samesite=lax
Set-Cookie: soul_meatcom_session=eyJpdiI6Ii8wTktnTVFMZUZBYXVTeTFTRjd4dmc9PSIsInZhbHVlIjoicDUzNjFJVEYyTVR5cXdrTGZQZWZ1NzF4UEZ6QUJXSWF3YUsya0lUZy9qb0IwNk0rM0cwa3RwV1YyZ1Q0T0JqWW90cjZKd2d3OXNqOW13aGswc2tPMGw0d0hPRkxDZDdqamFUQWpKSktVd2ZpS1c2b3NqQm5WMVhoK2VsLzJWeEkiLCJtYWMiOiI4YjM3ZWEwZWMwNzlhNDIxMWNhNjBhMjAzNzcxNDM0NGMxNTczOTU1YWQ0ZGFjNzEyYWJkNDI2ZWJiNjI2ZTZkIiwidGFnIjoiIn0%3D; expires=Wed, 27 Dec 2023 04:25:13 GMT; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax
X-Powered-By:                        PHP/8.3.1

Request Headers:

Accept:                   */*
Accept-Encoding:          gzip, deflate, br
Accept-Language:          en-GB,en;q=0.9,de;q=0.8
Cache-Control:            no-cache
Connection:               keep-alive
Content-Length:           87
Content-Type:             application/x-www-form-urlencoded
Host:                     localhost:8000
Origin:                   http://localhost:3000
Pragma:                   no-cache
Referer:                  http://localhost:3000/
Sec-Ch-Ua:                "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"
Sec-Ch-Ua-Mobile:         ?0
Sec-Ch-Ua-Platform:       "macOS"
Sec-Fetch-Dest:           empty
Sec-Fetch-Mode:           cors
Sec-Fetch-Site:           same-site
User-Agent:               Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
X-Requested-With:         XMLHttpRequest

Browser Application Tab

there are two cookies in /storage/cookies/http://localhost:3000:

#1 soul_meatcom_session:

eyJpdiI6ImI4dUNWL0pCRmRibFMzNUdyT0JrL3c9PSIsInZhbHVlIjoiQTN4L0djZm52bjlCSFV4TmM0QU1oNUFoT0JBUlFGcGNWMFVwSDEvTFZBWmZwVi9kSEFnUitHcit6MzRLNXVkNkxLU1o5a0VhWmJ2OTNvYUdxMkpyVDVUcVZoQWRzckVlVi84Tis3UTdxazhkR0ozU1EyeldnaFowcStTRFFJYjgiLCJtYWMiOiJkNjc3MGM1ODc2MWM1NWFiMDBlNjYzMTg0OWI3M2RiZmNmZGU5NzU4Y2QzZDA0NmViZDQzZjIzODBiMWZiYWM1IiwidGFnIjoiIn0%3D

#2 XSRF-TOKEN:

eyJpdiI6Ik1xc0tCckEzS2RNNURFVWJ5aGc2Z0E9PSIsInZhbHVlIjoidVE4TElScENjRFlEbUFtVk1sVzZ1MGU5WDI2NXk2b214aEpWbU10K1hJUGZtQzdFOFBHV3JYblZiYmlFSmQvaSt2ZWQ5cWtjOXhtZXJQTmQ0NUNSVjAvQ2xmVDNwcUw0dkRFMHZnclRSc08wanVqaHdlbGFWeE5JMk1pTzRXOFgiLCJtYWMiOiJkNzQwODAwYzU2ZGE0OTVjNzQ0MjQxNzAwZDIxMGVkNGNkZTJjNWI2NjQ2YjMzZjk4NGM1YzI4MWJhOWZmMGI2IiwidGFnIjoiIn0%3D

I’m not very experienced in reading network headers. Any idea what the problem is with the pusher route or how I could further debug this? Thank you in advance

Edit:

I’ve tried @suxgri’s approach:

import Pusher from 'pusher-js'

Pusher.logToConsole = true

const pusher = new Pusher('bf29be46d8eb2ea8ccd4', {
  cluster: 'eu',
  forceTLS: true,
  authEndpoint: 'http://localhost:8000/api/pusher/auth',
  withCredentials: true,
  wsPort: 443,
  wssPort: 443,
  enableStats: false,
  enabledTransports: ['ws', 'wss'],
  auth: {
    headers: {
      'X-Requested-With': 'XMLHttpRequest',
      'Authorization':
        'Bearer eyJpdiI6ImpCdC96YjVqNmh3VXhic0tkeXlYQ3c9PSIsInZhbHVlIjoiN0YzMzB3QkVySDdyeFdIK0JLaXM1cWVaTDZSd3FTUkNoWHdDSEFVaTQ3ZktYMi94ak5yQVpMRkNNNjBNbWswamM0emJGa2RVRktuNU4yUXBLMUxoMU90UGxyZk94bzdUSythMlFFNmNGdFlydjdhYVI4WmlIRXk4dEdKQU9kRnYiLCJtYWMiOiIwOWI3MDg4MGZmYTAzYWY3N2QzOGM5ZmQ4MjNkMjIyNDg5OGRjZTk5YjNjNTAwZjE5MWY2YjIxZTMyMGQ3NWU0IiwidGFnIjoiIn0=',
    },
  },
})

export default pusher

but I still get the 401.

2

Answers


  1. Laravel respond with a 401 error code because the auth:sanctum middleware stops the request as you are not sending the auth parameter (access token), please see the new line in auth -> headers below.
    Then it should work or at least reach the controller and log ‘test’

    import Pusher from 'pusher-js'
    
    Pusher.logToConsole = true
    
    const pusher = new Pusher('bf29be46d8eb2ea8ccd4', {
      cluster: 'eu',
      forceTLS: true,
      authEndpoint: 'http://localhost:8000/api/pusher/auth',
      withCredentials: true,
      wsPort: 443,
      wssPort: 443,
      enableStats: false,
      enabledTransports: ['ws', 'wss'],
      auth: {
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          'Authorization': 'Bearer your-token',
        },
      }
    })
    
    export default pusher
    

    Please note the above answer is correct for mobile+api apps but not for spa+api apps.

    EDIT:

    from the Laravel docs (sanctum#spa authentiction)

    During this request, Laravel will set an XSRF-TOKEN cookie containing the current CSRF token. This token should then be passed in an X-XSRF-TOKEN header on subsequent requests, which some HTTP client libraries like Axios and the Angular HttpClient will do automatically for you. If your JavaScript HTTP library does not set the value for you, you will need to manually set the X-XSRF-TOKEN header to match the value of the XSRF-TOKEN cookie that is set by this route.

    It seems like axios is not passing the XSRF-TOKEN in any of the request header you posted so i would modify as below:

    auth: {
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          'X-XSRF-TOKEN': 'your-token',
        },
    }
    

    Next i would inspect the http://localhost:8000/api/pusher/auth request to make sure the token is passed, if the problem still persists i would follow the steps in:

    Laravel docs -> Sanctum -> Authorizing Private Broadcast Channels

    Login or Signup to reply.
  2. I used the Laravel Echo client package recommended by the documentation to establish broadcasting communication.

    The Laravel Documentation also covers the authentication methods required for channel listening.

    Solution

    Install the laravel-echo package as a development dependency alongside the pusher-js package.

    npm install --save-dev laravel-echo pusher-js
    

    Create the necessary Echo class on the client side at the desired location for establishing the connection.

    import Echo from 'laravel-echo';
    import Pusher from 'pusher-js';
     
    window.Pusher = Pusher;
     
    window.Echo = new Echo({
      broadcaster: 'pusher',
      key: import.meta.env.VITE_PUSHER_APP_KEY,
      cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
      forceTLS: true,
      
      // can use encrypted connection
      // encrypted: true,
    
      // if using Sanctum or another method for API authentication, the broadcast listener will need a valid Bearer Token, which must be passed in the header
      auth: {
        headers: {
          Authorization: `Bearer your-key-here`,
        },
      },
    
      // for specifying a custom endpoint, use:
      // authEndpoint: '/custom/endpoint/auth',
      
      // for entirely custom authentication, remember to pass the token in the headers
      // authorizer: (channel, options) => { ... }
    });
    

    After this, by invoking the global window.Echo, you can connect to the channels.

    const inquireChatSession = () => {
      axios
        .get(`/api/chat/${props.id}`)
        .then((res) => {
          state.chatSessionId = res.data.id
          state.messages = res.data.messages
          state.loadingSession = false
    
          const channel = window.Echo.private(`private-chat.${state.chatSessionId}`)
    
          channel.listen('App\Events\ChatMessageSent', (event) => {
            state.messages.push(event.chatMessage)
          })
        })
        .catch((error) => {
          state.errors = error.response.data.errors
          state.loadingSession = false
        })
    }
    

    I don’t know your intentions, but I must point out that listening to the channel in your code will immediately cease once the then() function is executed. Therefore, it is advisable to define an external channel variable and write the channel listening into it when the then() branch is executed.

    let channel; // here
    
    const inquireChatSession = () => {
      axios
        .get(`/api/chat/${props.id}`)
        .then((res) => {
          state.chatSessionId = res.data.id
          state.messages = res.data.messages
          state.loadingSession = false
    
          channel = window.Echo.private(`private-chat.${state.chatSessionId}`)
    
          channel.listen('App\Events\ChatMessageSent', (event) => {
            state.messages.push(event.chatMessage)
          })
        })
        .catch((error) => {
          state.errors = error.response.data.errors
          state.loadingSession = false
        })
    }
    

    Extra

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search