diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor
index 4ba3abfa..af926189 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor
@@ -20,9 +20,9 @@
LDAP binding
Enabled@(_options.Enabled ? "yes" : "no")
Server@_options.Server:@_options.Port
- UseTls@_options.UseTls
+ Transport@_options.Transport
SearchBase@_options.SearchBase
- @if (!_options.UseTls && _options.AllowInsecureLdap)
+ @if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure)
{
WarningPlaintext credentials over LDAP — dev mode only
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
index 0d3f0377..e95377c8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
@@ -10,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// TCP port; when disabled — or when DevStubMode bypasses the real bind — all checks are
/// skipped. ServiceAccountDn/Password are
/// intentionally not required — an empty pair selects the direct-bind path (see
-/// ). Failure messages use "Ldap:" as a
+/// ). The plaintext-transport-without-AllowInsecure
+/// guard is enforced at the auth boundary () 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 "Ldap:" as a
/// human-readable field prefix — not the literal bound section path, which is
/// Security:Ldap (see ).
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs
index 45410dbe..62858193 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs
@@ -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 adapter that bridges OPC UA UserName
/// tokens to the same 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 OperationContext.UserIdentity downstream.
+/// session identities. Roles are resolved through the shared
+/// 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.
///
+///
+/// This authenticator is registered as a singleton, but
+/// (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.
+///
public sealed class LdapOpcUaUserAuthenticator(
ILdapAuthService ldap,
+ IServiceScopeFactory scopeFactory,
ILogger logger)
: IOpcUaUserAuthenticator
{
- /// Authenticates an OPC UA UserName token via LDAP.
+ /// Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.
/// The username to authenticate.
/// The password to authenticate.
/// Cancellation token.
@@ -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");
}
}
+
+ ///
+ /// Resolves the user's roles from their LDAP groups via the scoped
+ /// , 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.
+ ///
+ /// The LDAP groups returned by the directory.
+ /// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).
+ /// The login name, for diagnostics.
+ /// Cancellation token.
+ private async Task> ResolveRolesAsync(
+ IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct)
+ {
+ try
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var mapper = scope.ServiceProvider.GetRequiredService>();
+ var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
+
+ var roles = new HashSet(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;
+ }
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 82dcf61b..740e0c88 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -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(sp => sp.GetRequiredService());
builder.Services.AddValidatedOptions(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();
+ // 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
+ // 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();
+ builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>();
builder.Services.AddSingleton();
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs
index 4b53b524..43de033e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs
@@ -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 LoginAsync(
HttpContext http,
ILdapAuthService ldap,
- ILdapGroupRoleMappingService roleMappings,
+ IGroupRoleMapper 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 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 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()?
.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
@@ -119,6 +126,21 @@ public static class AuthEndpoints
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
}
+ ///
+ /// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
+ /// legacy RoleMapper.Merge applied. Used to fold any pre-resolved roles (the DevStub
+ /// FleetAdmin grant) into the mapper-resolved set.
+ ///
+ /// The first role set (pre-resolved baseline).
+ /// The second role set (mapper output).
+ private static IReadOnlyList Union(IReadOnlyList first, IReadOnlyList second)
+ {
+ var roles = new HashSet(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();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs
deleted file mode 100644
index bf3e4ea7..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs
+++ /dev/null
@@ -1,183 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Novell.Directory.Ldap;
-
-namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
-
-///
-/// LDAP bind-and-search authentication mirrored from ScadaLink's LdapAuthService
-/// (CLAUDE.md memory: scadalink_reference.md) — 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 ).
-///
-public sealed class LdapAuthService(IOptions options, ILogger logger)
- : ILdapAuthService
-{
- private readonly LdapOptions _options = options.Value;
-
- /// Authenticates a user via LDAP bind and retrieves their group memberships and roles.
- /// The username to authenticate.
- /// The password to validate against the LDAP directory.
- /// A cancellation token to observe while waiting for the operation to complete.
- 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, 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();
-
- 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 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}";
- }
-
- /// Escapes special characters in an LDAP filter string according to RFC 4515.
- /// The unescaped string to escape.
- /// The escaped LDAP filter string.
- internal static string EscapeLdapFilter(string input) =>
- input.Replace("\\", "\\5c")
- .Replace("*", "\\2a")
- .Replace("(", "\\28")
- .Replace(")", "\\29")
- .Replace("\0", "\\00");
-
- ///
- /// Pulls the first ou=Value segment from a DN. GLAuth encodes a user's primary
- /// group as an ou= RDN immediately above the user's cn=, so this recovers
- /// the group name when is absent from the entry.
- ///
- /// The distinguished name to extract the OU from.
- /// The extracted OU value, or null if no OU segment is found.
- 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;
- }
-
- /// Extracts the value portion of the first RDN (relative distinguished name) from a DN.
- /// The distinguished name to extract from.
- /// The value of the first RDN.
- 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..];
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs
index 102671c0..96238de5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs
@@ -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;
///
@@ -5,6 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// Security:Ldap section. Defaults point at the local GLAuth dev instance (see
/// C:\publish\glauth\auth.md).
///
+///
+/// Carries both the wire fields the shared ZB.MOM.WW.Auth.Ldap directory client needs
+/// (///…) AND the app-only concerns
+/// the shared library has no notion of ( master switch,
+/// dev bypass, appsettings role baseline).
+/// The app wrapper (OtOpcUaLdapAuthService) projects this onto the library's
+/// at construction; see .
+///
public sealed class LdapOptions
{
public const string SectionName = "Security:Ldap";
@@ -18,14 +29,21 @@ public sealed class LdapOptions
/// Gets or sets the LDAP server port.
public int Port { get; set; } = 3893;
- /// Gets or sets a value indicating whether to use TLS for LDAP connection.
- public bool UseTls { get; set; }
+ ///
+ /// Transport security for the LDAP connection — (implicit
+ /// TLS), (upgrade), or
+ /// (plaintext, dev/test only — requires ). Replaces the former
+ /// UseTls bool (Task 1.4): true→,
+ /// false→.
+ ///
+ public LdapTransport Transport { get; set; } = LdapTransport.None;
- /// Dev-only escape hatch — must be false in production.
- public bool AllowInsecureLdap { get; set; }
+ /// Dev-only escape hatch — must be false in production. Maps to the shared
+ /// library's (renamed from AllowInsecureLdap).
+ public bool AllowInsecure { get; set; }
///
- /// Dev-only stub: when true, bypasses the real LDAP
+ /// Dev-only stub: when true, 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 false in production.
///
@@ -62,4 +80,26 @@ public sealed class LdapOptions
/// "ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"
///
public Dictionary GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Projects the wire fields onto the shared ZB.MOM.WW.Auth.Ldap
+ /// the directory client consumes. App-only concerns
+ /// (, ) have no library counterpart and are
+ /// handled by the app wrapper around the library service; is carried
+ /// through so the library's own feature gate stays consistent with the app master switch.
+ ///
+ 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,
+ };
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs
new file mode 100644
index 00000000..9bfcb22d
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs
@@ -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;
+
+///
+/// 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 a FleetAdmin
+/// 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 FleetAdmin grant survives the move to the mapper.
+///
+///
+/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
+/// DevStub result mirrors the legacy bespoke service exactly (group "dev", role
+/// "FleetAdmin") so behaviour is preserved bit-for-bit on dev nodes.
+///
+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 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);
+ }
+
+ ///
+ /// 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);
+
+ return new(false, null, username, [], [], FailureToError(result.Failure));
+ }
+
+ /// 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",
+ };
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
index fbf412ff..3c809188 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
@@ -36,11 +36,13 @@ public static class ServiceCollectionExtensions
services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName));
services.AddSingleton();
- // 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();
+ services.TryAddSingleton();
// 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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj
index b33345ba..3dcc405a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj
@@ -12,8 +12,8 @@
-
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs
index 9adc49b4..0287f9dc 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs
@@ -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;
///
-/// F13c — verifies faithfully translates
-/// outcomes into OpcUaUserAuthResult and turns LDAP
-/// backend exceptions into a denial rather than letting them escape into the SDK.
+/// Verifies translates app
+/// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups
+/// through the shared 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.
///
public sealed class LdapOpcUaUserAuthenticatorTests
{
- /// Verifies that successful LDAP authentication returns Allow result with user roles.
+ /// 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.
[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.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(), null));
+ var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
+ var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
@@ -27,12 +35,45 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.Roles.ShouldBe(new[] { "ConfigEditor" });
}
+ /// 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.
+ [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());
+ var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
+
+ result.Success.ShouldBeTrue();
+ result.Roles.ShouldBe(new[] { "FleetAdmin" });
+ }
+
+ /// 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.
+ [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.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
+
+ result.Success.ShouldBeTrue();
+ result.Roles.ShouldBe(new[] { "FleetAdmin" });
+ }
+
/// Verifies that LDAP authentication failure returns Deny result with error text.
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password"));
- var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+ var mapper = new FakeMapper(g => g.ToArray());
+ var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.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.Instance);
+ var mapper = new FakeMapper(g => g.ToArray());
+ var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.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(), new[] { "ReadOnly" }, null));
- var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+ var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty(), null));
+ var mapper = new FakeMapper(g => g.ToArray());
+ var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
@@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.DisplayName.ShouldBe("alice");
}
+ /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.
+ private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper)
+ {
+ var services = new ServiceCollection();
+ services.AddScoped(_ => mapper);
+ return services.BuildServiceProvider().GetRequiredService();
+ }
+
/// Test fake implementation of LDAP authentication service.
private sealed class FakeLdap : ILdapAuthService
{
@@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
public Task AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
+
+ /// Test fake group→role mapper driven by a delegate over the supplied groups.
+ private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper
+ {
+ /// Maps groups to roles via the configured delegate; Scope is always null.
+ /// The LDAP groups to map.
+ /// The cancellation token.
+ public Task> MapAsync(IReadOnlyList groups, CancellationToken ct)
+ => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null));
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
index 7a43abfd..8485b498 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
@@ -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);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs
deleted file mode 100644
index c215c044..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs
+++ /dev/null
@@ -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
-{
- /// Verifies that LDAP filter special characters are properly escaped.
- /// The input string.
- /// The expected escaped output.
- [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);
- }
-
- /// Verifies that the first organizational unit segment is correctly extracted from a DN.
- /// The distinguished name.
- /// The expected organizational unit value.
- [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);
- }
-
- /// Verifies that the first RDN value is correctly extracted from various DN formats.
- /// The distinguished name.
- /// The expected RDN value.
- [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);
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs
new file mode 100644
index 00000000..d6c30137
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs
@@ -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;
+
+///
+/// Task 1.2 — proves (the app's ILdapAuthService wrapper over
+/// the shared ZB.MOM.WW.Auth.Ldap service) preserves the two app-only concerns the library
+/// does not model: the Enabled master switch and the DevStubMode 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.
+///
+public sealed class OtOpcUaLdapAuthServiceTests
+{
+ private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
+ new(options, inner, NullLogger.Instance);
+
+ /// DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.
+ [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");
+ }
+
+ /// Enabled=false → denial, no library call (master switch wins over DevStubMode).
+ [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");
+ }
+
+ /// Real path: a library success surfaces its Groups; Roles are left empty for the
+ /// downstream mapper (the library returns groups, not roles).
+ [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();
+ }
+
+ /// Real path: a library failure folds into a fail-closed error string.
+ [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");
+ }
+
+ /// 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.
+ [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();
+ }
+
+ /// Empty username/password are rejected up front without a library call.
+ [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();
+ }
+
+ /// Records whether the library service was invoked and returns a canned result.
+ private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
+ {
+ public bool Called { get; private set; }
+
+ public Task AuthenticateAsync(string username, string password, CancellationToken ct)
+ {
+ Called = true;
+ return Task.FromResult(result);
+ }
+ }
+}