Search code examples
asp.net-coresignalr-hubasp.net-core-signalr

ASP.Net Core SignalR authentication always responding with 403 - Forbidden


Summary

I am trying to add security/authentication to my SignalR hubs, but no matter what I try the client requests keep getting a 403 - Forbidden responses (despite the requests successfully authenticating).

Setup

My project is based on Microsoft's SignalRChat example from:

https://learn.microsoft.com/en-us/aspnet/core/tutorials/signalr?view=aspnetcore-3.1&tabs=visual-studio

Basically I have an ASP.Net Core web application with Razor Pages. The project is targeting .Net Core 3.1.

The client library being used is v3.1.0 of Microsoft's JavaScript client library.

I also referenced their authentication and authorization document for the security side:

https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-3.1

The key difference is rather than using the JWT Bearer middleware, I made my own custom token authentication handler.

Code

chat.js:

"use strict";

var connection = new signalR.HubConnectionBuilder()
  .withUrl("/chatHub", { accessTokenFactory: () => 'mytoken' })
  .configureLogging(signalR.LogLevel.Debug)
  .build();

//Disable send button until connection is established
document.getElementById("sendButton").disabled = true;

connection.on("ReceiveMessage", function (user, message) {
  var msg = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  var encodedMsg = user + " says " + msg;
  var li = document.createElement("li");
  li.textContent = encodedMsg;
  document.getElementById("messagesList").appendChild(li);
});

connection.start().then(function () {
  document.getElementById("sendButton").disabled = false;
}).catch(function (err) {
  return console.error(err.toString());
});

document.getElementById("sendButton").addEventListener("click", function (event) {
  var user = document.getElementById("userInput").value;
  var message = document.getElementById("messageInput").value;
  connection.invoke("SendMessage", user, message).catch(function (err) {
    return console.error(err.toString());
  });
  event.preventDefault();
});

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SignalRChat.Hubs;
using SignalRChat.Security;

namespace SignalRChat
{
  public class Startup
  {
    public Startup(IConfiguration configuration)
    {
      Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
      // Found other cases where CORS caused authentication issues, so
      // making sure that everything is allowed.
      services.AddCors(options =>
      {
        options.AddPolicy("AllowAny", policy =>
        {
          policy
          .WithOrigins("http://localhost:44312/", "https://localhost:44312/")
          .AllowCredentials()
          .AllowAnyHeader()
          .AllowAnyMethod();
        });
      });

      services
        .AddAuthentication()
        .AddHubTokenAuthenticationScheme();

      services.AddAuthorization(options =>
      {
        options.AddHubAuthorizationPolicy();
      });

      services.AddRazorPages();

      services.AddSignalR(options =>
      {
        options.EnableDetailedErrors = true;
      });
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
      if (env.IsDevelopment())
      {
        app.UseDeveloperExceptionPage();
      }
      else
      {
        app.UseExceptionHandler("/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
      }

      app.UseHttpsRedirection();

      app.UseStaticFiles();

      app.UseRouting();

      app.UseAuthentication();

      app.UseAuthorization();

      app.UseEndpoints(endpoints =>
      {
        endpoints.MapRazorPages();
        endpoints.MapHub<ChatHub>("/chatHub");
      });
    }
  }
}

ChatHub.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SignalRChat.Security;
using System.Threading.Tasks;

namespace SignalRChat.Hubs
{
  [Authorize(HubRequirementDefaults.PolicyName)]
  public class ChatHub : Hub
  {
    public async Task SendMessage(string user, string message)
    {
      await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
  }
}

HubTokenAuthenticationHandler.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace SignalRChat.Security
{
  public class HubTokenAuthenticationHandler : AuthenticationHandler<HubTokenAuthenticationOptions>
  {
    public IServiceProvider ServiceProvider { get; set; }

    public HubTokenAuthenticationHandler(
      IOptionsMonitor<HubTokenAuthenticationOptions> options,
      ILoggerFactory logger,
      UrlEncoder encoder,
      ISystemClock clock,
      IServiceProvider serviceProvider)
      : base(options, logger, encoder, clock)
    {
      ServiceProvider = serviceProvider;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
      bool isValid = TryAuthenticate(out AuthenticationTicket ticket, out string message);

      if (isValid) return Task.FromResult(AuthenticateResult.Success(ticket));

      return Task.FromResult(AuthenticateResult.Fail(message));
    }

    private bool TryAuthenticate(out AuthenticationTicket ticket, out string message)
    {
      message = null;
      ticket = null;

      var token = GetToken();

      if (string.IsNullOrEmpty(token))
      {
        message = "Token is missing";
        return false;
      }

      bool tokenIsValid = token.Equals("mytoken");

      if (!tokenIsValid)
      {
        message = $"Token is invalid: token={token}";
        return false;
      }

      var claims = new[] { new Claim("token", token) };
      var identity = new ClaimsIdentity(claims, nameof(HubTokenAuthenticationHandler));
      ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);

      return true;
    }

    #region Get Token

    private string GetToken()
    {
      string token = Request.Query["access_token"];

      if (string.IsNullOrEmpty(token))
      {
        token = GetTokenFromHeader();
      }

      return token;
    }

    private string GetTokenFromHeader()
    {
      string token = Request.Headers["Authorization"];

      if (string.IsNullOrEmpty(token)) return null;

      // The Authorization header value should be in the format "Bearer [token_value]"
      string[] authorizationParts = token.Split(new char[] { ' ' });

      if (authorizationParts == null || authorizationParts.Length < 2) return token;

      return authorizationParts[1];
    }

    #endregion
  }
}

HubTokenAuthenticationOptions.cs

using Microsoft.AspNetCore.Authentication;

namespace SignalRChat.Security
{
  public class HubTokenAuthenticationOptions : AuthenticationSchemeOptions { }
}

HubTokenAuthenticationDefaults.cs

using Microsoft.AspNetCore.Authentication;
using System;

namespace SignalRChat.Security
{
  public static class HubTokenAuthenticationDefaults
  {
    public const string AuthenticationScheme = "HubTokenAuthentication";

