using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Security.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints; public static class AuthEndpoints { /// JSON body schema for API-side login callers (kept stable for tests). public sealed record LoginRequest(string Username, string Password); /// Response for a token issue request. public sealed record TokenResponse(string Token); /// Maps OtOpcUa authentication endpoints to the application route builder. /// The endpoint route builder. /// The endpoint route builder for chaining. public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app) { // 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(); return app; } private static async Task LoginAsync( HttpContext http, ILdapAuthService ldap, IGroupRoleMapper roleMapper, 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( 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(username, password, ct); } catch (Exception) { return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } if (!result.Success) { 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); } // Role resolution now lives behind the shared IGroupRoleMapper seam // (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges // system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real // LDAP path (the library returns groups, not roles); it is only pre-populated on the // DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant // survives the move to the mapper. IReadOnlyList roles = result.Roles; try { var mapping = await roleMapper.MapAsync(result.Groups, ct); roles = Union(result.Roles, mapping.Roles); } catch (Exception ex) when (ex is not OperationCanceledException) { // A DB hiccup (or any mapper fault) must never block sign-in — fall back to the // pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub). // This is intentionally FAIL-CLOSED on the real LDAP path: result.Roles is empty there // (the library returns groups, never roles — the mapper is the sole role source), so a // mapper fault signs the user in AUTHENTICATED but with ZERO role claims. They can prove // identity but are denied every role-gated action until the mapper recovers — strictly // safer than failing open with a stale/guessed role set. (See AuthEndpoints test // Login_when_role_mapper_throws_signs_in_with_no_role_claims.) http.RequestServices.GetService()? .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints") .LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username); } var claims = new List { // ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically. new(ZbClaimTypes.Name, result.Username ?? username), new(ZbClaimTypes.Username, result.Username ?? username), new(ZbClaimTypes.DisplayName, result.DisplayName ?? username), }; foreach (var role in roles) // ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work. claims.Add(new Claim(ZbClaimTypes.Role, role)); var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); if (!isForm) return Results.NoContent(); return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); } /// /// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the /// legacy RoleMapper.Merge applied. Used to fold any pre-resolved roles (the DevStub /// FleetAdmin grant) into the mapper-resolved set. /// /// The first role set (pre-resolved baseline). /// The second role set (mapper output). private static IReadOnlyList Union(IReadOnlyList first, IReadOnlyList second) { var roles = new HashSet(first, StringComparer.OrdinalIgnoreCase); foreach (var role in second) roles.Add(role); return [.. roles]; } private static IResult Ping(HttpContext http) => http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized(); private static IResult IssueToken(HttpContext http, JwtTokenService jwt) { var user = http.User; var username = user.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? user.Identity?.Name ?? string.Empty; var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username; var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray(); return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles))); } private static async Task LogoutAsync(HttpContext http) { await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); // Browser form POST → redirect to /login so the user lands somewhere visible. // API callers that prefer the status-only contract should hit the endpoint with // Accept: application/json and we'll hand them a 204 instead. var wantsJson = http.Request.Headers.Accept.Any(v => v?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true); if (wantsJson) return Results.NoContent(); return Results.Redirect("/login"); } }