13
votes

I have two aspnet.core services. One for IdentityServer 4, and one for the API used by Angular4+ clients. The SignalR hub runs on the API. The whole solution runs on docker but that should not matter (see below).

I use implicit auth flow which works flawlessly. The NG app redirects to the login page of IdentityServer where the user logs in. After that the browser is redirected back to the NG app with the access token. The token is then used to call the API and to build up the communication with SignalR. I think I've read everything that is available (see sources below).

Since SignalR is using websockets that does not support headers, the token should be sent in the querystring. Then on the API side the token is extracted and set for the request just as it was in the header. Then the token is validated and the user is authorized.

The API works without any problem the users gets authorized and the claims can be retrieved on the API side. So there should be no problem with the IdentityServer then since SignalR does not need any special configuration. Am I right?

When I do not use the [Authorized] attribute on the SignalR hub the handshake succeeds. This is why I think there is nothing wrong with the docker infrastructure and reverse proxy I use (the proxy is set to enable websockets).

So, without authorization SignalR works. With authorization the NG client gets the following response during handshake:

Failed to load resource: the server responded with a status of 401
Error: Failed to complete negotiation with the server: Error
Error: Failed to start the connection: Error

The request is

Request URL: https://publicapi.localhost/context/negotiate?signalr_token=eyJhbGciOiJSUz... (token is truncated for simplicity)
Request Method: POST
Status Code: 401 
Remote Address: 127.0.0.1:443
Referrer Policy: no-referrer-when-downgrade

The response I get:

access-control-allow-credentials: true
access-control-allow-origin: http://localhost:4200
content-length: 0
date: Fri, 01 Jun 2018 09:00:41 GMT
server: nginx/1.13.10
status: 401
vary: Origin
www-authenticate: Bearer

According to the logs, the token is validated successfully. I can include the full logs however I suspect where the problem is. So I will include that part here:

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

I get these in the log file and I am not sure what it means. I include the code part on the API where I get and extract the token along with the authentication configuration.

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;
            };
        });

There are no other errors, exceptions in the system. I can debug the app and everything seems to be fine.

What does the included log lines mean? How can I debug what is going on during the authorization?

EDIT: I almost forgot to mention, that I thought the problem was with the authentication schemes so, I set every scheme to the one I think was needed. However sadly it did not help.

I am kind of clueless here, so I appreciate any suggestion. Thanks.

Sources of information:

Pass auth token to SignalR

Securing SignalR with IdentityServer

Microsoft docs on SignalR authorization

Another GitHub question

Authenticate against SignalR

Identity.Application was not authenticated

3

3 Answers

21
votes

I have to answer my own question because I had a deadline and surprisingly I managed to solve this one. So I write it down hoping it is going to help someone in the future.

First I needed to have some understanding what was happening, so I replaced the whole authorization mechanism to my own. I could do it by this code. It is not required for the solution, however if anyone needed it, this is the way to do.

services.Configure<AuthenticationOptions>(options =>
{
    var scheme = options.Schemes.SingleOrDefault(s => s.Name == JwtBearerDefaults.AuthenticationScheme);
    scheme.HandlerType = typeof(CustomAuthenticationHandler);
});

With the help of IdentityServerAuthenticationHandler and overriding every possible method I finally understood that the OnMessageRecieved event is executed after the token is checked. So if there weren't any token during the call for HandleAuthenticateAsync the response would be 401. This helped me to figure out where to put my custom code.

I needed to implement my own "protocol" during token retrieval. So I replaced that 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";
    });

The important part is the TokenRetriever property and what comes to replace it.

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;
    }

And this is my custom token retriever algorithm that tries the standard header and query string first to support the common situations such as web API calls. But if the token is still empty it tries to get it from the query string where client put it during websocket handshake.

EDIT: I use the following client side (TypeScript) code in order to provide the token for the SignalR handshake

import { HubConnection, HubConnectionBuilder, HubConnectionState } from '@aspnet/signalr';
// ...
const url = `${apiUrl}/${hubPath}?signalr_token=${accessToken}`;
const hubConnection = new HubConnectionBuilder().withUrl(url).build();
await hubConnection.start();

Where apiUrl, hubPath and accessToken are the required parameters of the connection.

4
votes

I know this is an old thread, but in case someone stumbles upon this like I did. I found an alternative solution.

TLDR: JwtBearerEvents.OnMessageReceived, will catch the token before it is checked when used as illustrated below:

public void ConfigureServices(IServiceCollection services)
{
    // Code removed for brevity
    services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
    .AddIdentityServerAuthentication(options =>
    {
        options.Authority = "https://myauthority.io";
        options.ApiName = "MyApi";
        options.JwtBearerEvents = new JwtBearerEvents
        {
            OnMessageReceived = 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/myhubname")))
                {
                    // Read the token out of the query string
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
}

This Microsoft doc gave me a hint: https://docs.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-3.1. However, in the Microsoft example, options.Events is called, because it is not an example using IdentityServerAuthentication. If options.JwtBearerEvents is used the same way as options.Events in the Microsoft example, IdentityServer4 is happy!

2
votes

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.