    public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder)
    {
      return AddHubTokenAuthenticationScheme(builder, (options) => { });
    }

    public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder, Action<HubTokenAuthenticationOptions> configureOptions)
    {
      return builder.AddScheme<HubTokenAuthenticationOptions, HubTokenAuthenticationHandler>(AuthenticationScheme, configureOptions);
    }
  }
}

HubRequirement.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace SignalRChat.Security
{
  public class HubRequirement : AuthorizationHandler<HubRequirement, HubInvocationContext>, IAuthorizationRequirement
  {
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HubRequirement requirement, HubInvocationContext resource)
    {
      // Authorization logic goes here.  Just calling it a success for demo purposes.
      context.Succeed(requirement);
      return Task.CompletedTask;
    }
  }
}

HubRequirementDefaults.cs

using Microsoft.AspNetCore.Authentication;
using System;

namespace SignalRChat.Security
{
  public static class HubTokenAuthenticationDefaults
  {
    public const string AuthenticationScheme = "HubTokenAuthentication";

    public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder)
    {
      return AddHubTokenAuthenticationScheme(builder, (options) => { });
    }

    public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder, Action<HubTokenAuthenticationOptions> configureOptions)
    {
      return builder.AddScheme<HubTokenAuthenticationOptions, HubTokenAuthenticationHandler>(AuthenticationScheme, configureOptions);
    }
  }
}

The Results

On the client side, I see the following errors in the browser's developer console:

On the server side, all I see is:

  • SignalRChat.Security.HubTokenAuthenticationHandler: Debug: AuthenticationScheme: HubTokenAuthentication was successfully authenticated.
  • SignalRChat.Security.HubTokenAuthenticationHandler: Information: AuthenticationScheme: HubTokenAuthentication was forbidden.

Next Steps

I did see that others had issues with CORS preventing them from security from working, but I believe it usually said that explicitly in the client side errors. Despite that, I added the CORS policies in Startup.cs that I believe should have circumvented that.

I also experimented around with changing the order of service configurations in Startup, but nothing seemed to help.

If I remove the Authorize attribute (i.e. have an unauthenticated hub) everything works fine.

Finally, I found the server side messages to be very interesting in that the authentication succeeded, yet the request was still forbidden.

I'm not really sure where to go from here. Any insights would be most appreciated.

Update

I have been able to debug this a little bit.

By loading system symbols and moving up the call stack, I found my way to Microsoft.AspNetCore.Authorization.Policy.PolicyEvaluator:

PolicyEvaluator Authentication Results

As can be seen, the authentication succeeded but apparently the authorization did not. Looking at the requirements, there are two: a DenyAnonymousAuthorizationRequirement and my HubRequirement (which automatically succeeds).

Because the debugger never hit my breakpoint in my HubRequirement class, I am left to assume that the DenyAnonymousAuthorizationRequirement is what is failing. Interesting, because based on the code listing on github (https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authorization/Core/src/DenyAnonymousAuthorizationRequirement.cs) I should be meeting all the requirements:

PolicyEvaluator context User members

There is a User defined on the context, the user has an identity, and there are no identities that are unauthenticated.

I have to be missing something, because this isn't adding up.


Solution

  • Turns out the failure was actually happening in my HubRequirement class, and not DenyAnonymousAuthorizationRequirement.

    While my HubRequirement class implemented HandleRequirementAsync(), it did not implement HandleAsync(), which is what happened to be what was called instead.

    If I update my HubRequirement class to the following, everything works as expected:

    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.SignalR;
    using System.Threading.Tasks;
    
    namespace SignalRChat.Security
    {
      public class HubRequirement : AuthorizationHandler<HubRequirement, HubInvocationContext>, IAuthorizationRequirement
      {
        public override Task HandleAsync(AuthorizationHandlerContext context)
        {
          foreach (var requirement in context.PendingRequirements)
          {
            // TODO: Validate each requirement
          }
    
          // Authorization logic goes here.  Just calling it a success for demo purposes.
          context.Succeed(this);
          return Task.CompletedTask;
        }
    
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HubRequirement requirement, HubInvocationContext resource)
        {
          // Authorization logic goes here.  Just calling it a success for demo purposes.
          context.Succeed(requirement);
          return Task.CompletedTask;
        }
      }
    }