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