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]