InteractiveFrameworks

Guards and the handshake

Decide who may do what, at two moments: a guard that reads the handshake and refuses one kind of message, and a socket.io middleware, installed by an IoAdapter of your own, that refuses the connection itself before any message is sent.

What you'll learn

  • Write a guard for a gateway, reach the socket with context.switchToWs().getClient(), and refuse with a WsException that says why
  • Know what a guard that returns false sends: an exception event with Forbidden resource
  • Refuse a connection at the handshake with a socket.io middleware, server.use(), and know what the client receives: connect_error with the error's message
  • Extend IoAdapter, override createIOServer(), and install it with app.useWebSocketAdapter()

Anyone may look at the cats; only staff may remove one. Over HTTP a guard checks each request, and every request carries its credentials. A socket is different: it authenticates once, while connecting, and then sends many messages over the same connection. So there are two moments to decide at. At the handshake, whether this client may connect at all. At each message, whether this connection may do this particular thing. Nest's guards cover the second; socket.io's own middleware covers the first.

Guards on a gateway

A guard for a gateway is the same CanActivate class as for a controller, bound with @UseGuards() on a handler or on the gateway class, or globally (global guards, unlike global filters, do reach gateways). What changes is where it finds the caller. The execution context is a WebSocket one: context.switchToWs() gives getClient(), the socket, getData(), the payload, and getPattern(), the event name. Credentials are in the socket's handshake:

@Injectable()
export class VolunteerGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const client = context.switchToWs().getClient<Socket>();
    if (!this.rota.isOnShift(client.handshake.auth.volunteer)) {
      throw new WsException('Only volunteers on shift can do that');
    }
    return true;
  }
}

A guard that returns false refuses with a WsException saying Forbidden resource, sent as the usual exception event. Throwing your own WsException says why. Either way the handler never runs.

Refusing the connection

A guard runs per message, so a client the server should never have accepted can still connect, sit there, and hear every broadcast. socket.io lets the server refuse a connection during the handshake with middleware: functions on the server, or on one namespace, called with each connecting socket before it is connected. Calling next() lets it in; calling next(new Error('…')) refuses it, and the client's connect_error handler receives that error's message (and its data, if you set one). No hook runs and no message is ever handled for a refused socket.

Middleware is also where to do the identity work once: check the token, look the user up, and keep the result on socket.data, which socket.io keeps with the socket for every handler to read.

Where to install it: the adapter

The middleware has to be installed on the socket.io server when it is created. Nest creates that server through its adapter, IoAdapter from @nestjs/platform-socket.io, and the docs' way to change it is to extend the adapter and override createIOServer(), the method that builds the server:

export class ApiKeyIoAdapter extends IoAdapter {
  createIOServer(port: number, options?: ServerOptions): Server {
    const server: Server = super.createIOServer(port, options);
    server.use((socket, next) => (socket.handshake.auth.key === process.env.API_KEY ? next() : next(new Error('Bad API key'))));
    return server;
  }
}

and to install it in main.ts, before listen():

app.useWebSocketAdapter(new ApiKeyIoAdapter(app));

The docs' Redis adapter, for running several servers behind a load balancer, is the same override with server.adapter(...) in it. Two details: server.use() applies to the root namespace only, so a gateway with its own namespace needs server.of('/that').use(...); and the middleware runs before any gateway exists for the socket, so it uses socket.io's API, not Nest's.

Your task

  1. VisitorsIoAdapter refuses a connection whose handshake auth has no name, or only spaces, with the error Tell the desk your name. Otherwise it keeps the trimmed name in socket.data.name.
  2. The app's sockets go through VisitorsIoAdapter.
  3. StaffGuard lets a message through when the handshake's auth.token is whiskers, and otherwise throws a WsException saying Staff only.
  4. Only staff may send cats:remove.

In the Socket mode, connect with an empty auth field, then with {"name": "You"}, then with {"name": "Sam", "token": "whiskers"}.

When it fails

  • anon connected, expected the server to refuse it: the adapter's middleware is not installed. useWebSocketAdapter() is missing from main.ts, or createIOServer() returns the server without calling use().
  • no acknowledgement for "desk:whoami": the middleware let the client in without setting socket.data.name, and a handler returning undefined sends nothing.
  • "message":"Forbidden resource", expected Staff only: the guard returns false instead of throwing the WsException that says why.
  • acknowledged with {"removed":"Tom"} for Ada: the guard is not bound to cats:remove, so nothing checks her handshake.

Remember

  • A gateway's guard reads the socket with context.switchToWs().getClient(); its credentials are in handshake.auth.
  • false from a guard is Forbidden resource; throw a WsException to say why.
  • Refuse a connection itself with socket.io middleware: next(new Error(...)) is the client's connect_error.
  • Install middleware by extending IoAdapter's createIOServer(), and register the adapter with app.useWebSocketAdapter().
Stuck? Show a hint

staff.guard.ts: const client = context.switchToWs().getClient<Socket>(); throw new WsException('Staff only') unless client.handshake.auth.token === 'whiskers'. Gateway: @UseGuards(StaffGuard) on remove. Adapter: server.use((socket, next) => { const name = socket.handshake.auth.name; if (typeof name !== 'string' || !name.trim()) return next(new Error('Tell the desk your name')); socket.data.name = name.trim(); next(); }). main.ts: app.useWebSocketAdapter(new VisitorsIoAdapter(app)).