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 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);
public sealed record TokenResponse(string Token);
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,
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);
}
var claims = new List
{
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));
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);
}
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(ClaimTypes.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");
}
}