using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
///
/// Shared authentication for the management/CLI surfaces (,
/// , and the debug-stream hub). Accepts either
/// Authorization: Bearer <jwt> (a token minted by POST /auth/token) or
/// HTTP Basic (an LDAP bind per request). Centralising the flow here means the dev/test
/// DisableLogin bypass and both schemes are applied identically on every
/// CLI-facing surface.
///
///
///
/// Bearer (review 2026-10-21 05/U1, 05/P1): the token is validated by
/// — HS256 signature with the shared
/// JwtSigningKey, issuer, audience and lifetime (small clock skew). The username, roles
/// and permitted site ids inside a valid token are authoritative for its lifetime: a bearer
/// request performs no LDAP bind and no role re-map, mirroring "LDAP failure: active sessions
/// continue with current roles". An invalid token is a plain 401 — there is no LDAP bind to
/// throttle, so it does not feed .
///
///
/// ScadaBridge:Security:Auth:DisableLogin swaps the cookie authentication
/// scheme for AutoLoginAuthenticationHandler, which lets the interactive browser UI
/// in without credentials. The CLI does not use cookies — it authenticates the
/// POST /management (and audit REST) surfaces with HTTP Basic, which run their own
/// manual Basic → LDAP check. That check is independent of the cookie scheme, so without the
/// bypass below the CLI is locked out of a login-disabled deployment whenever LDAP is not in
/// use (the bind fails and every call returns 401 AUTH_FAILED).
///
///
/// The bypass mirrors AutoLoginAuthenticationHandler exactly: the configured dev user
/// (default multi-role) with ALL roles, system-wide (empty
/// ). The startup AUTH DISABLED warning
/// (emitted by AddSecurity) already announces this posture. Dev/test ONLY.
///
///
public static class ManagementAuthenticator
{
/// Outcome of : exactly one of the two fields is set.
/// The authenticated principal on success; otherwise .
/// The 401 to return on failure; otherwise .
public readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
///
/// When ScadaBridge:Security:Auth:DisableLogin is true, returns the synthesized dev
/// principal (configured user, ALL roles, system-wide) that mirrors the cookie-scheme
/// AutoLoginAuthenticationHandler — so the Basic-Auth CLI surfaces are reachable in a
/// login-disabled (e.g. no-LDAP) deployment. Returns when the flag is
/// off, in which case the caller must fall back to real Basic → LDAP authentication.
/// Dev/test ONLY.
///
/// The current HTTP request context (used to resolve the options).
/// The dev principal when login is disabled; otherwise .
public static AuthenticatedUser? TryDisableLoginUser(HttpContext context)
{
var opts = context.RequestServices.GetService>()?.Value;
if (opts is null || !opts.DisableLogin)
return null;
var user = string.IsNullOrWhiteSpace(opts.User) ? "multi-role" : opts.User;
return new AuthenticatedUser(user, user, Roles.All, Array.Empty());
}
///
/// Authenticates a management/CLI request. Honours the dev/test DisableLogin bypass
/// first (); otherwise accepts a bearer JWT (validated
/// locally, no LDAP) or decodes HTTP Basic Auth, binds against LDAP, and resolves roles.
/// Returns a populated on success, or an
/// carrying the 401 (or 429 throttle) response on any failure.
///
/// The HTTP request to authenticate.
/// An carrying either the user or the 401 failure result.
public static async Task AuthenticateAsync(HttpContext context)
{
// Dev/test bypass: when login is disabled, the CLI surface auto-authenticates as the
// configured dev user — no credential check — mirroring the interactive cookie handler.
var bypass = TryDisableLoginUser(context);
if (bypass is not null)
return new AuthOutcome(bypass, null);
var authHeader = context.Request.Headers.Authorization.ToString();
// 0. Bearer token minted by POST /auth/token — validated locally, no LDAP bind.
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return AuthenticateBearer(context, authHeader["Bearer ".Length..].Trim());
// 1. Decode Basic Auth
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return new AuthOutcome(null, Results.Json(
new { error = "Authorization header required (Basic or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401));
}
string username, password;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
var colon = decoded.IndexOf(':');
if (colon < 0) throw new FormatException();
username = decoded[..colon];
password = decoded[(colon + 1)..];
}
catch
{
return new AuthOutcome(null, Results.Json(
new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthOutcome(null, Results.Json(
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
// 1a. Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5). Consulted BELOW the
// DisableLogin bypass — a login-disabled deployment never binds, so it never throttles.
var throttle = context.RequestServices.GetRequiredService();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return new AuthOutcome(null, Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429));
}
// 2. LDAP authentication
var ldapAuth = context.RequestServices.GetRequiredService();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return new AuthOutcome(null, Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
}
throttle.RecordSuccess(username, remoteIp!);
// 3. Role resolution
var roleMapper = context.RequestServices.GetRequiredService();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
var permittedSiteIds = mappingResult.IsSystemWideDeployment
? Array.Empty()
: mappingResult.PermittedSiteIds.ToArray();
var resolvedUser = new AuthenticatedUser(
authResult.Username,
authResult.DisplayName,
mappingResult.Roles.ToArray(),
permittedSiteIds);
return new AuthOutcome(resolvedUser, null);
}
///
/// Validates a bearer JWT and projects its claims onto an .
/// The token's roles and site scope are taken as-is (authoritative until expiry); an
/// empty site-id set is the same system-wide signal the Basic path produces.
///
private static AuthOutcome AuthenticateBearer(HttpContext context, string token)
{
var jwt = context.RequestServices.GetService();
var principal = jwt?.ValidateToken(token);
var username = principal?.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
if (principal is null || string.IsNullOrWhiteSpace(username))
{
return new AuthOutcome(null, Results.Json(
new { error = "Invalid or expired bearer token.", code = "AUTH_FAILED" }, statusCode: 401));
}
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
var roles = principal.FindAll(JwtTokenService.RoleClaimType)
.Select(c => c.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType)
.Select(c => c.Value)
.Distinct(StringComparer.Ordinal)
.ToArray();
return new AuthOutcome(
new AuthenticatedUser(
username,
string.IsNullOrWhiteSpace(displayName) ? username : displayName,
roles,
permittedSiteIds),
null);
}
}