From 8d5dbb46f2d0bf700c5528f9055d95409e0cf074 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 12:06:29 -0400 Subject: [PATCH] fix(admin): authenticate SignalR hub clients with a bearer-token scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Components/Pages/Clusters/AclsTab.razor | 6 +- .../Pages/Clusters/ClusterDetail.razor | 6 +- .../Pages/Clusters/RedundancyTab.razor | 6 +- .../Components/Pages/Hosts.razor | 4 +- .../Components/Pages/RoleGrants.razor | 6 +- .../Components/Pages/ScriptLog.razor | 6 +- .../Components/_Imports.razor | 1 + src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 29 +++++-- .../Security/HubTokenAuthenticationHandler.cs | 58 ++++++++++++++ .../Security/HubTokenService.cs | 79 +++++++++++++++++++ .../Services/AdminHubConnectionFactory.cs | 55 +++++++++++++ 11 files changed, 228 insertions(+), 28 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenAuthenticationHandler.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminHubConnectionFactory.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor index 5fe7c5c..077109b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor @@ -7,6 +7,7 @@ @inject NodeAclService AclSvc @inject PermissionProbeService ProbeSvc @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable
@@ -222,10 +223,7 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender || _hub is not null) return; - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet")) - .WithAutomaticReconnect() - .Build(); + _hub = HubFactory.Create("/hubs/fleet"); _hub.On("NodeAclChanged", async msg => { if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor index afd02d4..87f6e26 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor @@ -13,6 +13,7 @@ @inject ClusterService ClusterSvc @inject GenerationService GenerationSvc @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @if (!_loaded) { @@ -179,10 +180,7 @@ else private async Task ConnectHubAsync() { - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet")) - .WithAutomaticReconnect() - .Build(); + _hub = HubFactory.Create("/hubs/fleet"); _hub.On("NodeStateChanged", async msg => { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor index ad7240a..dae9fa3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor @@ -5,6 +5,7 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @inject ClusterNodeService NodeSvc @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable

Redundancy topology

@@ -130,10 +131,7 @@ else private async Task ConnectHubAsync() { - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet")) - .WithAutomaticReconnect() - .Build(); + _hub = HubFactory.Create("/hubs/fleet"); _hub.On("RoleChanged", async msg => { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor index f897969..fffa3cb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor @@ -9,6 +9,7 @@ @rendermode RenderMode.InteractiveServer @inject IServiceScopeFactory ScopeFactory @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable

Driver host status

@@ -155,8 +156,7 @@ else // poll stays as a safety net in case the hub connection is down. private async Task ConnectHubAsync() { - var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet"); - _hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build(); + _hub = HubFactory.Create("/hubs/fleet"); _hub.On("ResilienceStatusChanged", OnResilienceChanged); try { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor index 68f3f27..f10d4c0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor @@ -12,6 +12,7 @@ @inject ClusterService ClusterSvc @inject AclChangeNotifier Notifier @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable

LDAP group → Admin role grants

@@ -171,10 +172,7 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender || _hub is not null) return; - _hub = new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet")) - .WithAutomaticReconnect() - .Build(); + _hub = HubFactory.Create("/hubs/fleet"); _hub.On("RoleGrantsChanged", async _ => { await ReloadAsync(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor index bb7d398..324a735 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/ScriptLog.razor @@ -5,6 +5,7 @@ @using Microsoft.AspNetCore.SignalR.Client @using ZB.MOM.WW.OtOpcUa.Admin.Hubs @inject NavigationManager Nav +@inject AdminHubConnectionFactory HubFactory @implements IAsyncDisposable

Script log viewer

@@ -120,10 +121,7 @@ else try { - _hub ??= new HubConnectionBuilder() - .WithUrl(Nav.ToAbsoluteUri("/hubs/script-log")) - .WithAutomaticReconnect() - .Build(); + _hub ??= HubFactory.Create("/hubs/script-log"); if (_hub.State == HubConnectionState.Disconnected) await _hub.StartAsync(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor index 10288f9..ba6320b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor @@ -12,3 +12,4 @@ @using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages @using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters +@using ZB.MOM.WW.OtOpcUa.Admin.Services diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 5634d96..89ca20b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -25,7 +25,15 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc o.Cookie.Name = "OtOpcUa.Admin"; o.LoginPath = "/login"; o.ExpireTimeSpan = TimeSpan.FromHours(8); - }); + }) + // Bearer-token scheme for the SignalR hub clients. A server-side Blazor circuit cannot + // forward the browser's HttpOnly auth cookie to the loopback hub connection, so pages + // authenticate to the hubs with a HubTokenService token instead (see HubTokenService). + .AddScheme( + HubTokenDefaults.AuthenticationScheme, _ => { }); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Secure-by-default: the fallback policy requires an authenticated user for any // endpoint (and any routable page) that carries no explicit authorization metadata, @@ -35,6 +43,13 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc builder.Services.AddAuthorizationBuilder() .AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin)) .AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin)) + // SignalR hubs accept either the browser auth cookie or a HubToken bearer token, so the + // hub endpoint authorization must run both schemes (the default fallback policy is + // cookie-only and would 401 the token-authenticated hub clients). + .AddPolicy("HubClients", p => p + .AddAuthenticationSchemes( + CookieAuthenticationDefaults.AuthenticationScheme, HubTokenDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()) .SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build()); @@ -152,11 +167,13 @@ app.UseAntiforgery(); app.MapAuthEndpoints(); // Admin-003: every SignalR hub requires an authenticated caller. The [Authorize] attribute -// on each Hub class is the primary gate; .RequireAuthorization() on the endpoint is the -// belt-and-braces backstop so a hub added without the attribute still cannot ship anonymous. -app.MapHub("/hubs/fleet").RequireAuthorization(); -app.MapHub("/hubs/alerts").RequireAuthorization(); -app.MapHub("/hubs/script-log").RequireAuthorization(); +// on each Hub class is the primary gate; .RequireAuthorization("HubClients") on the endpoint +// is the belt-and-braces backstop AND the scheme gate — the "HubClients" policy runs both the +// cookie and the HubToken scheme so server-side Blazor hub clients (which cannot present the +// browser cookie) authenticate with a bearer token instead. +app.MapHub("/hubs/fleet").RequireAuthorization("HubClients"); +app.MapHub("/hubs/alerts").RequireAuthorization("HubClients"); +app.MapHub("/hubs/script-log").RequireAuthorization("HubClients"); if (metricsEnabled) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenAuthenticationHandler.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenAuthenticationHandler.cs new file mode 100644 index 0000000..f1ce02a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenAuthenticationHandler.cs @@ -0,0 +1,58 @@ +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Security; + +/// Scheme name for the SignalR hub bearer-token authentication. +public static class HubTokenDefaults +{ + public const string AuthenticationScheme = "HubToken"; +} + +/// +/// Authenticates SignalR hub requests that carry a -minted +/// token. SignalR supplies the token as an Authorization: Bearer header on the +/// negotiate / long-poll requests and as an access_token query-string parameter on +/// the WebSocket upgrade (custom headers cannot be set on a browser WebSocket handshake) — +/// this handler accepts either. +/// +public sealed class HubTokenAuthenticationHandler : AuthenticationHandler +{ + private readonly HubTokenService _tokens; + + public HubTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + HubTokenService tokens) + : base(options, logger, encoder) + { + _tokens = tokens; + } + + protected override Task 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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenService.cs new file mode 100644 index 0000000..c2749f7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/HubTokenService.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Security; + +/// +/// Mints and validates short-lived bearer tokens that let the Admin UI's server-side +/// Blazor clients +/// authenticate to the [Authorize]-gated SignalR hubs. +/// +/// +/// 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 HttpConnectionOptions.AccessTokenProvider; +/// validates it on the hub endpoint. +/// +/// 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). +/// +/// +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(); + } + + /// Mints a token carrying the user's name and roles, valid for 30 minutes. + 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); + } + + /// + /// Validates a token and rebuilds the . Returns + /// null when the token is missing, tampered with, or expired. + /// + public ClaimsPrincipal? Validate(string? token) + { + if (string.IsNullOrEmpty(token)) return null; + + try + { + var payload = JsonSerializer.Deserialize(_protector.Unprotect(token)); + if (payload is null) return null; + + var claims = new List(); + 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); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminHubConnectionFactory.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminHubConnectionFactory.cs new file mode 100644 index 0000000..81a9081 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminHubConnectionFactory.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.SignalR.Client; +using ZB.MOM.WW.OtOpcUa.Admin.Security; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Builds s to the Admin UI's own SignalR hubs with bearer-token +/// authentication. +/// +/// +/// The hubs are [Authorize]-gated (Admin-003). A server-side Blazor circuit cannot +/// forward the browser's HttpOnly auth cookie to the loopback hub connection, so every page +/// that needs live updates resolves this factory and lets it wire an +/// AccessTokenProvider that mints a token for the +/// circuit's authenticated user. The provider re-mints on every (re)connect so a long-lived +/// page outlives the token lifetime. +/// +public sealed class AdminHubConnectionFactory +{ + private readonly NavigationManager _nav; + private readonly HubTokenService _tokens; + private readonly AuthenticationStateProvider _authState; + + public AdminHubConnectionFactory( + NavigationManager nav, + HubTokenService tokens, + AuthenticationStateProvider authState) + { + _nav = nav; + _tokens = tokens; + _authState = authState; + } + + /// + /// Creates an auto-reconnecting connection to + /// (e.g. /hubs/fleet), authenticated as the current circuit's user. + /// + public HubConnection Create(string hubPath) + { + var hubUrl = _nav.ToAbsoluteUri(hubPath); + return new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = async () => + { + var state = await _authState.GetAuthenticationStateAsync(); + return _tokens.Issue(state.User); + }; + }) + .WithAutomaticReconnect() + .Build(); + } +}