fix(host,security): wire static assets, DI lifetimes, form login, dev-stub LDAP

Six interlocking fixes surfaced while smoke-testing the fused Host in a browser:

- Host/Program.cs: UseStaticWebAssets() opts into the RCL static-asset pipeline
  in any environment (auto-only in Development), MapStaticAssets().AllowAnonymous()
  exempts CSS/JS from the AddOtOpcUaAuth fallback policy, and
  AddCascadingAuthenticationState() lets <AuthorizeView/> work inside interactive
  components (NavSidebar's session block).
- Security/ServiceCollectionExtensions: ILdapAuthService Scoped → Singleton —
  consumed by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
  Crash only surfaced in Development (ValidateOnBuild=true).
- Security/Endpoints/AuthEndpoints: /auth/login now dispatches on Content-Type —
  application/json keeps the original 204/401/503 contract for tests, and
  application/x-www-form-urlencoded (the browser <form>) gets a redirect dance.
  DisableAntiforgery on the login endpoint (it's the entry point, no prior session)
  and AllowAnonymous to override the fallback policy.
- Security/Ldap/LdapOptions + LdapAuthService: real DevStubMode property; when
  true the auth service bypasses the LDAP bind and returns a FleetAdmin role so
  dev/test can navigate the full Admin UI without GLAuth running.
- AdminUI/EndpointRouteBuilderExtensions: doc-comment update about static-asset
  flow (the actual MapStaticAssets call lives in Host/Program.cs).
This commit is contained in:
Joseph Doherty
2026-05-26 13:48:18 -04:00
parent a5c6ce279e
commit 2915755a7c
6 changed files with 80 additions and 9 deletions
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
@@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
public static class AuthEndpoints
{
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
public sealed record LoginRequest(string Username, string Password);
public sealed record TokenResponse(string Token);
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
{
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous();
// The login endpoint serves two callers with different ergonomics:
// - Browser form POST (application/x-www-form-urlencoded) → redirect dance
// - API JSON POST (application/json) → 204 / 401 / 503 status codes
// DisableAntiforgery: the login form is the entry point — anonymous by definition,
// no prior session, so XSRF doesn't apply. AllowAnonymous: override the
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
@@ -26,15 +34,35 @@ public static class AuthEndpoints
}
private static async Task<IResult> LoginAsync(
LoginRequest request,
HttpContext http,
ILdapAuthService ldap,
CancellationToken ct)
{
var isForm = http.Request.HasFormContentType;
string username, password, returnUrl;
if (isForm)
{
var form = await http.Request.ReadFormAsync(ct);
username = form["username"].ToString();
password = form["password"].ToString();
returnUrl = form["returnUrl"].ToString();
}
else
{
var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
http.Request.Body,
new JsonSerializerOptions(JsonSerializerDefaults.Web),
ct);
username = body?.Username ?? string.Empty;
password = body?.Password ?? string.Empty;
returnUrl = string.Empty;
}
LdapAuthResult result;
try
{
result = await ldap.AuthenticateAsync(request.Username, request.Password, ct);
result = await ldap.AuthenticateAsync(username, password, ct);
}
catch (Exception)
{
@@ -42,13 +70,20 @@ public static class AuthEndpoints
}
if (!result.Success)
return Results.Unauthorized();
{
if (!isForm) return Results.Unauthorized();
var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
if (!string.IsNullOrWhiteSpace(returnUrl))
qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Results.Redirect("/login" + qs);
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, result.Username ?? request.Username),
new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username),
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username),
new(ClaimTypes.NameIdentifier, result.Username ?? username),
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
};
foreach (var role in result.Roles)
claims.Add(new Claim(ClaimTypes.Role, role));
@@ -57,7 +92,9 @@ public static class AuthEndpoints
var principal = new ClaimsPrincipal(identity);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.NoContent();
if (!isForm) return Results.NoContent();
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
}
private static IResult Ping(HttpContext http) =>
@@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
if (string.IsNullOrWhiteSpace(password))
return new(false, null, null, [], [], "Password is required");
if (_options.DevStubMode)
{
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
}
if (!_options.UseTls && !_options.AllowInsecureLdap)
return new(false, null, username, [], [],
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
@@ -17,6 +17,13 @@ public sealed class LdapOptions
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
public bool AllowInsecureLdap { get; set; }
/// <summary>
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
/// </summary>
public bool DevStubMode { get; set; }
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
@@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
services.AddSingleton<JwtTokenService>();
services.AddScoped<ILdapAuthService, LdapAuthService>();
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
// across both paths keeps ValidateScopes-on-Build clean.
services.AddSingleton<ILdapAuthService, LdapAuthService>();
services.AddDataProtection()
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()