diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
new file mode 100644
index 00000000..dd01aa3a
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs
@@ -0,0 +1,28 @@
+using ZB.MOM.WW.Configuration;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Fail-fast startup validator for , built on the shared
+/// ZB.MOM.WW.Configuration . When LDAP login
+/// is enabled, Server and SearchBase must be set and Port must be a valid
+/// TCP port; when disabled, 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.
+///
+public sealed class LdapOptionsValidator : OptionsValidatorBase
+{
+ ///
+ protected override void Validate(ValidationBuilder builder, LdapOptions options)
+ {
+ if (!options.Enabled) return;
+
+ builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server),
+ "Ldap:Server is required when LDAP login is enabled.");
+ builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase),
+ "Ldap:SearchBase is required when LDAP login is enabled.");
+ builder.Port(options.Port, "Ldap:Port");
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index f0bacbd0..7bdfe959 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Host;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
using ZB.MOM.WW.OtOpcUa.Host.Health;
@@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.Telemetry.Serilog;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
@@ -96,7 +98,7 @@ if (hasDriver)
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService().CreateLogger()));
builder.Services.AddSingleton(sp => sp.GetRequiredService());
- builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap"));
+ builder.Services.AddValidatedOptions(builder.Configuration, "Ldap");
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
index 9317e6c4..6e540e7d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
@@ -32,6 +32,7 @@
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs
new file mode 100644
index 00000000..8ab750ea
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs
@@ -0,0 +1,84 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Task 3 — verifies the net-new (built on the shared
+/// ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder) gates on
+/// , and that when enabled it requires Server,
+/// SearchBase, and a valid Port. Failure messages carry the real "Ldap:"
+/// section prefix so they read correctly when surfaced at host startup.
+///
+public sealed class LdapOptionsValidatorTests
+{
+ private static readonly LdapOptionsValidator Sut = new();
+
+ /// Valid enabled options pass validation.
+ [Fact]
+ public void Valid_enabled_options_succeed()
+ {
+ var options = new LdapOptions
+ {
+ Enabled = true,
+ Server = "ldap",
+ SearchBase = "dc=x",
+ Port = 389,
+ };
+
+ Sut.Validate(null, options).Succeeded.ShouldBeTrue();
+ }
+
+ /// When LDAP is disabled all checks are skipped, so a blank config still passes.
+ [Fact]
+ public void Disabled_options_succeed_even_when_blank()
+ {
+ var options = new LdapOptions
+ {
+ Enabled = false,
+ Server = string.Empty,
+ SearchBase = string.Empty,
+ Port = 0,
+ };
+
+ Sut.Validate(null, options).Succeeded.ShouldBeTrue();
+ }
+
+ /// Enabled with a blank server reports the required-server failure.
+ [Fact]
+ public void Enabled_with_blank_server_fails()
+ {
+ var options = new LdapOptions
+ {
+ Enabled = true,
+ Server = string.Empty,
+ SearchBase = "dc=x",
+ Port = 389,
+ };
+
+ var result = Sut.Validate(null, options);
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("Ldap:Server 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()
+ {
+ var options = new LdapOptions
+ {
+ Enabled = true,
+ Server = "ldap",
+ SearchBase = "dc=x",
+ Port = 0,
+ };
+
+ var result = Sut.Validate(null, options);
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("Ldap:Port must be between 1 and 65535 (was 0)");
+ }
+}