From e0a3fbf35b1008228ba6b340bfa4871b0af1c085 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 4 Jun 2026 14:01:05 -0400 Subject: [PATCH] fix(dashboard)!: move login POST to /auth/login to resolve AmbiguousMatchException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The themed Blazor 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). --- .../Dashboard/Components/Pages/Login.razor | 14 ++++++++++---- .../DashboardEndpointRouteBuilderExtensions.cs | 8 +++++++- .../Gateway/GatewayApplicationTests.cs | 11 ++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor index c27c9c9..2d29e0e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/Login.razor @@ -6,13 +6,19 @@ cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users. The card is the shared kit's : it renders a NATIVE static -
(username/password + hidden returnUrl). A native - form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint + (username/password + hidden returnUrl). A native + 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. 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] - + diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 69661b8..bf68cd1 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -29,8 +29,14 @@ public static class DashboardEndpointRouteBuilderExtensions // kit's . Its [AllowAnonymous] attribute overrides the // RequireAuthorization(ViewerPolicy) that MapRazorComponents() applies, // 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 form posts here. endpoints.MapPost( - "/login", + "/auth/login", (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => PostLoginAsync(httpContext, antiforgery, authenticator)) .AllowAnonymous() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 98ee67e..2eb1667 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -87,13 +87,18 @@ public sealed class GatewayApplicationTests Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings"); - // GET /login is now served by the [AllowAnonymous] Blazor component + // GET /login is served by the [AllowAnonymous] Blazor component // (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" && endpoint.Metadata.GetMetadata() is not null); Assert.Contains(endpoints, endpoint => - endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLoginPost"); + endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLoginPost" + && endpoint.RoutePattern.RawText == "/auth/login"); Assert.Contains(endpoints, endpoint => endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); }