using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
using LibLdapAuthService = ZB.MOM.WW.Auth.Ldap.LdapAuthService;
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
///
/// OtOpcUa's application — a thin wrapper around the shared
/// ZB.MOM.WW.Auth.Ldap directory client that adds the two app-only concerns the shared
/// library deliberately does not model:
///
/// - the master switch (disabled ⇒ deny, no bind); and
/// - — the dev bypass that grants an Administrator
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
/// against a machine with no directory.
///
/// On the real path it delegates to the library and adapts the
/// library result (which returns groups, never roles) back onto the app's
/// shape. Role resolution itself now lives downstream in
/// (the IGroupRoleMapper<string> seam), which
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
/// . The only path that pre-populates
/// is the DevStub success; consumers union that pre-resolved
/// set with the mapper output so the dev Administrator grant survives the move to the mapper.
///
///
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
/// DevStub result grants the canonical "Administrator" control-plane role (group
/// "dev") so the dev session can navigate the full Admin UI (renamed from the prior
/// "FleetAdmin" to the canonical "Administrator").
///
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
{
private readonly LdapOptions _options;
private readonly LibILdapAuthService _inner;
private readonly ILogger _logger;
///
/// Production constructor: builds the shared-library directory client from the wire fields
/// of the bound app .
///
/// The app LDAP options (wire fields + app-only concerns).
/// The logger.
public OtOpcUaLdapAuthService(IOptions options, ILogger logger)
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
{
}
///
/// Seam constructor: accepts an injected library so the
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
///
/// The app LDAP options.
/// The shared-library directory client to delegate the real path to.
/// The logger.
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger logger)
{
_options = options;
_inner = inner;
_logger = logger;
}
///
public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
// refuse to authenticate at all (no bind, no dev-stub bypass).
if (!_options.Enabled)
return new(false, null, null, [], [], "LDAP authentication is disabled.");
if (string.IsNullOrWhiteSpace(username))
return new(false, null, null, [], [], "Username is required");
if (string.IsNullOrWhiteSpace(password))
return new(false, null, username, [], [], "Password is required");
if (_options.DevStubMode)
{
// Dev bypass: accept any non-empty credentials and grant Administrator WITHOUT a real bind.
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
// survives the move to IGroupRoleMapper. (The role string was canonicalized from the
// prior "FleetAdmin" to "Administrator".)
_logger.LogWarning(
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
return new(true, username, username, ["dev"], ["Administrator"], null);
}
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
// enforced this at login (not startup), so the host still boots with an insecure-by-default
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
// migration. The shared library's directory client does not re-check this.
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
return new(false, null, username, [], [],
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
var libResult = await _inner.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
return Adapt(libResult, username);
}
///
/// Maps the shared-library onto the app's
/// . The library returns groups (never roles) on success, so
/// is left empty on the delegated path — role resolution
/// happens downstream in the mapper. Library codes are folded
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
///
/// The library authentication result.
/// The login name, used to populate the app result's Username field.
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
{
if (result.Succeeded)
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
// ServiceAccountBindFailed is the library's directory-unreachable / service-account-misconfigured
// bucket (its enum has no dedicated DirectoryUnavailable) — flag it as system-side so the OPC UA
// authenticator's outage circuit can distinguish an outage from a user-credential deny (03/S2).
return new(false, null, username, [], [], FailureToError(result.Failure))
{
IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed,
};
}
/// Folds a structured library failure code into the app's user-facing error text.
/// The library failure code (null defensively treated as a generic error).
private static string FailureToError(LdapAuthFailure? failure) => failure switch
{
// The directory found no single matching user, or the password did not verify — both
// surface as the same opaque message so a probe cannot distinguish "unknown user" from
// "wrong password".
LdapAuthFailure.BadCredentials => "Invalid username or password",
LdapAuthFailure.UserNotFound => "Invalid username or password",
LdapAuthFailure.AmbiguousUser => "Invalid username or password",
LdapAuthFailure.GroupLookupFailed => "Invalid username or password",
// System-side faults (directory unreachable / service-account misconfiguration) — kept as a
// generic backend message rather than leaking the cause to the caller.
LdapAuthFailure.ServiceAccountBindFailed => "Unexpected authentication error",
LdapAuthFailure.Disabled => "LDAP authentication is disabled.",
_ => "Unexpected authentication error",
};
}