From 7fc1955287501dbbec2207467750a025fdbeda9e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 25 May 2026 23:40:39 -0400 Subject: [PATCH] Dashboard: handle GET /logout (was 405) by signing out + redirecting to /login Browsers that navigate directly to /logout via the address bar issued a GET against a POST-only route and got 405 Method Not Allowed. Logout is self-destructive, so the GET path can skip antiforgery; the existing POST form (used by the layout's Sign out button) is unchanged and still antiforgery-protected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DashboardEndpointRouteBuilderExtensions.cs | 18 ++++++++++++++++++ .../Gateway/GatewayApplicationTests.cs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index f214778..b1e02f3 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -44,6 +44,15 @@ public static class DashboardEndpointRouteBuilderExtensions .AllowAnonymous() .WithName("DashboardLogout"); + // Browsers that navigate directly to /logout issue a GET. Sign out and + // redirect to /login so the URL works as users expect. Logout is + // self-destructive, so the GET path intentionally skips antiforgery. + endpoints.MapGet( + "/logout", + (Delegate)(static (HttpContext httpContext) => GetLogoutAsync(httpContext))) + .AllowAnonymous() + .WithName("DashboardLogoutGet"); + endpoints.MapGet("/denied", () => Results.Content( RenderPage("Access denied", "

The signed-in user is not authorized for dashboard access.

"), "text/html")) @@ -140,6 +149,15 @@ public static class DashboardEndpointRouteBuilderExtensions return Results.LocalRedirect("/login"); } + private static async Task GetLogoutAsync(HttpContext httpContext) + { + await httpContext + .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme) + .ConfigureAwait(false); + + return Results.LocalRedirect("/login"); + } + private static string RenderLoginPage( HttpContext httpContext, IAntiforgery antiforgery, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index dbc226f..c590409 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -63,7 +63,7 @@ public sealed class GatewayApplicationTests IReadOnlyList endpoints = GetRouteEndpoints(app); string[] anonymousEndpointNames = - ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardAccessDenied"]; + ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"]; foreach (string endpointName in anonymousEndpointNames) { RouteEndpoint endpoint = Assert.Single(