feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection)

This commit is contained in:
Joseph Doherty
2026-10-01 00:55:04 -04:00
parent fa0ca66a05
commit 302d1245cd
3 changed files with 336 additions and 70 deletions
@@ -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
/// </summary>
public int BindAttempts { 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();
// ---- builder methods -----
/// <summary>
@@ -83,6 +88,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> 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.
/// </summary>
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
{
_firstBindResultCode = resultCode;
return this;
}
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
public FakeLdapConnection ThrowOnSearch()
{
_throwOnSearch = true;
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> 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<string> 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 */ }
}