namespace ZB.MOM.WW.Auth.Ldap.Internal;
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
// Disambiguate: Novell also declares a RemoteCertificateValidationCallback delegate; the seam and
// LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback both use the BCL one.
using RemoteCertificateValidationCallback = System.Net.Security.RemoteCertificateValidationCallback;
///
/// Production backed by Novell.Directory.Ldap.LdapConnection.
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
///
///
/// TLS server-certificate validation: by default the underlying
/// Novell.Directory.Ldap.NETStandard client validates the server certificate against the OS
/// trust store (it does NOT blind-accept). A caller-supplied
/// RemoteCertificateValidationCallback overrides that default (CA pinning / SAN checks); when
/// none is supplied and allowInsecure is set, validation is bypassed for dev/test only.
///
internal sealed class NovellLdapConnection : ILdapConnection
{
private readonly LdapConnection _conn;
private bool _disposed;
///
/// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
/// wins; otherwise
/// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
///
public NovellLdapConnection(
bool allowInsecure = false,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
if (serverCertificateValidationCallback is not null)
{
var options = new LdapConnectionOptions()
.ConfigureRemoteCertificateValidationCallback(serverCertificateValidationCallback);
_conn = new LdapConnection(options);
}
else if (allowInsecure)
{
// Dev/test only: accept any server certificate. Reachable solely when an operator has set
// AllowInsecure (rejected for plaintext-without-AllowInsecure by LdapOptionsValidator).
var options = new LdapConnectionOptions()
.ConfigureRemoteCertificateValidationCallback((_, _, _, _) => true);
_conn = new LdapConnection(options);
}
else
{
// Default: validate against the OS trust store (no blind-accept).
_conn = new LdapConnection();
}
}
///
public void Connect(
string host,
int port,
LdapTransport transport,
bool allowInsecure,
int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback)
{
// The TLS-validation policy (allowInsecure / callback) is wired at construction time on the
// LdapConnectionOptions; the per-call arguments here are accepted for seam symmetry.
ApplyTimeout(timeoutMs);
// LDAPS: TLS is negotiated at the TCP-connection level.
if (transport == LdapTransport.Ldaps)
_conn.SecureSocketLayer = true;
_conn.Connect(host, port);
// StartTLS: connect plaintext first, then upgrade inside the session.
if (transport == LdapTransport.StartTls)
{
_conn.StartTls();
if (!_conn.Tls)
throw new InvalidOperationException(
"StartTLS upgrade did not produce an encrypted session.");
}
}
///
public void Bind(string dn, string password)
=> _conn.Bind(dn, password);
///
public IReadOnlyList Search(
string searchBase,
string filter,
IReadOnlyList attributes)
{
var results = _conn.Search(
searchBase,
LdapConnection.ScopeSub,
filter,
attributes.ToArray(),
typesOnly: false);
return ReadEntries(results);
}
///
/// Drains a result set into entries, skipping continuation referrals.
///
///
///
/// Active Directory answers any search based at a domain root with one or more
/// continuation references (to CN=Configuration, DC=ForestDnsZones and
/// friends). With referral following disabled — the default, and what we want, since
/// chasing a referral would re-bind somewhere we did not authenticate — Novell surfaces
/// each one by throwing from .
///
///
/// A continuation reference is a pointer to another naming context, not an error:
/// it says "there may be more results over there", never that this search failed. Letting
/// it escape made the whole search stage fail, which LdapAuthService maps to
/// — so against a
/// real AD domain root, every login failed with a system-side error while the directory,
/// the credentials and both DCs were perfectly healthy. Verified against
/// DC=nam,DC=zimmer,DC=com on 2026-08-12.
///
///
/// Skipping is the correct behaviour rather than a workaround: the entries we can see are
/// exactly the ones in the naming context we bound to, which is the set the caller asked
/// about. Internal so the referral handling can be unit-tested through the
/// seam without a live directory.
///
///
internal static List ReadEntries(ILdapSearchResults results)
{
var entries = new List();
var referrals = 0;
while (true)
{
try
{
if (!results.HasMore()) break;
var entry = results.Next();
var attrs = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (LdapAttribute attr in entry.GetAttributeSet())
{
attrs[attr.Name] = attr.StringValueArray.ToList();
}
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
}
catch (LdapException ex) when (ex is LdapReferralException || ex.ResultCode == LdapException.Referral)
{
// Novell dequeues the reference before throwing, so the enumeration advances and
// this terminates. The cap is a belt-and-braces guard: a client that threw without
// advancing would otherwise spin forever inside a fail-closed auth path, which is a
// far worse failure than under-reporting an implausible number of referrals.
if (++referrals > MaxReferrals) break;
}
}
return entries;
}
/// Upper bound on continuation references tolerated in one search — see .
private const int MaxReferrals = 1_000;
///
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_conn.Connected)
_conn.Disconnect();
_conn.Dispose();
}
// -------------------------------------------------------------------------
private void ApplyTimeout(int timeoutMs)
{
if (timeoutMs <= 0)
return;
_conn.ConnectionTimeout = timeoutMs;
// SearchConstraints.TimeLimit is per-operation (ms). SearchConstraints getter
// returns the live LdapSearchConstraints object (read-only property), but
// TimeLimit is mutable in-place via the base LdapConstraints type.
// We then assign it back through the writable Constraints property so
// Novell picks up the change — mirrors ScadaBridge idiom.
var constraints = _conn.SearchConstraints;
constraints.TimeLimit = timeoutMs;
_conn.Constraints = constraints;
}
}
///
/// Factory that produces fresh instances, carrying the TLS
/// server-certificate validation policy (a supplied callback, or an allowInsecure bypass) so
/// it is wired onto each connection at construction time.
///
internal sealed class NovellLdapConnectionFactory(
bool allowInsecure = false,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
: ILdapConnectionFactory
{
public ILdapConnection Create() =>
new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
}