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); } }