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 c662e96c..0d3f0377 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
@@ -10,8 +10,9 @@ 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 carry the real "Ldap:"
-/// section prefix matching the bound configuration section.
+/// ). Failure messages use "Ldap:" as a
+/// human-readable field prefix — not the literal bound section path, which is
+/// Security:Ldap (see ).
///
public sealed class LdapOptionsValidator : OptionsValidatorBase
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 482e6ddb..82dcf61b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -1,4 +1,5 @@
using Akka.Hosting;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
@@ -100,7 +101,9 @@ if (hasDriver)
builder.Services.AddSingleton(sp => sp.GetRequiredService());
builder.Services.AddValidatedOptions(builder.Configuration, LdapOptions.SectionName);
- builder.Services.AddSingleton();
+ // 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();
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/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs
index f09e4292..bf3e4ea7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs
@@ -21,6 +21,11 @@ public sealed class LdapAuthService(IOptions options, ILoggerA 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))
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
index 9709b78c..fa715648 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -36,7 +37,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
- services.AddSingleton();
+ // 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.AddDataProtection()
.PersistKeysToDbContext()
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs
index 99f8d5ad..1de27607 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs
@@ -83,6 +83,24 @@ public sealed class LdapOptionsValidatorTests
result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled.");
}
+ /// Enabled with a blank search base reports the required-search-base failure.
+ [Fact]
+ public void Enabled_with_blank_search_base_fails()
+ {
+ var options = new LdapOptions
+ {
+ Enabled = true,
+ Server = "ldap",
+ SearchBase = string.Empty,
+ Port = 389,
+ };
+
+ var result = Sut.Validate(null, options);
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled.");
+ }
+
/// Enabled with port 0 reports the port-range failure using the shared primitive wording.
[Fact]
public void Enabled_with_zero_port_fails()