Search code examples
asp.net-coreazure-signalr

Azure SignalR - Client connection fails with 401-Unauthorized 'invalid_token. The signature key was not found'


Question Clarification: The examples Microsoft provides and feedback I have received assume that the client connection and Hub code are in the same application running ASP.NET Core.

My Hub code is a separate application running in ASP.NET Core. The clients are in another application running in ASP.NET. They are not combined.


I had a working local SignalR service that I need to integrate with Azure SignalR Service (to manage connections).

The clients are Javascript. The server-side hub code is ASP.NET Core (v9).

The hub connects to the signalr service with no problems and I see the connections in Live Trace Tool.

The client fetches a JWT token and attempts to connect to signalr service. The attempt appears in Live Trace Tool. It fails with a 401 - Unauthorized. DevTools shows the specific error in Www-Authenticate as "Bearer error='invalid_token', error_description='The signature key was not found'.

I verified the JWT signature key is present in the Hub code and that it matches the key used to generate the token.

Server-Side Hub Code (program.cs)


var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("AzureSignalRConnectionString");

//builder.Services.AddCors();
builder.Services.AddSignalR().AddAzureSignalR(connectionString);

builder.Services.AddAuthorization(options => {
    options.AddPolicy("AuthenticatedUsers", policy => {
        policy.RequireAuthenticatedUser();
    });
});

builder.Services.AddAuthentication(options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
    .AddJwtBearer(options => {
        options.TokenValidationParameters = new TokenValidationParameters {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "lobbycentral.com",
            ValidAudience = "https://lobbycentral.service.signalr.net",
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["TokenKey"]))
        };

        options.Events = new JwtBearerEvents {
            OnMessageReceived = context => {
                var token = context.Request.Query["token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hub")) {
                    context.Token = token;
                }
                return Task.CompletedTask;

            }
        };
    });

builder.Services.AddAuthorization();

builder.Services.AddCors(options => options.AddPolicy("testing", policy => {
    policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin();
}));

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("testing");
app.UseAuthorization();
app.MapHub<NotificationHub>("NotificationHub");
app.Run();

JWT Token API (localhost)

    var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TOKEN_KEY));
    var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

var clientID = "user123";
    var issuer = "<my issuer>";
    var audience = "https://<resource_name>.service.signalr.net";

    var claims = new[] {
new Claim(JwtRegisteredClaimNames.Sub, clientID),
new Claim(JwtRegisteredClaimNames.Iss, issuer),
new Claim(JwtRegisteredClaimNames.Aud, audience),
new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Nbf, DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new Claim("role", "client"),

    };

    var token = new JwtSecurityToken(
        issuer: issuer,
        audience: audience,
        claims: claims,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: credentials
    );

    return new JwtSecurityTokenHandler().WriteToken(token);

Javascript Client (index.html)

    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.js"></script>

<script type='text/javascript'>

var URL = 'https://<resource_name>.service.signalr.net/client/?hub=NotificationHub';

notifyConnection = new signalR.HubConnectionBuilder()
    .withUrl(URL, {
        accessTokenFactory: async () => {
            const response = await fetch("https://localhost:44328/v1/Authenticate/GetSRToken?companyID=123&clientID=user123");
            const data = await response.json();
            console.debug('token is ' + data.token);
            return data.token;
        }
    })
    .withAutomaticReconnect()
    .configureLogging(signalR.LogLevel.Information)
    .build();

console.debug("starting connection");

notifyConnection.start();

</script>

What I've checked:

  1. Verified that all required claims are present in the JWT token using jwt.io.
  2. Verified that the Signature Key on the server-side hub and token generator match.
  3. Verified the token is being generated and was present in the Bearer header.
  4. Added CORS policy.

Azure SignalR Instance details:

  1. Cors is set to '*'
  2. Pricing tier is 'Free'
  3. Service Mode is 'Default'

Solution

  • The Microsoft documentation here states that clients connect directly to the service instead of the app server.

    However, it left off the fact that you must first connect to your app server to negotiate. After that, the connection is handed off to the SignalR service.

    This is what Copilot had to say when I asked why the article says clients connect to the service:

    That's a great observation. The article is not misleading; rather, it emphasizes the final connection state while omitting some implementation details. Let me clarify:

    Key Point: The client does ultimately connect directly to the Azure SignalR service for real-time messaging. However, the negotiation process involves the app server.

    Here's a more detailed breakdown:

    1. Initial Connection to App Server: The client first makes a request to the app server to initiate the connection. This step is necessary for the app server to perform any required authentication, authorization, and to provide the client with the necessary connection details.

    2. Negotiation: The app server handles the negotiation process. It communicates with the Azure SignalR service to get a connection token and the endpoint information. The app server then provides this information to the client.

    3. Direct Connection to SignalR Service: After receiving the connection details from the app server, the client uses this information to connect directly to the Azure SignalR service for the actual real-time communication.

    So, while the negotiation starts with the app server, the actual messaging happens directly between the client and the Azure SignalR service. The article is correct in stating that clients connect directly to the Azure SignalR service, but the initial negotiation step involving the app server is also a necessary part of the process.

    After I made the change to the client, everything worked. I also verified in Azure portal that there is one client connection.

    var URL = 'https://<resource_name>.service.signalr.net/client/?hub=NotificationHub';
    
    Changed to:
    var URL = 'https://localhost/NotificationHub