using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap;
///
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
/// bind as the service account, search for the user entry, then re-bind as the user
/// to verify their password. Connection mechanics are delegated to an
/// so the logic is unit-testable without a live server.
///
///
/// Fully fail-closed: authentication never throws to the caller — every error, expected
/// or unexpected, is mapped to a structured .
/// A success is only returned for a user that resolved to exactly one entry, whose password
/// verified, AND who has at least one group (zero groups is never admitted as success).
/// Service-account bind failures () are
/// kept distinct from end-user bind failures () so
/// a system misconfiguration is not mistaken for bad user input.
///
public sealed class LdapAuthService : ILdapAuthService
{
private readonly LdapOptions _options;
private readonly ILdapConnectionFactory _connectionFactory;
///
/// Production constructor: binds against a live directory via the real
/// Novell-backed connection factory. The TLS server-certificate validation policy
/// ( or the
/// bypass) is carried into the factory so each
/// connection is built with it.
///
public LdapAuthService(LdapOptions options)
: this(options, new NovellLdapConnectionFactory(
options.AllowInsecure, options.ServerCertificateValidationCallback))
{
}
///
/// Test/seam constructor: accepts an injected
/// so the bind/search logic can be exercised without a live directory. Internal
/// because the connection seam is an implementation detail.
///
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
{
_options = options;
_connectionFactory = connectionFactory;
}
///
///
/// Fail-closed contract: this method never throws to the caller. Every stage of the
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
/// is mapped to a structured . A
/// Succeeded == true result is only ever returned when the user resolved to exactly
/// one entry, their password verified, AND at least one group was extracted.
///
public Task AuthenticateAsync(string username, string password, CancellationToken ct)
{
// The Novell calls behind ILdapConnection are synchronous and blocking, so the token
// cannot interrupt an in-progress operation; it is only observed here at entry, before
// any work begins.
ct.ThrowIfCancellationRequested();
return Task.FromResult(Authenticate(username, password));
}
private LdapAuthResult Authenticate(string username, string password)
{
// 1. Feature gate: an explicitly disabled provider must never touch the network.
if (!_options.Enabled)
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
// 2. Reject a missing username before anything else — guarding here means a null
// username can't NRE into the catch-all and surface as a system-side failure.
if (string.IsNullOrWhiteSpace(username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3. Normalise once, up front, so the same canonical value flows into the LDAP
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
// the most conservative system-side bucket. Either way the caller gets a structured result.
try
{
using var conn = _connectionFactory.Create();
// 4. Open the connection (transport/TLS handling lives in the adapter). The
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
// A failure to connect/upgrade means the directory is unreachable — a
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
// ServiceAccountBindFailed is the closest system-side bucket. A future
// Abstractions change could add DirectoryUnavailable to disambiguate.
try
{
conn.Connect(
_options.Server,
_options.Port,
_options.Transport,
_options.AllowInsecure,
_options.ConnectionTimeoutMs,
_options.ServerCertificateValidationCallback);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 5. Service-account bind so we can search for the user's DN. A bind failure
// here is a service-account misconfiguration — DISTINCT from a user-credential
// failure — so it maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 6. Search for the user entry by the configured username attribute. A search
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
// closest system-side bucket until a DirectoryUnavailable value exists.
IReadOnlyList entries;
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// We never attempt a user bind against an ambiguous DN.
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
// here is the end user's bad credentials.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.BadCredentials);
}
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
// extracting groups, is a GroupLookupFailed — never a zero-group success.
IReadOnlyList groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
// 10. Success — and only here, with a verified password and >= 1 group.
var displayName = ExtractDisplayName(entry, username);
return LdapAuthResult.Success(username, displayName, groups);
}
catch (StageFailure stage)
{
// A stage mapped its own failure; surface it as a structured result.
return LdapAuthResult.Fail(stage.Failure);
}
catch
{
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
// system-side bucket rather than propagating to the caller.
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
}
}
///
/// Internal control-flow exception that carries an already-mapped
/// out of a stage to the single fail-closed catch site. Never escapes this type.
///
private sealed class StageFailure : Exception
{
public StageFailure(LdapAuthFailure failure) => Failure = failure;
public LdapAuthFailure Failure { get; }
}
///
/// Builds the distinct attribute list requested from the directory. The display-name
/// and group attributes are de-duplicated so we never request the same attribute twice
/// when an operator configures them to the same value.
///
private IReadOnlyList BuildSearchAttributes()
{
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
return new[] { _options.DisplayNameAttribute };
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
}
///
/// Returns the first value of the configured display-name attribute, falling back to
/// the (already normalised) username when the directory entry has no such attribute.
///
private string ExtractDisplayName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
return values[0];
return username;
}
///
/// Extracts group short names from the configured group attribute. Each value is a
/// group DN (e.g. cn=Engineers,ou=g,dc=x); the first RDN's value is returned
/// (e.g. Engineers), RFC 4514 escape-aware so an escaped comma in the CN is
/// preserved rather than truncating the name.
///
private IReadOnlyList ExtractGroups(LdapSearchEntry entry)
{
if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
return Array.Empty();
var groups = new List(values.Count);
foreach (var value in values)
groups.Add(ToGroupShortName(value));
return groups;
}
///
/// Yields a group's short name from its DN by returning the value of the first RDN
/// (e.g. cn=Engineers,ou=g,dc=x → Engineers). The extraction is RFC 4514
/// escape-aware (), so a CN that legitimately
/// contains an escaped comma — cn=Eng\,ineers,... or cn=A\2cB,... — is
/// returned intact rather than truncated at the escaped comma. Values with no =
/// are returned unchanged.
///
private static string ToGroupShortName(string groupDn)
=> LdapEscaping.FirstRdnValue(groupDn);
}