fix(admin): authenticate SignalR hub clients with a bearer-token scheme

The Admin-003 fix gated every SignalR hub with [Authorize], but the server-side
Blazor HubConnection clients had no way to authenticate: the browser's HttpOnly
auth cookie is not reachable from the interactive circuit, so every hub negotiate
returned 401 and the Admin live-update feature was non-functional app-wide
(silently degraded on Hosts/ScriptLog, fatal on the cluster pages).

Introduce a token-based hub auth path:
- HubTokenService mints/validates short-lived tokens using ASP.NET Core Data
  Protection (the same primitive that protects the auth cookie — no signing-key
  management, no new packages). Tokens carry the user's name + roles.
- HubTokenAuthenticationHandler is a custom "HubToken" auth scheme that reads the
  token from the Authorization: Bearer header (negotiate) or the access_token
  query parameter (WebSocket upgrade).
- The "HubClients" authorization policy runs both the cookie and HubToken
  schemes; the hub endpoints use RequireAuthorization("HubClients").
- AdminHubConnectionFactory builds hub connections with an AccessTokenProvider
  that mints a fresh token for the circuit's authenticated user on every
  (re)connect. All six hub-consuming pages now resolve connections through it.

Hub negotiate now returns 200 and the WebSocket upgrades (101); live updates
work. The best-effort try/catch guards added previously are kept as defence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 12:06:29 -04:00
parent f2545392e0
commit 8d5dbb46f2
11 changed files with 228 additions and 28 deletions
@@ -0,0 +1,58 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>Scheme name for the SignalR hub bearer-token authentication.</summary>
public static class HubTokenDefaults
{
public const string AuthenticationScheme = "HubToken";
}
/// <summary>
/// Authenticates SignalR hub requests that carry a <see cref="HubTokenService"/>-minted
/// token. SignalR supplies the token as an <c>Authorization: Bearer</c> header on the
/// negotiate / long-poll requests and as an <c>access_token</c> query-string parameter on
/// the WebSocket upgrade (custom headers cannot be set on a browser WebSocket handshake) —
/// this handler accepts either.
/// </summary>
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly HubTokenService _tokens;
public HubTokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
HubTokenService tokens)
: base(options, logger, encoder)
{
_tokens = tokens;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = ExtractToken();
if (string.IsNullOrEmpty(token))
return Task.FromResult(AuthenticateResult.NoResult());
var principal = _tokens.Validate(token);
if (principal is null)
return Task.FromResult(AuthenticateResult.Fail("Invalid or expired hub token."));
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(principal, Scheme.Name)));
}
private string? ExtractToken()
{
var header = Request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
return Request.Query.TryGetValue("access_token", out var queryToken)
? queryToken.ToString()
: null;
}
}
@@ -0,0 +1,79 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
/// <summary>
/// Mints and validates short-lived bearer tokens that let the Admin UI's server-side
/// Blazor <see cref="Microsoft.AspNetCore.SignalR.Client.HubConnection"/> clients
/// authenticate to the <c>[Authorize]</c>-gated SignalR hubs.
/// </summary>
/// <remarks>
/// The Admin-003 fix gated every hub with authorization, but a server-side Blazor circuit
/// cannot forward the browser's HttpOnly auth cookie to the loopback hub connection — so the
/// hub negotiate would 401. Instead, a component mints a token here for its already-
/// authenticated user and supplies it through <c>HttpConnectionOptions.AccessTokenProvider</c>;
/// <see cref="HubTokenAuthenticationHandler"/> validates it on the hub endpoint.
/// <para>
/// The token is an ASP.NET Core Data Protection time-limited payload — the same
/// primitive that already protects the auth cookie — so there is no signing-key
/// management and no extra package. It is only ever presented loopback (the Admin
/// server connecting to its own hub).
/// </para>
/// </remarks>
public sealed class HubTokenService
{
private const string ProtectorPurpose = "ZB.MOM.WW.OtOpcUa.Admin.HubToken.v1";
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
private readonly ITimeLimitedDataProtector _protector;
public HubTokenService(IDataProtectionProvider dataProtection)
{
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
}
/// <summary>Mints a token carrying the user's name and roles, valid for 30 minutes.</summary>
public string Issue(ClaimsPrincipal user)
{
var payload = new HubTokenPayload(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray());
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
}
/// <summary>
/// Validates a token and rebuilds the <see cref="ClaimsPrincipal"/>. Returns
/// <c>null</c> when the token is missing, tampered with, or expired.
/// </summary>
public ClaimsPrincipal? Validate(string? token)
{
if (string.IsNullOrEmpty(token)) return null;
try
{
var payload = JsonSerializer.Deserialize<HubTokenPayload>(_protector.Unprotect(token));
if (payload is null) return null;
var claims = new List<Claim>();
if (!string.IsNullOrEmpty(payload.Name))
claims.Add(new Claim(ClaimTypes.Name, payload.Name));
if (!string.IsNullOrEmpty(payload.NameIdentifier))
claims.Add(new Claim(ClaimTypes.NameIdentifier, payload.NameIdentifier));
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
var identity = new ClaimsIdentity(
claims, HubTokenDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
catch
{
// Crypto failure (tampered / expired / wrong key) or malformed JSON — unauthenticated.
return null;
}
}
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
}