Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ The library targets **both** `net472` (Windows PowerShell 5.1) and `net8.0` (Pow

| Package | Ceiling | Why |
|---------|---------|-----|
| Microsoft.Data.SqlClient | 6.x only | DacFx/SMO compiled against 6.x; 7.x causes type-load failures |
| Microsoft.PowerShell.SDK | 7.4.x only | 7.5+ requires net9.0 target change |
| MSTest.* | 3.x only | 4.x drops `Assert.ThrowsException<T>()` on net472 |
| Microsoft.NET.Test.Sdk | 17.x only | 18.x aligns with MSTest 4.x ecosystem |
| Microsoft.Data.SqlClient | 7.x | 7.0.1 is validated with the pinned SMO and DacFx packages; revalidate their loaders before upgrading |
| Microsoft.PowerShell.SDK | 7.4.x | 7.5+ requires a net9.0 target change |
| MSTest.* | 3.x | 4.x drops `Assert.ThrowsException<T>()` on net472 |
| Microsoft.NET.Test.Sdk | 17.x | 18.x aligns with the MSTest 4.x ecosystem |

For full details and current versions, see the [dependency constraints memory](file://memory/dependency_constraints.md).

Expand Down
2 changes: 1 addition & 1 deletion dbatools.library.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#
@{
# Version number of this module.
ModuleVersion = '2026.5.3'
ModuleVersion = '2026.9.14'

# ID used to uniquely identify this module
GUID = '00b61a37-6c36-40d8-8865-ac0180288c84'
Expand Down
76 changes: 76 additions & 0 deletions project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.Data.SqlClient;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Net;

namespace Dataplat.Dbatools.Connection
{
[TestClass]
public class SqlClientCompatibilityTest
{
[TestMethod]
public void SqlConnectionExposesPluggableSspiProvider()
{
Assert.IsNotNull(typeof(SqlConnection).GetProperty("SspiContextProvider"));
}

[TestMethod]
public void ActiveDirectoryAuthenticationProviderIsRegistered()
{
Assert.IsNotNull(SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated));
}

#if NET8_0_OR_GREATER
[TestMethod]
public void NetworkCredentialSspiProviderCanBeAssignedToSqlConnection()
{
using (NetworkCredentialSspiContextProvider provider = new NetworkCredentialSspiContextProvider(
new NetworkCredential("user", "password", "domain")))
using (SqlConnection connection = new SqlConnection())
{
connection.SspiContextProvider = provider;

Assert.AreSame(provider, connection.SspiContextProvider);
}
}

[TestMethod]
public void NetworkCredentialSspiProviderRejectsNullCredential()
{
Assert.ThrowsExactly<ArgumentNullException>(() => new NetworkCredentialSspiContextProvider(null));
}

[TestMethod]
public void NetworkCredentialSspiProviderExposesPasswordFreePrincipal()
{
using (NetworkCredentialSspiContextProvider domainQualified = new NetworkCredentialSspiContextProvider(
new NetworkCredential("user", "password", "domain")))
using (NetworkCredentialSspiContextProvider domainless = new NetworkCredentialSspiContextProvider(
new NetworkCredential("user", "password", string.Empty)))
{
Assert.AreEqual("domain\\user", domainQualified.Principal);
Assert.AreEqual("user", domainless.Principal);
}
}

[TestMethod]
public void NetworkCredentialSspiProviderHasStablePoolIdentityPerCredential()
{
using (NetworkCredentialSspiContextProvider first = new NetworkCredentialSspiContextProvider(
new NetworkCredential("user", "password", "domain")))
using (NetworkCredentialSspiContextProvider second = new NetworkCredentialSspiContextProvider(
new NetworkCredential("USER", "password", "DOMAIN")))
using (NetworkCredentialSspiContextProvider different = new NetworkCredentialSspiContextProvider(
new NetworkCredential("other-user", "password", "domain")))
using (NetworkCredentialSspiContextProvider differentPassword = new NetworkCredentialSspiContextProvider(
new NetworkCredential("user", "different-password", "domain")))
{
Assert.AreEqual(first, second);
Assert.AreEqual(first.GetHashCode(), second.GetHashCode());
Assert.AreNotEqual(first, different);
Assert.AreNotEqual(first, differentPassword);
}
}
#endif
}
}
151 changes: 151 additions & 0 deletions project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#if NET8_0_OR_GREATER
using Microsoft.Data.SqlClient;
using System;
using System.Buffers;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Text;

namespace Dataplat.Dbatools.Connection
{
/// <summary>
/// Generates SQL Server integrated-authentication tokens from an explicit Windows credential.
/// </summary>
public sealed class NetworkCredentialSspiContextProvider : SspiContextProvider, IDisposable
{
private readonly NetworkCredential credential;
private readonly byte[] credentialIdentity;
private readonly string principal;
private NegotiateAuthentication authentication;
private string resource;
private bool disposed;

/// <summary>
/// The case-insensitive Windows principal ("domain\user" or "user") this provider
/// authenticates as. Password-free, safe to use as part of a registry/cache key.
/// </summary>
public string Principal
{
get { return principal; }
}

/// <summary>
/// Creates a provider that authenticates with the supplied Windows credential.
/// </summary>
/// <param name="credential">The Windows credential used for Negotiate authentication.</param>
public NetworkCredentialSspiContextProvider(NetworkCredential credential)
{
if (credential == null)
throw new ArgumentNullException(nameof(credential));

this.credential = new NetworkCredential(credential.UserName, credential.Password, credential.Domain);
principal = String.IsNullOrEmpty(credential.Domain)
? credential.UserName
: credential.Domain + "\\" + credential.UserName;
using (SHA256 sha256 = SHA256.Create())
{
credentialIdentity = sha256.ComputeHash(Encoding.UTF8.GetBytes(
principal.ToUpperInvariant() + "\0" + credential.Password));
}
}

/// <summary>
/// Compares providers by Windows principal and credential identity for the SqlClient pool key.
/// </summary>
public override bool Equals(object obj)
{
NetworkCredentialSspiContextProvider other = obj as NetworkCredentialSspiContextProvider;
return other != null && String.Equals(principal, other.principal, StringComparison.OrdinalIgnoreCase) &&
CryptographicOperations.FixedTimeEquals(credentialIdentity, other.credentialIdentity);
}

/// <summary>
/// Returns the case-insensitive Windows principal hash used by the SqlClient pool key.
/// Credential identity is intentionally excluded to avoid disclosing a password-derived hash value.
/// </summary>
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(principal);
}

/// <inheritdoc />
protected override bool GenerateContext(
ReadOnlySpan<byte> incomingBlob,
IBufferWriter<byte> outgoingBlobWriter,
SspiAuthenticationParameters authParams)
{
if (disposed)
throw new ObjectDisposedException(nameof(NetworkCredentialSspiContextProvider));
if (outgoingBlobWriter == null)
throw new ArgumentNullException(nameof(outgoingBlobWriter));
if (authParams == null)
throw new ArgumentNullException(nameof(authParams));

if (authentication == null || authentication.IsAuthenticated ||
!String.Equals(resource, authParams.Resource, StringComparison.Ordinal))
{
if (authentication != null)
authentication.Dispose();

resource = authParams.Resource;
authentication = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
Package = "Negotiate",
TargetName = resource,
Credential = credential
});
}

try
{
NegotiateAuthenticationStatusCode statusCode;
byte[] outgoingBlob = authentication.GetOutgoingBlob(incomingBlob, out statusCode);
if (statusCode != NegotiateAuthenticationStatusCode.Completed &&
statusCode != NegotiateAuthenticationStatusCode.ContinueNeeded)
{
ResetAuthentication();
return false;
}

if (outgoingBlob != null)
{
Span<byte> destination = outgoingBlobWriter.GetSpan(outgoingBlob.Length);
outgoingBlob.AsSpan().CopyTo(destination);
outgoingBlobWriter.Advance(outgoingBlob.Length);
}

if (statusCode == NegotiateAuthenticationStatusCode.Completed)
ResetAuthentication();

return true;
}
catch
{
ResetAuthentication();
throw;
}
}

private void ResetAuthentication()
{
if (authentication != null)
authentication.Dispose();
authentication = null;
resource = null;
}

/// <summary>
/// Releases the active Negotiate authentication context.
/// </summary>
public void Dispose()
{
if (disposed)
return;

disposed = true;
ResetAuthentication();
}
}
}
#endif
6 changes: 5 additions & 1 deletion project/dbatools/dbatools.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@
<!-- Shared packages for both frameworks -->
<ItemGroup>
<PackageReference Include="Microsoft.AnalysisServices" Version="19.113.7" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.5" />
<!-- SqlClient 7 no longer brings these dependencies transitively, but dbatools.library loads Azure.Identity directly. -->
<PackageReference Include="Azure.Identity" Version="1.18.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient.Extensions.Azure" Version="1.0.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.0" />
<PackageReference Include="Microsoft.SqlServer.DacFx" Version="170.3.93" />
<PackageReference Include="Microsoft.SqlServer.SqlManagementObjects" Version="181.19.0" />
<PackageReference Include="Microsoft.SqlServer.XEvent.XELite" Version="2024.2.5.1" />
Expand Down
Loading