feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4)

This commit is contained in:
Joseph Doherty
2026-06-02 00:55:10 -04:00
parent 6534875476
commit 257caa7bd1
14 changed files with 495 additions and 274 deletions
@@ -20,9 +20,9 @@
<div class="panel-head">LDAP binding</div>
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
<div class="kv"><span class="k">Transport</span><span class="v">@_options.Transport</span></div>
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
@if (!_options.UseTls && _options.AllowInsecureLdap)
@if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure)
{
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
}
@@ -10,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
/// intentionally not required — an empty pair selects the direct-bind path (see
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
/// <see cref="LdapOptions.ServiceAccountDn"/>). The plaintext-transport-without-AllowInsecure
/// guard is enforced at the auth boundary (<see cref="OtOpcUaLdapAuthService"/>) rather than here,
/// to preserve the bespoke service's behaviour of booting and failing closed at login (not at
/// startup) when a config selects insecure transport. Failure messages use <c>"Ldap:"</c> as a
/// human-readable field prefix — not the literal bound section path, which is
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
/// </summary>
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -8,15 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
/// tokens to the same <see cref="ILdapAuthService"/> 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 flow through unchanged — the data-plane ACL evaluator reads
/// them off <c>OperationContext.UserIdentity</c> downstream.
/// session identities. Roles are resolved through the shared
/// <see cref="IGroupRoleMapper{TRole}"/> 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.
/// </summary>
/// <remarks>
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
/// (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.
/// </remarks>
public sealed class LdapOpcUaUserAuthenticator(
ILdapAuthService ldap,
IServiceScopeFactory scopeFactory,
ILogger<LdapOpcUaUserAuthenticator> logger)
: IOpcUaUserAuthenticator
{
/// <summary>Authenticates an OPC UA UserName token via LDAP.</summary>
/// <summary>Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">Cancellation token.</param>
@@ -29,7 +39,9 @@ public sealed class LdapOpcUaUserAuthenticator(
{
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
}
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
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)
{
@@ -37,4 +49,36 @@ public sealed class LdapOpcUaUserAuthenticator(
return OpcUaUserAuthResult.Deny("Authentication backend error");
}
}
/// <summary>
/// Resolves the user's roles from their LDAP groups via the scoped
/// <see cref="IGroupRoleMapper{TRole}"/>, 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.
/// </summary>
/// <param name="groups">The LDAP groups returned by the directory.</param>
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
/// <param name="username">The login name, for diagnostics.</param>
/// <param name="ct">Cancellation token.</param>
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
var roles = new HashSet<string>(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;
}
}
}
+10 -3
View File
@@ -20,6 +20,7 @@ using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -101,9 +102,15 @@ if (hasDriver)
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers this) ends up
// with exactly one descriptor; on a driver-only node this is the sole registration.
builder.Services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up
// with exactly one descriptor; on a driver-only node these are the sole registrations.
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
// <string> per call to turn the directory's groups into roles, so register it here for driver-
// only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above.
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -43,7 +43,7 @@ public static class AuthEndpoints
private static async Task<IResult> LoginAsync(
HttpContext http,
ILdapAuthService ldap,
ILdapGroupRoleMappingService roleMappings,
IGroupRoleMapper<string> roleMapper,
CancellationToken ct)
{
var isForm = http.Request.HasFormContentType;
@@ -87,18 +87,25 @@ public static class AuthEndpoints
return Results.Redirect("/login" + qs);
}
// Role resolution now lives behind the shared IGroupRoleMapper<string> seam
// (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
// system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
// LDAP path (the library returns groups, not roles); it is only pre-populated on the
// DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
// survives the move to the mapper.
IReadOnlyList<string> roles = result.Roles;
try
{
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
roles = RoleMapper.Merge(result.Roles, dbRows);
var mapping = await roleMapper.MapAsync(result.Groups, ct);
roles = Union(result.Roles, mapping.Roles);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
// A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
// pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
http.RequestServices.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
.LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
}
var claims = new List<Claim>
@@ -119,6 +126,21 @@ public static class AuthEndpoints
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
}
/// <summary>
/// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
/// legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
/// FleetAdmin grant) into the mapper-resolved set.
/// </summary>
/// <param name="first">The first role set (pre-resolved baseline).</param>
/// <param name="second">The second role set (mapper output).</param>
private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
{
var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
foreach (var role in second)
roles.Add(role);
return [.. roles];
}
private static IResult Ping(HttpContext http) =>
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
@@ -1,183 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <summary>
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
/// </summary>
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
: ILdapAuthService
{
private readonly LdapOptions _options = options.Value;
/// <summary>Authenticates a user via LDAP bind and retrieves their group memberships and roles.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to validate against the LDAP directory.</param>
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
public async Task<LdapAuthResult> 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, null, [], [], "Password is required");
if (_options.DevStubMode)
{
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
}
if (!_options.UseTls && !_options.AllowInsecureLdap)
return new(false, null, username, [], [],
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
try
{
using var conn = new LdapConnection();
if (_options.UseTls) conn.SecureSocketLayer = true;
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
var bindDn = await ResolveUserDnAsync(conn, username, ct);
await Task.Run(() => conn.Bind(bindDn, password), ct);
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
var displayName = username;
var groups = new List<string>();
try
{
var filter = $"(cn={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
typesOnly: false), ct);
while (results.HasMore())
{
try
{
var entry = results.Next();
var name = entry.GetAttribute(_options.DisplayNameAttribute);
if (name is not null) displayName = name.StringValue;
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
if (groupAttr is not null)
{
foreach (var groupDn in groupAttr.StringValueArray)
groups.Add(ExtractFirstRdnValue(groupDn));
}
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
// directory doesn't populate memberOf (or populates it differently), the
// user's primary group name is recoverable from the second RDN of the DN.
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
{
var primary = ExtractOuSegment(entry.Dn);
if (primary is not null) groups.Add(primary);
}
}
catch (LdapException) { break; } // no-more-entries signalled by exception
}
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
}
conn.Disconnect();
var roles = RoleMapper.Map(groups, _options.GroupToRole);
return new(true, displayName, username, groups, roles, null);
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
return new(false, null, username, [], [], "Invalid username or password");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
return new(false, null, username, [], [], "Unexpected authentication error");
}
}
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
{
if (username.Contains('=')) return username; // already a DN
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
{
await Task.Run(() =>
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
if (results.HasMore())
return results.Next().Dn;
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry for {filter}");
}
return string.IsNullOrWhiteSpace(_options.SearchBase)
? $"cn={username}"
: $"cn={username},{_options.SearchBase}";
}
/// <summary>Escapes special characters in an LDAP filter string according to RFC 4515.</summary>
/// <param name="input">The unescaped string to escape.</param>
/// <returns>The escaped LDAP filter string.</returns>
internal static string EscapeLdapFilter(string input) =>
input.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
/// <summary>
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
/// </summary>
/// <param name="dn">The distinguished name to extract the OU from.</param>
/// <returns>The extracted OU value, or null if no OU segment is found.</returns>
internal static string? ExtractOuSegment(string dn)
{
var segments = dn.Split(',');
foreach (var segment in segments)
{
var trimmed = segment.Trim();
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
return trimmed[3..];
}
return null;
}
/// <summary>Extracts the value portion of the first RDN (relative distinguished name) from a DN.</summary>
/// <param name="dn">The distinguished name to extract from.</param>
/// <returns>The value of the first RDN.</returns>
internal static string ExtractFirstRdnValue(string dn)
{
var equalsIdx = dn.IndexOf('=');
if (equalsIdx < 0) return dn;
var valueStart = equalsIdx + 1;
var commaIdx = dn.IndexOf(',', valueStart);
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
}
}
@@ -1,3 +1,6 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using LibLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <summary>
@@ -5,6 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
/// <c>C:\publish\glauth\auth.md</c>).
/// </summary>
/// <remarks>
/// Carries both the wire fields the shared <c>ZB.MOM.WW.Auth.Ldap</c> directory client needs
/// (<see cref="Server"/>/<see cref="Port"/>/<see cref="Transport"/>/…) AND the app-only concerns
/// the shared library has no notion of (<see cref="Enabled"/> master switch,
/// <see cref="DevStubMode"/> dev bypass, <see cref="GroupToRole"/> appsettings role baseline).
/// The app wrapper (<c>OtOpcUaLdapAuthService</c>) projects this onto the library's
/// <see cref="LibLdapOptions"/> at construction; see <see cref="ToLibraryOptions"/>.
/// </remarks>
public sealed class LdapOptions
{
public const string SectionName = "Security:Ldap";
@@ -18,14 +29,21 @@ public sealed class LdapOptions
/// <summary>Gets or sets the LDAP server port.</summary>
public int Port { get; set; } = 3893;
/// <summary>Gets or sets a value indicating whether to use TLS for LDAP connection.</summary>
public bool UseTls { get; set; }
/// <summary>
/// Transport security for the LDAP connection — <see cref="LdapTransport.Ldaps"/> (implicit
/// TLS), <see cref="LdapTransport.StartTls"/> (upgrade), or <see cref="LdapTransport.None"/>
/// (plaintext, dev/test only — requires <see cref="AllowInsecure"/>). Replaces the former
/// <c>UseTls</c> bool (Task 1.4): <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
/// <c>false</c>→<see cref="LdapTransport.None"/>.
/// </summary>
public LdapTransport Transport { get; set; } = LdapTransport.None;
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
public bool AllowInsecureLdap { get; set; }
/// <summary>Dev-only escape hatch — must be <c>false</c> in production. Maps to the shared
/// library's <see cref="LibLdapOptions.AllowInsecure"/> (renamed from <c>AllowInsecureLdap</c>).</summary>
public bool AllowInsecure { get; set; }
/// <summary>
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
/// Dev-only stub: when <c>true</c>, <see cref="OtOpcUaLdapAuthService"/> bypasses the real LDAP
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
/// </summary>
@@ -62,4 +80,26 @@ public sealed class LdapOptions
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
/// </summary>
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
/// (<see cref="DevStubMode"/>, <see cref="GroupToRole"/>) have no library counterpart and are
/// handled by the app wrapper around the library service; <see cref="Enabled"/> is carried
/// through so the library's own feature gate stays consistent with the app master switch.
/// </summary>
public LibLdapOptions ToLibraryOptions() => new()
{
Enabled = Enabled,
Server = Server,
Port = Port,
Transport = Transport,
AllowInsecure = AllowInsecure,
SearchBase = SearchBase,
ServiceAccountDn = ServiceAccountDn,
ServiceAccountPassword = ServiceAccountPassword,
UserNameAttribute = UserNameAttribute,
DisplayNameAttribute = DisplayNameAttribute,
GroupAttribute = GroupAttribute,
};
}
@@ -0,0 +1,134 @@
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;
/// <summary>
/// OtOpcUa's application <see cref="ILdapAuthService"/> — a thin wrapper around the shared
/// <c>ZB.MOM.WW.Auth.Ldap</c> directory client that adds the two app-only concerns the shared
/// library deliberately does not model:
/// <list type="number">
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants a FleetAdmin
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
/// against a machine with no directory.</item>
/// </list>
/// On the real path it delegates to the library <see cref="LibLdapAuthService"/> and adapts the
/// library result (which returns <em>groups</em>, never roles) back onto the app's
/// <see cref="LdapAuthResult"/> shape. Role resolution itself now lives downstream in
/// <see cref="OtOpcUaGroupRoleMapper"/> (the <c>IGroupRoleMapper&lt;string&gt;</c> seam), which
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper.
/// </summary>
/// <remarks>
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
/// DevStub result mirrors the legacy bespoke service exactly (group <c>"dev"</c>, role
/// <c>"FleetAdmin"</c>) so behaviour is preserved bit-for-bit on dev nodes.
/// </remarks>
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
{
private readonly LdapOptions _options;
private readonly LibILdapAuthService _inner;
private readonly ILogger<OtOpcUaLdapAuthService> _logger;
/// <summary>
/// Production constructor: builds the shared-library directory client from the wire fields
/// of the bound app <see cref="LdapOptions"/>.
/// </summary>
/// <param name="options">The app LDAP options (wire fields + app-only concerns).</param>
/// <param name="logger">The logger.</param>
public OtOpcUaLdapAuthService(IOptions<LdapOptions> options, ILogger<OtOpcUaLdapAuthService> logger)
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
{
}
/// <summary>
/// Seam constructor: accepts an injected library <see cref="LibILdapAuthService"/> so the
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
/// </summary>
/// <param name="options">The app LDAP options.</param>
/// <param name="inner">The shared-library directory client to delegate the real path to.</param>
/// <param name="logger">The logger.</param>
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger<OtOpcUaLdapAuthService> logger)
{
_options = options;
_inner = inner;
_logger = logger;
}
/// <inheritdoc />
public async Task<LdapAuthResult> 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 FleetAdmin WITHOUT a real bind.
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
// survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly.
_logger.LogWarning(
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
return new(true, username, username, ["dev"], ["FleetAdmin"], 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 (Task 1.4). 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);
}
/// <summary>
/// Maps the shared-library <see cref="LibLdapAuthResult"/> onto the app's
/// <see cref="LdapAuthResult"/>. The library returns groups (never roles) on success, so
/// <see cref="LdapAuthResult.Roles"/> is left empty on the delegated path — role resolution
/// happens downstream in the mapper. Library <see cref="LdapAuthFailure"/> codes are folded
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
/// </summary>
/// <param name="result">The library authentication result.</param>
/// <param name="username">The login name, used to populate the app result's Username field.</param>
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
{
if (result.Succeeded)
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
return new(false, null, username, [], [], FailureToError(result.Failure));
}
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
/// <param name="failure">The library failure code (null defensively treated as a generic error).</param>
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",
};
}
@@ -36,11 +36,13 @@ public static class ServiceCollectionExtensions
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
services.AddSingleton<JwtTokenService>();
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
// TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
// LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
// driver path) ends up with exactly one descriptor regardless of registration order.
services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
// Shared ZB.MOM.WW.Auth group→role mapper seam (Task 1.1, additive). Wraps the existing
// RoleMapper.Map + RoleMapper.Merge logic; the login flow is rewired to consume it in a
@@ -12,8 +12,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
</ItemGroup>
<ItemGroup>
@@ -1,24 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
/// backend exceptions into a denial rather than letting them escape into the SDK.
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather
/// than letting them escape into the SDK.
/// </summary>
public sealed class LdapOpcUaUserAuthenticatorTests
{
/// <summary>Verifies that successful LDAP authentication returns Allow result with user roles.</summary>
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
[Fact]
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
{
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
// group "configeditor" -> "ConfigEditor".
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
@@ -27,12 +35,45 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.Roles.ShouldBe(new[] { "ConfigEditor" });
}
/// <summary>The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
[Fact]
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
{
// DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to
// nothing, so the union is exactly {FleetAdmin}.
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
var mapper = new FakeMapper(_ => Array.Empty<string>());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "FleetAdmin" });
}
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
[Fact]
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
{
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "FleetAdmin" });
}
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
@@ -45,7 +86,8 @@ public sealed class LdapOpcUaUserAuthenticatorTests
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
{
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
@@ -58,8 +100,9 @@ public sealed class LdapOpcUaUserAuthenticatorTests
[Fact]
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
{
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
@@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.DisplayName.ShouldBe("alice");
}
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
{
var services = new ServiceCollection();
services.AddScoped(_ => mapper);
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
/// <summary>Test fake implementation of LDAP authentication service.</summary>
private sealed class FakeLdap : ILdapAuthService
{
@@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
/// <summary>Test fake group→role mapper driven by a delegate over the supplied groups.</summary>
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
{
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
/// <param name="groups">The LDAP groups to map.</param>
/// <param name="ct">The cancellation token.</param>
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
}
}
@@ -178,14 +178,16 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
if (harness.Mode.UseRealLdap)
{
configOverrides["Authentication:Ldap:Enabled"] = "true";
configOverrides["Authentication:Ldap:Server"] = "localhost";
configOverrides["Authentication:Ldap:Port"] = "3894";
configOverrides["Authentication:Ldap:UseTls"] = "false";
configOverrides["Authentication:Ldap:AllowInsecureLdap"] = "true";
configOverrides["Authentication:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
configOverrides["Authentication:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
configOverrides["Authentication:Ldap:ServiceAccountPassword"] = "ldapadmin";
// Bound section is Security:Ldap (see LdapOptions.SectionName); Transport replaces the
// old UseTls bool and AllowInsecure replaces AllowInsecureLdap (Task 1.4).
configOverrides["Security:Ldap:Enabled"] = "true";
configOverrides["Security:Ldap:Server"] = "localhost";
configOverrides["Security:Ldap:Port"] = "3894";
configOverrides["Security:Ldap:Transport"] = "None";
configOverrides["Security:Ldap:AllowInsecure"] = "true";
configOverrides["Security:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
configOverrides["Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
configOverrides["Security:Ldap:ServiceAccountPassword"] = "ldapadmin";
}
builder.Configuration.AddInMemoryCollection(configOverrides);
@@ -1,46 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
public sealed class LdapHelperTests
{
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
/// <param name="input">The input string.</param>
/// <param name="expected">The expected escaped output.</param>
[Theory]
[InlineData("joe", "joe")]
[InlineData("jo*e", "jo\\2ae")]
[InlineData("jo(e", "jo\\28e")]
[InlineData("jo)e", "jo\\29e")]
[InlineData("jo\\e", "jo\\5ce")]
public void EscapeLdapFilter_escapes_special_chars(string input, string expected)
{
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
}
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected organizational unit value.</param>
[Theory]
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
[InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")]
public void ExtractOuSegment_returns_first_ou(string dn, string? expected)
{
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
}
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected RDN value.</param>
[Theory]
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=Admins", "Admins")]
[InlineData("Admins", "Admins")]
public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected)
{
LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected);
}
}
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure;
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// Task 1.2 — proves <see cref="OtOpcUaLdapAuthService"/> (the app's ILdapAuthService wrapper over
/// the shared <c>ZB.MOM.WW.Auth.Ldap</c> service) preserves the two app-only concerns the library
/// does not model: the <c>Enabled</c> master switch and the <c>DevStubMode</c> bypass. Both must
/// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result
/// (groups, never roles) onto the app result shape with roles left for the downstream mapper.
/// </summary>
public sealed class OtOpcUaLdapAuthServiceTests
{
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
/// <summary>DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.</summary>
[Fact]
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
{
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Username.ShouldBe("anyone");
result.Groups.ShouldBe(new[] { "dev" });
result.Roles.ShouldBe(new[] { "FleetAdmin" });
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
}
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
[Fact]
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner);
var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldBe("LDAP authentication is disabled.");
inner.Called.ShouldBeFalse("a disabled provider must never touch the network");
}
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
/// downstream mapper (the library returns groups, not roles).</summary>
[Fact]
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
{
var inner = new RecordingLibService(
LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" }));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
inner);
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
inner.Called.ShouldBeTrue();
result.Success.ShouldBeTrue();
result.Username.ShouldBe("alice");
result.DisplayName.ShouldBe("Alice User");
result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" });
result.Roles.ShouldBeEmpty();
}
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
[Fact]
public async Task Real_path_failure_folds_into_error()
{
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
inner);
var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None);
inner.Called.ShouldBeTrue();
result.Success.ShouldBeFalse();
result.Error.ShouldBe("Invalid username or password");
}
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
[Fact]
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false },
inner);
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error!.ShouldContain("Insecure LDAP is disabled");
inner.Called.ShouldBeFalse();
}
/// <summary>Empty username/password are rejected up front without a library call.</summary>
[Theory]
[InlineData("", "pw")]
[InlineData("user", "")]
public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw)
{
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner);
var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None);
result.Success.ShouldBeFalse();
inner.Called.ShouldBeFalse();
}
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
{
public bool Called { get; private set; }
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
Called = true;
return Task.FromResult(result);
}
}
}