SolvedIdentityServer4 Unauthorized (401) during websocket handshake when authorizing SignalR client with JWT bearer token

I am not sure if it is a problem with IdentityServer or ASP.Net identity or maybe I am missing something. However, I think I am not doing anything wrong and according to the examples it should work this way. I already posted my problem on stackoverflow.

I have two services: aspnet-core IdentityServer 4, and aspnet-core web API on the server side, Angular4 on the client side. The SignalR hub is hosted by the web API. Without authorization everything works fine. However, I need authorization on the SignalR hub.

When I put the [Authorize] attribute on the hub, I get 401 for the negotiation request with SignalR. I send the access_token in the querystring to the API and on the server side I extract and set the token for the request so the authorization pipeline could use it. The token validation is successful and regardless I get the unauthorized error.

services.AddAuthentication(options =>
{
	options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultSignOutScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddIdentityServerAuthentication(options =>
	{
		options.Authority = "http://identitysrv";
		options.RequireHttpsMetadata = false;
		options.ApiName = "publicAPI";
		options.JwtBearerEvents.OnMessageReceived = context =>
		{
			if (context.Request.Query.TryGetValue("signalr_token", out StringValues token))
			{
				context.Options.Authority = "http://identitysrv";
				context.Options.Audience = "publicAPI";
				context.Token = token;
				context.Options.Validate();
			}

			return Task.CompletedTask;
		};
	});

On the API side I get the following log messages

[08:59:06:2760 Information] Starting web host
[09:00:39:6118 Debug] IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler AuthenticationScheme: Bearer was not authenticated.
[09:00:41:2541 Information] Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler Successfully validated the token.
[09:00:41:2639 Information] Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler AuthenticationScheme: BearerIdentityServerAuthenticationJwt was challenged.
[09:00:41:2641 Information] IdentityServer4.AccessTokenValidation.IdentityServerAuthenticationHandler AuthenticationScheme: Bearer was challenged.

The IdentityServer logs (after the time of the token validation)

[09:00:40:8838 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.
[09:00:40:8840 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.
[09:00:40:8841 Debug] IdentityServer4.Hosting.EndpointRouter Request path /.well-known/openid-configuration matched to endpoint type Discovery
[09:00:40:8849 Debug] IdentityServer4.Hosting.EndpointRouter Endpoint enabled: Discovery, successfully created handler: IdentityServer4.Endpoints.DiscoveryEndpoint
[09:00:40:8850 Information] IdentityServer4.Hosting.IdentityServerMiddleware Invoking IdentityServer endpoint: IdentityServer4.Endpoints.DiscoveryEndpoint for /.well-known/openid-configuration
[09:00:40:8850 Debug] IdentityServer4.Endpoints.DiscoveryEndpoint Start discovery request
[09:00:41:0561 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.
[09:00:41:0564 Debug] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler AuthenticationScheme: Identity.Application was not authenticated.
[09:00:41:0565 Debug] IdentityServer4.Hosting.EndpointRouter Request path /.well-known/openid-configuration/jwks matched to endpoint type Discovery
[09:00:41:0574 Debug] IdentityServer4.Hosting.EndpointRouter Endpoint enabled: Discovery, successfully created handler: IdentityServer4.Endpoints.DiscoveryKeyEndpoint
[09:00:41:0575 Information] IdentityServer4.Hosting.IdentityServerMiddleware Invoking IdentityServer endpoint: IdentityServer4.Endpoints.DiscoveryKeyEndpoint for /.well-known/openid-configuration/jwks
[09:00:41:0576 Debug] IdentityServer4.Endpoints.DiscoveryKeyEndpoint Start key discovery request

According to this issue I suspected that the problem was with the authentication schemes so I set every schema for what I think was appropriate. It did not help.

15 Answers

✔️Accepted Answer

I managed to replace the whole authentication handling mechanism and figured out what was happening.
The IdentityServerAuthenticationHandler is checking if there is a token in HandleAuthenticateAsync. The OnMessageRecieved event however is being called after that. Even though the request header is changed during the process it is not checked again. The final decision whether the request should be forbidden is based on the first check. Could someone tell me why was it designed this way?

So what I did finally is replaced the token retrieval mechanism and incorporated my own "protocol". I needed to know some constants using by IdentityServer which I got from the source code. However, I would be happy to hear about a better solution.

This is my final code and that is working in every tested cases, which does not mean it would work in all circumstances.

The code that replaces the token retrieval mechanism:

services.AddAuthentication(options =>
{
	options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;                
}).AddIdentityServerAuthentication(JwtBearerDefaults.AuthenticationScheme,
	options =>
	{
		options.Authority = "http://identitysrv";
		options.TokenRetriever = CustomTokenRetriever.FromHeaderAndQueryString;
		options.RequireHttpsMetadata = false;
		options.ApiName = "publicAPI";
	});

And the custom token retrieval implementation

public class CustomTokenRetriever
{
	internal const string TokenItemsKey = "idsrv4:tokenvalidation:token";
	// custom token key change it to the one you use for sending the access_token to the server
	// during websocket handshake
	internal const string SignalRTokenKey = "signalr_token";

	static Func<HttpRequest, string> AuthHeaderTokenRetriever { get; set; }
	static Func<HttpRequest, string> QueryStringTokenRetriever { get; set; }

	static CustomTokenRetriever()
	{
		AuthHeaderTokenRetriever = TokenRetrieval.FromAuthorizationHeader();
		QueryStringTokenRetriever = TokenRetrieval.FromQueryString();
	}

	public static string FromHeaderAndQueryString(HttpRequest request)
	{
		var token = AuthHeaderTokenRetriever(request);

		if (string.IsNullOrEmpty(token))
		{
			token = QueryStringTokenRetriever(request);
		}

		if (string.IsNullOrEmpty(token))
		{
			token = request.HttpContext.Items[TokenItemsKey] as string;
		}

		if (string.IsNullOrEmpty(token) && request.Query.TryGetValue(SignalRTokenKey, out StringValues extract))
		{
			token = extract.ToString();
		}

		return token;
	}
}

I hope that can be help to someone. I am still not sure whether this behavior is expected or it is a bug in IdentityServer or in Asp.NET Core.

Other Answers:

@danielleiszen let me put my two cents to this. I think most of us store tokens in cookies and during WebSockets handshake they are also sent to the server, so I suggest using token retrieval from cookie.

To do this add this below last if statement:

if (string.IsNullOrEmpty(token) && request.Cookies.TryGetValue(SignalRCookieTokenKey, out string cookieToken))
{
    token = cookieToken;
}

Actually we could delete retrieval from query string at all as according to Microsoft docs this is not truly secure and can be logged somewhere.

@LodeKennes use app.UseAuthentication(); before the app.UseSignalR() and it works

More Issues: