Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty 5df2ef0d1e chore(theme): bump ZB.MOM.WW.Theme 0.3.0 -> 0.3.1 (interactive-render nav fix) 2026-06-05 07:19:11 -04:00
Joseph Doherty e5785fd769 chore(theme): consume ZB.MOM.WW.Theme 0.3.0 (nav/login kit fixes) 2026-06-05 05:13:06 -04:00
Joseph Doherty 22370ca4da docs(glauth): repoint glauth.md at the shared GLAuth on 10.100.0.35
No more per-box C:\publish\glauth NSSM service — dev/test LDAP is the shared
zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local). Provisioning now via
scadaproj/infra/glauth/config.toml. Old localhost/NSSM procedures kept as
retired reference; test users multi-role/gw-viewer.
2026-06-04 16:38:24 -04:00
Joseph Doherty e0a3fbf35b fix(dashboard)!: move login POST to /auth/login to resolve AmbiguousMatchException
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.

Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.

Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
2026-06-04 14:01:05 -04:00
Joseph Doherty 161ed6f80d chore(theme): bump ZB.MOM.WW.Theme 0.2.0 -> 0.2.1 (desktop app-shell render fix) 2026-06-04 10:23:44 -04:00
Joseph Doherty e57d864ab2 fix(dashboard): make dashboard auth cookie name configurable
The dashboard auth cookie name was hardcoded to the constant
DashboardAuthenticationDefaults.CookieName (MxGatewayDashboard). Browser
cookies are scoped by host+path but NOT by port, so two gateway instances
sharing a hostname would clobber each other's dashboard session under the
shared name.

Add DashboardOptions.CookieName (MxGateway:Dashboard:CookieName); null/blank
keeps the canonical default. Applied in the existing dashboard cookie
PostConfigure (runs after the inline AddCookie default, so it wins). Behaviour
is unchanged when unset. Adds a Tests case for the override.
2026-06-03 13:11:29 -04:00
10 changed files with 151 additions and 51 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
## Design Sources To Consult Before Non-Trivial Changes ## Design Sources To Consult Before Non-Trivial Changes
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling. - `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
- `glauth.md`local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there. - `glauth.md`shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.). - `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs. - `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`. - `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
+1
View File
@@ -148,6 +148,7 @@ the affected stream while the MXAccess session remains active.
| `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: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: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: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:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `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: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. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
+81 -40
View File
@@ -1,27 +1,36 @@
# GLAuth — LDAP authn reference for mxaccessgw # GLAuth — LDAP authn reference for mxaccessgw
GLAuth is a lightweight LDAP server installed on this dev box at > **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
`C:\publish\glauth\` and run as a Windows service via NSSM. It already > Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa > the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
Admin UI's cookie login; this doc captures everything mxaccessgw needs > The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
to consume the same directory so a single set of dev credentials covers
both stacks.
The authoritative copy of LmxOpcUa's reference lives at GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
mxaccessgw — what users + groups are already provisioned, how to bind docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
against them, and what's needed to add a gw-specific role. that directory so a single set of dev credentials covers all stacks.
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
kept only as a rollback option. Do not edit or restart it for new work.)*
The single source of truth for the shared GLAuth is
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
what users + groups are provisioned, how to bind against them, and what's needed to add a
gw-specific role.
## Connection details ## Connection details
| Setting | Value | | Setting | Value |
|---|---| |---|---|
| Protocol | LDAP (unencrypted) | | Protocol | LDAP (unencrypted) |
| Host | `localhost` | | Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
| Port | `3893` | | Port | `3893` |
| LDAPS | disabled in dev (set `[ldaps]` block to enable) | | LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
| Base DN | `dc=zb,dc=local` | | Base DN | `dc=zb,dc=local` |
| Bind DN format | `cn={username},dc=zb,dc=local` | | Bind DN format | `cn={username},dc=zb,dc=local` |
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` | | Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) | | Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
@@ -59,13 +68,13 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
`readonly` is the right "negative" case for proving Browse-OK / `readonly` is the right "negative" case for proving Browse-OK /
Write-denied. Write-denied.
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy: The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the `GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
dashboard login and `DashboardLdapLiveTests` require `admin` to be a These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline The dashboard test users are **`multi-role`/`password`** (Administrator) and
GLAuth config — it must be provisioned before dashboard authn or the **`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
LDAP live tests work. See [Provisioning the GwAdmin See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
group](#provisioning-the-gwadmin-group) below. (now-retired) per-box procedure and for the shared-config equivalent.
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to > **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader` > the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
@@ -118,7 +127,7 @@ record:
```yaml ```yaml
ldap: ldap:
enabled: true enabled: true
server: localhost server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
port: 3893 port: 3893
useTls: false useTls: false
allowInsecureLdap: true # dev only allowInsecureLdap: true # dev only
@@ -143,13 +152,29 @@ look that up in `groupToRole`.
## Provisioning the GwAdmin group ## Provisioning the GwAdmin group
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
>
> ```bash
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose up -d --force-recreate
> ```
>
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
> rollback reference only — do not use it for new provisioning.
`GwAdmin` is the gateway-specific dashboard-admin role. It is the `GwAdmin` is the gateway-specific dashboard-admin role. It is the
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject `DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
`admin` until a `GwAdmin` group exists and `admin` is a member. logins unless the user is a member of `GwAdmin`.
GLAuth's baseline config ships only the five LmxOpcUa role groups, so The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
server: `multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
---
**RETIRED — per-box provisioning (reference/rollback only):**
1. Edit `C:\publish\glauth\glauth.cfg` 1. Edit `C:\publish\glauth\glauth.cfg`
2. Append the group: 2. Append the group:
@@ -199,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
## Quick verification ## Quick verification
From mxaccessgw's dev box, prove the directory is reachable: From mxaccessgw's dev box, prove the shared directory is reachable:
```powershell ```powershell
# Plain bind via PowerShell + System.DirectoryServices.Protocols # Plain bind via PowerShell + System.DirectoryServices.Protocols
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893") # (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic $ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$ldap.SessionOptions.ProtocolVersion = 3 $ldap.SessionOptions.ProtocolVersion = 3
$ldap.SessionOptions.SecureSocketLayer = $false $ldap.SessionOptions.SecureSocketLayer = $false
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123") $cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
$ldap.Bind($cred) $ldap.Bind($cred)
"Bind OK" "Bind OK"
``` ```
@@ -215,17 +241,32 @@ $ldap.Bind($cred)
Or via `ldapsearch` if you have OpenLDAP CLI tools: Or via `ldapsearch` if you have OpenLDAP CLI tools:
```bash ```bash
ldapsearch -x -H ldap://localhost:3893 \ ldapsearch -x -H ldap://10.100.0.35:3893 \
-D "cn=admin,dc=zb,dc=local" -w admin123 \ -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
-b "dc=zb,dc=local" "(uid=admin)" -b "dc=zb,dc=local" "(uid=multi-role)"
``` ```
The response should list `admin`'s entry with `memberOf` populated for The response should list `multi-role`'s entry with `memberOf` including
all five role groups — plus `GwAdmin` once the gateway-specific group `ou=GwAdmin,ou=groups,dc=zb,dc=local`.
is provisioned.
## Service management ## Service management
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
>
> **Active (shared) management:**
> ```bash
> ssh 10.100.0.35
> cd ~/Desktop/scadaproj/infra/glauth
> docker compose ps # check container status
> docker compose up -d --force-recreate # apply config.toml changes
> docker compose logs -f # tail logs
> ```
**RETIRED — per-box NSSM commands (rollback reference):**
```powershell ```powershell
# Status / start / stop / restart # Status / start / stop / restart
nssm status GLAuth nssm status GLAuth
@@ -259,7 +300,7 @@ applies to mxaccessgw verbatim. Keys that change:
| Field | GLAuth dev value | AD production value | | Field | GLAuth dev value | AD production value |
|---|---|---| |---|---|---|
| `Server` | `localhost` | a domain controller FQDN, or the domain itself | | `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement | | `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
| `UseTls` | `false` | `true` | | `UseTls` | `false` | `true` |
| `AllowInsecureLdap` | `true` | `false` | | `AllowInsecureLdap` | `true` | `false` |
@@ -275,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
## Security notes for production ## Security notes for production
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is - **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
unencrypted on disk; anyone with read access to `C:\publish\glauth\` `scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
can SHA256-rainbow-table the entries. Treat the dev creds as `10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
throwaway. Production LDAP is Active Directory. Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
- The 3-fail / 10-minute lockout is per source IP, not per user — a - The 3-fail / 10-minute lockout is per source IP, not per user — a
shared NAT can lock out a whole office. Tunable in `[behaviors]`. shared NAT can lock out a whole office. Tunable in `[behaviors]`.
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the - LDAPS isn't enabled in dev; binding sends passwords cleartext on the
wire. Fine for `localhost`, never expose port 3893 off-box without wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
enabling TLS first. expose port 3893 externally without enabling TLS first.
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
/// </summary> /// </summary>
public bool RequireHttpsCookie { get; init; } = true; public bool RequireHttpsCookie { get; init; } = true;
/// <summary>
/// Dashboard auth cookie name. When null/blank (the default) the canonical
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) 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 this signs out
/// existing dashboard sessions on next deploy.
/// </summary>
public string? CookieName { get; init; }
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary> /// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000; public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
@@ -6,13 +6,19 @@
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users. cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
The card is the shared kit's <LoginCard>: it renders a NATIVE static The card is the shared kit's <LoginCard>: it renders a NATIVE static
<form method="post" action="/login"> (username/password + hidden returnUrl). A native <form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@ token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
sharing a route. *@
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error"> <LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
<AntiforgeryToken /> <AntiforgeryToken />
</LoginCard> </LoginCard>
@@ -29,8 +29,14 @@ public static class DashboardEndpointRouteBuilderExtensions
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the // kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, // RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users. // so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
//
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
endpoints.MapPost( endpoints.MapPost(
"/login", "/auth/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
@@ -66,6 +66,8 @@ public static class DashboardServiceCollectionExtensions
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8)); ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply // Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
// so they are never overwritten by the shared helper (Apply intentionally skips name). // so they are never overwritten by the shared helper (Apply intentionally skips name).
// This is the canonical default; it is overridden per-environment from
// DashboardOptions.CookieName by the PostConfigure below.
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName; cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.Path = "/"; cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = "/login"; cookieOptions.LoginPath = "/login";
@@ -77,13 +79,22 @@ public static class DashboardServiceCollectionExtensions
_ => { }); _ => { });
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev // Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
// HTTP deployments → SameAsRequest). This overrides the Apply default above. // HTTP deployments → SameAsRequest) and the optional per-environment cookie-name
// override. Both run after the inline AddCookie config above, so they win.
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme) services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) => .Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{ {
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
? CookieSecurePolicy.Always ? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest; : CookieSecurePolicy.SameAsRequest;
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
// the canonical default set above, so a misconfiguration cannot unname the cookie.
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
if (!string.IsNullOrWhiteSpace(cookieName))
{
cookieOptions.Cookie.Name = cookieName;
}
}); });
services.AddAuthorization(authorization => services.AddAuthorization(authorization =>
@@ -11,7 +11,7 @@
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" /> <PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" /> <PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.2.0" /> <PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
@@ -49,4 +49,23 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy); Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
} }
/// <summary>
/// Verifies that <c>MxGateway:Dashboard:CookieName</c> 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).
/// </summary>
[Fact]
public async Task Build_WithCookieNameOverride_UsesConfiguredName()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:CookieName=MxGatewayDashboard.env2"]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal("MxGatewayDashboard.env2", options.Cookie.Name);
}
} }
@@ -87,13 +87,18 @@ public sealed class GatewayApplicationTests
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
// GET /login is now served by the [AllowAnonymous] Blazor <Login> component // GET /login is served by the [AllowAnonymous] Blazor <Login> component
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API // (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
// endpoint. The form still POSTs to the minimal-API DashboardLoginPost endpoint. // endpoint. The credential POST goes to the DashboardLoginPost endpoint at
// /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
// methods, so sharing the "/login" route with MapPost previously made POST /login
// ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
// is the regression guard for that fix.
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login" Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null); && endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
Assert.Contains(endpoints, endpoint => Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"); endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
&& endpoint.RoutePattern.RawText == "/auth/login");
Assert.Contains(endpoints, endpoint => Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout"); endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
} }