Home > Mobile >  SignalR token authorization via a remote service
SignalR token authorization via a remote service

Time:10-26

I'm making a SignalR-based service with a token-based authorization. The token is validated by an external API, which returns the user's ID. Then, notifications are sent to users with specific IDs.

The trouble that I can't get around is that SignalR's client code apparently sends 2 requests: one without a token (authentication fails) and the other with a token (authentication succeeds). For some reason, the first result gets cached and the user does not receive any notifications.

If I comment the checks and always return the correct ID, even if there's no token specified, the code suddenly starts working.

HubTOkenAuthenticationHandler.cs:

public class HubTokenAuthenticationHandler : AuthenticationHandler<HubTokenAuthenticationOptions>
{
    public HubTokenAuthenticationHandler(
        IOptionsMonitor<HubTokenAuthenticationOptions> options,
        ILoggerFactory logFactory,
        UrlEncoder encoder,
        ISystemClock clock,
        IAuthApiClient api
    )
        : base(options, logFactory, encoder, clock)
    {
        _api = api;
    }

    private readonly IAuthApiClient _api;

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        try
        {
            // uncommenting this line makes everything suddenly work
            // return SuccessResult(1);
            
            var token = GetToken();
            if (string.IsNullOrEmpty(token))
                return AuthenticateResult.NoResult();
        
            var userId = await _api.GetUserIdAsync(token); // always returns 1
            return SuccessResult(userId);
        }
        catch (Exception ex)
        {
            return AuthenticateResult.Fail(ex);
        }
    }
    
    /// <summary>
    /// Returns an identity with the specified user id.
    /// </summary>
    private AuthenticateResult SuccessResult(int userId)
    {
        var identity = new ClaimsIdentity(
            new[]
            {
                new Claim(ClaimTypes.Name, userId.ToString())
            }
        );
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);
        return AuthenticateResult.Success(ticket);
    }

    /// <summary>
    /// Checks if there is a token specified.
    /// </summary>
    private string GetToken()
    {
        const string Scheme = "Bearer ";

        var auth = Context.Request.Headers["Authorization"].ToString() ?? "";
        return auth.StartsWith(Scheme)
            ? auth.Substring(Scheme.Length)
            : "";
    }
}

Startup.cs:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddHostedService<FakeNotificationService>();
        services.AddSingleton<IAuthApiClient, FakeAuthApiClient>();
        
        services.AddSingleton<IUserIdProvider, NameUserIdProvider>();

        services.AddAuthentication(opts =>
                {
                    opts.DefaultAuthenticateScheme = HubTokenAuthenticationDefaults.AuthenticationScheme;
                    opts.DefaultChallengeScheme = HubTokenAuthenticationDefaults.AuthenticationScheme;
                })
                .AddHubTokenAuthenticationScheme();

        services.AddRouting(opts =>
        {
            opts.AppendTrailingSlash = false;
            opts.LowercaseUrls = false;
        });

        services.AddSignalR(opts => opts.EnableDetailedErrors = true);
        services.AddControllers();
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseDeveloperExceptionPage();

        app.UseRouting();

        app.UseAuthentication();

        app.UseEndpoints(x =>
        {
            x.MapHub<InfoHub>("/signalr/info");
            x.MapControllers();
        });
    }
}

FakeNotificationsService.cs (Sends a notification to user "1" every 2 seconds):

public class FakeNotificationService: IHostedService
{
    public FakeNotificationService(IHubContext<InfoHub> hubContext, ILogger<FakeNotificationService> logger)
    {
        _hubContext = hubContext;
        _logger = logger;
        _cts = new CancellationTokenSource();
    }

    private readonly IHubContext<InfoHub> _hubContext;
    private readonly ILogger _logger;
    private readonly CancellationTokenSource _cts;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // run in the background
        Task.Run(async () =>
        {
            var id = 1;
            while (!_cts.Token.IsCancellationRequested)
            {
                await Task.Delay(2000);
                await _hubContext.Clients.Users(new[] {"1"})
                                 .SendAsync("NewNotification", new {Id = id, Date = DateTime.Now});
                
                _logger.LogInformation("Sent notification "   id);

                id  ;
            }
        });

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _cts.Cancel();
        return Task.CompletedTask;
    }
}

Debug.cshtml (client code):

<html>
<head>
    <title>SignalRPipe Debug Page</title>
</head>
<body>
    <h3>Notifications log</h3>
    <textarea id="log" cols="180" rows="40"></textarea>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/5.0.11/signalr.min.js"
            integrity="sha512-LGhr8/QqE/4Ci4RqXolIPC H9T0OSY2kWK2IkqVXfijt4aaNiI8/APVgji3XWCLbE5J0wgSg3x23LieFHVK62g=="
            crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script language="javascript">
        var token = "123";

        var conn = new signalR
            .HubConnectionBuilder()
            .withUrl('/signalr/info', { accessTokenFactory: () => token })
            .configureLogging(signalR.LogLevel.Debug)
            .build();

        var logElem = document.getElementById('log');
        var id = 1;

        function log(text) {
            logElem.innerHTML = text   '\n\n'   logElem.innerHTML;
        }

        conn.on("NewNotification", alarm => {
            log(`[Notification ${id}]:\n${JSON.stringify(alarm)}`);
            id  ;
        });

        conn.start()
            .then(() => log('Connection established.'))
            .catch(err => log(`Connection failed:\n${err.toString()}`));
    </script>
</body>
</html>

Minimal repro as a runnable project: https://github.com/impworks/signalr-auth-problem

I tried the following, to no success:

  • Adding a fake authorization handler which just allows everything
  • Extracting the debug view to a separate project (express.js-based server)

What is that I'm missing here?

CodePudding user response:

It doesn't look like you're handling the auth token coming from the query string which is required in certain cases such as a WebSocket connection from the browser.

See https://docs.microsoft.com/aspnet/core/signalr/authn-and-authz?view=aspnetcore-5.0#built-in-jwt-authentication for some info on how bearer auth should be handled.

CodePudding user response:

Issue solved. As @Brennan correctly guessed, WebSockets do not support headers, so the token is passed via query string instead. We just need a little code to get the token from either source:

private string GetHeaderToken()
{
    const string Scheme = "Bearer ";

    var auth = Context.Request.Headers["Authorization"].ToString() ?? "";
    return auth.StartsWith(Scheme)
        ? auth.Substring(Scheme.Length)
        : null;
}

private string GetQueryToken()
{
    return Context.Request.Query["access_token"];
}

And then, in HandleAuthenticateAsync:

var token = GetHeaderToken() ?? GetQueryToken();
  • Related