diff --git a/.vsconfig b/.vsconfig
new file mode 100644
index 0000000..f99b4e3
--- /dev/null
+++ b/.vsconfig
@@ -0,0 +1,11 @@
+{
+ "version": "1.0",
+ "components": [
+ "Microsoft.VisualStudio.Workload.NativeDesktop",
+ "Microsoft.VisualStudio.Workload.ManagedDesktop",
+ "Microsoft.VisualStudio.Workload.NetWeb",
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "Microsoft.VisualStudio.Component.VC.CMake.Project",
+ "Microsoft.VisualStudio.Component.Windows11SDK.28000"
+ ]
+}
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..5865df0
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+ latest
+ enable
+ disable
+ enable
+ disable
+ true
+ true
+ true
+
+
diff --git a/README.md b/README.md
index 683a176..3b611ef 100644
--- a/README.md
+++ b/README.md
@@ -1,256 +1,72 @@
-
+# SGU Windows Credential Provider
-# Windows Credential Provider
-
-
+Windows Credential Provider and ASP.NET Core authentication broker for the
+`lci.lasalle.mx` Active Directory laboratory.
-A library for creating secure Windows Credential Providers in .NET, without the COM complications.
+The repository starts from the current
+[Lithnet Windows Credential Provider](https://github.com/lithnet/windows-credential-provider)
+source and adds an SGU-specific provider, an mTLS-protected broker, Active
+Directory synchronization, deployment scripts, and tests.
-The Lithnet Credential Provider for Windows provides an easy way to create a credential provider, without having to implement the COM components. The COM components are still there, but abstracted away into a fully managed implementation.
+## Authentication contract
-## Getting started
-* Create a new Class Library project. You can use .NET Framework 4.7.2 or later, or .NET 8.0, 9.0, or 10.0. You must build an x64 or x86 binary. You cannot use AnyCPU.
-* Install the package from nuget `Install-Package Lithnet.CredentialProvider`
+1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
+2. It sends that exact password over mutually authenticated TLS to the broker.
+3. The broker validates the same key/password pair against the configured SGU
+ NTLM endpoint.
+4. On success, the broker creates or moves the AD user and sets the AD password
+ to the exact submitted password.
+5. The Credential Provider serializes the original `SecureString` to Windows.
-* Modify the `csproj` file and set `RegisterForComInterop` to `false`
-```xml
-
- net472
- false
- x64
-
-```
+No derived password is created. Passwords are not written to a database, file,
+event log, application log, command line, or response.
-* If you are using .NET 8.0, 9.0, or 10.0, you must also set `EnableComHosting` to `true`.
+| Prefix | Role | Default OU |
+|---|---|---|
+| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
+| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
+| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
-```xml
-
- net8.0-windows
- false
- x64
- true
-
-```
+If the broker or institutional NTLM authority is unavailable, the provider
+submits the unchanged credentials to Windows for normal AD/cached-domain
+validation. This is not an unauthenticated bypass: Windows LSA must still accept
+the last password registered in AD. An explicit NTLM `401` is rejected and is
+not treated as an outage.
-* Create a new class an inherit from `CredentialProviderBase`, as shown below, replacing the `ProgId` and `Guid` values with ones of your own
+## Projects
-```cs
-[ComVisible(true)]
-[ClassInterface(ClassInterfaceType.None)]
-[ProgId("MyCredentialProvider")]
-[Guid("00000000-0000-0000-0000-000000000000")]
-public class MyCredentialProvider : CredentialProviderBase
-{
-}
-```
+- `src/SGU.CredentialProvider` — x64 .NET 10 COM Credential Provider based on Lithnet.
+- `src/SGU.AuthBroker` — Windows-hosted ASP.NET Core broker with mTLS, NTLM validation,
+ and Active Directory provisioning.
+- `src/SGU.AuthBroker.Core` — testable authentication workflow and prefix classifier.
+- `tests` — exact-password, role mapping, rejection, and outage-fallback tests.
+- `scripts` — publishing, certificate, server deployment, client installation,
+ broker testing, and rollback.
-* Override the `IsUsageScenarioSupported` method, to specify which scenarios you want to support with your credential provider
+## Build
-```cs
-public override bool IsUsageScenarioSupported(UsageScenario cpus, CredUIWinFlags dwFlags)
-{
- switch (cpus)
- {
- case UsageScenario.Logon:
- case UsageScenario.UnlockWorkstation:
- case UsageScenario.CredUI:
- case UsageScenario.ChangePassword:
- return true;
-
- default:
- return false;
- }
-}
-```
-
-* Override the `GetControls` method, and provide the controls to render your UI. You can conditionally render based on the current scenario
-```cs
-public override IEnumerable GetControls(UsageScenario cpus)
-{
- yield return new CredentialProviderLabelControl("CredProviderLabel", "My first credential provider");
-
- var infoLabel = new SmallLabelControl("InfoLabel", "Enter your username and password please!");
- infoLabel.State = FieldState.DisplayInSelectedTile;
- yield return infoLabel;
-
- yield return new TextboxControl("UsernameField", "Username");
- var password = new SecurePasswordTextboxControl("PasswordField", "Password");
- yield return password;
-
- if (cpus == UsageScenario.ChangePassword)
- {
- var confirmPassword = new SecurePasswordTextboxControl("ConfirmPasswordField", "Confirm password");
- yield return confirmPassword;
- yield return new SubmitButtonControl("SubmitButton", "Submit", confirmPassword);
- }
- else
- {
- yield return new SubmitButtonControl("SubmitButton", "Submit", password);
- }
-}
-```
-
-* Windows will ask for the tiles to show. You can determine if you want to show a generic tile (that is, a tile not associated with a user), or a user-specific tile. Windows will provide the list of known users for you to create tiles for.
-```cs
-public override bool ShouldIncludeUserTile(CredentialProviderUser user)
-{
- return true;
-}
-
-public override bool ShouldIncludeGenericTile()
-{
- return true;
-}
-
-public override CredentialTile CreateGenericTile()
-{
- return new MyTile(this);
-}
-
-public override CredentialTile2 CreateUserTile(CredentialProviderUser user)
-{
- return new MyTile(this, user);
-}
-```
-
-* Create your tile class. Inherit from `CredentialTile2` if you want to create personalized tiles supported by Windows 8 and later, or `CredentialTile` if you only want to implement a generic tile. Use `CredentialTile3` when an image must preserve transparency. Grab the instances of your controls in the `Initialize` method, so you can attach to their properties to read and respond to value changes. Finally, override the `GetCredentials` method, which is called when the user clicks the submit button.
-
-```cs
-public class MyTile : CredentialTile2
-{
- private TextboxControl UsernameControl;
- private SecurePasswordTextboxControl PasswordControl;
- private SecurePasswordTextboxControl PasswordConfirmControl;
-
- public MyTile(CredentialProviderBase credentialProvider) : base(credentialProvider)
- {
- }
-
- public MyTile(CredentialProviderBase credentialProvider, CredentialProviderUser user) : base(credentialProvider, user)
- {
- }
-
- public string Username
- {
- get => UsernameControl.Text;
- set => UsernameControl.Text = value;
- }
-
- public SecureString Password
- {
- get => PasswordControl.Password;
- set => PasswordControl.Password = value;
- }
-
- public SecureString ConfirmPassword
- {
- get => PasswordConfirmControl.Password;
- set => PasswordConfirmControl.Password = value;
- }
-
- public override void Initialize()
- {
- if (UsageScenario == UsageScenario.ChangePassword)
- {
- this.PasswordConfirmControl = this.Controls.GetControl("ConfirmPasswordField");
- }
-
- this.PasswordControl = this.Controls.GetControl("PasswordField");
- this.UsernameControl = this.Controls.GetControl("UsernameField");
-
- Username = this.User?.QualifiedUserName;
- }
-
- protected override CredentialResponseBase GetCredentials()
- {
- string username;
- string domain;
-
- if (Username.Contains("\\"))
- {
- domain = Username.Split('\\')[0];
- username = Username.Split('\\')[1];
- }
- else
- {
- username = Username;
- domain = Environment.MachineName;
- }
-
- var spassword = Controls.GetControl("PasswordField").Password;
-
- return new CredentialResponseSecure()
- {
- IsSuccess = true,
- Password = spassword,
- Domain = domain,
- Username = username
- };
- }
-}
-```
-
-* Build your project and you have a functional credential provider!
-
-## Bitmap transparency
-
-`CredentialProviderLogoControl` and `UserTileControl` both accept a `Bitmap`. The tile base class controls how Windows receives images that contain transparent or partially transparent pixels.
-
-| Tile base class | Image behaviour |
-|-----------------|-----------------|
-| `CredentialTile` | Renders transparency against the control's `BackgroundColor`. |
-| `CredentialTile2` | Renders transparency against the control's `BackgroundColor`. |
-| `CredentialTile3` | Preserves the image's alpha channel and ignores `BackgroundColor`. |
-
-Existing providers that inherit from `CredentialTile` or `CredentialTile2` keep their current behaviour. The default `BackgroundColor` is `#464646`.
-
-To preserve transparency, inherit your tile class from `CredentialTile3` and provide a `Bitmap` with an alpha channel. The library selects the image representation required by Windows, so your provider does not need to handle that conversion.
-
-```cs
-public class MyTile : CredentialTile3
-{
- public MyTile(CredentialProviderBase credentialProvider) : base(credentialProvider)
- {
- }
-
- public MyTile(CredentialProviderBase credentialProvider, CredentialProviderUser user) : base(credentialProvider, user)
- {
- }
-}
-
-public override IEnumerable GetControls(UsageScenario cpus)
-{
- Bitmap image = LoadTransparentBitmap();
- yield return new UserTileControl("UserTile", "User tile image", image);
- yield return new CredentialProviderLogoControl("ProviderLogo", "Credential provider logo", image);
-}
-```
-
-## Installing the credential provider
-You can use the traditional methods of registering a credential provider (regasm, regsvr32, create registry keys etc), but we've provided a PowerShell module to automatically register your credential provider with a single command.
+Prerequisites are captured in `.vsconfig`; the pinned SDK is .NET `10.0.400`.
```powershell
-Install-Module Lithnet.CredentialProvider.Management
-Register-CredentialProvider -File C:\path-to-your-provider.dll
+dotnet restore .\SGU-CredentialProvider.sln
+dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
+dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
+.\scripts\Publish-Lab.ps1
```
-You can disable, enable, and uninstall the provider with the following commands
+The provider's .NET COM host is framework-dependent, so the Windows client needs
+the latest .NET 10 x64 runtime. The broker is published self-contained.
-```powershell
-Disable-CredentialProvider -File "C:\path-to-your-provider.dll"
-Enable-CredentialProvider -File "C:\path-to-your-provider.dll"
-Unregister-CredentialProvider -File "C:\path-to-your-provider.dll"
-```
+## Deployment and test
-Once the credential provider is registered, you can use the `Invoke-CredUI` cmdlet provided as part of the module, to bring up CredUI window and render your credential provider.
+Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
+[docs/security.md](docs/security.md) before production deployment and
+[docs/architecture.md](docs/architecture.md) for the component contract.
-## How can I contribute to the project?
-* Found an issue and want us to fix it? [Log it](https://github.com/lithnet/windows-credential-provider/issues)
-* Want to fix an issue yourself or add functionality? Clone the project and submit a pull request
+Never disable the built-in Microsoft password Credential Provider. It is the
+supported recovery path if a third-party provider fails to load.
-## Enteprise support
-Enterprise support is not currently offered for this product.
+## Upstream license
-## Keep up to date
-* [Visit our blog](http://blog.lithnet.io)
-* [Follow us on twitter](https://twitter.com/lithnet_io)
+The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project
+additions are distributed under the same license.
diff --git a/SGU-CredentialProvider.sln b/SGU-CredentialProvider.sln
new file mode 100644
index 0000000..e5a887a
--- /dev/null
+++ b/SGU-CredentialProvider.sln
@@ -0,0 +1,116 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker.Core", "src\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj", "{2B89086E-7B72-423F-AD6A-39CAB730F22A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker", "src\SGU.AuthBroker\SGU.AuthBroker.csproj", "{0832D10C-DE2B-468E-82BB-229F20BC8996}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider", "src\SGU.CredentialProvider\SGU.CredentialProvider.csproj", "{30CE3366-9C37-4095-86D0-CE59637B1D1E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.AuthBroker.Core.Tests", "tests\SGU.AuthBroker.Core.Tests\SGU.AuthBroker.Core.Tests.csproj", "{5749FA85-9760-4884-9475-C760879B1953}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.Tests", "tests\SGU.CredentialProvider.Tests\SGU.CredentialProvider.Tests.csproj", "{72DF14BC-9050-4AF3-B311-36F2A4140366}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SGU.CredentialProvider.SmokeProbe", "tests\SGU.CredentialProvider.SmokeProbe\SGU.CredentialProvider.SmokeProbe.csproj", "{B5171244-2BBD-465B-BBAF-96D5C6F9A84C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x64.Build.0 = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Debug|x86.Build.0 = Debug|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x64.ActiveCfg = Release|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x64.Build.0 = Release|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x86.ActiveCfg = Release|Any CPU
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A}.Release|x86.Build.0 = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x64.Build.0 = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Debug|x86.Build.0 = Debug|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x64.ActiveCfg = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x64.Build.0 = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x86.ActiveCfg = Release|Any CPU
+ {0832D10C-DE2B-468E-82BB-229F20BC8996}.Release|x86.Build.0 = Release|Any CPU
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|Any CPU.Build.0 = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x64.ActiveCfg = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x64.Build.0 = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x86.ActiveCfg = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Debug|x86.Build.0 = Debug|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|Any CPU.ActiveCfg = Release|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|Any CPU.Build.0 = Release|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x64.ActiveCfg = Release|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x64.Build.0 = Release|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x86.ActiveCfg = Release|x64
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E}.Release|x86.Build.0 = Release|x64
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|x64.Build.0 = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Debug|x86.Build.0 = Debug|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|x64.ActiveCfg = Release|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|x64.Build.0 = Release|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|x86.ActiveCfg = Release|Any CPU
+ {5749FA85-9760-4884-9475-C760879B1953}.Release|x86.Build.0 = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x64.Build.0 = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Debug|x86.Build.0 = Debug|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|Any CPU.Build.0 = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x64.ActiveCfg = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x64.Build.0 = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x86.ActiveCfg = Release|Any CPU
+ {72DF14BC-9050-4AF3-B311-36F2A4140366}.Release|x86.Build.0 = Release|Any CPU
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|Any CPU.Build.0 = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x64.ActiveCfg = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x64.Build.0 = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x86.ActiveCfg = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Debug|x86.Build.0 = Debug|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|Any CPU.ActiveCfg = Release|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|Any CPU.Build.0 = Release|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x64.ActiveCfg = Release|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x64.Build.0 = Release|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.ActiveCfg = Release|x64
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C}.Release|x86.Build.0 = Release|x64
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {2B89086E-7B72-423F-AD6A-39CAB730F22A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {0832D10C-DE2B-468E-82BB-229F20BC8996} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {30CE3366-9C37-4095-86D0-CE59637B1D1E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {5749FA85-9760-4884-9475-C760879B1953} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {72DF14BC-9050-4AF3-B311-36F2A4140366} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {B5171244-2BBD-465B-BBAF-96D5C6F9A84C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..352efee
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,48 @@
+# Architecture
+
+## Online authentication
+
+```text
+LogonUI
+ -> SGU Credential Provider (SecureString)
+ -> HTTPS 1.1 + client certificate
+ -> SGU Auth Broker
+ -> SGU IIS NTLM endpoint (original password)
+ -> Active Directory (same original password)
+ <- domain + canonical username; never a password
+ -> Windows credential serialization (original SecureString)
+ -> LSA / Kerberos / cached domain logon
+```
+
+The broker performs the NTLM exchange and AD update before returning `200 OK`.
+It follows only HTTPS redirects whose host appears in `AllowedRedirectHosts`,
+which prevents credential forwarding to an unexpected redirect target. HTTP/1.1
+is forced because NTLM authentication is connection-bound.
+
+## Offline authentication
+
+```text
+Broker timeout, TLS failure, 429, or 5xx
+ -> provider preserves the entered username/password
+ -> Windows LSA validates against AD or its cached domain verifier
+ -> only the last AD password succeeds
+```
+
+An explicit `400` or `401` from the broker is different: the provider displays
+an error and does not serialize the rejected credential.
+
+## Account synchronization
+
+The broker normalizes the username to uppercase and accepts exactly two letters
+plus six digits. It searches `BaseDn` by `sAMAccountName`, creates the user when
+absent, moves it to the mapped OU when required, sets `userPrincipalName`, and
+passes the submitted password directly to ADSI `SetPassword`.
+
+The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
+and `Administrativos` are direct child OUs beneath it.
+
+Per-user synchronization is serialized inside the broker to prevent concurrent
+create/reset races. Production deployments should run the broker as a gMSA with
+delegated create-user, move-user, write-property, enable-account, and reset-password
+rights limited to `Usuarios-SGU` and its three managed child OUs. The lab can run it on the domain
+controller as LocalSystem.
diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md
new file mode 100644
index 0000000..684973e
--- /dev/null
+++ b/docs/lab-runbook.md
@@ -0,0 +1,144 @@
+# Hyper-V lab runbook
+
+Validated lab inventory:
+
+- Domain controller/broker: Windows Server 2025 Standard,
+ `WIN-1AIQMMA1EPR.lci.lasalle.mx`, `192.168.50.10`.
+- Client: Windows 10 Pro 22H2,
+ `DESKTOP-U1I3BNN.lci.lasalle.mx`, `192.168.50.20`.
+- Domain: `lci.lasalle.mx` / `LCI`.
+- Private switch: `Laboratorio AD`.
+
+Run guest commands from an elevated PowerShell console inside each VM. Do not
+put an institutional password on a command line or in a script file.
+
+## 1. Build on the Windows 11 host
+
+```powershell
+Set-Location C:\Users\alex\Documents\projects\SGU-CredentialProvider
+dotnet restore .\SGU-CredentialProvider.sln
+dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
+dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
+.\scripts\Publish-Lab.ps1
+```
+
+Copy `artifacts\broker` and the deployment/certificate scripts to Windows Server.
+Copy `artifacts\credential-provider` and the installation/certificate scripts to
+Windows 10. Hyper-V Guest Service Interface or an ISO can be used because the
+lab switch is private.
+
+## 2. Create non-exportable lab certificates
+
+On Windows Server:
+
+```powershell
+.\New-LabCertificate.ps1 -Role BrokerServer
+```
+
+On Windows 10:
+
+```powershell
+.\New-LabCertificate.ps1 -Role CredentialProviderClient
+```
+
+Exchange only the two generated `.cer` public files. Never move a private key.
+The helper also trusts each self-signed public certificate on the machine where
+it was created. This is required because the provider deliberately refuses
+client certificates whose chain is not locally valid.
+On Windows Server, import the client public certificate; on Windows 10, import
+the server public certificate:
+
+```powershell
+.\Import-LabPeerCertificate.ps1 -CertificatePath .\peer.cer
+```
+
+Record both reported thumbprints. For a production CA, import the issuing CA
+chain instead and leave revocation checking enabled.
+
+## 3. DNS and broker
+
+The broker VM needs an internet-capable adapter in addition to the private lab
+adapter. On Windows Server, create the broker DNS record and set explicit lab
+forwarders so public SGU resolution survives a reboot. Use the Hyper-V Default
+Switch gateway shown by `Get-NetIPConfiguration` as the first forwarder; the
+public resolvers below are lab fallbacks. Production must use organization-
+approved DNS forwarders.
+
+```powershell
+Get-NetIPConfiguration
+.\Set-LabBrokerDns.ps1 `
+ -ExternalForwarders 172.30.32.1,1.1.1.1,8.8.8.8
+Resolve-DnsName sgu-auth.lci.lasalle.mx
+Resolve-DnsName sgu.ulsa.edu.mx
+```
+
+Deploy the broker, supplying the server certificate subject and client
+certificate thumbprint:
+
+```powershell
+.\Deploy-AuthBroker.ps1 `
+ -PublishPath C:\Deploy\broker `
+ -ServerCertificateSubject sgu-auth.lci.lasalle.mx `
+ -AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
+ -CreateMissingOus `
+ -DisableCertificateRevocationCheckForLab
+```
+
+Verify the service and managed OUs:
+
+```powershell
+Get-Service SGUAuthBroker
+Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
+```
+
+## 4. Broker preflight from Windows 10
+
+Use the interactive credential prompt so the password is not placed in shell
+history:
+
+```powershell
+.\Test-Broker.ps1 `
+ -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
+ -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT
+```
+
+Verify the returned domain/username, then confirm the user exists in the mapped
+OU on Windows Server. Use separate authorized test accounts for `DO`, `AL`, and
+`AD` when available.
+
+## 5. Install the Credential Provider
+
+On Windows 10:
+
+```powershell
+.\Install-CredentialProvider.ps1 `
+ -PublishPath C:\Deploy\credential-provider `
+ -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
+ -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
+ -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
+ -InstallDotNetRuntime `
+ -DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
+```
+
+Use Lithnet's `Invoke-CredUI` test utility when available, or lock the VM and
+select **Acceso institucional SGU** under sign-in options. Keep the built-in
+Windows password tile visible.
+
+## 6. Required end-to-end cases
+
+1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
+2. Explicit bad institutional password; verify rejection and no AD password reset.
+3. Change the institutional password, log on online once, and verify the new value
+ becomes the AD password.
+4. Stop `SGUAuthBroker`; verify the last synchronized AD password still logs on
+ through Windows cached/domain validation.
+5. While the broker is stopped, verify a different password fails.
+6. Start `SGUAuthBroker`; verify online synchronization recovers.
+7. Verify the Microsoft password Credential Provider still works throughout.
+
+## Rollback
+
+Run `Uninstall-CredentialProvider.ps1` in an elevated Windows 10 session. By
+default it removes only registration; add `-RemoveFiles` after reboot when the
+COM DLL is no longer loaded. Stop/remove the `SGUAuthBroker` service and firewall
+rule separately only after clients have been rolled back.
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..d79d789
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,53 @@
+# Security model
+
+## Password handling
+
+- The Credential Provider receives the password in Lithnet's secure password
+ control and keeps that `SecureString` for Windows serialization.
+- A temporary managed string is unavoidable when creating the HTTPS JSON body.
+ The unmanaged conversion buffer is zeroed immediately with
+ `Marshal.ZeroFreeGlobalAllocUnicode`; managed references are released as soon
+ as each request completes.
+- The broker uses the exact received value for both NTLM and AD `SetPassword`.
+- There is no HMAC password, pepper, local password cache, Supabase password, or
+ other derived credential in this Windows path.
+- Neither application logs request bodies or passwords. Deployment configuration
+ contains certificate thumbprints, not passwords or private keys.
+
+## Transport
+
+- HTTPS is mandatory on both links.
+- Credential Provider to broker uses mutual TLS. The provider requires normal
+ Windows certificate validation and pins the configured server certificate.
+- The broker requires a trusted client certificate and an explicit allow-listed
+ thumbprint.
+- Client private keys are non-exportable and reside in `LocalMachine\My`.
+- The NTLM validator rejects non-HTTPS redirects, URI user information, and hosts
+ outside its explicit redirect allow-list.
+
+Lab self-signed certificates are appropriate only for the isolated VM network.
+Use an enterprise CA with revocation checking in production.
+
+## Recovery and availability
+
+The provider distinguishes an authoritative rejection from unavailable
+infrastructure:
+
+- `400`/`401`: fail closed and show an error.
+- timeout, TLS/connectivity error, `429`, or `5xx`: submit the unchanged credential
+ to Windows. Windows must still validate it against AD or the cached domain
+ verifier, so this does not grant access without the last synchronized password.
+
+The installer never registers a Credential Provider filter and never disables
+Microsoft's password, PIN, smart-card, or Windows Hello providers.
+
+## Operational controls
+
+- Limit the firewall rule to the Domain profile and required client networks.
+- Delegate the broker service account only to `OU=Usuarios-SGU` and its managed child OUs.
+- Keep broker/client certificates short lived and rotate their allow-list entries.
+- Monitor service availability and AD provisioning events without enabling HTTP
+ body logging.
+- Test uninstall and the built-in password provider before broad rollout.
+- Do not test a new build first on a physical production endpoint; use a VM with
+ a recent checkpoint and a known local administrator recovery account.
diff --git a/docs/toolchain.md b/docs/toolchain.md
new file mode 100644
index 0000000..7e9b4e8
--- /dev/null
+++ b/docs/toolchain.md
@@ -0,0 +1,37 @@
+# Toolchain
+
+## Development host
+
+- Windows 11 with Hyper-V.
+- Visual Studio 2026 with `.vsconfig` workloads.
+- .NET SDK 10.0.400 or a compatible later 10.0 feature band.
+- Windows 11 SDK 10.0.28000.
+- MSVC x64/x86 tools, CMake, Ninja, WinDbg, Git, and PowerShell 7/Windows PowerShell.
+
+The SGU additions are C#, but the repository keeps the upstream native/COM
+interop source and benefits from the full Windows desktop toolchain.
+
+## Windows Server target
+
+The broker is published self-contained for `win-x64`. It requires:
+
+- Windows Server 2025 or supported Windows Server with AD management APIs.
+- An HTTPS server certificate in `LocalMachine\My`.
+- Trusted and allow-listed client certificates.
+- Delegated AD rights for `OU=Usuarios-SGU` and its managed child OUs.
+- Network access to the configured HTTPS NTLM endpoint.
+
+## Windows client target
+
+- Windows 10 22H2 for the lab; Windows 11 is the production target.
+- Latest .NET 10 x64 runtime. Managed COM hosting cannot be self-contained.
+- Client certificate with non-exportable private key in `LocalMachine\My`.
+- Trust for the broker certificate or issuing CA.
+- Domain membership and DNS resolution for the broker.
+
+No Visual Studio, compiler, SDK, PowerShell module, or source tree is required on
+the target client.
+
+For an isolated target, download the current `Microsoft.DotNet.Runtime.10`
+offline installer on the development host, verify its publisher/signature, stage
+it with the provider, and pass its path to `Install-CredentialProvider.ps1`.
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..786b012
--- /dev/null
+++ b/global.json
@@ -0,0 +1,10 @@
+{
+ "sdk": {
+ "version": "10.0.400",
+ "rollForward": "latestFeature",
+ "allowPrerelease": false
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1
new file mode 100644
index 0000000..04e76f0
--- /dev/null
+++ b/scripts/Deploy-AuthBroker.ps1
@@ -0,0 +1,168 @@
+[CmdletBinding(SupportsShouldProcess)]
+param(
+ [Parameter(Mandatory)]
+ [string]$PublishPath,
+
+ [Parameter(Mandatory)]
+ [string]$ServerCertificateSubject,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
+ [string[]]$AllowedClientThumbprints,
+
+ [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/',
+ [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'),
+ [string]$LdapHost = 'localhost',
+ [string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
+ [string]$DomainNetbios = 'LCI',
+ [string]$UpnSuffix = 'lci.lasalle.mx',
+ [switch]$CreateMissingOus,
+ [switch]$DisableCertificateRevocationCheckForLab
+)
+
+$ErrorActionPreference = 'Stop'
+$serviceName = 'SGUAuthBroker'
+$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
+$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
+if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
+ throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
+}
+
+$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+$principal = [Security.Principal.WindowsPrincipal]::new($identity)
+if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this script from an elevated PowerShell session on the broker server.'
+}
+
+$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
+ Where-Object { $_.Subject -like "*$ServerCertificateSubject*" -and $_.HasPrivateKey } |
+ Sort-Object NotAfter -Descending |
+ Select-Object -First 1
+if (-not $serverCertificate) {
+ throw 'The HTTPS server certificate with private key was not found in LocalMachine\My.'
+}
+if (-not $serverCertificate.Verify()) {
+ throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
+}
+
+if ($CreateMissingOus) {
+ Import-Module ActiveDirectory -ErrorAction Stop
+ $usersOuName = 'Usuarios-SGU'
+ $usersOuDn = "OU=$usersOuName,$BaseDn"
+ if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
+ New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
+ }
+
+ foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
+ $targetOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
+ if ($targetOu) {
+ if (-not $targetOu.ProtectedFromAccidentalDeletion) {
+ $targetOuDn = [string]$targetOu.DistinguishedName
+ Set-ADOrganizationalUnit -Identity $targetOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
+ }
+ continue
+ }
+
+ $legacyOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
+ if ($legacyOu) {
+ $legacyOuDn = [string]$legacyOu.DistinguishedName
+ try {
+ if ($legacyOu.ProtectedFromAccidentalDeletion) {
+ Set-ADOrganizationalUnit -Identity $legacyOuDn -ProtectedFromAccidentalDeletion $false -Server $LdapHost -Confirm:$false
+ Start-Sleep -Seconds 1
+ }
+ Move-ADObject -Identity $legacyOuDn -TargetPath $usersOuDn -Server $LdapHost -Confirm:$false -ErrorAction Stop
+ }
+ finally {
+ $currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
+ if (-not $currentOu) {
+ $currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
+ }
+ if ($currentOu) {
+ $currentOuDn = [string]$currentOu.DistinguishedName
+ Set-ADOrganizationalUnit -Identity $currentOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
+ }
+ }
+ }
+ else {
+ New-ADOrganizationalUnit -Name $ouName -Path $usersOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
+ }
+ }
+}
+
+foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
+ if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
+ throw "PublishPath is missing $file."
+ }
+}
+
+$productionSettings = @{
+ Kestrel = @{
+ Endpoints = @{
+ Https = @{
+ Url = 'https://0.0.0.0:8443'
+ Certificate = @{
+ Subject = $ServerCertificateSubject
+ Store = 'My'
+ Location = 'LocalMachine'
+ AllowInvalid = $false
+ }
+ }
+ }
+ }
+ Broker = @{
+ Tls = @{
+ AllowedClientThumbprints = $normalizedClientThumbprints
+ CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
+ }
+ Ntlm = @{
+ Endpoint = $NtlmEndpoint
+ Domain = ''
+ TimeoutSeconds = 15
+ MaxRedirects = 5
+ AllowedRedirectHosts = $AllowedNtlmRedirectHosts
+ }
+ Directory = @{
+ LdapHost = $LdapHost
+ BaseDn = $BaseDn
+ DomainNetbios = $DomainNetbios
+ UpnSuffix = $UpnSuffix
+ ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
+ StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
+ AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
+ CreateMissingOus = [bool]$CreateMissingOus
+ }
+ }
+}
+
+if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
+ if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
+ Stop-Service -Name $serviceName -Force
+ }
+
+ New-Item -ItemType Directory -Path $installPath -Force | Out-Null
+ Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
+ $settingsJson = $productionSettings | ConvertTo-Json -Depth 8
+ $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
+ [System.IO.File]::WriteAllText(
+ (Join-Path $installPath 'appsettings.Production.json'),
+ $settingsJson,
+ $utf8WithoutBom)
+
+ if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
+ New-Service -Name $serviceName `
+ -DisplayName 'SGU Authentication Broker' `
+ -Description 'Validates SGU NTLM credentials and synchronizes Active Directory accounts.' `
+ -BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
+ -StartupType Automatic
+ }
+
+ if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
+ New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
+ -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
+ }
+
+ Start-Service -Name $serviceName
+}
+
+Get-Service -Name $serviceName | Select-Object Name, Status, StartType
diff --git a/scripts/Import-LabPeerCertificate.ps1 b/scripts/Import-LabPeerCertificate.ps1
new file mode 100644
index 0000000..cc9c9ca
--- /dev/null
+++ b/scripts/Import-LabPeerCertificate.ps1
@@ -0,0 +1,15 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string]$CertificatePath
+)
+
+$ErrorActionPreference = 'Stop'
+$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+$principal = [Security.Principal.WindowsPrincipal]::new($identity)
+if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this script from an elevated PowerShell session.'
+}
+
+$certificate = Import-Certificate -FilePath $CertificatePath -CertStoreLocation Cert:\LocalMachine\Root
+$certificate | Select-Object Subject, Thumbprint, NotAfter
diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1
new file mode 100644
index 0000000..3c2549b
--- /dev/null
+++ b/scripts/Install-CredentialProvider.ps1
@@ -0,0 +1,160 @@
+[CmdletBinding(SupportsShouldProcess)]
+param(
+ [Parameter(Mandatory)]
+ [string]$PublishPath,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^https://')]
+ [string]$BrokerEndpoint,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
+ [string]$ClientCertificateThumbprint,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
+ [string]$ServerCertificateThumbprint,
+
+ [string]$DomainNetbios = 'LCI',
+
+ [ValidateRange(2, 30)]
+ [int]$TimeoutSeconds = 6,
+
+ [switch]$InstallDotNetRuntime,
+
+ [string]$DotNetRuntimeInstallerPath
+)
+
+$ErrorActionPreference = 'Stop'
+$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
+$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
+$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
+$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
+$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
+
+$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+$principal = [Security.Principal.WindowsPrincipal]::new($identity)
+if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this script from an elevated PowerShell session.'
+}
+
+function Test-DotNet10Runtime {
+ $dotnetCandidates = @(
+ (Get-Command dotnet -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
+ (Join-Path $env:ProgramFiles 'dotnet\dotnet.exe')
+ ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique
+
+ foreach ($dotnet in $dotnetCandidates) {
+ if (& $dotnet --list-runtimes | Select-String '^Microsoft\.NETCore\.App 10\.') {
+ return $true
+ }
+ }
+ return $false
+}
+
+if (-not (Test-DotNet10Runtime)) {
+ if (-not $InstallDotNetRuntime) {
+ throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
+ }
+
+ if ($DotNetRuntimeInstallerPath) {
+ if (-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
+ throw 'DotNetRuntimeInstallerPath does not exist.'
+ }
+
+ $runtimeInstaller = Start-Process -FilePath $DotNetRuntimeInstallerPath `
+ -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru
+ if ($runtimeInstaller.ExitCode -notin @(0, 1641, 3010)) {
+ throw "The Microsoft .NET 10 runtime installer returned $($runtimeInstaller.ExitCode)."
+ }
+ }
+ else {
+ $winget = Get-Command winget -ErrorAction SilentlyContinue
+ if (-not $winget) {
+ throw 'winget is unavailable. Supply the offline installer with -DotNetRuntimeInstallerPath.'
+ }
+
+ & $winget.Source install --id Microsoft.DotNet.Runtime.10 --exact --silent `
+ --accept-package-agreements --accept-source-agreements --disable-interactivity
+ if ($LASTEXITCODE -ne 0) {
+ throw 'winget could not install the Microsoft .NET 10 runtime.'
+ }
+ }
+
+ if (-not (Test-DotNet10Runtime)) {
+ throw 'The Microsoft .NET 10 runtime installation failed.'
+ }
+}
+
+$requiredFiles = @(
+ 'SGU.CredentialProvider.dll',
+ 'SGU.CredentialProvider.comhost.dll',
+ 'SGU.CredentialProvider.runtimeconfig.json',
+ 'SGU.CredentialProvider.deps.json',
+ 'Lithnet.CredentialProvider.dll',
+ 'SGU.AuthBroker.Core.dll'
+)
+foreach ($file in $requiredFiles) {
+ if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
+ throw "PublishPath is missing $file."
+ }
+}
+
+$clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
+$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
+if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
+ throw 'Certificate thumbprints must contain exactly 40 hexadecimal characters.'
+}
+$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
+ Where-Object Thumbprint -eq $clientThumbprint |
+ Select-Object -First 1
+if (-not $clientCertificate -or -not $clientCertificate.HasPrivateKey) {
+ throw 'The client certificate with private key is not installed in LocalMachine\My.'
+}
+if (-not $clientCertificate.Verify()) {
+ throw 'The client certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
+}
+
+$serverCertificate = Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA | Where-Object Thumbprint -eq $serverThumbprint
+if (-not $serverCertificate) {
+ throw 'The broker server certificate or its issuing CA is not trusted by LocalMachine.'
+}
+
+if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
+ New-Item -ItemType Directory -Path $installPath -Force | Out-Null
+ Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
+
+ New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
+ $settingsJson = @{
+ BrokerEndpoint = $BrokerEndpoint
+ DomainNetbios = $DomainNetbios
+ TimeoutSeconds = $TimeoutSeconds
+ ClientCertificateThumbprint = $clientThumbprint
+ ServerCertificateThumbprint = $serverThumbprint
+ } | ConvertTo-Json
+ $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
+ [System.IO.File]::WriteAllText($settingsPath, $settingsJson, $utf8WithoutBom)
+
+ $acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
+ $acl.SetAccessRuleProtection($true, $false)
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
+ 'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+ $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
+ 'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+ Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
+
+ New-Item -Path $classRegistryPath -Force | Out-Null
+ Set-Item -Path $classRegistryPath -Value (Join-Path $installPath 'SGU.CredentialProvider.comhost.dll')
+ New-ItemProperty -Path $classRegistryPath -Name ThreadingModel -Value Both -PropertyType String -Force | Out-Null
+
+ New-Item -Path $providerRegistryPath -Force | Out-Null
+ Set-Item -Path $providerRegistryPath -Value 'SGU Institutional Login'
+}
+
+[pscustomobject]@{
+ ProviderClassId = $providerClassId
+ InstallPath = $installPath
+ SettingsPath = $settingsPath
+ Registered = Test-Path -LiteralPath $providerRegistryPath
+ SystemPasswordProviderPreserved = $true
+}
diff --git a/scripts/New-LabCertificate.ps1 b/scripts/New-LabCertificate.ps1
new file mode 100644
index 0000000..d956fab
--- /dev/null
+++ b/scripts/New-LabCertificate.ps1
@@ -0,0 +1,52 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [ValidateSet('BrokerServer', 'CredentialProviderClient')]
+ [string]$Role,
+
+ [string]$BrokerDnsName = 'sgu-auth.lci.lasalle.mx',
+ [string]$OutputDirectory = "$env:PUBLIC\Documents\SGU-Certificates"
+)
+
+$ErrorActionPreference = 'Stop'
+$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+$principal = [Security.Principal.WindowsPrincipal]::new($identity)
+if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this script from an elevated PowerShell session.'
+}
+
+New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
+
+if ($Role -eq 'BrokerServer') {
+ $certificate = New-SelfSignedCertificate `
+ -DnsName $BrokerDnsName `
+ -CertStoreLocation Cert:\LocalMachine\My `
+ -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 `
+ -KeyExportPolicy NonExportable `
+ -NotAfter (Get-Date).AddYears(2) `
+ -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.1')
+ $output = Join-Path $OutputDirectory 'sgu-auth-broker.cer'
+}
+else {
+ $certificate = New-SelfSignedCertificate `
+ -Subject 'CN=SGU Credential Provider Client' `
+ -CertStoreLocation Cert:\LocalMachine\My `
+ -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 `
+ -KeyExportPolicy NonExportable `
+ -NotAfter (Get-Date).AddYears(2) `
+ -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
+ $output = Join-Path $OutputDirectory 'sgu-credential-provider-client.cer'
+}
+
+Export-Certificate -Cert $certificate -FilePath $output -Force | Out-Null
+# These certificates are self-signed end-entity certificates. Trust the public
+# half locally as well as on the peer so valid-only certificate lookup and the
+# local TLS server both reject expired/untrusted lab certificates deterministically.
+Import-Certificate -FilePath $output -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
+[pscustomobject]@{
+ Role = $Role
+ Thumbprint = $certificate.Thumbprint
+ PublicCertificatePath = $output
+ PrivateKeyExportable = $false
+ TrustedLocally = $certificate.Verify()
+}
diff --git a/scripts/Publish-Lab.ps1 b/scripts/Publish-Lab.ps1
new file mode 100644
index 0000000..931229b
--- /dev/null
+++ b/scripts/Publish-Lab.ps1
@@ -0,0 +1,44 @@
+[CmdletBinding()]
+param(
+ [string]$Configuration = 'Release',
+ [string]$OutputRoot = (Join-Path $PSScriptRoot '..\artifacts')
+)
+
+$ErrorActionPreference = 'Stop'
+$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+$dotnet = (Get-Command dotnet -ErrorAction Stop).Source
+
+$brokerOutput = Join-Path $OutputRoot 'broker'
+$providerOutput = Join-Path $OutputRoot 'credential-provider'
+
+& $dotnet publish (Join-Path $repositoryRoot 'src\SGU.AuthBroker\SGU.AuthBroker.csproj') `
+ --configuration $Configuration `
+ --runtime win-x64 `
+ --self-contained true `
+ --output $brokerOutput
+if ($LASTEXITCODE -ne 0) { throw 'Auth Broker publish failed.' }
+
+& $dotnet publish (Join-Path $repositoryRoot 'src\SGU.CredentialProvider\SGU.CredentialProvider.csproj') `
+ --configuration $Configuration `
+ --runtime win-x64 `
+ --self-contained false `
+ --output $providerOutput
+if ($LASTEXITCODE -ne 0) { throw 'Credential Provider publish failed.' }
+
+$requiredProviderFiles = @(
+ 'SGU.CredentialProvider.dll',
+ 'SGU.CredentialProvider.comhost.dll',
+ 'SGU.CredentialProvider.runtimeconfig.json',
+ 'Lithnet.CredentialProvider.dll',
+ 'SGU.AuthBroker.Core.dll'
+)
+foreach ($file in $requiredProviderFiles) {
+ if (-not (Test-Path -LiteralPath (Join-Path $providerOutput $file))) {
+ throw "Credential Provider output is missing $file."
+ }
+}
+
+[pscustomobject]@{
+ BrokerOutput = (Resolve-Path $brokerOutput).Path
+ CredentialProviderOutput = (Resolve-Path $providerOutput).Path
+}
diff --git a/scripts/Set-LabBrokerDns.ps1 b/scripts/Set-LabBrokerDns.ps1
new file mode 100644
index 0000000..08dad65
--- /dev/null
+++ b/scripts/Set-LabBrokerDns.ps1
@@ -0,0 +1,31 @@
+[CmdletBinding()]
+param(
+ [string]$ZoneName = 'lci.lasalle.mx',
+ [string]$RecordName = 'sgu-auth',
+ [ipaddress]$IPv4Address = '192.168.50.10',
+
+ [ipaddress[]]$ExternalForwarders = @()
+)
+
+$ErrorActionPreference = 'Stop'
+$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
+if ($existing) {
+ $current = @($existing.RecordData.IPv4Address.IPAddressToString)
+ if ($current -notcontains $IPv4Address.IPAddressToString) {
+ throw "$RecordName.$ZoneName already exists with a different address: $($current -join ', ')."
+ }
+}
+else {
+ Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
+}
+
+if ($ExternalForwarders.Count -gt 0) {
+ Set-DnsServerForwarder -IPAddress $ExternalForwarders -UseRootHint $false
+ Clear-DnsServerCache -Force
+ Clear-DnsClientCache
+}
+
+[pscustomobject]@{
+ BrokerRecord = Resolve-DnsName "$RecordName.$ZoneName" | Select-Object Name, Type, IPAddress
+ ExternalForwarders = @(Get-DnsServerForwarder | Select-Object -ExpandProperty IPAddress | ForEach-Object IPAddressToString)
+}
diff --git a/scripts/Test-Broker.ps1 b/scripts/Test-Broker.ps1
new file mode 100644
index 0000000..a3b5413
--- /dev/null
+++ b/scripts/Test-Broker.ps1
@@ -0,0 +1,32 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^https://')]
+ [string]$BrokerEndpoint,
+
+ [Parameter(Mandatory)]
+ [string]$ClientCertificateThumbprint
+)
+
+$ErrorActionPreference = 'Stop'
+$credential = Get-Credential -Message 'Enter an institutional DO, AL, or AD account. The password is sent only to the configured mTLS broker.'
+$clave = $credential.UserName -replace '^.*\\', '' -replace '@.*$', ''
+$password = $credential.GetNetworkCredential().Password
+
+$certificate = Get-ChildItem Cert:\LocalMachine\My |
+ Where-Object Thumbprint -eq ($ClientCertificateThumbprint -replace ' ', '') |
+ Select-Object -First 1
+if (-not $certificate -or -not $certificate.HasPrivateKey) {
+ throw 'The client certificate with private key was not found in LocalMachine\My.'
+}
+
+try {
+ $body = @{ clave = $clave; password = $password } | ConvertTo-Json -Compress
+ Invoke-RestMethod -Method Post -Uri $BrokerEndpoint -Certificate $certificate `
+ -ContentType 'application/json' -Body $body -TimeoutSec 20
+}
+finally {
+ $password = $null
+ $body = $null
+ $credential = $null
+}
diff --git a/scripts/Uninstall-CredentialProvider.ps1 b/scripts/Uninstall-CredentialProvider.ps1
new file mode 100644
index 0000000..48aea3f
--- /dev/null
+++ b/scripts/Uninstall-CredentialProvider.ps1
@@ -0,0 +1,21 @@
+[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
+param(
+ [switch]$RemoveFiles
+)
+
+$ErrorActionPreference = 'Stop'
+$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
+$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
+$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
+$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId"
+
+if ($PSCmdlet.ShouldProcess($providerClassId, 'Unregister the SGU Credential Provider')) {
+ Remove-Item -LiteralPath $providerRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $classRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+if ($RemoveFiles -and $PSCmdlet.ShouldProcess($installPath, 'Remove Credential Provider files')) {
+ Remove-Item -LiteralPath $installPath -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Output 'The built-in Windows password Credential Provider was not changed.'
diff --git a/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs b/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs
new file mode 100644
index 0000000..9610f2f
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Authentication/AuthenticationFlowResult.cs
@@ -0,0 +1,18 @@
+using SGU.AuthBroker.Core.Directory;
+using SGU.AuthBroker.Core.Identity;
+
+namespace SGU.AuthBroker.Core.Authentication;
+
+public enum AuthenticationFlowOutcome
+{
+ Authorized,
+ InvalidCredentials,
+ InvalidUserName,
+ Unavailable
+}
+
+public sealed record AuthenticationFlowResult(
+ AuthenticationFlowOutcome Outcome,
+ string? ErrorCode = null,
+ UserIdentity? Identity = null,
+ DirectorySyncResult? Directory = null);
diff --git a/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs
new file mode 100644
index 0000000..d22b842
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs
@@ -0,0 +1,80 @@
+using SGU.AuthBroker.Core.Directory;
+using SGU.AuthBroker.Core.Identity;
+
+namespace SGU.AuthBroker.Core.Authentication;
+
+public sealed class AuthenticationWorkflow(
+ INtlmCredentialValidator ntlmValidator,
+ IActiveDirectorySynchronizer directorySynchronizer)
+{
+ public async Task AuthenticateAsync(
+ string userName,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ if (!UserIdentityClassifier.TryParse(userName, out UserIdentity? identity) || identity is null)
+ {
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.InvalidUserName,
+ "INVALID_USERNAME_FORMAT");
+ }
+
+ NtlmValidationResult validation;
+ try
+ {
+ validation = await ntlmValidator
+ .ValidateAsync(identity.UserName, password, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.Unavailable,
+ "INSTITUTIONAL_AUTHORITY_UNAVAILABLE",
+ identity);
+ }
+
+ if (validation.Status == NtlmValidationStatus.Invalid)
+ {
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.InvalidCredentials,
+ validation.ErrorCode,
+ identity);
+ }
+
+ if (validation.Status == NtlmValidationStatus.Unavailable)
+ {
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.Unavailable,
+ validation.ErrorCode,
+ identity);
+ }
+
+ try
+ {
+ DirectorySyncResult directory = await directorySynchronizer
+ .SynchronizeAsync(identity, password, cancellationToken)
+ .ConfigureAwait(false);
+
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.Authorized,
+ Identity: identity,
+ Directory: directory);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return new AuthenticationFlowResult(
+ AuthenticationFlowOutcome.Unavailable,
+ "DIRECTORY_SYNCHRONIZATION_FAILED",
+ identity);
+ }
+ }
+}
diff --git a/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs
new file mode 100644
index 0000000..703fcfd
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs
@@ -0,0 +1,9 @@
+namespace SGU.AuthBroker.Core.Authentication;
+
+public interface INtlmCredentialValidator
+{
+ Task ValidateAsync(
+ string userName,
+ string password,
+ CancellationToken cancellationToken);
+}
diff --git a/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs
new file mode 100644
index 0000000..2d1d1ff
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs
@@ -0,0 +1,18 @@
+namespace SGU.AuthBroker.Core.Authentication;
+
+public enum NtlmValidationStatus
+{
+ Valid,
+ Invalid,
+ Unavailable
+}
+
+public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null)
+{
+ public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid);
+
+ public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
+
+ public static NtlmValidationResult Unavailable(string errorCode = "INSTITUTIONAL_AUTHORITY_UNAVAILABLE") =>
+ new(NtlmValidationStatus.Unavailable, errorCode);
+}
diff --git a/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs b/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs
new file mode 100644
index 0000000..b4a4592
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Directory/DirectorySyncResult.cs
@@ -0,0 +1,8 @@
+namespace SGU.AuthBroker.Core.Directory;
+
+public sealed record DirectorySyncResult(
+ string DomainNetbios,
+ string UserName,
+ string UserPrincipalName,
+ bool Created,
+ bool Moved);
diff --git a/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs
new file mode 100644
index 0000000..4674c8d
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs
@@ -0,0 +1,11 @@
+using SGU.AuthBroker.Core.Identity;
+
+namespace SGU.AuthBroker.Core.Directory;
+
+public interface IActiveDirectorySynchronizer
+{
+ Task SynchronizeAsync(
+ UserIdentity identity,
+ string password,
+ CancellationToken cancellationToken);
+}
diff --git a/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs b/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs
new file mode 100644
index 0000000..1a29a21
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Identity/InstitutionalRole.cs
@@ -0,0 +1,8 @@
+namespace SGU.AuthBroker.Core.Identity;
+
+public enum InstitutionalRole
+{
+ Professor,
+ Student,
+ Administrative
+}
diff --git a/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs b/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs
new file mode 100644
index 0000000..2345f17
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Identity/UserIdentity.cs
@@ -0,0 +1,7 @@
+namespace SGU.AuthBroker.Core.Identity;
+
+public sealed record UserIdentity(
+ string UserName,
+ string Prefix,
+ string NumericId,
+ InstitutionalRole Role);
diff --git a/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs b/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs
new file mode 100644
index 0000000..39f1bb0
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/Identity/UserIdentityClassifier.cs
@@ -0,0 +1,50 @@
+using System.Text.RegularExpressions;
+
+namespace SGU.AuthBroker.Core.Identity;
+
+public static partial class UserIdentityClassifier
+{
+ public static bool TryParse(string? value, out UserIdentity? identity)
+ {
+ identity = null;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ string candidate = value.Trim();
+ int slash = candidate.LastIndexOf('\\');
+ if (slash >= 0)
+ {
+ candidate = candidate[(slash + 1)..];
+ }
+
+ int at = candidate.IndexOf('@');
+ if (at >= 0)
+ {
+ candidate = candidate[..at];
+ }
+
+ Match match = InstitutionalUserName().Match(candidate);
+ if (!match.Success)
+ {
+ return false;
+ }
+
+ string prefix = match.Groups["prefix"].Value.ToUpperInvariant();
+ InstitutionalRole role = prefix switch
+ {
+ "DO" => InstitutionalRole.Professor,
+ "AL" => InstitutionalRole.Student,
+ "AD" => InstitutionalRole.Administrative,
+ _ => throw new InvalidOperationException("Validated prefix was not mapped.")
+ };
+
+ string numericId = match.Groups["id"].Value;
+ identity = new UserIdentity(prefix + numericId, prefix, numericId, role);
+ return true;
+ }
+
+ [GeneratedRegex("^(?DO|AL|AD)(?[0-9]{6})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex InstitutionalUserName();
+}
diff --git a/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj b/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj
new file mode 100644
index 0000000..b061b37
--- /dev/null
+++ b/src/SGU.AuthBroker.Core/SGU.AuthBroker.Core.csproj
@@ -0,0 +1,7 @@
+
+
+ net10.0
+ SGU.AuthBroker.Core
+ SGU.AuthBroker.Core
+
+
diff --git a/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs b/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs
new file mode 100644
index 0000000..1d71382
--- /dev/null
+++ b/src/SGU.AuthBroker/Contracts/AuthenticationRequest.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace SGU.AuthBroker.Contracts;
+
+public sealed class AuthenticationRequest
+{
+ [JsonPropertyName("clave")]
+ public string Clave { get; set; } = string.Empty;
+
+ [JsonPropertyName("password")]
+ public string Password { get; set; } = string.Empty;
+
+ public void ReleasePasswordReference() => Password = string.Empty;
+}
diff --git a/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs b/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs
new file mode 100644
index 0000000..69ed387
--- /dev/null
+++ b/src/SGU.AuthBroker/Contracts/AuthenticationResponse.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace SGU.AuthBroker.Contracts;
+
+public sealed record AuthenticationResponse(
+ [property: JsonPropertyName("domain")] string Domain,
+ [property: JsonPropertyName("username")] string UserName,
+ [property: JsonPropertyName("upn")] string UserPrincipalName,
+ [property: JsonPropertyName("created")] bool Created,
+ [property: JsonPropertyName("moved")] bool Moved);
+
+public sealed record ErrorResponse(
+ [property: JsonPropertyName("code")] string Code,
+ [property: JsonPropertyName("message")] string Message);
diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs
new file mode 100644
index 0000000..b899a13
--- /dev/null
+++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs
@@ -0,0 +1,114 @@
+using SGU.AuthBroker.Core.Identity;
+
+namespace SGU.AuthBroker.Options;
+
+public sealed class BrokerOptions
+{
+ public const string SectionName = "Broker";
+
+ public TlsOptions Tls { get; init; } = new();
+
+ public NtlmOptions Ntlm { get; init; } = new();
+
+ public ActiveDirectoryOptions Directory { get; init; } = new();
+
+ public void Validate()
+ {
+ if (Tls.AllowedClientThumbprints.Length == 0 ||
+ Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value)))
+ {
+ throw new InvalidOperationException("At least one client certificate thumbprint is required.");
+ }
+
+ if (!Uri.TryCreate(Ntlm.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme != Uri.UriSchemeHttps)
+ {
+ throw new InvalidOperationException("The institutional NTLM endpoint must be an absolute HTTPS URL.");
+ }
+
+ if (Ntlm.AllowedRedirectHosts.Length == 0 ||
+ !Ntlm.AllowedRedirectHosts.Contains(endpoint.IdnHost, StringComparer.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("The NTLM endpoint host must be present in AllowedRedirectHosts.");
+ }
+
+ if (Ntlm.TimeoutSeconds is < 2 or > 60 || Ntlm.MaxRedirects is < 0 or > 10)
+ {
+ throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
+ }
+
+ if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
+ string.IsNullOrWhiteSpace(Directory.BaseDn) ||
+ string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
+ string.IsNullOrWhiteSpace(Directory.UpnSuffix))
+ {
+ throw new InvalidOperationException("Active Directory connection and domain settings are required.");
+ }
+
+ foreach (InstitutionalRole role in Enum.GetValues())
+ {
+ string ouDn = Directory.GetOuDn(role);
+ if (string.IsNullOrWhiteSpace(ouDn))
+ {
+ throw new InvalidOperationException($"An OU mapping is required for {role}.");
+ }
+
+ if (!ouDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
+ }
+ }
+ }
+
+ private static bool IsCertificateThumbprint(string value)
+ {
+ string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
+ return normalized.Length == 40 && normalized.All(Uri.IsHexDigit);
+ }
+}
+
+public sealed class TlsOptions
+{
+ public string[] AllowedClientThumbprints { get; init; } = [];
+
+ public bool CheckCertificateRevocation { get; init; } = true;
+}
+
+public sealed class NtlmOptions
+{
+ public string Endpoint { get; init; } = "https://sgu.ulsa.edu.mx/";
+
+ public string Domain { get; init; } = string.Empty;
+
+ public int TimeoutSeconds { get; init; } = 15;
+
+ public int MaxRedirects { get; init; } = 5;
+
+ public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
+}
+
+public sealed class ActiveDirectoryOptions
+{
+ public string LdapHost { get; init; } = "localhost";
+
+ public string BaseDn { get; init; } = "DC=lci,DC=lasalle,DC=mx";
+
+ public string DomainNetbios { get; init; } = "LCI";
+
+ public string UpnSuffix { get; init; } = "lci.lasalle.mx";
+
+ public string ProfessorOuDn { get; init; } = "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
+
+ public string StudentOuDn { get; init; } = "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
+
+ public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
+
+ public bool CreateMissingOus { get; init; }
+
+ public string GetOuDn(InstitutionalRole role) => role switch
+ {
+ InstitutionalRole.Professor => ProfessorOuDn,
+ InstitutionalRole.Student => StudentOuDn,
+ InstitutionalRole.Administrative => AdministrativeOuDn,
+ _ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
+ };
+}
diff --git a/src/SGU.AuthBroker/Program.cs b/src/SGU.AuthBroker/Program.cs
new file mode 100644
index 0000000..6db45c9
--- /dev/null
+++ b/src/SGU.AuthBroker/Program.cs
@@ -0,0 +1,127 @@
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using SGU.AuthBroker.Contracts;
+using SGU.AuthBroker.Core.Authentication;
+using SGU.AuthBroker.Core.Directory;
+using SGU.AuthBroker.Options;
+using SGU.AuthBroker.Services;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
+
+BrokerOptions brokerOptions = builder.Configuration
+ .GetSection(BrokerOptions.SectionName)
+ .Get() ?? throw new InvalidOperationException("Broker configuration is missing.");
+brokerOptions.Validate();
+
+HashSet allowedClientThumbprints = brokerOptions.Tls.AllowedClientThumbprints
+ .Select(NormalizeThumbprint)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+builder.WebHost.ConfigureKestrel(kestrel =>
+{
+ kestrel.AddServerHeader = false;
+ kestrel.Limits.MaxRequestBodySize = 4096;
+ kestrel.ConfigureHttpsDefaults(https =>
+ {
+ https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
+ https.CheckCertificateRevocation = brokerOptions.Tls.CheckCertificateRevocation;
+ https.ClientCertificateValidation = (certificate, _, policyErrors) =>
+ policyErrors == SslPolicyErrors.None &&
+ allowedClientThumbprints.Contains(NormalizeThumbprint(certificate.Thumbprint));
+ });
+});
+
+builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
+builder.Services.AddSingleton(brokerOptions);
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddRateLimiter(options =>
+{
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.AddPolicy("credential-auth", context =>
+ {
+ string partition = NormalizeThumbprint(context.Connection.ClientCertificate?.Thumbprint ?? "none");
+ return RateLimitPartition.GetFixedWindowLimiter(partition, _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 12,
+ QueueLimit = 0,
+ Window = TimeSpan.FromMinutes(1),
+ AutoReplenishment = true
+ });
+ });
+});
+
+WebApplication app = builder.Build();
+app.UseRateLimiter();
+app.Use(async (context, next) =>
+{
+ context.Response.Headers.CacheControl = "no-store";
+ context.Response.Headers.Pragma = "no-cache";
+ context.Response.Headers["X-Content-Type-Options"] = "nosniff";
+ await next(context).ConfigureAwait(false);
+});
+
+app.MapGet("/health/live", () => Results.Ok(new { status = "ok" }));
+
+app.MapPost("/v1/authenticate", async (
+ AuthenticationRequest request,
+ AuthenticationWorkflow workflow,
+ HttpContext context,
+ CancellationToken cancellationToken) =>
+{
+ if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
+ {
+ request.ReleasePasswordReference();
+ return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
+ }
+
+ try
+ {
+ AuthenticationFlowResult result = await workflow
+ .AuthenticateAsync(request.Clave, request.Password, cancellationToken)
+ .ConfigureAwait(false);
+
+ return result.Outcome switch
+ {
+ AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
+ result.Directory!.DomainNetbios,
+ result.Directory.UserName,
+ result.Directory.UserPrincipalName,
+ result.Directory.Created,
+ result.Directory.Moved)),
+
+ AuthenticationFlowOutcome.InvalidUserName => Results.BadRequest(new ErrorResponse(
+ result.ErrorCode ?? "INVALID_USERNAME_FORMAT",
+ "La clave debe usar DO, AL o AD seguido de seis dígitos.")),
+
+ AuthenticationFlowOutcome.InvalidCredentials => Results.Json(
+ new ErrorResponse(
+ result.ErrorCode ?? "INVALID_INSTITUTIONAL_CREDENTIALS",
+ "Credenciales institucionales inválidas."),
+ statusCode: StatusCodes.Status401Unauthorized),
+
+ _ => Unavailable(context, result.ErrorCode)
+ };
+ }
+ finally
+ {
+ request.ReleasePasswordReference();
+ }
+}).RequireRateLimiting("credential-auth");
+
+app.Run();
+
+static IResult Unavailable(HttpContext context, string? errorCode)
+{
+ context.Response.Headers.RetryAfter = "2";
+ return Results.Json(
+ new ErrorResponse(errorCode ?? "AUTHENTICATION_SERVICE_UNAVAILABLE", "El servicio no está disponible."),
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+}
+
+static string NormalizeThumbprint(string value) =>
+ value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
diff --git a/src/SGU.AuthBroker/SGU.AuthBroker.csproj b/src/SGU.AuthBroker/SGU.AuthBroker.csproj
new file mode 100644
index 0000000..5930771
--- /dev/null
+++ b/src/SGU.AuthBroker/SGU.AuthBroker.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0-windows
+ win-x64
+ SGU.AuthBroker
+ SGU.AuthBroker
+ false
+
+
+
+
+
+
+
+
diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs
new file mode 100644
index 0000000..eb1ac37
--- /dev/null
+++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs
@@ -0,0 +1,198 @@
+using System.Collections.Concurrent;
+using System.DirectoryServices;
+using SGU.AuthBroker.Core.Directory;
+using SGU.AuthBroker.Core.Identity;
+using SGU.AuthBroker.Options;
+
+namespace SGU.AuthBroker.Services;
+
+public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
+{
+ private const int AccountDisabled = 0x0002;
+ private const int NormalAccount = 0x0200;
+ private static readonly AuthenticationTypes BindFlags =
+ AuthenticationTypes.Secure | AuthenticationTypes.Signing | AuthenticationTypes.Sealing;
+
+ private readonly ActiveDirectoryOptions options = options.Directory;
+ private readonly ConcurrentDictionary userLocks =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public async Task SynchronizeAsync(
+ UserIdentity identity,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return await Task.Run(
+ () => Synchronize(identity, password),
+ cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ gate.Release();
+ if (gate.CurrentCount == 1)
+ {
+ userLocks.TryRemove(new KeyValuePair(identity.UserName, gate));
+ }
+ }
+ }
+
+ private DirectorySyncResult Synchronize(UserIdentity identity, string password)
+ {
+ string targetOuDn = options.GetOuDn(identity.Role);
+ using DirectoryEntry root = Bind(options.BaseDn);
+ using DirectoryEntry targetOu = BindOrCreateOu(targetOuDn, root);
+
+ using DirectorySearcher searcher = new(root)
+ {
+ Filter = $"(&(objectCategory=person)(objectClass=user)(sAMAccountName={EscapeLdapFilter(identity.UserName)}))",
+ SearchScope = SearchScope.Subtree,
+ PageSize = 1,
+ SizeLimit = 1
+ };
+ searcher.PropertiesToLoad.Add("distinguishedName");
+
+ SearchResult? result = searcher.FindOne();
+ bool created = result is null;
+ bool moved = false;
+ DirectoryEntry? user = null;
+
+ try
+ {
+ if (created)
+ {
+ user = targetOu.Children.Add($"CN={EscapeRdn(identity.UserName)}", "user");
+ user.Properties["sAMAccountName"].Value = identity.UserName;
+ user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
+ user.Properties["displayName"].Value = identity.UserName;
+ user.CommitChanges();
+ }
+ else
+ {
+ user = result!.GetDirectoryEntry();
+ string distinguishedName = Convert.ToString(user.Properties["distinguishedName"].Value) ?? string.Empty;
+ string parentDn = ParentDn(distinguishedName);
+ if (!string.Equals(parentDn, targetOuDn, StringComparison.OrdinalIgnoreCase))
+ {
+ user.MoveTo(targetOu);
+ moved = true;
+ }
+
+ user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
+ user.CommitChanges();
+ }
+
+ // The exact institutional password received by the broker is passed to AD.
+ // It is not derived, transformed, written to disk, or included in logs.
+ user.Invoke("SetPassword", [password]);
+ int flags = user.Properties["userAccountControl"].Value is int currentFlags
+ ? currentFlags
+ : NormalAccount;
+ user.Properties["userAccountControl"].Value = (flags | NormalAccount) & ~AccountDisabled;
+ user.Properties["pwdLastSet"].Value = -1;
+ user.CommitChanges();
+
+ return new DirectorySyncResult(
+ options.DomainNetbios,
+ identity.UserName,
+ $"{identity.UserName}@{options.UpnSuffix}",
+ created,
+ moved);
+ }
+ finally
+ {
+ user?.Dispose();
+ }
+ }
+
+ private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
+ {
+ try
+ {
+ DirectoryEntry existing = Bind(ouDn);
+ _ = existing.NativeObject;
+ return existing;
+ }
+ catch (DirectoryServicesCOMException) when (options.CreateMissingOus)
+ {
+ string parent = ParentDn(ouDn);
+ if (!ouDn.EndsWith($",{options.BaseDn}", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("Automatic OU creation is limited to descendants of BaseDn.");
+ }
+
+ string rdn = ouDn[..FirstUnescapedComma(ouDn)];
+ DirectoryEntry? parentEntry = null;
+ try
+ {
+ DirectoryEntry container = root;
+ if (!string.Equals(parent, options.BaseDn, StringComparison.OrdinalIgnoreCase))
+ {
+ parentEntry = BindOrCreateOu(parent, root);
+ container = parentEntry;
+ }
+
+ DirectoryEntry created = container.Children.Add(rdn, "organizationalUnit");
+ created.CommitChanges();
+ return created;
+ }
+ finally
+ {
+ parentEntry?.Dispose();
+ }
+ }
+ }
+
+ private DirectoryEntry Bind(string distinguishedName) =>
+ new($"LDAP://{options.LdapHost}/{distinguishedName}", null, null, BindFlags);
+
+ private static string ParentDn(string distinguishedName)
+ {
+ int comma = FirstUnescapedComma(distinguishedName);
+ return comma < 0 ? string.Empty : distinguishedName[(comma + 1)..];
+ }
+
+ private static int FirstUnescapedComma(string value)
+ {
+ bool escaped = false;
+ for (int i = 0; i < value.Length; i++)
+ {
+ if (escaped)
+ {
+ escaped = false;
+ continue;
+ }
+
+ if (value[i] == '\\')
+ {
+ escaped = true;
+ }
+ else if (value[i] == ',')
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private static string EscapeLdapFilter(string value) => value
+ .Replace("\\", "\\5c", StringComparison.Ordinal)
+ .Replace("*", "\\2a", StringComparison.Ordinal)
+ .Replace("(", "\\28", StringComparison.Ordinal)
+ .Replace(")", "\\29", StringComparison.Ordinal)
+ .Replace("\0", "\\00", StringComparison.Ordinal);
+
+ private static string EscapeRdn(string value) => value
+ .Replace("\\", "\\\\", StringComparison.Ordinal)
+ .Replace(",", "\\,", StringComparison.Ordinal)
+ .Replace("+", "\\+", StringComparison.Ordinal)
+ .Replace("\"", "\\\"", StringComparison.Ordinal)
+ .Replace("<", "\\<", StringComparison.Ordinal)
+ .Replace(">", "\\>", StringComparison.Ordinal)
+ .Replace(";", "\\;", StringComparison.Ordinal)
+ .Replace("=", "\\=", StringComparison.Ordinal);
+}
diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
new file mode 100644
index 0000000..01dcbc7
--- /dev/null
+++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
@@ -0,0 +1,125 @@
+using System.Net;
+using SGU.AuthBroker.Core.Authentication;
+using SGU.AuthBroker.Options;
+
+namespace SGU.AuthBroker.Services;
+
+public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
+{
+ private readonly NtlmOptions options = options.Ntlm;
+
+ public async Task ValidateAsync(
+ string userName,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ Uri current = new(this.options.Endpoint, UriKind.Absolute);
+ HashSet allowedHosts = new(
+ this.options.AllowedRedirectHosts,
+ StringComparer.OrdinalIgnoreCase);
+
+ NetworkCredential credential = new(userName, password, this.options.Domain);
+ CredentialCache credentialCache = new();
+ HashSet credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
+
+ using HttpClientHandler handler = new()
+ {
+ AllowAutoRedirect = false,
+ AutomaticDecompression = DecompressionMethods.All,
+ CheckCertificateRevocationList = true,
+ Credentials = credentialCache,
+ MaxConnectionsPerServer = 4,
+ MaxResponseHeadersLength = 64,
+ PreAuthenticate = false,
+ UseCookies = false,
+ UseDefaultCredentials = false,
+ UseProxy = false
+ };
+
+ using HttpClient client = new(handler)
+ {
+ Timeout = Timeout.InfiniteTimeSpan,
+ DefaultRequestVersion = HttpVersion.Version11,
+ DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
+ };
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
+
+ try
+ {
+ for (int hop = 0; hop <= this.options.MaxRedirects; hop++)
+ {
+ if (!IsAllowedHttpsUri(current, allowedHosts))
+ {
+ return NtlmValidationResult.Unavailable("NTLM_REDIRECT_REJECTED");
+ }
+
+ string authority = current.GetLeftPart(UriPartial.Authority);
+ if (credentialedAuthorities.Add(authority))
+ {
+ credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
+ }
+
+ using HttpRequestMessage request = new(HttpMethod.Get, current);
+ using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(this.options.TimeoutSeconds));
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await client
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return NtlmValidationResult.Unavailable("NTLM_TIMEOUT");
+ }
+ catch (HttpRequestException)
+ {
+ return NtlmValidationResult.Unavailable();
+ }
+
+ using (response)
+ {
+ if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
+ {
+ return NtlmValidationResult.Invalid();
+ }
+
+ int statusCode = (int)response.StatusCode;
+ if (statusCode >= 500)
+ {
+ return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR");
+ }
+
+ if (statusCode is >= 300 and < 400)
+ {
+ Uri? location = response.Headers.Location;
+ if (location is null)
+ {
+ return NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT");
+ }
+
+ current = location.IsAbsoluteUri ? location : new Uri(current, location);
+ continue;
+ }
+
+ return statusCode is >= 200 and < 300
+ ? NtlmValidationResult.Valid()
+ : NtlmValidationResult.Invalid();
+ }
+ }
+
+ return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
+ }
+ finally
+ {
+ credential.Password = string.Empty;
+ }
+ }
+
+ private static bool IsAllowedHttpsUri(Uri uri, HashSet allowedHosts) =>
+ uri.Scheme == Uri.UriSchemeHttps &&
+ string.IsNullOrEmpty(uri.UserInfo) &&
+ allowedHosts.Contains(uri.IdnHost);
+}
diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json
new file mode 100644
index 0000000..f60e263
--- /dev/null
+++ b/src/SGU.AuthBroker/appsettings.json
@@ -0,0 +1,49 @@
+{
+ "AllowedHosts": "*",
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Kestrel": {
+ "Endpoints": {
+ "Https": {
+ "Url": "https://0.0.0.0:8443",
+ "Certificate": {
+ "Subject": "sgu-auth.lci.lasalle.mx",
+ "Store": "My",
+ "Location": "LocalMachine",
+ "AllowInvalid": false
+ }
+ }
+ }
+ },
+ "Broker": {
+ "Tls": {
+ "AllowedClientThumbprints": [
+ "SET-BY-DEPLOYMENT"
+ ],
+ "CheckCertificateRevocation": true
+ },
+ "Ntlm": {
+ "Endpoint": "https://sgu.ulsa.edu.mx/",
+ "Domain": "",
+ "TimeoutSeconds": 15,
+ "MaxRedirects": 5,
+ "AllowedRedirectHosts": [
+ "sgu.ulsa.edu.mx"
+ ]
+ },
+ "Directory": {
+ "LdapHost": "localhost",
+ "BaseDn": "DC=lci,DC=lasalle,DC=mx",
+ "DomainNetbios": "LCI",
+ "UpnSuffix": "lci.lasalle.mx",
+ "ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
+ "StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
+ "AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
+ "CreateMissingOus": false
+ }
+ }
+}
diff --git a/src/SGU.CredentialProvider/BrokerClient.cs b/src/SGU.CredentialProvider/BrokerClient.cs
new file mode 100644
index 0000000..d679d20
--- /dev/null
+++ b/src/SGU.CredentialProvider/BrokerClient.cs
@@ -0,0 +1,165 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using System.Text.Json.Serialization;
+
+namespace SGU.CredentialProvider;
+
+internal sealed class BrokerClient : IDisposable
+{
+ private readonly ProviderSettings settings;
+ private readonly HttpClient client;
+
+ public BrokerClient(ProviderSettings settings)
+ : this(settings, CreateHandler(settings))
+ {
+ }
+
+ internal BrokerClient(ProviderSettings settings, HttpMessageHandler handler)
+ {
+ this.settings = settings;
+ client = new HttpClient(handler, disposeHandler: true)
+ {
+ Timeout = Timeout.InfiniteTimeSpan,
+ DefaultRequestVersion = HttpVersion.Version11,
+ DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
+ };
+ }
+
+ public async Task AuthenticateAsync(
+ string userName,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ BrokerRequest body = new() { Clave = userName, Password = password };
+ using HttpRequestMessage request = new(HttpMethod.Post, settings.BrokerEndpoint)
+ {
+ Content = JsonContent.Create(body)
+ };
+ request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoStore = true };
+
+ using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
+
+ try
+ {
+ using HttpResponseMessage response = await client
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
+ .ConfigureAwait(false);
+
+ if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.BadRequest)
+ {
+ ErrorBody? error = await ReadJsonAsync(response, timeout.Token).ConfigureAwait(false);
+ return BrokerDecision.Invalid(error?.Code);
+ }
+
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ AuthorizedBody? authorized = await ReadJsonAsync(response, timeout.Token).ConfigureAwait(false);
+ if (authorized is null ||
+ string.IsNullOrWhiteSpace(authorized.Domain) ||
+ string.IsNullOrWhiteSpace(authorized.UserName))
+ {
+ return BrokerDecision.Unavailable("INVALID_BROKER_RESPONSE");
+ }
+
+ return BrokerDecision.Authorized(authorized.Domain, authorized.UserName);
+ }
+
+ return BrokerDecision.Unavailable($"BROKER_HTTP_{(int)response.StatusCode}");
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return BrokerDecision.Unavailable("BROKER_TIMEOUT");
+ }
+ catch (HttpRequestException)
+ {
+ return BrokerDecision.Unavailable("BROKER_UNAVAILABLE");
+ }
+ finally
+ {
+ body.ReleasePasswordReference();
+ }
+ }
+
+ public void Dispose() => client.Dispose();
+
+ private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ if (response.Content.Headers.ContentLength is > 16 * 1024)
+ {
+ return default;
+ }
+
+ try
+ {
+ return await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is System.Text.Json.JsonException or NotSupportedException)
+ {
+ return default;
+ }
+ }
+
+ private static HttpMessageHandler CreateHandler(ProviderSettings settings)
+ {
+ X509Certificate2 clientCertificate = LoadClientCertificate(settings.ClientCertificateThumbprint);
+ string expectedServerThumbprint = ProviderSettings.NormalizeThumbprint(settings.ServerCertificateThumbprint);
+
+ HttpClientHandler handler = new()
+ {
+ AllowAutoRedirect = false,
+ CheckCertificateRevocationList = true,
+ ClientCertificateOptions = ClientCertificateOption.Manual,
+ MaxConnectionsPerServer = 2,
+ MaxResponseHeadersLength = 32,
+ UseCookies = false,
+ UseDefaultCredentials = false,
+ UseProxy = false,
+ ServerCertificateCustomValidationCallback = (_, certificate, _, policyErrors) =>
+ policyErrors == SslPolicyErrors.None &&
+ certificate is not null &&
+ string.Equals(
+ ProviderSettings.NormalizeThumbprint(certificate.GetCertHashString()),
+ expectedServerThumbprint,
+ StringComparison.OrdinalIgnoreCase)
+ };
+ handler.ClientCertificates.Add(clientCertificate);
+ return handler;
+ }
+
+ private static X509Certificate2 LoadClientCertificate(string thumbprint)
+ {
+ using X509Store store = new(StoreName.My, StoreLocation.LocalMachine);
+ store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
+ X509Certificate2Collection matches = store.Certificates.Find(
+ X509FindType.FindByThumbprint,
+ ProviderSettings.NormalizeThumbprint(thumbprint),
+ validOnly: true);
+
+ X509Certificate2? certificate = matches
+ .OfType()
+ .FirstOrDefault(item => item.HasPrivateKey);
+ return certificate is null
+ ? throw new InvalidOperationException("The Credential Provider client certificate is unavailable.")
+ : new X509Certificate2(certificate);
+ }
+
+ private sealed class BrokerRequest
+ {
+ [JsonPropertyName("clave")]
+ public string Clave { get; init; } = string.Empty;
+
+ [JsonPropertyName("password")]
+ public string Password { get; set; } = string.Empty;
+
+ public void ReleasePasswordReference() => Password = string.Empty;
+ }
+
+ private sealed record AuthorizedBody(
+ [property: JsonPropertyName("domain")] string Domain,
+ [property: JsonPropertyName("username")] string UserName);
+
+ private sealed record ErrorBody([property: JsonPropertyName("code")] string? Code);
+}
diff --git a/src/SGU.CredentialProvider/BrokerDecision.cs b/src/SGU.CredentialProvider/BrokerDecision.cs
new file mode 100644
index 0000000..7698c00
--- /dev/null
+++ b/src/SGU.CredentialProvider/BrokerDecision.cs
@@ -0,0 +1,24 @@
+namespace SGU.CredentialProvider;
+
+internal enum BrokerDecisionKind
+{
+ Authorized,
+ InvalidCredentials,
+ Unavailable
+}
+
+internal sealed record BrokerDecision(
+ BrokerDecisionKind Kind,
+ string? Domain = null,
+ string? UserName = null,
+ string? ErrorCode = null)
+{
+ public static BrokerDecision Authorized(string domain, string userName) =>
+ new(BrokerDecisionKind.Authorized, domain, userName);
+
+ public static BrokerDecision Invalid(string? errorCode = null) =>
+ new(BrokerDecisionKind.InvalidCredentials, ErrorCode: errorCode);
+
+ public static BrokerDecision Unavailable(string? errorCode = null) =>
+ new(BrokerDecisionKind.Unavailable, ErrorCode: errorCode);
+}
diff --git a/src/SGU.CredentialProvider/ControlKeys.cs b/src/SGU.CredentialProvider/ControlKeys.cs
new file mode 100644
index 0000000..d04d31d
--- /dev/null
+++ b/src/SGU.CredentialProvider/ControlKeys.cs
@@ -0,0 +1,10 @@
+namespace SGU.CredentialProvider;
+
+internal static class ControlKeys
+{
+ public const string ProviderLabel = "ProviderLabel";
+ public const string InformationLabel = "InformationLabel";
+ public const string UserName = "UserName";
+ public const string Password = "Password";
+ public const string Submit = "Submit";
+}
diff --git a/src/SGU.CredentialProvider/ProviderSettings.cs b/src/SGU.CredentialProvider/ProviderSettings.cs
new file mode 100644
index 0000000..9b1199c
--- /dev/null
+++ b/src/SGU.CredentialProvider/ProviderSettings.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+
+namespace SGU.CredentialProvider;
+
+internal sealed class ProviderSettings
+{
+ private const int MaximumSettingsBytes = 16 * 1024;
+
+ public Uri BrokerEndpoint { get; init; } = new("https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate");
+
+ public string DomainNetbios { get; init; } = "LCI";
+
+ public int TimeoutSeconds { get; init; } = 6;
+
+ public string ClientCertificateThumbprint { get; init; } = string.Empty;
+
+ public string ServerCertificateThumbprint { get; init; } = string.Empty;
+
+ public static string DefaultPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "SGU",
+ "CredentialProvider",
+ "settings.json");
+
+ public static ProviderSettings Load(string? path = null)
+ {
+ path ??= Environment.GetEnvironmentVariable("SGU_CREDENTIAL_PROVIDER_CONFIG") ?? DefaultPath;
+ FileInfo file = new(path);
+ if (!file.Exists || file.Length is <= 0 or > MaximumSettingsBytes)
+ {
+ throw new InvalidOperationException("Credential Provider settings are missing or invalid.");
+ }
+
+ using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ ProviderSettings settings = JsonSerializer.Deserialize(stream, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? throw new InvalidOperationException("Credential Provider settings could not be read.");
+ settings.Validate();
+ return settings;
+ }
+
+ public void Validate()
+ {
+ if (!BrokerEndpoint.IsAbsoluteUri ||
+ BrokerEndpoint.Scheme != Uri.UriSchemeHttps ||
+ !string.IsNullOrEmpty(BrokerEndpoint.UserInfo))
+ {
+ throw new InvalidOperationException("BrokerEndpoint must be an absolute HTTPS URL without user information.");
+ }
+
+ if (!string.Equals(BrokerEndpoint.AbsolutePath.TrimEnd('/'), "/v1/authenticate", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
+ }
+
+ if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30)
+ {
+ throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
+ }
+
+ if (NormalizeThumbprint(ClientCertificateThumbprint).Length != 40 ||
+ NormalizeThumbprint(ServerCertificateThumbprint).Length != 40)
+ {
+ throw new InvalidOperationException("Client and server SHA-1 certificate thumbprints are required.");
+ }
+ }
+
+ public static string NormalizeThumbprint(string value) =>
+ value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
+}
diff --git a/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj b/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj
new file mode 100644
index 0000000..29bc647
--- /dev/null
+++ b/src/SGU.CredentialProvider/SGU.CredentialProvider.csproj
@@ -0,0 +1,31 @@
+
+
+ net10.0-windows
+ win-x64
+ x64
+ x64
+ Library
+ SGU.CredentialProvider
+ SGU.CredentialProvider
+ false
+ true
+ true
+ true
+ false
+ false
+ false
+
+
+
+
+ Configuration=$(Configuration)
+
+
+
+
+
+
+ <_Parameter1>SGU.CredentialProvider.Tests
+
+
+
diff --git a/src/SGU.CredentialProvider/SguCredentialProvider.cs b/src/SGU.CredentialProvider/SguCredentialProvider.cs
new file mode 100644
index 0000000..7e7d538
--- /dev/null
+++ b/src/SGU.CredentialProvider/SguCredentialProvider.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using Lithnet.CredentialProvider;
+
+namespace SGU.CredentialProvider;
+
+[ComVisible(true)]
+[ClassInterface(ClassInterfaceType.None)]
+[ProgId("SGU.CredentialProvider")]
+[Guid(ProviderClassId)]
+public sealed class SguCredentialProvider : CredentialProviderBase
+{
+ public const string ProviderClassId = "D789CFD8-5AD4-489F-9B83-7EB5D9D09335";
+
+ public override IEnumerable GetControls(UsageScenario cpus)
+ {
+ yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
+ yield return new SmallLabelControl(
+ ControlKeys.InformationLabel,
+ "Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
+ yield return new TextboxControl(ControlKeys.UserName, "Clave institucional");
+ SecurePasswordTextboxControl password = new(ControlKeys.Password, "Contraseña");
+ yield return password;
+ yield return new SubmitButtonControl(ControlKeys.Submit, "Iniciar sesión", password);
+ }
+
+ public override bool IsUsageScenarioSupported(UsageScenario cpus, CredUIWinFlags dwFlags) =>
+ cpus is UsageScenario.Logon or UsageScenario.UnlockWorkstation or UsageScenario.CredUI;
+
+ public override bool ShouldIncludeUserTile(CredentialProviderUser user) => false;
+
+ public override bool ShouldIncludeGenericTile() => true;
+
+ public override CredentialTile CreateGenericTile() => new SguCredentialTile(this);
+
+ public override CredentialTile2 CreateUserTile(CredentialProviderUser user) =>
+ new SguCredentialTile(this, user);
+}
diff --git a/src/SGU.CredentialProvider/SguCredentialTile.cs b/src/SGU.CredentialProvider/SguCredentialTile.cs
new file mode 100644
index 0000000..6cc950e
--- /dev/null
+++ b/src/SGU.CredentialProvider/SguCredentialTile.cs
@@ -0,0 +1,132 @@
+using System.Runtime.InteropServices;
+using System.Security;
+using Lithnet.CredentialProvider;
+using SGU.AuthBroker.Core.Identity;
+
+namespace SGU.CredentialProvider;
+
+internal sealed class SguCredentialTile : CredentialTile2
+{
+ private TextboxControl userNameControl = null!;
+ private SecurePasswordTextboxControl passwordControl = null!;
+
+ public SguCredentialTile(CredentialProviderBase credentialProvider)
+ : base(credentialProvider)
+ {
+ }
+
+ public SguCredentialTile(CredentialProviderBase credentialProvider, CredentialProviderUser user)
+ : base(credentialProvider, user)
+ {
+ }
+
+ public override void Initialize()
+ {
+ userNameControl = Controls.GetControl(ControlKeys.UserName);
+ passwordControl = Controls.GetControl(ControlKeys.Password);
+ userNameControl.Text = User?.QualifiedUserName ?? string.Empty;
+ }
+
+ protected override CredentialResponseBase GetCredentials()
+ {
+ if (!UserIdentityClassifier.TryParse(userNameControl.Text, out UserIdentity? identity) || identity is null)
+ {
+ return Failure("La clave debe usar DO, AL o AD seguido de seis dígitos.");
+ }
+
+ SecureString securePassword = passwordControl.Password;
+ if (securePassword.Length == 0)
+ {
+ return Failure("La contraseña es requerida.");
+ }
+
+ string plainTextPassword = CopyToManagedString(securePassword);
+ try
+ {
+ ProviderSettings settings;
+ BrokerDecision decision;
+ try
+ {
+ settings = ProviderSettings.Load();
+ using BrokerClient broker = new(settings);
+ decision = broker
+ .AuthenticateAsync(identity.UserName, plainTextPassword, CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+ }
+ catch
+ {
+ settings = TryLoadDomainOnlySettings();
+ decision = BrokerDecision.Unavailable("LOCAL_CONFIGURATION_OR_TLS_ERROR");
+ }
+
+ if (decision.Kind == BrokerDecisionKind.InvalidCredentials)
+ {
+ return Failure("Credenciales institucionales inválidas.");
+ }
+
+ string domain = decision.Kind == BrokerDecisionKind.Authorized
+ ? decision.Domain!
+ : settings.DomainNetbios;
+ string userName = decision.Kind == BrokerDecisionKind.Authorized
+ ? decision.UserName!
+ : identity.UserName;
+
+ return new CredentialResponseSecure
+ {
+ IsSuccess = true,
+ StatusIcon = decision.Kind == BrokerDecisionKind.Unavailable ? StatusIcon.Warning : StatusIcon.None,
+ StatusText = decision.Kind == BrokerDecisionKind.Unavailable
+ ? "Servicio institucional no disponible; Windows validará la última contraseña de dominio registrada."
+ : null,
+ Domain = domain,
+ Username = userName,
+ Password = securePassword
+ };
+ }
+ finally
+ {
+ // The immutable managed string cannot be zeroed; release our reference immediately.
+ // The unmanaged copy used to create it is zeroed by CopyToManagedString.
+ plainTextPassword = string.Empty;
+ }
+ }
+
+ private static ProviderSettings TryLoadDomainOnlySettings()
+ {
+ try
+ {
+ return ProviderSettings.Load();
+ }
+ catch
+ {
+ // LCI is the configured lab domain. This fallback still delegates the actual
+ // password decision to Windows LSA/cached domain credentials.
+ return new ProviderSettings { DomainNetbios = "LCI" };
+ }
+ }
+
+ private static CredentialResponseSecure Failure(string message) => new()
+ {
+ IsSuccess = false,
+ StatusIcon = StatusIcon.Error,
+ StatusText = message
+ };
+
+ private static string CopyToManagedString(SecureString value)
+ {
+ IntPtr pointer = IntPtr.Zero;
+ try
+ {
+ pointer = Marshal.SecureStringToGlobalAllocUnicode(value);
+ return Marshal.PtrToStringUni(pointer, value.Length) ?? string.Empty;
+ }
+ finally
+ {
+ if (pointer != IntPtr.Zero)
+ {
+ Marshal.ZeroFreeGlobalAllocUnicode(pointer);
+ }
+ }
+ }
+}
diff --git a/src/SGU.CredentialProvider/settings.example.json b/src/SGU.CredentialProvider/settings.example.json
new file mode 100644
index 0000000..84b654f
--- /dev/null
+++ b/src/SGU.CredentialProvider/settings.example.json
@@ -0,0 +1,7 @@
+{
+ "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
+ "DomainNetbios": "LCI",
+ "TimeoutSeconds": 6,
+ "ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
+ "ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
+}
diff --git a/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs
new file mode 100644
index 0000000..fe5c24b
--- /dev/null
+++ b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs
@@ -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 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 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));
+ }
+ }
+}
diff --git a/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj b/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj
new file mode 100644
index 0000000..6804abd
--- /dev/null
+++ b/tests/SGU.AuthBroker.Core.Tests/SGU.AuthBroker.Core.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs b/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs
new file mode 100644
index 0000000..8672168
--- /dev/null
+++ b/tests/SGU.AuthBroker.Core.Tests/UserIdentityClassifierTests.cs
@@ -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 _));
+ }
+}
diff --git a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs
new file mode 100644
index 0000000..4c3729d
--- /dev/null
+++ b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs
@@ -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 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(descriptorPointer);
+ labels.Add(descriptor.Label ?? string.Empty);
+ }
+ finally
+ {
+ Marshal.DestroyStructure(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 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(
+ Marshal.ReadIntPtr(vtable, 14 * IntPtr.Size));
+ GetSerializationDelegate getSerialization = Marshal.GetDelegateForFunctionPointer(
+ 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(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(
+ 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()
+ .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);
+}
diff --git a/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj b/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj
new file mode 100644
index 0000000..d60214b
--- /dev/null
+++ b/tests/SGU.CredentialProvider.SmokeProbe/SGU.CredentialProvider.SmokeProbe.csproj
@@ -0,0 +1,8 @@
+
+
+ Exe
+ net10.0-windows
+ win-x64
+ x64
+
+
diff --git a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs
new file mode 100644
index 0000000..37f5b4c
--- /dev/null
+++ b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs
@@ -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 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")
+ };
+ }
+ }
+}
diff --git a/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj b/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj
new file mode 100644
index 0000000..a3bb460
--- /dev/null
+++ b/tests/SGU.CredentialProvider.Tests/SGU.CredentialProvider.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0-windows
+ win-x64
+ false
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+