DEV Community

Cover image for Pusher Custom Authentication
K
K

Posted on • Updated on

Pusher Custom Authentication

Last week I entered the first ever dev.to contest and submitted a serverless multiplayer clicker game.

It would be awesome to get your ❤️ & 🦄 on my entry-post

I'd also like to give you some know-how in return.

Custom Authentication for Pusher

Pusher allows for custom authorisers that can save you a bunch of requests.

What

An authoriser for Pusher is just a function that takes a context, a socketId and a callback as arguments.

function customAuth(context, socketId, callback) {
  ... 
}
Enter fullscreen mode Exit fullscreen mode

It gets called by the Pusher client when you try to join a private or a presence channel.

The regular implementation sends a HTTP request to your backend and you have to return a token that the client will use to connect to Pusher.

The socketId is the current socket ID of the client.

The callback needs to be called when the authentication is done.

// The first argument needs to be false if everything went well
// and the second one needs to include the credentials from the server
callback(false, authCredentials);

// The first argument needs to be true if the authentication failed
// and the second one can be a error description
callback(true, errorText);
Enter fullscreen mode Exit fullscreen mode

Why

The authoriser function is called every time a client subscribes to a private or presence channel and the default one sends a HTTP request every time, so depending on the amount of channels one client joins in short time it could be a good idea to consolidate these requests.

Also, as in the game I created, it could be that the client gets the information about which channel to join from the server. So you would end up with one request to get the channel and one request to authenticate it. With a custom authoriser you could create the authCredentials in the same request that chooses the channel.

How

Regular Auth

The regular auth procedure is as follows:

  1. The client connects to Pusher and gets a socketId
  2. The client tries to subscribe to a private or presence channel
  3. The client sends its socketId and the channelName to the server (your server, not the Pusher server)
  4. The server asks the Pusher API to authenticate the socketId for the channelName
  5. The Pusher API creates authCredentials which get send back to the client
  6. The client uses the authCredenatials to subscribe to the channel

The server side authentication looks like this

const authCredentials = pusher.authenticate(
  socketId,
  channelName,
  {user_id: socketId}
);
Enter fullscreen mode Exit fullscreen mode

The arguments can come in via HTTP query parameters or body, the authCredentials need to be sent back to the client via HTTP.

Custom Auth

A custom version, like I used in my game, could look different.

Before

  1. The client connects to Pusher and gets a socketId
  2. The client requests a channelName from the server
  3. The client gets a channelName from the server
  4. The client tries to subscribe to the Pusher channel with the channelName
  5. The client gets authCredentials from the server
  6. The client subscribes to the Pusher channel with authCredentials

After

  1. The client connects to Pusher and gets a socketId
  2. The client requests a channelName from the server
  3. The client gets a channelName and authCredentials from the server
  4. The client subscribes to the Pusher channel with the authCredentials

So we need two new parts. A new authoriser, that wouldn't call the server but use some local data for authentication and a way to get the channelName and authCredentials from the server in one request.

Lets start from the back, how to get the two informations from the server.

For this we could add a new method to the Pusher client.

pusher.subscribeServerChannel = function() {
  const {socket_id} = pusher.connection;

  return fetch("/getChannel?socketId=" + socket_id)
  .then(r => r.json())
  .then(({channelName, authCredentials}) => {
    // used by the authoriser later
    pusher.config.auth.preAuth[channelName] = authCredentials;

    // calls the autoriser
    return pusher.subscribe(channelName);
  })
};
Enter fullscreen mode Exit fullscreen mode

This function tries to subscribe to a channel it requests from the server (your back-end). The GET /getChannel endpoint needs the socketId to create the authCredentials then channelName will also be created server side.

Next we need the new authoriser, also client side.

First get the old ones and add the new one to them. All before we create a connection.

const supportedAuthorizers = Pusher.Runtime.getAuthorizers();

supportedAuthorizers.preAuthenticated = function(context, socketId, callback) {
  const { authOptions, channel } = this;

  // getting the credentials we saved in subscribeServerChannel
  const authCredentials = authOptions.preAuth[channel.name];

  if (authCredentials) return callback(false, authCredentials);

  callback(true, "You need to pre-authenticate for channel: " + channel.name);
};

Pusher.Runtime.getAuthorizers = () => supportedAuthorizers;

// Later when the connection is created

const pusher = new Pusher(APP_KEY, {
  auth: {
    preAuth: {} // where the auth credentials will be stored
  },
  // set the transport to the new authoriser
  authTransport: "preAuthenticated",
});
Enter fullscreen mode Exit fullscreen mode

Last but not least the server endpoint that creates the channelName and handles authentication.

server.get("/getChannel", (req, res) => {
  const {socketId} = req.query;
  const channelName = "private-" + Math.random();

  const authCredentials = pusher.authenticate(socketId, channelName, {user_id: socketId});

  res.send({channelName, authCredentials});
});
Enter fullscreen mode Exit fullscreen mode

On the client we can now simply call subscribeServerChannel and get a pusher channel in return.

  const pusher = new Pusher(APP_KEY, {
    auth: { preAuth: {} },
    authTransport: "preAuthenticated",
    ...
  });

  pusher.connection.bind("connected", async () =>
    const channel = await pusher.subscribeServerChannel();
    channel.bind("my:event", ...);
  );
Enter fullscreen mode Exit fullscreen mode

And that's basically it.

You make one request and get all the data you need to join the channel with the client.

Conclusion

The Pusher client is a very flexible piece of software that allows you to modify authentication-flow to your liking. This eases integration rather much and allows for some performance tweaks in the long run.

Contest

Also, if you liked this post:

I would appreciate your ❤️ & 🦄 on my entry-post

Top comments (1)

Collapse
 
jleonardolemos profile image
Leonardo Lemos • Edited

We where having authentication request spikes when the websocket server was restarted. All the clients were trying to authenticate at the same time and everriding the customHandler was the solution:

import { createPusherInstance } from '@services/Sockets/pusherFactory'
import { token } from '@modules/authHelpers'
import axios from 'axios'
import store from '@/store'

export default createPusherInstance(
  import.meta.env.VITE_REVERB_APP_KEY,
  '',
  {
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    wssPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: [ 'ws', 'wss' ],
    channelAuthorization: {
      customHandler: (channel, callback) => {
        const minDelay = 1000; // 1 second
        const maxDelay = 120000; // 120 seconds

        const delay = store.state.notification.isReconnecting ?
          Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay : 0;
        console.log(`websocket authentication in ${delay} milisseconds`)

        setTimeout(() => {
          axios.post(import.meta.env.VITE_REVERB_AUTH_ENDPOINT, {
            socket_id: channel.socketId,
            channel_name: channel.channelName,
          }, {
            headers: {
              Authorization: `Bearer ${token}`,
              Accept: 'application/json',
            },
          }).then(response => {
            callback(false, response.data);
          }).catch(error => {
            callback(true, error.response ? error.response.data : error.message);
          })
        }, delay);
      }
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

In this solution we are spreading the requests over time.

Thanks for the tip!!!