diff --git a/CLAUDE.md b/CLAUDE.md
index 19038be..75fa909 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -159,7 +159,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
-| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
+| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.0`** (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props
index 7e57b98..d8cc15d 100644
--- a/ZB.MOM.WW.Auth/Directory.Build.props
+++ b/ZB.MOM.WW.Auth/Directory.Build.props
@@ -5,12 +5,10 @@
enable
enable
latest
-
- 0.3.0
+
+ 0.4.0
true
diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md
index 1fc55f3..2be3643 100644
--- a/ZB.MOM.WW.Auth/README.md
+++ b/ZB.MOM.WW.Auth/README.md
@@ -30,7 +30,16 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning
-All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
+All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
+
+### 0.4.0 — LDAP sign-in by user bind (opt-in)
+
+- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `{0}@example.com` or `EXAMPLE\{0}`) and **`AcceptDomainQualifiedNames`** (default `true`).
+- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
+- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
+- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling.
+- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
+- `UserBindTemplate` output is used verbatim as the bind name, so use a UPN (`{0}@example.com`) or down-level (`EXAMPLE\{0}`) template; a DN template is not escaped.
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
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/Internal/LdapUserName.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs
new file mode 100644
index 0000000..8c500a6
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs
@@ -0,0 +1,57 @@
+namespace ZB.MOM.WW.Auth.Ldap.Internal;
+
+///
+/// Reduces a typed login to the bare account name used by :
+/// one spelling flows into the bind template, the search filter and the returned username, so audit
+/// rows never carry DOMAIN\jdoe for one sign-in and jdoe for the next.
+///
+internal static class LdapUserName
+{
+ ///
+ /// Trims and, when , strips one
+ /// leading DOMAIN\ or one trailing @suffix. Returns (with
+ /// empty) for blank input, for both forms at once, for a repeated
+ /// separator, for an empty side, or for any qualified form when
+ /// is .
+ ///
+ public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
+ {
+ name = "";
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ var s = input.Trim();
+ var slash = s.IndexOf('\\');
+ var at = s.IndexOf('@');
+
+ if (slash >= 0 || at >= 0)
+ {
+ if (!acceptDomainQualified || (slash >= 0 && at >= 0))
+ return false;
+
+ if (slash >= 0)
+ {
+ if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
+ return false;
+ s = s[(slash + 1)..];
+ }
+ else
+ {
+ // Unlike the slash branch (whose extracted suffix is checked for emptiness by the
+ // final length check below), the extracted piece here is the *prefix* — so an empty
+ // domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
+ if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
+ return false;
+ s = s[..at];
+ }
+
+ s = s.Trim();
+ }
+
+ if (s.Length == 0)
+ return false;
+
+ name = s;
+ return true;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
index bbce996..676cbd6 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
/// is one login starting at a stale endpoint and walking on.
///
+///
+/// With BindMode = User the service-account bind and the user re-bind are replaced by a
+/// single bind as the user (); see .
+///
///
public sealed class LdapAuthService : ILdapAuthService
{
@@ -141,6 +145,18 @@ public sealed class LdapAuthService : ILdapAuthService
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
+ // 3b. User-bind mode guards, before any network call. An empty password must never reach
+ // the directory: AD answers a simple bind with an empty password as an UNAUTHENTICATED
+ // bind and reports success, which would admit anyone. The name is reduced to the bare
+ // account name so the bind template, the search filter and the result share one spelling.
+ if (_options.BindMode == LdapBindMode.User)
+ {
+ if (string.IsNullOrEmpty(password))
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
var preferred = Volatile.Read(ref _preferredIndex);
@@ -235,74 +251,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
}
- // 6. Service-account bind so we can search for the user's DN. A bind failure
- // here is a service-account misconfiguration — DISTINCT from a user-credential
- // failure — so it maps to ServiceAccountBindFailed.
- try
- {
- conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
- }
- catch (LdapException)
- {
- throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
- }
-
- // 7. Search for the user entry by the configured username attribute. A search
- // failure is infrastructure (directory unreachable / unhealthy) — system-side.
- // NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
- // closest system-side bucket until a DirectoryUnavailable value exists.
- IReadOnlyList entries;
- try
- {
- var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
- entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
- }
- catch (LdapException)
- {
- throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
- }
-
- // 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
- // Both are authoritative answers from a healthy directory, so they end the walk.
- // We never attempt a user bind against an ambiguous DN.
- if (entries.Count == 0)
- return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
- if (entries.Count >= 2)
- return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
-
- var entry = entries[0];
-
- // 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
- // here is the end user's bad credentials — the directory answered, so this is
- // returned directly and never retried against another endpoint.
- try
- {
- conn.Bind(entry.Dn, password);
- }
- catch (LdapException)
- {
- return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
- }
-
- // 10. Group extraction. Fail closed: an empty/missing group set, or any error while
- // extracting groups, is a GroupLookupFailed — never a zero-group success. This
- // too is an answer about the user, not about the endpoint, so it ends the walk.
- IReadOnlyList groups;
- try
- {
- groups = ExtractGroups(entry);
- }
- catch
- {
- return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
- }
-
- if (groups.Count == 0)
- return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
-
- // 11. Success — and only here, with a verified password and >= 1 group.
- var displayName = ExtractDisplayName(entry, username);
- return LdapAuthResult.Success(username, displayName, groups);
+ return _options.BindMode == LdapBindMode.User
+ ? CompleteAsUser(conn, username, password)
+ : CompleteWithServiceAccount(conn, username, password);
}
catch (StageFailure stage)
{
@@ -323,6 +274,106 @@ public sealed class LdapAuthService : ILdapAuthService
}
}
+ /// Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.
+ private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
+ {
+ // 6. Service-account bind so we can search for the user's DN. A bind failure here is a
+ // service-account misconfiguration — DISTINCT from a user-credential failure.
+ try
+ {
+ conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
+ }
+
+ // 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
+ var entries = SearchUser(conn, username);
+ if (entries.Count == 0)
+ return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
+ if (entries.Count >= 2)
+ return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
+
+ var entry = entries[0];
+
+ // 9. User bind verifies the password; a failure is the user's bad credentials and final.
+ try
+ {
+ conn.Bind(entry.Dn, password);
+ }
+ catch (LdapException)
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+
+ return SucceedFromEntry(entry, username);
+ }
+
+ ///
+ /// : the user's own bind is the authentication; the same
+ /// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
+ /// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
+ /// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
+ ///
+ private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
+ {
+ var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
+ try
+ {
+ conn.Bind(bindName, password);
+ }
+ catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
+ }
+
+ // After a good bind, anything but exactly one readable entry is a failure to establish the
+ // user's groups — never a success, and never UserNotFound (the directory just accepted them).
+ var entries = SearchUser(conn, username);
+ if (entries.Count != 1)
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+
+ return SucceedFromEntry(entries[0], username);
+ }
+
+ /// Step 7: search for the user entry; a search fault is system-side.
+ private IReadOnlyList SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
+ {
+ try
+ {
+ var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
+ return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
+ }
+ catch (LdapException)
+ {
+ throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
+ }
+ }
+
+ /// Steps 10–11: fail-closed group extraction, then success.
+ private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
+ {
+ IReadOnlyList groups;
+ try
+ {
+ groups = ExtractGroups(entry);
+ }
+ catch
+ {
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+ }
+
+ if (groups.Count == 0)
+ return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
+
+ return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
+ }
+
///
/// Internal control-flow exception that carries an already-mapped
/// out of a stage to the single fail-closed catch site. Never escapes this type.
@@ -337,7 +388,7 @@ public sealed class LdapAuthService : ILdapAuthService
public LdapAuthFailure Failure { get; }
- /// Which stage faulted (connect / service-bind / search), for diagnostics.
+ /// Which stage faulted (connect / service-bind / user-bind / search), for diagnostics.
public string Stage { get; }
}
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/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
index 97ec79d..ddb3e23 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
private bool _throwOnConnect;
private bool _throwOnServiceBind;
private bool _throwOnUserBind;
+ private int? _firstBindResultCode;
+ private bool _throwOnSearch;
// ---- observation -----
@@ -32,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
///
public int BindAttempts { get; private set; }
+ /// Every call in order: connect, bind:<dn>, search:<filter>.
+ public List Calls { get; } = new();
+
// ---- builder methods -----
///
@@ -83,6 +88,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this;
}
+ ///
+ /// Throw with on the
+ /// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
+ /// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
+ ///
+ public FakeLdapConnection ThrowOnFirstBind(int resultCode)
+ {
+ _firstBindResultCode = resultCode;
+ return this;
+ }
+
+ /// Throw from (unhealthy directory).
+ public FakeLdapConnection ThrowOnSearch()
+ {
+ _throwOnSearch = true;
+ return this;
+ }
+
///
/// Throw from to
/// simulate an unreachable directory (infrastructure failure).
@@ -120,6 +143,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
+ Calls.Add("connect");
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
ConnectCertCallback = serverCertificateValidationCallback;
if (_throwOnConnect)
@@ -130,8 +154,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
public void Bind(string dn, string password)
{
BindAttempts++;
+ Calls.Add($"bind:{dn}");
var isServiceBind = BindAttempts == 1;
+ if (isServiceBind && _firstBindResultCode is { } code)
+ throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
+
if ((_throwOnServiceBind && isServiceBind)
|| (_throwOnUserBind && !isServiceBind)
|| _throwBindDns.Contains(dn))
@@ -147,7 +175,13 @@ internal sealed class FakeLdapConnection : ILdapConnection
string searchBase,
string filter,
IReadOnlyList attributes)
- => _scriptedEntries.AsReadOnly();
+ {
+ Calls.Add($"search:{filter}");
+ if (_throwOnSearch)
+ throw new Novell.Directory.Ldap.LdapException(
+ "Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
+ return _scriptedEntries.AsReadOnly();
+ }
public void Dispose() { /* nothing to clean up */ }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs
new file mode 100644
index 0000000..1276c1a
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceUserBindTests.cs
@@ -0,0 +1,181 @@
+using Novell.Directory.Ldap;
+using ZB.MOM.WW.Auth.Abstractions.Ldap;
+
+namespace ZB.MOM.WW.Auth.Ldap.Tests;
+
+///
+/// : bind as the signing-in user, then read their own entry on the
+/// same connection. The service account in is deliberately populated so the
+/// tests prove it is never used.
+///
+public class LdapAuthServiceUserBindTests
+{
+ private static LdapOptions Opts(params string[] fallbacks) => new()
+ {
+ Enabled = true,
+ Server = "dc1",
+ Port = 636,
+ Transport = LdapTransport.Ldaps,
+ SearchBase = "dc=x",
+ UserNameAttribute = "sAMAccountName",
+ DisplayNameAttribute = "displayName",
+ GroupAttribute = "memberOf",
+ BindMode = LdapBindMode.User,
+ UserBindTemplate = "{0}@corp.test",
+ ServiceAccountDn = "cn=svc,dc=x",
+ ServiceAccountPassword = "svcpw",
+ FallbackServers = fallbacks,
+ };
+
+ private static FakeLdapConnection Jane() => new FakeLdapConnection()
+ .WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
+
+ private static Task Login(LdapAuthService svc, string user, string pw = "pw") =>
+ svc.AuthenticateAsync(user, pw, CancellationToken.None);
+
+ [Fact]
+ public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("jdoe", result.Username);
+ Assert.Equal("Jane Doe", result.DisplayName);
+ Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
+ var conn = Assert.Single(factory.Created);
+ Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
+ Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
+ }
+
+ [Theory]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ [InlineData(" jdoe ")]
+ public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), typed);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal("jdoe", result.Username);
+ Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
+ }
+
+ [Fact]
+ public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Theory]
+ [InlineData("@x")]
+ [InlineData(@"a\b\c")]
+ [InlineData("a@b@c")]
+ public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var result = await Login(new LdapAuthService(Opts(), factory), typed);
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Fact]
+ public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane());
+ var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
+
+ var result = await Login(svc, @"NAM\jdoe");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Empty(factory.Created);
+ }
+
+ [Fact]
+ public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
+
+ Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
+ Assert.Single(factory.Created);
+ }
+
+ [Fact]
+ public async Task NonCredentialBindError_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
+ }
+
+ [Fact]
+ public async Task ConnectFailure_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ }
+
+ [Fact]
+ public async Task SearchFailure_FailsOver()
+ {
+ var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(2, factory.Created.Count);
+ }
+
+ [Fact]
+ public async Task EveryEndpointDown_IsSystemSide()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
+ }
+
+ [Fact]
+ public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
+ var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ Assert.Single(factory.Created);
+ }
+
+ [Fact]
+ public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ }
+
+ [Fact]
+ public async Task NoGroups_IsGroupLookupFailed()
+ {
+ var factory = new SequenceLdapConnectionFactory(
+ new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty(), "Jane"));
+ var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
+
+ Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
+ }
+}
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);
+ }
}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs
new file mode 100644
index 0000000..8d0da1b
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.Auth.Ldap.Internal;
+
+namespace ZB.MOM.WW.Auth.Ldap.Tests;
+
+public class LdapUserNameTests
+{
+ [Theory]
+ [InlineData("jdoe")]
+ [InlineData(" jdoe ")]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ public void Accepting_ReducesToBareName(string input)
+ {
+ Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
+ Assert.Equal("jdoe", name);
+ }
+
+ [Theory]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ public void Strict_RefusesQualifiedNames(string input) =>
+ Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
+
+ [Fact]
+ public void Strict_AcceptsBareName()
+ {
+ Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
+ Assert.Equal("jdoe", name);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("@x")]
+ [InlineData(@"\jdoe")]
+ [InlineData("jdoe@")]
+ [InlineData(@"NAM\")]
+ [InlineData(@"a\b\c")]
+ [InlineData("a@b@c")]
+ [InlineData(@"NAM\jdoe@corp.test")]
+ public void Unusable_IsRefused(string? input)
+ {
+ Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
+ Assert.Equal("", name);
+ }
+}