node.jsazure-functionsserverlessazure-signalrsignalr-service

How to customize user in negotiate response for serverless SignalR Service in Nodejs Azure function


I have Azure functions app written in Node.js, that uses Azure SignalR Service in serverless mode for some raal-time functionality. For clients to be able to connect to this SignaR Service, my Azure functions expose the standard /negotiate endpoint, which provides SignalR Service connection info as follows:

const inputSignalR = input.generic({
  type: "signalRConnectionInfo",
  name: "connectionInfo",
  hubName: "my-hub",
});

async function negotiate(request: HttpRequest, context: InvocationContext) {
  try {
    return { body: JSON.stringify(context.extraInputs.get(inputSignalR)) };
  } catch (error) {
    context.log(error);
    return {
      status: 500,
      jsonBody: error,
    };
  }
}

app.post("negotiate", {
  authLevel: "anonymous",
  handler: negotiate,
  route: "negotiate",
  extraInputs: [inputSignalR],
});

this works as expected - SignalR clients call the negotiate endpoint, from which they get SignalR Service connection info (url and access token) and they use those to connect to the SignalR Service. This is standard SignalR connection flow and it works without problems.

The issue comes when I want to make that generated access token in connection info aware of the user (in the above state it does not contain user identity and without it SignaR Service does not know what user is connected to it). I need that to be able to send SignalR messages to specfic users.

Since our Azure functions (including the SignalR specific /negotiate one) are already called with our standard JWT token in Authorization header (for which we already have shared logic to verify and parse the claims), I would like to take the user identifier claim from our JWT token and use it as user identifier in that connection info access token.

According to docs there are a few ways to augment the generated SignalR Service access token by using additional input binding properties/settings:

In documentation I found that this is doable in C#, using Azure SignalR Service Management SDK or using Azure SignalR Service function extension. Using either of those you can specify the user identity "manually" in the function body, before returning the connection info. This is exactly what I would need, as the logic for verifying and obtaining user identity from our standard JWT token already exists in our code base, the only thing missing is how to push it to SignalR access token.

I could not find anything like it for NodeJS Azure functions. Is is even possible?


Solution

  • There is no build-in way to inject custom claim from incoming third-party JWT into SignalR access token to be used as user identity claim for Node.js Azure functions. It needs to be done "manualy" on your own.

    Based on https://learn.microsoft.com/en-us/azure/azure-signalr/signalr-concept-client-negotiation#self-exposing-negotiate-endpoint I managed to inject custom user identity claim from our custom incoming JWT into SignaR access token as user identity claim (simplified version without error handling):

    import { sign, decode } from "jws";
    
    const inputSignalR = input.generic({
      type: "signalRConnectionInfo",
      name: "connectionInfo",
      hubName: "my-hub",
    });
    
    async function negotiate(request: HttpRequest, context: InvocationContext) {
      const signalRDefaultConnectionInfo = context.extraInputs.get(inputSignalR);
      const signalRConnectionString = process.env.AzureSignalRConnectionString;
      const signalRAccessKey = /AccessKey=(.*?);/.exec(signalRConnectionString)[1];
    
      const userId = extractUserId(request); // extract user identity claim from your incoming JWT
      const originalDecodedToken = decode(signalRDefaultConnectionInfo.accessToken);
      const customizedToken = sign({
        header: originalDecodedToken.header,
        payload: {
          ...originalDecodedToken.payload,
          "asrs.s.uid": userId, // claim used by SignalR Service to hold user identity
          },
        secret: signalRAccessKey
      });
    
     return { url: signalRDefaultConnectionInfo.url, accessToken: customizedToken };
    }
    
    app.post("negotiate", {
      authLevel: "anonymous",
      handler: negotiate,
      route: "negotiate",
      extraInputs: [inputSignalR],
    });