diff --git a/CLAUDE.md b/CLAUDE.md
index 24f7800..73c7c0c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -123,7 +123,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
-Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
+Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
## Process / Platform Notes
diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md
index 7dca44b..1cbff3e 100644
--- a/docs/GatewayConfiguration.md
+++ b/docs/GatewayConfiguration.md
@@ -178,7 +178,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
-| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
+| `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md
index ac16b09..3fef468 100644
--- a/docs/GatewayDashboardDesign.md
+++ b/docs/GatewayDashboardDesign.md
@@ -435,7 +435,11 @@ Implemented behavior:
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
claims;
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
- (`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure);
+ (HttpOnly, SameSite=Strict, Secure); the cookie is named
+ `__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
+ true (default) and no `MxGateway:Dashboard:CookieName` override is set,
+ otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
+ only honoured on a Secure cookie);
- a user with no matching group cannot sign in — the login screen returns the
generic credential-rejected message;
- antiforgery tokens guard the login and logout POSTs.
diff --git a/docs/GatewayProcessDesign.md b/docs/GatewayProcessDesign.md
index 3437b38..0e5da17 100644
--- a/docs/GatewayProcessDesign.md
+++ b/docs/GatewayProcessDesign.md
@@ -699,8 +699,11 @@ password in a form post, calls `DashboardAuthenticator` to bind against
`MxGateway:Ldap`, resolves the user's LDAP groups through
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
-secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout
-clears it. Login and logout posts validate antiforgery tokens. SignalR
+secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
+`MxGateway:Dashboard:RequireHttpsCookie` is true (default) and no
+`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
+plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
+Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
connections additionally accept a 30-minute data-protected bearer minted at
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
to bypass the cookie requirement and defaults to `true`.
diff --git a/docs/ImplementationPlanGateway.md b/docs/ImplementationPlanGateway.md
index 04bd134..f57477b 100644
--- a/docs/ImplementationPlanGateway.md
+++ b/docs/ImplementationPlanGateway.md
@@ -451,7 +451,9 @@ Deliverables:
- LDAP bind against `MxGateway:Ldap`,
- LDAP-group → role mapping (`Admin` / `Viewer`) via
`MxGateway:Dashboard:GroupToRole`,
-- HTTP-only secure cookie (`__Host-MxGatewayDashboard`),
+- HTTP-only secure cookie (`__Host-MxGatewayDashboard` when
+ `RequireHttpsCookie` is true and no `CookieName` override; else
+ `MxGatewayDashboard`),
- `/hubs/token` bearer mint for SignalR connections,
- `/logout`,
- antiforgery protection,
diff --git a/gateway.md b/gateway.md
index ae94596..0a6c8d0 100644
--- a/gateway.md
+++ b/gateway.md
@@ -219,7 +219,12 @@ Dashboard authentication is LDAP-backed (distinct from the API-key model on
the gRPC API). `/login` accepts username and password in a form body, binds
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
-`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout
+cookie. The cookie is named `__Host-MxGatewayDashboard` when
+`MxGateway:Dashboard:RequireHttpsCookie` is true (the default, forcing Secure)
+and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
+`MxGatewayDashboard` (the `__Host-` prefix is only valid on a Secure cookie, so
+it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
+Login and logout
posts validate antiforgery tokens. SignalR hub connections accept either the
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
index 1566d4a..f00caf3 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs
@@ -35,5 +35,23 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
+
+ ///
+ /// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
+ /// (RequireHttpsCookie=false → )
+ /// or when an explicit MxGateway:Dashboard:CookieName override is absent but the
+ /// secure default cannot be applied. This plain name carries no browser-enforced guarantees.
+ ///
public const string CookieName = "MxGatewayDashboard";
+
+ ///
+ /// Dashboard auth cookie name applied when the cookie is guaranteed Secure
+ /// (RequireHttpsCookie=true → )
+ /// and no explicit MxGateway:Dashboard:CookieName override is set. The __Host-
+ /// prefix instructs browsers to enforce Secure, no Domain, and Path=/; those
+ /// guarantees only hold for a Secure cookie, so this name must never be applied unless
+ /// is in
+ /// effect — a __Host- cookie without Secure is silently dropped by browsers.
+ ///
+ public const string SecureCookieName = "__Host-MxGatewayDashboard";
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
index a50b502..658d42e 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
@@ -123,13 +123,22 @@ public static class DashboardServiceCollectionExtensions
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
- // Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
- // the canonical default set above, so a misconfiguration cannot unname the cookie.
+ // Config-driven cookie name (MxGateway:Dashboard:CookieName). An explicit override
+ // always wins. With no override, restore the __Host- prefix when the cookie is
+ // guaranteed Secure (RequireHttpsCookie true → SecurePolicy Always): the __Host-
+ // browser guarantees (Secure required, no Domain, Path=/) hold only for a Secure
+ // cookie, and a __Host- cookie without Secure is silently dropped — so the prefix
+ // is never applied unless SecurePolicy is Always. Otherwise keep the plain
+ // canonical default set by AddCookie above, so a misconfiguration cannot unname it.
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
if (!string.IsNullOrWhiteSpace(cookieName))
{
cookieOptions.Cookie.Name = cookieName;
}
+ else if (cookieOptions.Cookie.SecurePolicy == CookieSecurePolicy.Always)
+ {
+ cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.SecureCookieName;
+ }
});
services.AddAuthorization(authorization =>
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs
index 435a859..bbfeb6b 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs
@@ -10,7 +10,12 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
- /// Verifies that the application configures secure dashboard authentication cookies.
+ ///
+ /// Verifies that the application configures secure dashboard authentication cookies.
+ /// With RequireHttpsCookie defaulting to and no explicit
+ /// CookieName override, the cookie is named with the __Host- prefix so
+ /// browsers enforce the Secure/no-Domain/Path=/ guarantees the prefix promises.
+ ///
/// A task that represents the asynchronous operation.
[Fact]
public async Task Build_ConfiguresSecureDashboardCookie()
@@ -22,7 +27,7 @@ public sealed class DashboardCookieOptionsTests
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
- Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
+ Assert.Equal(DashboardAuthenticationDefaults.SecureCookieName, options.Cookie.Name);
Assert.True(options.Cookie.HttpOnly);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
@@ -35,11 +40,14 @@ public sealed class DashboardCookieOptionsTests
///
/// Verifies that setting MxGateway:Dashboard:RequireHttpsCookie=false
/// relaxes the cookie to so
- /// the dashboard can be reached over plain HTTP in dev.
+ /// the dashboard can be reached over plain HTTP in dev, and that the plain
+ /// is used rather than the
+ /// __Host- name — a __Host- cookie without a guaranteed Secure flag is
+ /// silently dropped by browsers.
///
/// A task that represents the asynchronous operation.
[Fact]
- public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
+ public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequestAndPlainName()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
@@ -50,12 +58,14 @@ public sealed class DashboardCookieOptionsTests
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
+ Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
}
///
- /// Verifies that MxGateway:Dashboard:CookieName overrides the dashboard auth
- /// cookie name, so a gateway instance sharing a hostname with another can be given a
- /// distinct name (browser cookies are scoped by host+path, not port).
+ /// Verifies that an explicit MxGateway:Dashboard:CookieName override wins over the
+ /// secure __Host- default (this build leaves RequireHttpsCookie at its
+ /// default), so a gateway instance sharing a hostname with another
+ /// can be given a distinct name (browser cookies are scoped by host+path, not port).
///
/// A task that represents the asynchronous operation.
[Fact]