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?
- 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;
} - 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. - (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));
}
}); - 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);
} - 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
- This article was referenced a ton of times – so if you’re working with .NET Framework and not .NET Core, this is probably useful for you. For me, it wasn’t :)
- If you’re bound to forget the basics like adding an [Authorize] attribute, don’t ask about it on Stack Overflow or ASP.NET Forums like these folks did:
- The order of things in Configure() and ConfigureServices in Startup.cs is of utmost importance. Nothing will work properly, and you won’t get useful error messages at all if your order is wrong. One of the basic things is to have your Authentication and Authorization configuration always before anything that relies on them, and all of the route definitions (like mapping the SignalR Hub) in the end:
- Microsoft’s docs for IdentityServer4 tell you to do this weird extra hoop, but it didn’t make a difference for us (seems to do the same as token extraction otherwise would)
- A cool sample on how to use UserManager in hubs, but for whatever reason UserManager didn’t return anything for us either (since the authentication configuration was broken it didn’t really have any information available?)
- The URL looks kinda fishy so I’m leaving this as plaintext – click at your own discretion!
- http[:]//5.9.10.113/66215366/blazor-server-signalr-hub-is-missing-user-claims
- Microsoft’s somewhat comprehensive documentation didn’t get me even halfway through:
- A beautiful, near-complete instruction on what to do. I think I may have fallen in love with this guy.
- That friggin broken-tush services.AddIdentityServerJwt() does have some documentation but who is this supposed to help when you still get errors after adding the dependency? (╯°□°)╯︵ ┻━┻
- Useful discussions:
- How to associate connectionIds with users? Well, kind of – this article describes:
- “Performing cleanup” – Excel is stuck with an old, conflicted file and will never recover. - November 12, 2024
- How to add multiple app URIs for your Entra app registration? - November 5, 2024
- How to access Environment Secrets with GitHub Actions? - October 29, 2024