using System.Text.Encodings.Web; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.HttpResults; using MxGateway.Server.Configuration; using MxGateway.Server.Dashboard.Components; namespace MxGateway.Server.Dashboard; public static class DashboardEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints) { IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService(); IConfigurationSection dashboardSection = configuration .GetSection($"{GatewayOptions.SectionName}:Dashboard"); if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled) { return endpoints; } string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase); RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase); dashboard.MapGet( "/login", (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase)) .AllowAnonymous() .WithName("DashboardLogin"); dashboard.MapPost( "/login", (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => PostLoginAsync(httpContext, antiforgery, authenticator, pathBase)) .AllowAnonymous() .WithName("DashboardLoginPost"); dashboard.MapPost( "/logout", (HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase)) .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy) .WithName("DashboardLogout"); dashboard.MapGet("/denied", () => Results.Content( RenderPage("Access denied", "

The signed-in API key is not authorized for dashboard access.

"), "text/html")) .AllowAnonymous() .WithName("DashboardAccessDenied"); dashboard.MapRazorComponents() .AddInteractiveServerRenderMode() .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy); return endpoints; } private static Task GetLoginAsync( HttpContext httpContext, IAntiforgery antiforgery, string pathBase) { string returnUrl = SanitizeReturnUrl( httpContext.Request.Query["returnUrl"].ToString(), pathBase); return Task.FromResult(TypedResults.Content( RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null), "text/html")); } private static async Task PostLoginAsync( HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator, string pathBase) { await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); IFormCollection form = await httpContext.Request .ReadFormAsync(httpContext.RequestAborted) .ConfigureAwait(false); string returnUrl = SanitizeReturnUrl( form["returnUrl"].ToString(), pathBase); DashboardAuthenticationResult result = await authenticator .AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted) .ConfigureAwait(false); if (!result.Succeeded || result.Principal is null) { return TypedResults.Content( RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage), "text/html", statusCode: StatusCodes.Status401Unauthorized); } await httpContext .SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal) .ConfigureAwait(false); return Results.LocalRedirect(returnUrl); } private static async Task PostLogoutAsync( HttpContext httpContext, IAntiforgery antiforgery, string pathBase) { await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); await httpContext .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme) .ConfigureAwait(false); return Results.LocalRedirect($"{pathBase}/login"); } private static string RenderLoginPage( HttpContext httpContext, IAntiforgery antiforgery, string returnUrl, string pathBase, string? failureMessage) { AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); string requestToken = tokens.RequestToken ?? string.Empty; string alert = string.IsNullOrWhiteSpace(failureMessage) ? string.Empty : $"

{HtmlEncoder.Default.Encode(failureMessage)}

"; string body = $""" """; return RenderPage("Dashboard Sign In", body); } private static string RenderPage(string title, string body) { return $""" {HtmlEncoder.Default.Encode(title)}

{HtmlEncoder.Default.Encode(title)}

{body}
"""; } private static string NormalizePathBase(string pathBase) { string normalized = pathBase.TrimEnd('/'); return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal) ? "/dashboard" : normalized; } private static string SanitizeReturnUrl(string? returnUrl, string pathBase) { if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith("/", StringComparison.Ordinal) || returnUrl.StartsWith("//", StringComparison.Ordinal) || !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase) || Uri.TryCreate(returnUrl, UriKind.Absolute, out _)) { return pathBase; } return returnUrl; } }