Add SGU credential provider and authentication broker

This commit is contained in:
2026-08-31 17:48:18 -06:00
parent 5e216f42a4
commit 1f43f200b4
50 changed files with 3226 additions and 236 deletions
@@ -0,0 +1,99 @@
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class AuthenticationWorkflowTests
{
[Fact]
public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory()
{
const string original = "Árbol-Exacto-🔐-NoDerivar-27!";
CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid());
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(ntlm, directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"do123456",
original,
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome);
Assert.Same(original, ntlm.Password);
Assert.Same(original, directory.Password);
Assert.Equal("DO123456", ntlm.UserName);
Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role);
}
[Fact]
public async Task InvalidNtlmCredentialsNeverReachActiveDirectory()
{
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(
new CapturingNtlmValidator(NtlmValidationResult.Invalid()),
directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"AL123456",
"Wrong",
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.InvalidCredentials, result.Outcome);
Assert.Null(directory.Password);
}
[Fact]
public async Task NtlmOutageIsReportedAsUnavailableForProviderFallback()
{
CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(
new CapturingNtlmValidator(NtlmValidationResult.Unavailable()),
directory);
AuthenticationFlowResult result = await workflow.AuthenticateAsync(
"AD123456",
"LastKnownPassword",
TestContext.Current.CancellationToken);
Assert.Equal(AuthenticationFlowOutcome.Unavailable, result.Outcome);
Assert.Null(directory.Password);
}
private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator
{
public string? UserName { get; private set; }
public string? Password { get; private set; }
public Task<NtlmValidationResult> ValidateAsync(string userName, string password, CancellationToken cancellationToken)
{
UserName = userName;
Password = password;
return Task.FromResult(result);
}
}
private sealed class CapturingDirectorySynchronizer : IActiveDirectorySynchronizer
{
public UserIdentity? Identity { get; private set; }
public string? Password { get; private set; }
public Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken)
{
Identity = identity;
Password = password;
return Task.FromResult(new DirectorySyncResult(
"LCI",
identity.UserName,
$"{identity.UserName}@lci.lasalle.mx",
true,
false));
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
using SGU.AuthBroker.Core.Identity;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class UserIdentityClassifierTests
{
[Theory]
[InlineData("DO123456", "DO123456", InstitutionalRole.Professor)]
[InlineData("al000001", "AL000001", InstitutionalRole.Student)]
[InlineData("LCI\\AD654321", "AD654321", InstitutionalRole.Administrative)]
[InlineData("do123456@lci.lasalle.mx", "DO123456", InstitutionalRole.Professor)]
public void MapsPrefixesToExpectedRoles(string input, string expectedUserName, InstitutionalRole expectedRole)
{
Assert.True(UserIdentityClassifier.TryParse(input, out UserIdentity? identity));
Assert.NotNull(identity);
Assert.Equal(expectedUserName, identity.UserName);
Assert.Equal(expectedRole, identity.Role);
}
[Theory]
[InlineData("")]
[InlineData("XX123456")]
[InlineData("DO12345")]
[InlineData("AL1234567")]
[InlineData("AD12A456")]
public void RejectsUnknownOrMalformedUserNames(string input)
{
Assert.False(UserIdentityClassifier.TryParse(input, out _));
}
}
@@ -0,0 +1,610 @@
using System.Runtime.InteropServices;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace SGU.CredentialProvider.SmokeProbe;
internal static class Program
{
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
private static readonly string[] ExpectedLabels =
[
"Acceso institucional SGU",
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
"Clave institucional",
"Contraseña",
"Iniciar sesión"
];
private static int Main(string[] args)
{
string mode = args.Length == 0 ? "enumeration" : args.Single();
if (mode is not ("enumeration" or "direct-broker" or "online-rejection" or "offline-fallback"))
{
Console.Error.WriteLine("Usage: SGU.CredentialProvider.SmokeProbe.exe [enumeration|direct-broker|online-rejection|offline-fallback]");
return 64;
}
if (mode == "direct-broker")
{
return RunDirectBrokerProbe();
}
object? instance = null;
IntPtr credential = IntPtr.Zero;
NativeEmptyUserArray? users = null;
try
{
Type providerType = Type.GetTypeFromCLSID(ProviderClassId, throwOnError: true)
?? throw new InvalidOperationException("The SGU Credential Provider CLSID is not registered.");
instance = Activator.CreateInstance(providerType)
?? throw new InvalidOperationException("COM activation returned no provider instance.");
ICredentialProvider provider = (ICredentialProvider)instance;
ICredentialProviderSetUserArray setUserArray = (ICredentialProviderSetUserArray)instance;
users = new NativeEmptyUserArray();
ThrowIfFailed(provider.SetUsageScenario(UsageScenario.Logon, 0), "SetUsageScenario");
ThrowIfFailed(setUserArray.SetUserArray(users.Pointer), "SetUserArray");
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
List<string> labels = [];
for (uint index = 0; index < fieldCount; index++)
{
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
if (descriptorPointer == IntPtr.Zero)
{
throw new InvalidOperationException($"Field descriptor {index} was null.");
}
try
{
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
labels.Add(descriptor.Label ?? string.Empty);
}
finally
{
Marshal.DestroyStructure<FieldDescriptor>(descriptorPointer);
Marshal.FreeCoTaskMem(descriptorPointer);
}
}
ThrowIfFailed(provider.GetCredentialCount(out uint credentialCount, out uint defaultIndex, out int autoLogon), "GetCredentialCount");
if (credentialCount > 0)
{
ThrowIfFailed(provider.GetCredentialAt(0, out credential), "GetCredentialAt");
}
bool passed = fieldCount == ExpectedLabels.Length &&
credentialCount == 1 &&
credential != IntPtr.Zero &&
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
if (mode != "enumeration" && passed)
{
return RunSerializationProbe(mode, credential, labels);
}
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode,
providerClassId = ProviderClassId,
usageScenario = "Logon",
fieldCount,
labels,
credentialCount,
defaultIndex,
autoLogon = autoLogon != 0
}));
return passed ? 0 : 1;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return 2;
}
finally
{
if (credential != IntPtr.Zero)
{
Marshal.Release(credential);
}
if (instance is not null && Marshal.IsComObject(instance))
{
Marshal.FinalReleaseComObject(instance);
}
users?.Dispose();
}
}
private static void ThrowIfFailed(int hresult, string operation)
{
if (hresult < 0)
{
Marshal.ThrowExceptionForHR(hresult);
throw new COMException($"{operation} failed.", hresult);
}
}
private static int RunSerializationProbe(string mode, IntPtr credential, IReadOnlyList<string> labels)
{
string[] labelArray = labels.ToArray();
uint userNameFieldId = checked((uint)Array.IndexOf(labelArray, "Clave institucional"));
uint passwordFieldId = checked((uint)Array.IndexOf(labelArray, "Contraseña"));
string userName = "DO000000";
string password = mode == "offline-fallback"
? $"Probe-{Guid.NewGuid():N}-áΩ"
: $"Probe-{Guid.NewGuid():N}";
IntPtr vtable = Marshal.ReadIntPtr(credential);
SetStringValueDelegate setStringValue = Marshal.GetDelegateForFunctionPointer<SetStringValueDelegate>(
Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size));
GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer<GetSerializationDelegate>(
Marshal.ReadIntPtr(vtable, 18 * IntPtr.Size));
SetCredentialString(setStringValue, credential, userNameFieldId, userName, "SetStringValue(username)");
SetCredentialString(setStringValue, credential, passwordFieldId, password, "SetStringValue(password)");
CredentialSerialization serialization = default;
IntPtr statusTextPointer = IntPtr.Zero;
try
{
ThrowIfFailed(
getSerialization(credential, out int response, out serialization, out statusTextPointer, out int statusIcon),
"GetSerialization");
string statusText = statusTextPointer == IntPtr.Zero
? string.Empty
: Marshal.PtrToStringUni(statusTextPointer) ?? string.Empty;
if (mode == "online-rejection")
{
bool passed = response == (int)SerializationResponse.NoCredentialNotFinished &&
serialization.SerializationData == IntPtr.Zero &&
statusIcon == (int)StatusIcon.Error &&
statusText == "Credenciales institucionales inválidas.";
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode,
response = (SerializationResponse)response,
statusIcon = (StatusIcon)statusIcon,
statusText,
credentialReturned = serialization.SerializationData != IntPtr.Zero
}));
return passed ? 0 : 1;
}
KerberosInteractiveUnlockLogon logon = serialization.SerializationData == IntPtr.Zero
? default
: Marshal.PtrToStructure<KerberosInteractiveUnlockLogon>(serialization.SerializationData);
string packedDomain = ReadPackedString(serialization.SerializationData, logon.LogonDomainName);
string packedUserName = ReadPackedString(serialization.SerializationData, logon.Username);
string packedPassword = ReadPackedString(serialization.SerializationData, logon.Password);
bool passwordPreserved = string.Equals(packedPassword, password, StringComparison.Ordinal);
packedPassword = string.Empty;
bool fallbackPassed = response == (int)SerializationResponse.ReturnCredentialFinished &&
serialization.SerializationData != IntPtr.Zero &&
serialization.SerializationSize > 0 &&
statusIcon == (int)StatusIcon.Warning &&
statusText == "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada." &&
string.Equals(packedDomain, "LCI", StringComparison.Ordinal) &&
string.Equals(packedUserName, userName, StringComparison.Ordinal) &&
passwordPreserved;
Console.WriteLine(JsonSerializer.Serialize(new
{
passed = fallbackPassed,
mode,
response = (SerializationResponse)response,
statusIcon = (StatusIcon)statusIcon,
statusText,
packedDomain,
packedUserName,
passwordPreserved,
serializationSize = serialization.SerializationSize
}));
return fallbackPassed ? 0 : 1;
}
finally
{
password = string.Empty;
if (statusTextPointer != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(statusTextPointer);
}
if (serialization.SerializationData != IntPtr.Zero)
{
byte[] zeroes = new byte[serialization.SerializationSize];
Marshal.Copy(zeroes, 0, serialization.SerializationData, zeroes.Length);
Marshal.FreeCoTaskMem(serialization.SerializationData);
}
}
}
private static int RunDirectBrokerProbe()
{
string settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"SGU",
"CredentialProvider",
"settings.json");
ProbeSettings settings = JsonSerializer.Deserialize<ProbeSettings>(
File.ReadAllText(settingsPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Provider settings could not be read.");
using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
using X509Certificate2 certificate = store.Certificates
.Find(X509FindType.FindByThumbprint, settings.ClientCertificateThumbprint, validOnly: true)
.OfType<X509Certificate2>()
.First(item => item.HasPrivateKey);
string expectedThumbprint = NormalizeThumbprint(settings.ServerCertificateThumbprint);
using HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
ClientCertificateOptions = ClientCertificateOption.Manual,
MaxConnectionsPerServer = 2,
MaxResponseHeadersLength = 32,
UseCookies = false,
UseDefaultCredentials = false,
UseProxy = false,
ServerCertificateCustomValidationCallback = (_, serverCertificate, _, policyErrors) =>
policyErrors == SslPolicyErrors.None &&
serverCertificate is not null &&
string.Equals(
NormalizeThumbprint(serverCertificate.GetCertHashString()),
expectedThumbprint,
StringComparison.OrdinalIgnoreCase)
};
handler.ClientCertificates.Add(certificate);
using HttpClient client = new(handler)
{
Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds),
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
string password = $"Probe-{Guid.NewGuid():N}";
try
{
string json = JsonSerializer.Serialize(new { clave = "DO000000", password });
using StringContent content = new(json, Encoding.UTF8, "application/json");
using HttpResponseMessage response = client.PostAsync(settings.BrokerEndpoint, content).GetAwaiter().GetResult();
string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
string? responseCode = null;
try
{
responseCode = JsonDocument.Parse(responseBody).RootElement.GetProperty("code").GetString();
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
{
}
bool passed = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized;
Console.WriteLine(JsonSerializer.Serialize(new
{
passed,
mode = "direct-broker",
statusCode = (int)response.StatusCode,
responseCode
}));
return passed ? 0 : 1;
}
catch (Exception exception)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
passed = false,
mode = "direct-broker",
exception = exception.GetType().FullName,
innerException = exception.InnerException?.GetType().FullName,
hresult = exception.HResult
}));
return 1;
}
finally
{
password = string.Empty;
}
}
private static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
private static void SetCredentialString(
SetStringValueDelegate setStringValue,
IntPtr credential,
uint fieldId,
string value,
string operation)
{
IntPtr valuePointer = Marshal.StringToCoTaskMemUni(value);
try
{
ThrowIfFailed(setStringValue(credential, fieldId, valuePointer), operation);
}
finally
{
Marshal.ZeroFreeCoTaskMemUnicode(valuePointer);
}
}
private static string ReadPackedString(IntPtr buffer, PackedUnicodeString value)
{
if (buffer == IntPtr.Zero || value.Length == 0)
{
return string.Empty;
}
return Marshal.PtrToStringUni(
IntPtr.Add(buffer, checked((int)value.Buffer.ToInt64())),
value.Length / sizeof(char)) ?? string.Empty;
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int SetStringValueDelegate(IntPtr instance, uint fieldId, IntPtr value);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetSerializationDelegate(
IntPtr instance,
out int response,
out CredentialSerialization serialization,
out IntPtr statusText,
out int statusIcon);
}
internal sealed class ProbeSettings
{
public Uri BrokerEndpoint { get; init; } = null!;
public int TimeoutSeconds { get; init; }
public string ClientCertificateThumbprint { get; init; } = string.Empty;
public string ServerCertificateThumbprint { get; init; } = string.Empty;
}
internal enum SerializationResponse
{
NoCredentialNotFinished = 0,
NoCredentialFinished = 1,
ReturnCredentialFinished = 2,
ReturnNoCredentialFinished = 3
}
internal enum StatusIcon
{
None = 0,
Error = 1,
Warning = 2,
Success = 3
}
internal enum UsageScenario
{
Invalid = 0,
Logon = 1
}
internal enum FieldType
{
Invalid = 0,
LargeText,
SmallText,
CommandLink,
EditText,
PasswordText,
TileImage,
CheckBox,
ComboBox,
SubmitButton
}
[StructLayout(LayoutKind.Sequential, Pack = 4, CharSet = CharSet.Unicode)]
internal struct FieldDescriptor
{
public uint FieldId;
public FieldType FieldType;
[MarshalAs(UnmanagedType.LPWStr)]
public string? Label;
public Guid FieldTypeGuid;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct CredentialSerialization
{
public uint AuthenticationPackage;
public Guid ProviderClassGuid;
public uint SerializationSize;
public IntPtr SerializationData;
}
[StructLayout(LayoutKind.Sequential)]
internal struct PackedUnicodeString
{
public ushort Length;
public ushort MaxLength;
public IntPtr Buffer;
}
[StructLayout(LayoutKind.Sequential)]
internal struct KerberosInteractiveUnlockLogon
{
public int SubmitType;
public PackedUnicodeString LogonDomainName;
public PackedUnicodeString Username;
public PackedUnicodeString Password;
public long LoginId;
}
[ComImport]
[Guid("D27C3481-5A1C-45B2-8AAA-C20EBBE8229E")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICredentialProvider
{
[PreserveSig]
int SetUsageScenario(UsageScenario usageScenario, uint flags);
[PreserveSig]
int SetSerialization(IntPtr serialization);
[PreserveSig]
int Advise(IntPtr events, IntPtr adviseContext);
[PreserveSig]
int UnAdvise();
[PreserveSig]
int GetFieldDescriptorCount(out uint count);
[PreserveSig]
int GetFieldDescriptorAt(uint index, out IntPtr descriptor);
[PreserveSig]
int GetCredentialCount(out uint count, out uint defaultIndex, out int autoLogonWithDefault);
[PreserveSig]
int GetCredentialAt(
uint index,
out IntPtr credential);
}
[ComImport]
[Guid("095C1484-1C0C-4388-9C6D-500E61BF84BD")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICredentialProviderSetUserArray
{
[PreserveSig]
int SetUserArray(IntPtr users);
}
internal sealed class NativeEmptyUserArray : IDisposable
{
private static readonly Guid UserArrayInterfaceId = new("90C119AE-0F18-4520-A1F1-114366A40FE8");
private static readonly Guid UnknownInterfaceId = new("00000000-0000-0000-C000-000000000046");
private readonly QueryInterfaceDelegate queryInterface;
private readonly AddRefDelegate addRef;
private readonly ReleaseDelegate release;
private readonly SetProviderFilterDelegate setProviderFilter;
private readonly GetAccountOptionsDelegate getAccountOptions;
private readonly GetCountDelegate getCount;
private readonly GetAtDelegate getAt;
private IntPtr instance;
private IntPtr vtable;
private int referenceCount = 1;
public NativeEmptyUserArray()
{
queryInterface = QueryInterface;
addRef = AddRef;
release = Release;
setProviderFilter = SetProviderFilter;
getAccountOptions = GetAccountOptions;
getCount = GetCount;
getAt = GetAt;
vtable = Marshal.AllocHGlobal(IntPtr.Size * 7);
Marshal.WriteIntPtr(vtable, IntPtr.Size * 0, Marshal.GetFunctionPointerForDelegate(queryInterface));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 1, Marshal.GetFunctionPointerForDelegate(addRef));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 2, Marshal.GetFunctionPointerForDelegate(release));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 3, Marshal.GetFunctionPointerForDelegate(setProviderFilter));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 4, Marshal.GetFunctionPointerForDelegate(getAccountOptions));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 5, Marshal.GetFunctionPointerForDelegate(getCount));
Marshal.WriteIntPtr(vtable, IntPtr.Size * 6, Marshal.GetFunctionPointerForDelegate(getAt));
instance = Marshal.AllocHGlobal(IntPtr.Size);
Marshal.WriteIntPtr(instance, vtable);
}
public IntPtr Pointer => instance != IntPtr.Zero
? instance
: throw new ObjectDisposedException(nameof(NativeEmptyUserArray));
private int QueryInterface(IntPtr self, ref Guid interfaceId, out IntPtr result)
{
if (interfaceId == UnknownInterfaceId || interfaceId == UserArrayInterfaceId)
{
result = self;
AddRef(self);
return 0;
}
result = IntPtr.Zero;
return unchecked((int)0x80004002);
}
private uint AddRef(IntPtr self) => unchecked((uint)Interlocked.Increment(ref referenceCount));
private uint Release(IntPtr self) => unchecked((uint)Math.Max(0, Interlocked.Decrement(ref referenceCount)));
private static int SetProviderFilter(IntPtr self, ref Guid providerToFilterTo) => 0;
private static int GetAccountOptions(IntPtr self, out uint accountOptions)
{
accountOptions = 0;
return 0;
}
private static int GetCount(IntPtr self, out uint userCount)
{
userCount = 0;
return 0;
}
private static int GetAt(IntPtr self, uint userIndex, out IntPtr user)
{
user = IntPtr.Zero;
return unchecked((int)0x80070057);
}
public void Dispose()
{
if (instance != IntPtr.Zero)
{
Marshal.FreeHGlobal(instance);
instance = IntPtr.Zero;
}
if (vtable != IntPtr.Zero)
{
Marshal.FreeHGlobal(vtable);
vtable = IntPtr.Zero;
}
GC.KeepAlive(queryInterface);
GC.KeepAlive(addRef);
GC.KeepAlive(release);
GC.KeepAlive(setProviderFilter);
GC.KeepAlive(getAccountOptions);
GC.KeepAlive(getCount);
GC.KeepAlive(getAt);
}
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int QueryInterfaceDelegate(IntPtr self, ref Guid interfaceId, out IntPtr result);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate uint AddRefDelegate(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate uint ReleaseDelegate(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int SetProviderFilterDelegate(IntPtr self, ref Guid providerToFilterTo);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetAccountOptionsDelegate(IntPtr self, out uint accountOptions);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetCountDelegate(IntPtr self, out uint userCount);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetAtDelegate(IntPtr self, uint userIndex, out IntPtr user);
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
</Project>
@@ -0,0 +1,87 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Xunit;
namespace SGU.CredentialProvider.Tests;
public sealed class BrokerClientTests
{
[Fact]
public async Task SendsTheOriginalPasswordWithoutDerivation()
{
const string original = "Exacta-Árbol-🔐-27!";
CapturingHandler handler = new(HttpStatusCode.OK, """
{"domain":"LCI","username":"DO123456","upn":"DO123456@lci.lasalle.mx","created":true,"moved":false}
""");
using BrokerClient client = new(CreateSettings(), handler);
BrokerDecision decision = await client.AuthenticateAsync(
"DO123456",
original,
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.Authorized, decision.Kind);
using JsonDocument requestJson = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(original, requestJson.RootElement.GetProperty("password").GetString());
Assert.DoesNotContain("derived", handler.RequestBody, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExplicitUnauthorizedResponseStopsTheLogin()
{
using BrokerClient client = new(
CreateSettings(),
new CapturingHandler(HttpStatusCode.Unauthorized, "{\"code\":\"INVALID_INSTITUTIONAL_CREDENTIALS\"}"));
BrokerDecision decision = await client.AuthenticateAsync(
"AL123456",
"Wrong",
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.InvalidCredentials, decision.Kind);
}
[Fact]
public async Task BrokerOutageRequestsWindowsCachedCredentialFallback()
{
using BrokerClient client = new(
CreateSettings(),
new CapturingHandler(HttpStatusCode.ServiceUnavailable, "{\"code\":\"NTLM_UPSTREAM_ERROR\"}"));
BrokerDecision decision = await client.AuthenticateAsync(
"AD123456",
"LastKnown",
TestContext.Current.CancellationToken);
Assert.Equal(BrokerDecisionKind.Unavailable, decision.Kind);
}
private static ProviderSettings CreateSettings() => new()
{
BrokerEndpoint = new Uri("https://broker.example.test/v1/authenticate"),
DomainNetbios = "LCI",
TimeoutSeconds = 5,
ClientCertificateThumbprint = new string('A', 40),
ServerCertificateThumbprint = new string('B', 40)
};
private sealed class CapturingHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler
{
public string RequestBody { get; private set; } = string.Empty;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestBody = request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SGU.CredentialProvider\SGU.CredentialProvider.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>