diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
index bc89047..f143205 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs
@@ -19,6 +19,17 @@ public sealed record LdapOptions
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
+ ///
+ /// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
+ /// fails with a system-side error (connect/TLS, service-account bind, or search).
+ /// Each entry is "host" (adopts ) or "host:port".
+ /// Empty (the default) disables failover entirely — single-endpoint behavior is
+ /// unchanged. The TLS validation policy (
+ /// / ) applies to every endpoint. Endpoint preference is
+ /// sticky: the service keeps using the last endpoint that answered until it fails.
+ ///
+ public IReadOnlyList FallbackServers { get; init; } = Array.Empty();
+
///
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
/// / transports. When
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs
index 71102b0..c5fdf08 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.Auth.Ldap;
///
///
/// Validation is skipped entirely when is false
-/// (a disabled provider's connection fields are inert). When enabled, four conditions
+/// (a disabled provider's connection fields are inert). When enabled, five conditions
/// are enforced:
///
/// - plaintext transport () is rejected unless
@@ -19,7 +19,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
///
- must be specified (the DN root every
/// search runs against);
/// - must be specified — an empty value
-/// would bind anonymously, defeating the search-then-bind authentication flow.
+/// would bind anonymously, defeating the search-then-bind authentication flow;
+/// - every entry must parse as
+/// "host" or "host:port" — a typo'd backup DC must not lie dormant
+/// until the primary outage it exists to survive.
///
///
public sealed class LdapOptionsValidator : IValidateOptions
@@ -68,6 +71,21 @@ public sealed class LdapOptionsValidator : IValidateOptions
"(e.g. \"cn=svc,dc=example,dc=com\").");
}
+ // Fallback endpoints are walked by LdapAuthService on system-side failures. A
+ // malformed entry would be silently skipped there (the walk degrades to the
+ // parseable subset rather than throwing), so it must be caught at boot instead —
+ // otherwise an operator's typo'd backup DC would only surface as a failed
+ // failover during the primary outage it was meant to survive.
+ foreach (var entry in options.FallbackServers)
+ {
+ if (!Internal.LdapEndpointParser.TryParse(entry, options.Port, out _, out var error))
+ {
+ return ValidateOptionsResult.Fail(
+ $"{nameof(LdapOptions.FallbackServers)} contains an invalid entry — {error}. " +
+ "Each entry must be \"host\" or \"host:port\" (port 1–65535).");
+ }
+ }
+
return ValidateOptionsResult.Success;
}
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs
index 6535bff..144a64e 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs
@@ -88,4 +88,32 @@ public class LdapOptionsValidatorTests
ServiceAccountDn = "",
})
.Failed);
+
+ [Fact]
+ public void Validator_Succeeds_WithValidFallbackServers() =>
+ Assert.False(new LdapOptionsValidator()
+ .Validate(null, Opts() with { FallbackServers = new[] { "dc2", "dc3:3894" } })
+ .Failed);
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(":636")]
+ [InlineData("dc2:0")]
+ [InlineData("dc2:notaport")]
+ public void Validator_Fails_OnMalformedFallbackServer(string entry)
+ {
+ var result = new LdapOptionsValidator()
+ .Validate(null, Opts() with { FallbackServers = new[] { entry } });
+
+ Assert.True(result.Failed);
+ Assert.Contains(nameof(LdapOptions.FallbackServers), result.FailureMessage);
+ }
+
+ [Fact]
+ public void Validator_Skips_FallbackServers_WhenDisabled() =>
+ // Same inert-when-disabled rule as every other connection field: a disabled
+ // provider never opens a connection, so its fallback list is never walked.
+ Assert.False(new LdapOptionsValidator()
+ .Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
+ .Failed);
}