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 f143205..95a2527 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
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
public enum LdapTransport { Ldaps, StartTls, None }
+/// How authenticates a sign-in.
+public enum LdapBindMode
+{
+ /// Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).
+ ServiceAccount,
+
+ ///
+ /// Bind as the signing-in user (identity built from );
+ /// a successful bind authenticates them, and the same connection reads their own entry for groups.
+ /// No service account is used.
+ ///
+ User,
+}
+
public sealed record LdapOptions
{
public bool Enabled { get; init; } = true;
@@ -19,6 +33,27 @@ public sealed record LdapOptions
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
+ ///
+ /// Selects the authentication flow. (the default) is the
+ /// 0.3.0 bind-then-search-then-bind flow; binds as the user and needs
+ /// no / .
+ ///
+ public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
+
+ ///
+ /// only: the simple-bind identity, with {0} replaced by the
+ /// normalised login name — e.g. {0}@example.com (UPN) or EXAMPLE\{0}. Must contain
+ /// {0} exactly once.
+ ///
+ public string UserBindTemplate { get; init; } = "";
+
+ ///
+ /// only: when (default) a login typed as
+ /// DOMAIN\name or name@suffix is reduced to name; when
+ /// such input is refused as bad credentials without contacting the directory.
+ ///
+ public bool AcceptDomainQualifiedNames { get; init; } = true;
+
///
/// 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).
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 c5fdf08..ed4c26f 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
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// - must be specified (no sane default host);
/// - 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;
+/// - in mode
+/// must be specified — an empty value would bind anonymously, defeating the search-then-bind
+/// authentication flow; in mode
+/// must contain {0} exactly once instead;
/// - 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.
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
}
- if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
+ if (options.BindMode == LdapBindMode.User)
+ {
+ // User mode binds as the signing-in user, so there is no service account to require.
+ // The template is the bind identity: without exactly one {0} every login would bind as
+ // the same fixed name (or as a name with the user spliced in twice).
+ if (CountPlaceholders(options.UserBindTemplate) != 1)
+ {
+ return ValidateOptionsResult.Fail(
+ $"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
+ $"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
+ "or \"EXAMPLE\\{0}\".");
+ }
+ }
+ else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions
return ValidateOptionsResult.Success;
}
+
+ private static int CountPlaceholders(string? template)
+ {
+ if (string.IsNullOrEmpty(template))
+ return 0;
+
+ var count = 0;
+ for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
+ i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
+ count++;
+ return count;
+ }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
index e59fd30..cb2c958 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
await host.StartAsync();
await host.StopAsync();
}
+
+ [Fact]
+ public void AddZbLdapAuth_BindsUserBindModeKeys()
+ {
+ IConfiguration config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{LdapSection}:Server"] = LdapServer,
+ [$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
+ [$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
+ [$"{LdapSection}:BindMode"] = "User",
+ [$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
+ [$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
+ })
+ .Build();
+ var services = new ServiceCollection();
+
+ services.AddZbLdapAuth(config, LdapSection);
+
+ using ServiceProvider provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.Equal(LdapBindMode.User, options.BindMode);
+ Assert.Equal("{0}@corp.test", options.UserBindTemplate);
+ Assert.False(options.AcceptDomainQualifiedNames);
+ }
}
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 144a64e..5b63af7 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
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
.Failed);
+
+ private static LdapOptions UserOpts() => Opts() with
+ {
+ BindMode = LdapBindMode.User,
+ UserBindTemplate = "{0}@corp.test",
+ ServiceAccountDn = "",
+ ServiceAccountPassword = "",
+ };
+
+ [Fact]
+ public void Defaults_KeepServiceAccountMode()
+ {
+ var o = new LdapOptions();
+ Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
+ Assert.Equal("", o.UserBindTemplate);
+ Assert.True(o.AcceptDomainQualifiedNames);
+ }
+
+ [Fact]
+ public void Validator_UserMode_DoesNotRequireServiceAccount() =>
+ Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("jdoe@corp.test")]
+ [InlineData("{0}{0}@corp.test")]
+ public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
+ {
+ var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
+
+ Assert.True(result.Failed);
+ Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
+ }
}