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