From 711e8beb2b8623a2b9c2f7da849c487575489f70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 1 Oct 2026 00:47:04 -0400 Subject: [PATCH] feat(auth): LdapBindMode.User options and validation --- .../Ldap/LdapContracts.cs | 35 +++++++++++++++++++ .../LdapOptionsValidator.cs | 33 +++++++++++++++-- .../ServiceCollectionExtensionsTests.cs | 26 ++++++++++++++ .../LdapOptionsValidatorTests.cs | 34 ++++++++++++++++++ 4 files changed, 125 insertions(+), 3 deletions(-) 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); + } }