1

We are trying to scale out our calendar application, that uses SignalR to push updates to clients based on their user's OrganizationId. Previously, the SignalR stuff was hosted within the single App Server, but to make it work across multiple servers we have opted to use Azure SignalR Services.

However, when the application uses the Azure solution, autorisation breaks.

Authentication is set up in Startup.cs to look for the token in the url/query-string when dealing with Hub endpoints:

//From: Startup.cs (abridged)
public IServiceProvider ConfigureServices(IServiceCollection services)
    var authenticationBuilder = services.AddAuthentication(options => {
            options.DefaultAuthenticateScheme = OAuthValidationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OAuthValidationDefaults.AuthenticationScheme;
        });
        
    authenticationBuilder
        .AddOAuthValidation(options => {
            options.Events.OnRetrieveToken = async context => {
                // Based on https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-3.0
                var accessToken = context.HttpContext.Request.Query["access_token"];

                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/signalr/calendar")) {
                    context.Token = accessToken;
                }
                return;
            };
        })
        .AddOpenIdConnectServer(options => {
            options.TokenEndpointPath = "/token";
            options.ProviderType = typeof(ApplicationOAuthProvider);
            /*...*/
        });
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime applicationLifetime) {
        app.UseAuthentication();
}

When using the Azure SignalR Service, the OnRetrieveToken event code is simply never hit, which makes sense given that the request is no longer directed at the App Service, but instead to the url of the Azure SignalR Service.

This Hub works while SignalR is hosted on the App Server:

[Authorize(Roles = "Manager, Seller")]
public class CalendarHub : Hub<ICalendarClient> {
    private IHttpContextAccessor httpContextAccessor;
    public CalendarHub(IHttpContextAccessor httpContextAccessor) { this.httpContextAccessor = httpContextAccessor } 

    public override async Task OnConnectedAsync() {
        await Groups.AddToGroupAsync(Context.ConnectionId, GetClaimValue("OrganizationId"));
        await base.OnConnectedAsync();
    }

    private string GetClaimValue(string claimType) {
        var identity = (ClaimsIdentity)httpContextAccessor?.HttpContext?.User.Identity;
        var claim = identity?.FindFirst(c => c.Type == claimType);

        if (claim == null)
            throw new InvalidOperationException($"No claim of type {claimType} found.");

        return claim.Value;
    }
}

But when I switch to the Azure solution:

//From: Startup.cs (abridged)
public IServiceProvider ConfigureServices(IServiceCollection services)
    services.AddSignalR().AddAzureSignalR();
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime applicationLifetime) {
    app.UseAzureSignalR(routes => routes.MapHub<CalendarHub>("/signalr/calendar"));
}

...connecting to the hub causes exception No claim of type OrganizationId found. because the identity is completely empty, as if no user was authenticated. This is especially strange, given that I've restricted access to users of specific roles.

AsgerHB
  • 153
  • 12

1 Answers1

3

Turns out the error is the same as this question where HttpContext is used to get the claim values, because that's what we do everywhere else. And this seems to work as long as it is the App Service itself handling the connection to the client.

But Azure SignalR Service supplies the claims somewhere else:

The correct way is using just Context which has the type HubCallerContext when accessed from a SignalR Hub. All the claims are available from here with no extra work.

So the method for getting the claim becomes

private string GetClaimValue(string claimType) {
    var identity = Context.User.Identity;
    var claim = identity.FindFirst(c => c.Type == claimType);

    if (claim == null)
        throw new InvalidOperationException($"No claim of type {claimType} found.");

    return claim.Value;
}
AsgerHB
  • 153
  • 12