Skip to content
This repository has been archived by the owner on Jul 31, 2024. It is now read-only.

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

Closed
danielleiszen opened this issue Jun 1, 2018 · 14 comments
Labels

Comments

@danielleiszen
Copy link

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.

@danielleiszen
Copy link
Author

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.

@kevinlo
Copy link
Contributor

kevinlo commented Jun 4, 2018

IdentityServerAuthenticationOptions has the SupportedTokens default to SupportedTokens.Both, I believe if it is set to SupportedTokens.Reference only, the JwtBearerEvents.OnMessageReceived won't even fired.

From the IdentityServerAuthenticationHandler.HandleAuthenticateAsync, it needs to use the TokenRetriever to retrieve the token first and check if the token is JWT and if Options.SupportsJwt is true before letting the the JwtBearerHandler to handle it and fire the JwtBearerEvents.

I think using the TokenRetriever is the right way, but let Dominick comments on it.

However, from the spec, it should NOT pass the token in the query string parameter:

Don't pass bearer tokens in page URLs: Bearer tokens SHOULD NOT be
passed in page URLs (for example as query string parameters).
Instead, bearer tokens SHOULD be passed in HTTP message headers or
message bodies for which confidentiality measures are taken.
Browsers, web servers, and other software may not adequately
secure URLs in the browser history, web server logs, and other
data structures. If bearer tokens are passed in page URLs,
attackers might be able to steal them from the history data, logs,
or other unsecured locations.

@danielleiszen
Copy link
Author

Thanks for your answer. I am not satisfied with the query string approach either. However, according to this thread there seems to be no other option for websocket handshake. It goes over HTTPS so the only concern is that it can be logged during the way. It is maybe possible to initiate an immediate token refresh after the handshake. As long as the websocket channel is up it does not need to reauthorize. I am open to suggestions, please keep me posted. Thx

@kevinlo
Copy link
Contributor

kevinlo commented Jun 4, 2018

As stated by https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#The_WebSocket_Handshake,

common headers like User-Agent, Referer, Cookie, or authentication headers might be there as well

For my case, the token is passed from the JS client in cookie to the server and the server can see the cookie from the ws http(s) handshake request to do authentication and establish the WS connection.

Why do you close this issue if you want others to reply it? I am not sure if they will check the closed issue. I would like to hear their comments too.

@danielleiszen danielleiszen reopened this Jun 5, 2018
@danielleiszen
Copy link
Author

Thanks for the info. We use a HttpInterceptor to pass the bearer token during standard API calls, but that interceptor mechanism is not called during the SignalR handshake.

I have not enough experience on the front end side to figure out how to put the actual bearer token into the header. According to your link I understand that might be possible. However what I find everywhere is that for bearer tokens the query string approach is the standard. See PatrickJS/angular-websocket#15 for angular-websocket.

Could you please point out some implementation on how to intercept the websocket handshake on the client side (JS or TS)?

@kevinlo
Copy link
Contributor

kevinlo commented Jun 6, 2018

Sorry, I have not set the authorization header from the client js before. I just see the spec saying it MAY contain the Authorization header:

  1. The request MAY include any other header fields, for example,
    cookies [RFC6265] and/or authentication-related header fields
    such as the |Authorization| header field [RFC2616], which are
    processed according to documents that define them.

From the link you shown, it seems it cannot and people suggests using querystring or basic authentication.

For my case, the server put the token in the cookie and the client will send the cookie in the ws request.

@brockallen
Copy link
Member

All set on this issue -- can we close?

@danielleiszen
Copy link
Author

Thank you for the support. I am closing the issue. The custom token retriever approach seems to be a working solution for me - it works in production now.

@LodeKennes
Copy link

LodeKennes commented Aug 18, 2018

Hi,

I'm having the exact same issue as @danielleiszen is having. I've tried multiple solutions but couldn't get it to work through SignalR. The CustomTokenRetriever.FromHeaderAndQueryString method gets hit when making a webrequest to a controller that need authorization but not when trying to connect to a signalr hub.

This is my code:

services.AddAuthentication(options =>
      {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
      })
      .AddIdentityServerAuthentication("Bearer", (options) =>
        {
          options.Authority = Environment.GetEnvironmentVariable("Authority").ToString();
          options.RequireHttpsMetadata = bool.Parse(Environment.GetEnvironmentVariable("HttpsMetaData"));
          options.ApiName = "API_SERVER_CHAT";
          options.TokenRetriever = CustomTokenRetriever.FromHeaderAndQueryString;
        });

So my question is, which transport types are you using for SignalR and is it possible to share your SignalR settings? Got it to work with normal JWT but not with ID4.

@AltairCA
Copy link

I have the same issue @LodeKennes

@AltairCA
Copy link

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

@chybisov
Copy link

chybisov commented Sep 5, 2019

@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.

@ericbrumfield
Copy link

I'm recently migrating from .Net core 2.1 to 3.0, and experienced getting 401's during signalr websocket negotiate handshakes. What fixed this for me was adding options.LegacyAudienceValidation = true; to the AddIdentityServerAuthentication ConfigureServices parts in addition to ensuring that app.UseAuthorization() was between UseRouting and UseEndpoints in the Configure part.

Figured I'd comment here as there may be other folks coming across this while upgrading to .net core 3.0 and 3.1 like me. If the UseAuthorization() call is not in the correct spot of the pipeline, then the TokenRetriever isn't receiving the auth/bearer token during the websocket handshake.

Startup looks like this after porting to .net core 3.0:

ConfigureServices

services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = jwtBearerSettings.Authority;
                    options.RequireHttpsMetadata = jwtBearerSettings.RequireHttpsMetadata;
                    options.ApiName = jwtBearerSettings.Audience;
                    options.LegacyAudienceValidation = true;
                    options.NameClaimType = "sub";
                    options.TokenRetriever = new Func<HttpRequest, string>(req =>
                    {
                        var fromHeader = TokenRetrieval.FromAuthorizationHeader();
                        var fromQuery = TokenRetrieval.FromQueryString();   //needed for signalr and ws/wss conections to be authed via jwt
                        return fromHeader(req) ?? fromQuery(req);
                    });
                });

Configure:

            app.UseHttpsRedirection();
            app.UseRouting();
            app.UseCors("AllowAny");
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers().RequireCors("AllowAny");
                endpoints.MapHub<EntityCommentHub>("/comment-hub").RequireCors("AllowAny");
            });

@lock
Copy link

lock bot commented Jan 10, 2020

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@lock lock bot locked as resolved and limited conversation to collaborators Jan 10, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

7 participants