Reputation: 5811
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.
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
})
}
/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.
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
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
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
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
Reputation: 5811
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
Reputation: 8390
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
},
}
})
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
})
}
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.
event()
helper - Laravel Docsapp/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