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