Merge fix/auth-ldap-0.4.1: Auth 0.4.1 (canonical username, empty-password guard in both modes)

This commit is contained in:
Joseph Doherty
2026-10-01 01:31:06 -04:00
9 changed files with 185 additions and 32 deletions
+1 -1
View File
@@ -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.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/) |
| 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. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. 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/) |
+3 -1
View File
@@ -8,7 +8,9 @@
<!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
(UserBindTemplate) and read their own groups on the same connection; no service account.
ServiceAccount stays the default, so existing consumers need no change. -->
<Version>0.4.0</Version>
<!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
<Version>0.4.1</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
+11 -4
View File
@@ -30,16 +30,23 @@ 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.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.
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. 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.1 — LDAP review fixes
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
- No `Abstractions` contract change.
### 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`).
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) 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.
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
- 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.
- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
@@ -42,8 +42,14 @@ public sealed record LdapOptions
/// <summary>
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
/// normalised login name — e.g. <c>{0}@example.com</c> (UPN) or <c>EXAMPLE\{0}</c>. Must contain
/// <c>{0}</c> exactly once.
/// normalised login name. Must contain <c>{0}</c> exactly once.
/// <para>
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
/// group search uses, so the account that binds is the account whose groups are read. A UPN
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
/// </para>
/// </summary>
public string UserBindTemplate { get; init; } = "";
@@ -2,8 +2,11 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
/// one spelling flows into the bind template, the search filter and the returned username, so audit
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next.
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
/// normalised here: the returned username is canonicalised from the directory entry's
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
/// only the fallback when the entry has no such value.
/// </summary>
internal static class LdapUserName
{
@@ -145,16 +145,19 @@ 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)
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
// Refused here, before any network call, so no consumer depends on its own edge check.
if (string.IsNullOrEmpty(password))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
// search filter share. (The returned username is canonicalised from the entry later.)
if (_options.BindMode == LdapBindMode.User
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
{
if (string.IsNullOrEmpty(password))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
@@ -278,7 +281,8 @@ public sealed class LdapAuthService : ILdapAuthService
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.
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
// maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
@@ -288,7 +292,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
}
// 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// 7–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.
var entries = SearchUser(conn, username);
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
@@ -297,7 +303,9 @@ public sealed class LdapAuthService : ILdapAuthService
var entry = entries[0];
// 9. User bind verifies the password; a failure is the user's bad credentials and final.
// 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);
@@ -344,6 +352,10 @@ public sealed class LdapAuthService : ILdapAuthService
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
{
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
// Connect), until a DirectoryUnavailable value exists.
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
@@ -355,7 +367,12 @@ public sealed class LdapAuthService : ILdapAuthService
}
}
/// <summary>Steps 10–11: fail-closed group extraction, then success.</summary>
/// <summary>
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
/// fallback when the entry carries no such value.
/// </summary>
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
{
IReadOnlyList<string> groups;
@@ -371,7 +388,8 @@ public sealed class LdapAuthService : ILdapAuthService
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups);
var canonical = ExtractUserName(entry, username);
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
}
/// <summary>
@@ -393,16 +411,37 @@ public sealed class LdapAuthService : ILdapAuthService
}
/// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name
/// and group attributes are de-duplicated so we never request the same attribute twice
/// when an operator configures them to the same value.
/// Builds the distinct attribute list requested from the directory: display name, groups and
/// the username attribute (read back as the canonical username). They are de-duplicated,
/// case-insensitively, so we never request the same attribute twice when an operator
/// configures two of them to the same value.
/// </summary>
private IReadOnlyList<string> BuildSearchAttributes()
{
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
return new[] { _options.DisplayNameAttribute };
var attributes = new List<string>(3);
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
{
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
attributes.Add(name);
}
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
return attributes;
}
/// <summary>
/// Returns the first non-blank value of the configured username attribute — the directory's
/// canonical spelling — falling back to the (already normalised) typed username.
/// </summary>
private string ExtractUserName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
&& values.Count > 0
&& !string.IsNullOrWhiteSpace(values[0]))
{
return values[0];
}
return username;
}
/// <summary>
@@ -34,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// </summary>
public int BindAttempts { get; private set; }
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
public IReadOnlyList<string>? SearchAttributes { get; private set; }
/// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary>
public List<string> Calls { get; } = new();
@@ -41,9 +44,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
/// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
public FakeLdapConnection WithUserEntry(
string dn,
string[] memberOf,
string? displayName = null,
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
{
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
@@ -51,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
};
if (displayName is not null)
attrs["displayName"] = new[] { displayName };
if (extraAttributes is not null)
{
foreach (var (name, values) in extraAttributes)
attrs[name] = values;
}
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this;
@@ -177,6 +190,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
IReadOnlyList<string> attributes)
{
Calls.Add($"search:{filter}");
SearchAttributes = attributes;
if (_throwOnSearch)
throw new Novell.Directory.Ldap.LdapException(
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
{
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("ALICE", r.Username);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
var svc = new LdapAuthService(Opts(), factory);
var r = await svc.AuthenticateAsync("alice", password!, default);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Empty(factory.Created);
}
}
@@ -178,4 +178,45 @@ public class LdapAuthServiceUserBindTests
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
Assert.True(result.Succeeded);
Assert.Equal("JDOE", result.Username);
}
[Fact]
public async Task Search_RequestsTheUserNameAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
}
[Fact]
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
await Login(svc, "jdoe");
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
}
}