using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
///
/// Production adapter that bridges OPC UA UserName
/// tokens to the same the Admin UI cookie/JWT flows use, so a
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
/// session identities. Roles are resolved through the shared
/// seam from the LDAP groups returned by the directory —
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
/// session identity for the downstream data-plane ACL evaluator.
///
///
///
/// This authenticator is registered as a singleton, but
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
/// resolve the mapper so the singleton never captures a scoped dependency.
///
///
/// Resilience (arch-review 03/S2). The OPC UA SDK raises the impersonation callback that
/// reaches here synchronously, inside a SessionManager-wide event lock (verified
/// against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378 —
/// SessionManager.ActivateSessionAsync holds m_eventLock while invoking the
/// void impersonation delegate). A slow / hung LDAP bind inside that callback therefore does
/// not merely stall one SDK thread — it serializes every session activation server-wide
/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no
/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow
/// wall-clock bound () and a bounded in-flight concurrency cap
/// (), both fail-closed. All new paths deny.
///
///
public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
private readonly ILdapAuthService _ldap;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
private readonly LdapOptions _options;
/// Bounds in-flight authentication flows. A boundary timeout abandons its core task, which
/// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded
/// orphan binds.
private readonly SemaphoreSlim _gate;
/// Constructs the authenticator from its dependencies and the bound LDAP options.
/// The app LDAP auth service (shared with the Admin UI login flow).
/// Opens a per-call DI scope to resolve the scoped role mapper.
/// The bound (timeout + concurrency knobs).
/// The logger.
public LdapOpcUaUserAuthenticator(
ILdapAuthService ldap,
IServiceScopeFactory scopeFactory,
IOptions options,
ILogger logger)
{
_ldap = ldap;
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
_gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications));
}
///
public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs));
// Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots
// busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed).
if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false))
{
_logger.LogWarning(
"LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}",
_options.MaxConcurrentAuthentications, username);
return OpcUaUserAuthResult.Deny("Authentication backend busy");
}
// Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary
// timeout below abandons the core task (it runs to completion in the background; the underlying
// library is documented never-throw, so there are no unobserved exceptions), and it must keep
// counting against the cap until it finishes. The point of the boundary is to release the SDK
// thread (and m_eventLock), not to cancel the bind.
var core = AuthenticateCoreAsync(username, password, ct);
_ = core.ContinueWith(
static (_, state) => ((SemaphoreSlim)state!).Release(),
_gate,
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
try
{
return await core.WaitAsync(authTimeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.LogWarning(
"LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)",
authTimeout, username);
return OpcUaUserAuthResult.Deny("Authentication timed out");
}
}
///
/// The unbounded core authenticate flow (bind + role-map) that the outer
/// wraps in a wall-clock bound + concurrency cap. Fail-closed:
/// any backend throw becomes a denial rather than escaping into the SDK.
///
/// The username to authenticate.
/// The cleartext password.
/// Cancellation token.
private async Task AuthenticateCoreAsync(string username, string password, CancellationToken ct)
{
try
{
var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
if (!result.Success)
{
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
}
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
return OpcUaUserAuthResult.Deny("Authentication backend error");
}
}
///
/// Resolves the user's roles from their LDAP groups via the scoped
/// , unioned with any pre-resolved roles (the DevStub
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
///
/// The LDAP groups returned by the directory.
/// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).
/// The login name, for diagnostics.
/// Cancellation token.
private async Task> ResolveRolesAsync(
IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var mapper = scope.ServiceProvider.GetRequiredService>();
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
var roles = new HashSet(preResolved, StringComparer.OrdinalIgnoreCase);
foreach (var role in mapping.Roles)
roles.Add(role);
return [.. roles];
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
return preResolved;
}
}
}