401 (Unauthorized) when subscribing to private-channel in Pusher (Vue/Laravel)

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 vue spa + 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.

Upvotes: 2

Views: 1107

Answers (3)

suxgri
suxgri

Reputation: 1025

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

EDIT 2

Pusher js send requests using the Fetch api, when using axios the credentials were set to true in the config file, but the fetch api has it set to false and using withCredentials:true does not work.

Here i found a solution, pusher js set credentials. (note that if you test by hardcoding the token you should omit the part after % (last 3 char)).

The code would be:

Pusher.Runtime.createXHR = function () {
     const xhr = new XMLHttpRequest();
     xhr.withCredentials = true;
     return xhr;
};

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-XSRF-TOKEN': 'your-token',
    },
  }
})

export default pusher

Using a custom authorizer can be done with pusher js as explained here, or similarly with Echo. When using a custom authorizer the authEndpoint and auth keys become unnecessary. The authorizer is successful because axios send the required headers without additional setup.

Upvotes: 0

In the meantime I tried out laravel-echo only to find the exact same problem. I still don't know why pusher and sanctum authorization won't work by default but modifying my laravel-echo.js to use a custom authorizor, made the /api/broadcasting/auth route work:

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)
            })
        },
      }
    },
  }))
}

For plain pusher-js it is probably /api/pusher/auth route, that has to be configured (if pusher-js supports custom authorizor at all).

Upvotes: 0

rozsazoltan
rozsazoltan

Reputation: 8390

Reflection on your Pusher JS code

If authentication is used to access API routes, the presence and transmission of the appropriate tokens will be required for any request that involves accessing the API routes.

const pusher = new Pusher('your-public-key', {
  cluster: 'eu',
  forceTLS: true,
  authEndpoint: 'http://localhost:8000/api/pusher/auth',
  auth: {
    headers: {
      Authorization: "Bearer ${token}", // here | value of "token" can get from backend
    },
  }
})

Solution with Laravel Echo

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.

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 ${token}`, // here | value of "token" can get from backend
    },
  },

  // 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

Create channel in Laravel

It is advisable to define the event according to the documentation. Afterward, you can create an event on the defined channel anywhere in the application.

app/Events/ChatMessageSent.php

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 $chatMessage;

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

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

ChatController.php

use App\Events\ChatMessageSent;

public function sendMessage(Request $request)
{
    event(new ChatMessageSent($chatMessage));

    return response()->json(['status' => 'Message sent']);
}

Upvotes: 0

Related Questions