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 const string JaneDn = "CN=Jane Doe,OU=u,DC=x";
private static FakeLdapConnection Jane() => new FakeLdapConnection()
.WithWhoAmI("dn:" + JaneDn)
.WithUserEntry(JaneDn, 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", "whoami", "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().WithWhoAmI("dn:" + JaneDn).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().WithWhoAmI("dn:cn=dup1,dc=x").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().WithWhoAmI("dn:CN=Jane,DC=x").WithUserEntry("CN=Jane,DC=x", Array.Empty(), "Jane"));
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithWhoAmI(@"u:NAM\jdoe").WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary { ["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);
}
// ---- Auth-007 (scadaproj#40): the entry whose groups are returned must be the account that bound ----
///
/// The review's scenario: a UPN bind for jsmith is resolved by AD to Mallory (explicit UPN
/// jsmith@corp.test, sAMAccountName jsmith2), while (sAMAccountName=jsmith)
/// finds the admin. Before the fix this returned the admin's groups to Mallory.
///
[Fact]
public async Task UpnBind_ResolvedToAnotherAccount_DoesNotReturnTheSearchedEntrysGroups()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jsmith2")
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith",
new Dictionary { ["sAMAccountName"] = new[] { "jsmith" } });
var factory = new SequenceLdapConnectionFactory(fake);
var result = await Login(new LdapAuthService(Opts(), factory), "jsmith", "mallorys-own-password");
Assert.False(result.Succeeded);
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Empty(result.Groups);
Assert.Equal(new[] { "jsmith@corp.test" }, fake.BoundDns);
}
[Fact]
public async Task WhoAmIAccountName_MatchingTheEntry_Succeeds_AndRequestsSamAccountName()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\JSmith")
.WithUserEntry("CN=John Smith,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "John Smith",
new Dictionary { ["sAMAccountName"] = new[] { "jsmith" } });
var factory = new SequenceLdapConnectionFactory(fake);
var svc = new LdapAuthService(Opts() with { UserNameAttribute = "cn" }, factory);
var result = await Login(svc, "John Smith");
Assert.True(result.Succeeded);
Assert.Contains("sAMAccountName", fake.SearchAttributes!);
}
[Fact]
public async Task WhoAmIAccountName_WithoutDomain_IsAccepted()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("u:jdoe")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } });
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.True(result.Succeeded);
}
[Fact]
public async Task WhoAmIAccountName_EntryWithoutSamAccountName_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jdoe")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
// ---- scadaproj#50: an AD forest — the same sAMAccountName in two domains ----------------------
// A Global Catalog search base spans every domain in the forest. CORP\jsmith binds; the search
// (here on mail, so the filter is not sAMAccountName) finds OTHER\jsmith, whose sAMAccountName is
// also "jsmith". 0.4.4 compared sAMAccountName alone and returned OTHER\jsmith's groups.
private const string OtherDn = "CN=J Smith,OU=u,DC=other,DC=forest";
private const string CorpDn = "CN=John Smith,OU=u,DC=corp,DC=forest";
private static FakeLdapConnection ForestSearchFinding(string dn) => new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jsmith")
.WithUserEntry(dn, new[] { "CN=UG_Admins,OU=g,DC=other,DC=forest" }, "J Smith",
new Dictionary { ["sAMAccountName"] = new[] { "jsmith" }, ["mail"] = new[] { "jsmith" } });
private static LdapOptions ForestOpts() => Opts() with { UserNameAttribute = "mail", SearchBase = "DC=forest" };
[Fact]
public async Task Forest_PrincipalNameInAnotherDomain_FailsClosed()
{
var fake = ForestSearchFinding(OtherDn).WithPrincipalName(@"OTHER\jsmith");
var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Empty(result.Groups);
Assert.Contains("read:" + OtherDn, fake.Calls);
}
[Fact]
public async Task Forest_PrincipalNameInTheBoundDomain_Succeeds()
{
var fake = ForestSearchFinding(CorpDn).WithPrincipalName(@"corp\JSMITH");
var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.True(result.Succeeded);
}
[Fact]
public async Task Forest_NoPrincipalName_AndTheNameIsHeldInTwoDomains_FailsClosed()
{
// The directory offers no msDS-PrincipalName, so which jsmith bound cannot be proven; with two
// holders of the name in scope the sign-in is refused rather than guessed.
var fake = ForestSearchFinding(OtherDn)
.WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith"), (OtherDn, "jsmith"));
var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Forest_NoPrincipalName_AndTheReadFails_StillFallsBackToTheUniquenessCheck()
{
var fake = ForestSearchFinding(OtherDn).ThrowOnRead()
.WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith"), (OtherDn, "jsmith"));
var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task SingleDomain_NoPrincipalName_UniqueName_Succeeds()
{
// Compatibility: a single-domain directory without msDS-PrincipalName still signs in — the
// name has exactly one holder in scope, and it is the entry found.
var fake = ForestSearchFinding(CorpDn).WithSearchResult("(sAMAccountName=jsmith)", (CorpDn, "jsmith"));
var result = await Login(new LdapAuthService(ForestOpts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.True(result.Succeeded);
}
[Fact]
public async Task SamAccountNameSearch_ForTheBoundName_NeedsNoSecondSearch()
{
// When the user search itself was (sAMAccountName=) and found exactly one
// entry, the name is already proven unique in scope.
var fake = new FakeLdapConnection()
.WithWhoAmI(@"u:CORP\jdoe")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } });
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.True(result.Succeeded);
Assert.Single(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal));
}
[Fact]
public async Task WhoAmIDn_NamingAnotherEntry_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("dn:CN=Mallory,OU=u,DC=x")
.WithUserEntry("CN=Admin Smith,OU=u,DC=x", new[] { "CN=UG_Admins,OU=g,DC=x" }, "Admin Smith");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jsmith");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Empty(result.Groups);
}
[Fact]
public async Task WhoAmIDn_IsComparedAsADn_NotAsAString()
{
var fake = new FakeLdapConnection()
.WithWhoAmI("dn:cn=jane doe, ou=U, dc=X")
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.True(result.Succeeded);
}
[Theory]
[InlineData(null)] // server does not support Who-am-I (GLAuth: protocol error 2)
[InlineData("")] // anonymous / empty answer
[InlineData("x:garbage")]
public async Task UpnTemplate_WithoutAWhoAmIAnswer_FailsClosed_WithoutSearching(string? whoAmI)
{
var fake = new FakeLdapConnection()
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
if (whoAmI is not null)
fake.WithWhoAmI(whoAmI);
var result = await Login(new LdapAuthService(Opts(), new SequenceLdapConnectionFactory(fake)), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.DoesNotContain(fake.Calls, c => c.StartsWith("search:", StringComparison.Ordinal));
}
[Fact]
public async Task DownLevelTemplate_WithoutAWhoAmIAnswer_FailsClosed()
{
var fake = new FakeLdapConnection()
.WithUserEntry(JaneDn, new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe");
var svc = new LdapAuthService(Opts() with { UserBindTemplate = @"CORP\{0}" }, new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task DnTemplate_WithoutWhoAmI_AcceptsTheEntryAtTheBoundDn()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=jdoe,ou=Users,dc=x", new[] { "cn=UG_A,ou=g,dc=x" }, "Jane Doe");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "jdoe");
Assert.True(result.Succeeded);
}
[Fact]
public async Task DnTemplate_WithoutWhoAmI_RefusesAnEntryAtAnotherDn()
{
// Mallory binds as cn=mallory with her own password; (mail=mallory) finds the admin.
var fake = new FakeLdapConnection()
.WithUserEntry("cn=admin,ou=Users,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "mail" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "mallory");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Equal(new[] { "cn=mallory,ou=users,dc=x" }, fake.BoundDns);
}
[Fact]
public async Task DnTemplate_EscapesTheName_SoItCannotRewriteTheBindDn()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=admin,ou=Admins,dc=x", new[] { "cn=UG_Admins,ou=g,dc=x" }, "Admin");
var svc = new LdapAuthService(
Opts() with { UserBindTemplate = "cn={0},ou=users,dc=x", UserNameAttribute = "cn" },
new SequenceLdapConnectionFactory(fake));
var result = await Login(svc, "admin,ou=Admins");
Assert.Equal(new[] { @"cn=admin\,ou=Admins,ou=users,dc=x" }, fake.BoundDns);
Assert.False(result.Succeeded);
}
}