#SharePointProblems | Koskila.net

Solutions are worthless unless shared! Antti K. Koskela's Personal Professional Blog
>

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

koskila
Reading Time 8 min
Word Count 1412 words
Comments 4 comments
Rating 5 (1 votes)
View

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

Comments

Interactive comments not implemented yet. Showing legacy comments migrated from WordPress.
2022-03-03 06:19:54)
for this code to work .AddIdentityServerJwt(); necessary:
    public class ConfigureJwtBearerOptions : IPostConfigureOptions<JwtBearerOptions>
    {
        public void PostConfigure(string name, JwtBearerOptions options)
        {
            var originalOnMessageReceived = options.Events.OnMessageReceived;
            options.Events.OnMessageReceived = async context =>
            {
                await originalOnMessageReceived(context);


                if (string.IsNullOrEmpty(context.Token))
                {
                    var accessToken = context.Request.Query["access_token"];
                    var path = context.HttpContext.Request.Path;


                    if (!string.IsNullOrEmpty(accessToken) &&
                        path.StartsWithSegments($"/{NotifierConstants.Hubs_Url}"))
                    {
                        context.Token = accessToken;
                    }
                }
            };
        }
    }


on Startup add
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerOptions>();
https://docs.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-6.0#identity-server-jwt-authentication
Antti K. Koskela
2022-03-04 16:56:11
Thanks for your comment, Alexey! Interesting, adding .AddIdentityServerJwt(); just failed the build for me. I'll take another look if I ever need to touch it again! 😅
2022-09-14 22:44:53)
I'm dealing with this (I think) right now, except its a fresh project using Blazor Server, Microsoft Identity and Azure SignalR services... basically no claims / user information on the hub. I changed the transportation mode to long polling so I could capture the requests within Fiddler and see the bearer token being sent when I broadcast a message - I took the bearer token from this and deserialized it and can confirm the claims are present, just not being unbundled by the server. Anyhow, just wanted to thank you for this compilation of information... planning to spend more hours on it tonight to hopefully get it together.
Antti K. Koskela
2023-02-20 13:34:36
Thanks for your comment, Curtis! I know I'm a bit late on the party, but did you figure it out? 😁