Use SignalR. It'll be fun.

How to access claims of a SignalR user in ASP.NET Core?

This post was most recently updated on July 28th, 2022.

6 min read.

I tend to post a lot of articles about different gotchas and configuration tweaks, but this one goes back to the roots a bit – just me and a couple of other devs hacking some code together and being blocked by a bit of an obstacle, that’s then fixed by – you guessed it – writing niftier code.

Or actually, I suppose it was more about removing some and adding some of the right stuff… But isn’t that what most programming is about?

Anyway – let’s take a look at configuring SignalR for an ASP.NET Core web application, where Identity Server 4 is the authentication provider and all we need to do is configure a SignalR connection to be able to access connected users’ claims!

Problem

It’s easy to set up SignalR without authentication. It is also fairly simple to configure a super rudimentary basic level of authentication – slapping an [Authorize] on suitable methods and configuring token extraction – but actually getting access to the Claims from the user identity and making sure the access token extraction works consistently turned out to be a nightmare that my colleague and I spent about 2 days on.

That’s weird because, at first glance, the documentation is not bad. But time and time again, when done according to the docs, our app would fail to access the tokens.

I think our issue boiled down to our configuration with IdentityServer – and it is a large project with a lot of legacies, so perhaps there was something else interfering. Never found out the actual reason why we were struggling, but that didn’t much matter in the end, as we got it working.

Solution

This stuff has been tested in ASP.NET Core 3.1, but should be pretty much the same in ASP.NET 5, and who knows – maybe in .NET 6 as well. A big caveat is that we were using IdentityServer 4, and this probably changed a thing or two.

This solution worked great in our case, but I suspect it might become slow if taken into large-scale production use and forced to deal with hundreds of sockets.

Time needed: 30 minutes

How to wrangle SignalR to work with claims-based authorization in your ASP.NET web application?

  1. Configure your Authentication

    In your Startup.cs – this will look somewhat like this:

    services.AddAuthorization();
    services.AddAuthentication(options =>
    {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    }

  2. Extract the access token

    This is an interesting step, and it needs to be done in the Startup.cs again. By default, SignalR will have no idea of your authentication headers, so you’ll need to make it aware of them.

    This is something you can do when adding your identity provider.

    .AddIdentityServerAuthentication(options =>
    {
    options.Authority = identityUrl;
    options.RequireHttpsMetadata = false;
    options.TokenRetriever = new Func<HttpRequest, string>(req => {
    var fromHeader = TokenRetrieval.FromAuthorizationHeader();
    var fromQuery = TokenRetrieval.FromQueryString();
    return fromHeader(req) ?? fromQuery(req);
    });
    });


    The emphasis is on the TokenRetriever here – it’s the magical piece of code, that’ll expose your tokens.

  3. (OPTIONAL) Configure your Authorization

    This step allows you to use policy-based authorization, which is just a fancy way of saying that you can do this:
    [Authorize(Policy = "Administrator")]
    public static Task Function ...


    This is configured in Startup.cs, again, somewhat like this:
    services.AddAuthorization(options =>
    {
    Dictionary> policyClaims = new Dictionary>();
    // set up your policies here - what are the claims under "identity_roles"
    // (or other claim type) you want?
    foreach (var policyClaim in policyClaims)
    {
    options.AddPolicy(policyClaim.Key, policy => policy.RequireClaim("identity_roles", policyClaim.Value));
    }
    });

  4. Configure your Hub

    Now, find your Hub class. There you will want to override the following methods to make sure you manage to add your users to the right groups based on their claims:

    public override async Task OnConnectedAsync()
    {
    var roles = Context.User.Claims.Where(x => x.Type == "identity_roles").Select(x => x.Value).ToList();
    foreach (var role in roles)
    {
    await Groups.AddToGroupAsync(Context.ConnectionId, role);
    }
    await base.OnConnectedAsync();
    }


    And:

    public override async Task OnDisconnectedAsync(Exception exception)
    {
    var roles = Context.User.Claims.Where(x => x.Type == "identity_roles").ToList();
    foreach (var role in roles)
    {
    await Groups.RemoveFromGroupAsync(Context.ConnectionId, role.Value);
    }
    await base.OnDisconnectedAsync(exception);
    }

  5. Decorate your SignalR methods

    Well, obviously SignalR needs to know whether a method requires authentication – and authorization – or not! That makes sense, right?

    But the way that you decorate these might surprise you – or maybe it doesn’t. You should already be at the right place after the last step, just go ahead and add the good-old [Authorize] -attributes to your OnConnectedAsync and OnDisconnectedAsync methods.

    Now, IF you configured claims-based authorization, you can also use claim names in your attributes.

Caveats and frustrations

These are things that didn’t work for us but should’ve worked and might help you.

WTF is .AddIdentityServerJwt() and why wouldn’t it work?

I’m almost 100% sure this was of our own doing somehow (it’s a project with a considerable amount of legacy and quite a few cooks so it has had a few challenges before), but despite quite a few guides online stating simply calling AddIdentityServerJwt() on startup should be enough, it wasn’t.

So in short, no matter what we did, adding this:

services.AddIdentityServerJwt();

Lead to this:

Error CS1061 'IServiceCollection' does not contain a definition for 'AddIdentityServerJwt' and no accessible extension method 'AddIdentityServerJwt' accepting a first argument of type 'IServiceCollection' could be found (are you missing a using directive or an assembly reference?)

So after a while, I tossed it as equally broken as most other things I tried based on documentation and forum posts.

A note on extracting that damn access token…

That access token, when used with SignalR, is a pesky little critter. It’ll likely bring you all kinds of trouble.

First of all, if you’re configuring an OnMessageReceived event on options. Events, it wouldn’t even fire for me. But it’s how most guides online told you to do.

However, the code below would successfully fire and populate the access token (or, well, context.Token), but for whatever reason, the whole setup still wouldn’t work. It’s using options.JwtBearerEvents instead of options.Events.

options.JwtBearerEvents = new JwtBearerEvents
{
 OnMessageReceived = async context =>
 {
  await originalOnMessageReceived(context);

  var accessToken = context.Request.Query["access_token"];

  // If the request is for our hub...
  var path = context.HttpContext.Request.Path;
  if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/NOTIFICATIONHUB"))
  {
   // Read the token out of the query string
   context.Token = accessToken;
  }
 },
};

And yet another configuration that didn’t do jack squat:

.AddJwtBearer(options =>
{
 // auth server base endpoint (will use to search for disco doc)
 options.Authority = identityUrl;
 options.Audience = identityUrl;

 var originalOnMessageReceived = options.Events.OnMessageReceived;

 options.Events = new JwtBearerEvents
 {
  OnMessageReceived = async context =>
  {
   await originalOnMessageReceived(context);

   var accessToken = context.Request.Query["access_token"];

   // If the request is for our hub...
   var path = context.HttpContext.Request.Path;
   if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/ourhub"))
   {
    // Read the token out of the query string
    context.Token = accessToken;
   }
  },
 };
});

What a frustrating little journey.

References

mm
5 1 vote
Article Rating
Subscribe
Notify of
guest

4 Comments
most voted
newest oldest
Inline Feedbacks
View all comments