diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md
new file mode 100644
index 0000000000..c87f5757b9
--- /dev/null
+++ b/doc/connection-pooling-impersonation.md
@@ -0,0 +1,111 @@
+# Connection pooling and Windows impersonation
+
+On Windows, integrated-security connections are pooled by Windows identity and
+logon session, in addition to the connection-string pool group. This applies to
+both the wait-handle pool and the channel pool, with native or managed networking.
+
+Network-only logons (`LOGON32_LOGON_NEW_CREDENTIALS`, also used by `runas /netonly`)
+can retain the process user's local SID while supplying different credentials for
+outbound authentication. The token's `AuthenticationId` identifies its logon
+session and prevents these connections from sharing a pool with the process
+account or another network-only logon session.
+
+Duplicated tokens in the same logon session can reuse the same pool. Separate
+logon sessions, even for the same account, use separate pools. Applications that
+create a fresh network-only logon for every operation can therefore create more
+pools and should consider reusing a suitably scoped token.
+
+Background replenishment creates connections only when its effective identity
+matches the pool's identity, including the logon-session ID. Returning an expired
+connection outside impersonation can cause replenishment to skip that pool.
+`Min Pool Size` is therefore not guaranteed across impersonation boundaries;
+subsequent opens under the owning identity can create connections on demand.
+
+Open connections inside the intended impersonation scope, including awaiting
+asynchronous opens within that scope (see the
+[complete sample](samples/ConnectionPool_Impersonation.cs)):
+
+```csharp
+// token is a SafeAccessTokenHandle obtained from LogonUser.
+await WindowsIdentity.RunImpersonated(token, async () =>
+{
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+ using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection);
+ Console.WriteLine(await command.ExecuteScalarAsync());
+});
+```
+
+## Regression tests
+
+`DbConnectionPoolIdentityTest` uses synthetic network-only tokens and stub
+connections; it requires Windows but no SQL Server or valid outbound credentials.
+
+`PoolIdentityImpersonationTest` exercises real SQL Server connections using both
+pools and synchronous/asynchronous APIs.
+
+The `InvalidNetOnlyCredentials_*` tests need only an integrated-security
+`TCPConnectionString` (local or remote; on a local instance a named-pipe or
+shared-memory data source also works). They use a network-only token with
+placeholder credentials, which cannot open a new physical connection, so any
+successful open under that token proves it received a process-account connection.
+If the environment accepts the placeholder credentials (an unpooled probe open
+succeeds, as can happen with some loopback configurations), these tests skip.
+They also verify that the failed open does not put the process account's pool into
+its error blocking period.
+
+The remaining tests require an alternate account, supplied through the
+`SQLCLIENT_NETONLY_USER`, `SQLCLIENT_NETONLY_DOMAIN`, and
+`SQLCLIENT_NETONLY_PASSWORD` environment variables. Do not store credentials in
+source or test configuration. The login-reversion test requires both the process
+account and the alternate account to authenticate successfully as different SQL
+logins; the replenishment test requires only the alternate account to succeed. Use
+a TCP connection and verify the configured account is actually authenticated:
+some loopback configurations may fall back to the process identity. Local
+named-pipe and shared-memory transports do not exercise outbound credentials.
+Managed networking is selected through the
+existing `Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows` setting.
+
+Windows local-server Set 3 CI jobs for the default SQL instance provision a unique
+non-administrator local account and a SQL Windows login with access to `master`.
+The generated password is passed only as a secret test-process environment
+variable. An always-run cleanup step removes the SQL login and local account.
+These jobs set `SQLCLIENT_NETONLY_REQUIRED=true` and
+`SQLCLIENT_NETONLY_CONNECTION_STRING` to an explicit TCP endpoint using `master`;
+missing prerequisites fail the successful-login tests rather than skipping them.
+Both successful-login tests first verify that an unpooled connection authenticates
+as the configured alternate account. Required CI coverage also verifies that the
+process account authenticates as a different login, preventing loopback fallback
+from producing a false pass.
+
+## CI-only outbound-name experiment
+
+The same designated jobs also provision a second disposable non-admin account
+and SQL Windows login, using separate secret variables and always-run cleanup.
+`OutboundCredentialNameTest` compares a fresh, targetless Negotiate SSPI
+credential name acquired inside impersonation with `ORIGINAL_LOGIN()`,
+`SUSER_SNAME()`, `SUSER_SID()` and connection authentication/transport properties.
+All SQL opens are unpooled. It covers independent same-account sessions,
+different accounts, restoration, and invalid credentials with the same name,
+using sync/async APIs and the pipeline-selected native/managed networking.
+For these configured local accounts, SSPI can return `user@machine` while SQL
+returns `machine\user`; the experiment recognizes both exact account forms,
+logs the raw names, and independently verifies SQL's SAM-form login and SID.
+This is not general domain-alias normalization or a production pool-key rule.
+Matched no-prequery and SSPI-prequery controls reuse the passing regression
+preflight's unpooled open path and record logon-session IDs. A separate
+cacheable/recompiled SQL comparison distinguishes context-sensitive scalar
+results from the original authenticated login; normal identity probes recompile
+the statement and retain strict configured-login and SID assertions.
+The additional credentials are mapped through `SQLCLIENT_NETONLY_PEER_USER`,
+`SQLCLIENT_NETONLY_PEER_DOMAIN`, and `SQLCLIENT_NETONLY_PEER_PASSWORD`.
+Nothing is provisioned on the developer machine.
+
+A separate probe writes a SQL-SPN override using `CRED_PERSIST_SESSION` only
+inside a fresh disposable network-only session, then deletes that exact target.
+It records whether SQL selects the override and whether the targetless name,
+`AuthenticationId`, or `ModifiedId` changes. An unavailable credential store or
+an override not selected by SQL is explicitly reported as uncovered, not proof
+that name-based caching is safe. Local accounts exercise NTLM, not Kerberos or
+domain aliases. These probes do not implement outbound-name pooling or change
+the driver's identity/replenishment strategy.
diff --git a/doc/samples/ConnectionPool_Impersonation.cs b/doc/samples/ConnectionPool_Impersonation.cs
new file mode 100644
index 0000000000..df968e2fe5
--- /dev/null
+++ b/doc/samples/ConnectionPool_Impersonation.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+//
+using System.Security.Principal;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using Microsoft.Win32.SafeHandles;
+
+public static class ConnectionPoolImpersonationSample
+{
+ ///
+ /// Opens a pooled integrated-security connection under the supplied Windows token and
+ /// returns the authenticated SQL login. Requires Windows and an integrated-security
+ /// connection string. The caller retains ownership of the token.
+ ///
+ /// The Windows token supplying the intended outbound credentials.
+ /// A connection string with integrated security enabled.
+ /// The login that authenticated the SQL connection.
+ public static Task GetLoginAsync(SafeAccessTokenHandle token, string connectionString)
+ {
+ return WindowsIdentity.RunImpersonated(token, async () =>
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+ using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection);
+ return (string)await command.ExecuteScalarAsync();
+ });
+ }
+}
+//
diff --git a/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml
index 86ee3806fd..d6e3537df9 100644
--- a/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml
+++ b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml
@@ -341,6 +341,15 @@ jobs:
- pwsh: New-Item -ItemType Directory -Path TestResults -Force | Out-Null
displayName: 'Create TestResults Directory'
+ - ${{ if and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}:
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: setup
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: setup
+ variablePrefix: netOnlyPeer
+
# Gate: record that all setup steps (SDK install, build, SQL config, x86 SDK
# install, etc.) completed successfully. Test steps condition on this variable
# so they are skipped when setup fails, yet remain independent of each
@@ -359,6 +368,7 @@ jobs:
operatingSystem: ${{ parameters.operatingSystem }}
packageVersion: ${{ parameters.packageVersion }}
sqlServerPackageVersion: ${{ parameters.sqlServerPackageVersion }}
+ enableNetOnlyTests: ${{ and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}
- ${{ if and(eq(parameters.enableX86Test, true), eq(parameters.operatingSystem, 'Windows')) }}:
- template: /eng/pipelines/common/templates/steps/run-all-tests-step.yml@self
@@ -373,6 +383,16 @@ jobs:
operatingSystem: ${{ parameters.operatingSystem }}
packageVersion: ${{ parameters.packageVersion }}
sqlServerPackageVersion: ${{ parameters.sqlServerPackageVersion }}
+ enableNetOnlyTests: ${{ and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}
+
+ - ${{ if and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}:
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: cleanup
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: cleanup
+ variablePrefix: netOnlyPeer
- template: /eng/pipelines/common/templates/steps/publish-test-results-step.yml@self
parameters:
diff --git a/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml
new file mode 100644
index 0000000000..a413da4bc6
--- /dev/null
+++ b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml
@@ -0,0 +1,56 @@
+#################################################################################
+# Licensed to the .NET Foundation under one or more agreements. #
+# The .NET Foundation licenses this file to you under the MIT license. #
+# See the LICENSE file in the project root for more information. #
+#################################################################################
+
+# Invoke setup and cleanup in the same Windows job, with cleanup after the tests.
+# The agent must be a local administrator with permission to manage SQL logins.
+# Setup publishes netOnlyUser, netOnlyDomain, netOnlySid, netOnlyConnectionString,
+# and secret netOnlyPassword.
+# No server roles or database users are granted; tests must use master.
+parameters:
+ - name: mode
+ type: string
+ default: setup
+ values:
+ - setup
+ - cleanup
+
+ - name: instanceName
+ type: string
+ default: MSSQLSERVER
+
+ # Must match the SQL listener, not a client alias.
+ - name: port
+ type: string
+ default: '1433'
+
+ - name: variablePrefix
+ type: string
+ default: netOnly
+ values:
+ - netOnly
+ - netOnlyPeer
+
+steps:
+ - task: PowerShell@2
+ displayName: '${{ parameters.mode }} temporary ${{ parameters.variablePrefix }} account [Win]'
+ ${{ if eq(parameters.mode, 'cleanup') }}:
+ condition: always()
+ inputs:
+ targetType: inline
+ pwsh: false
+ script: |
+ & "$(Build.SourcesDirectory)\eng\scripts\Configure-NetOnlyAccount.ps1" `
+ -Mode $env:NETONLY_MODE -InstanceName $env:NETONLY_INSTANCE -Port $env:NETONLY_PORT `
+ -AccountName $env:NETONLY_ACCOUNT -AccountSid $env:NETONLY_SID `
+ -VariablePrefix $env:NETONLY_PREFIX
+ env:
+ NETONLY_MODE: ${{ parameters.mode }}
+ NETONLY_INSTANCE: ${{ parameters.instanceName }}
+ NETONLY_PORT: ${{ parameters.port }}
+ NETONLY_PREFIX: ${{ parameters.variablePrefix }}
+ # Undefined ADO macros are treated as absent by cleanup.
+ NETONLY_ACCOUNT: $(${{ parameters.variablePrefix }}User)
+ NETONLY_SID: $(${{ parameters.variablePrefix }}Sid)
diff --git a/eng/pipelines/common/templates/steps/run-all-tests-step.yml b/eng/pipelines/common/templates/steps/run-all-tests-step.yml
index 928c2e3abc..3bf9a9cd7c 100644
--- a/eng/pipelines/common/templates/steps/run-all-tests-step.yml
+++ b/eng/pipelines/common/templates/steps/run-all-tests-step.yml
@@ -68,6 +68,10 @@ parameters:
type: number
default: 2
+ - name: enableNetOnlyTests
+ type: boolean
+ default: false
+
steps:
- ${{ if parameters.debug }}:
- powershell: 'dotnet sdk check'
@@ -192,6 +196,16 @@ steps:
- task: DotNetCoreCLI@2
displayName: 'Run Manual Tests ${{parameters.msbuildArchitecture }}'
condition: and(eq(variables['setupSucceeded'], 'true'), succeededOrFailed())
+ ${{ if parameters.enableNetOnlyTests }}:
+ env:
+ SQLCLIENT_NETONLY_REQUIRED: 'true'
+ SQLCLIENT_NETONLY_USER: $(netOnlyUser)
+ SQLCLIENT_NETONLY_DOMAIN: $(netOnlyDomain)
+ SQLCLIENT_NETONLY_PASSWORD: $(netOnlyPassword)
+ SQLCLIENT_NETONLY_CONNECTION_STRING: $(netOnlyConnectionString)
+ SQLCLIENT_NETONLY_PEER_USER: $(netOnlyPeerUser)
+ SQLCLIENT_NETONLY_PEER_DOMAIN: $(netOnlyPeerDomain)
+ SQLCLIENT_NETONLY_PEER_PASSWORD: $(netOnlyPeerPassword)
inputs:
command: build
projects: build.proj
diff --git a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml
index c81fd8304d..6ae22e7824 100644
--- a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml
+++ b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml
@@ -208,9 +208,28 @@ jobs:
Write-Host "##vso[task.setvariable variable=TEST_MDS_CONFIG]$configPath"
displayName: 'Generate manual test config'
+ - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}:
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: setup
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: setup
+ variablePrefix: netOnlyPeer
+
# Execute TestSqlClientManual target from build.proj
- task: DotNetCoreCLI@2
displayName: 'Test: SqlClientManual ${{ parameters.testSet }}'
+ ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}:
+ env:
+ SQLCLIENT_NETONLY_REQUIRED: 'true'
+ SQLCLIENT_NETONLY_USER: $(netOnlyUser)
+ SQLCLIENT_NETONLY_DOMAIN: $(netOnlyDomain)
+ SQLCLIENT_NETONLY_PASSWORD: $(netOnlyPassword)
+ SQLCLIENT_NETONLY_CONNECTION_STRING: $(netOnlyConnectionString)
+ SQLCLIENT_NETONLY_PEER_USER: $(netOnlyPeerUser)
+ SQLCLIENT_NETONLY_PEER_DOMAIN: $(netOnlyPeerDomain)
+ SQLCLIENT_NETONLY_PEER_PASSWORD: $(netOnlyPeerPassword)
inputs:
command: build
projects: build.proj
@@ -224,6 +243,15 @@ jobs:
-p:TestSet=${{ parameters.testSet }}
-p:TestResultsFolderPath="${{ variables.testResultsPath }}"
+ - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}:
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: cleanup
+ - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self
+ parameters:
+ mode: cleanup
+ variablePrefix: netOnlyPeer
+
# Publish test results
- template: /eng/pipelines/pr/steps/publish-test-results-step.yml@self
parameters:
diff --git a/eng/scripts/Configure-NetOnlyAccount.ps1 b/eng/scripts/Configure-NetOnlyAccount.ps1
new file mode 100644
index 0000000000..679354ae38
--- /dev/null
+++ b/eng/scripts/Configure-NetOnlyAccount.ps1
@@ -0,0 +1,223 @@
+# Licensed to the .NET Foundation under one or more agreements.
+# The .NET Foundation licenses this file to you under the MIT license.
+# See the LICENSE file in the project root for more information.
+
+<#
+.SYNOPSIS
+Creates or removes a temporary local Windows account and its local SQL Windows login.
+.DESCRIPTION
+Run setup and cleanup on the same Windows agent using Windows PowerShell 5.1,
+as a local administrator with permission to create and drop SQL logins.
+Setup publishes the account identity before provisioning and registers the
+generated password only as a secret Azure DevOps variable. It publishes a
+dedicated TCP/master integrated connection string as netOnlyConnectionString;
+the caller maps it to SQLCLIENT_NETONLY_CONNECTION_STRING for manual tests.
+Port must match the configured local SQL instance TCP listener. Cleanup accepts
+netOnlyUser and netOnlySid from that job; blank/unexpanded values are safe.
+No policy, group membership, server role, or database user is changed.
+VariablePrefix selects independent netOnly or netOnlyPeer lifecycle variables.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('setup', 'cleanup')]
+ [string] $Mode,
+
+ [ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_$-]{0,127}$')]
+ [string] $InstanceName = 'MSSQLSERVER',
+
+ [ValidateRange(1, 65535)]
+ [int] $Port = 1433,
+
+ [ValidateSet('netOnly', 'netOnlyPeer')]
+ [string] $VariablePrefix = 'netOnly',
+
+ [AllowEmptyString()]
+ [string] $AccountName = '',
+
+ [AllowEmptyString()]
+ [string] $AccountSid = ''
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Set-PipelineVariable {
+ param([string] $Name, [string] $Value, [switch] $Secret)
+ $Name = $VariablePrefix + $Name.Substring('netOnly'.Length)
+ $escaped = $Value.Replace('%', '%AZP25').Replace("`r", '%0D').Replace("`n", '%0A')
+ $secretFlag = if ($Secret) { 'true' } else { 'false' }
+ Write-Host "##vso[task.setvariable variable=$Name;issecret=$secretFlag]$escaped"
+}
+
+function Invoke-NetOnlySql {
+ param([string] $Query, [string] $LoginName, [string] $SidHex)
+ $builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
+ # Shared memory forces a local connection without SQL Browser/TCP configuration.
+ $builder['Data Source'] = 'lpc:.'
+ if ($InstanceName -ne 'MSSQLSERVER') {
+ $builder['Data Source'] = "lpc:.\$InstanceName"
+ }
+ $builder['Initial Catalog'] = 'master'
+ $builder['Integrated Security'] = $true
+ $builder['Connect Timeout'] = 15
+ $connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString
+ try {
+ $connection.Open()
+ $command = $connection.CreateCommand()
+ try {
+ $command.CommandTimeout = 30
+ $command.CommandText = $Query
+ [void] $command.Parameters.Add('@login', [System.Data.SqlDbType]::NVarChar, 128)
+ $command.Parameters['@login'].Value = $LoginName
+ [void] $command.Parameters.Add('@sid', [System.Data.SqlDbType]::VarChar, 184)
+ $command.Parameters['@sid'].Value = $SidHex
+ [void] $command.ExecuteNonQuery()
+ }
+ finally {
+ $command.Dispose()
+ }
+ }
+ finally {
+ $connection.Dispose()
+ }
+}
+
+function Get-AccountSidHex {
+ param([System.Security.Principal.SecurityIdentifier] $Sid)
+ $bytes = New-Object byte[] $Sid.BinaryLength
+ $Sid.GetBinaryForm($bytes, 0)
+ return '0x' + ([System.BitConverter]::ToString($bytes)).Replace('-', '')
+}
+
+if ($Mode -eq 'cleanup' -and
+ ([string]::IsNullOrWhiteSpace($AccountName) -or $AccountName -eq ('$(' + $VariablePrefix + 'User)'))) {
+ Write-Host 'Net-only setup did not publish an account; cleanup has nothing to remove.'
+ return
+}
+
+$operation = 'validate prerequisites'
+try {
+ if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {
+ throw 'A 64-bit Windows PowerShell process on Windows is required.'
+ }
+ Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction Stop
+ Add-Type -AssemblyName System.Data
+
+ if ($Mode -eq 'setup') {
+ $operation = 'select a unique local account name'
+ $AccountName = 'netonly_' + [Guid]::NewGuid().ToString('N').Substring(0, 12)
+ # Check before publishing; an existing account must never become cleanup's target.
+ $existing = Get-LocalUser | Where-Object { $_.Name -eq $AccountName }
+ if ($existing) {
+ throw "Local account '$AccountName' already exists; refusing to reuse it."
+ }
+ Set-PipelineVariable -Name netOnlyUser -Value $AccountName
+ Set-PipelineVariable -Name netOnlyDomain -Value $env:COMPUTERNAME
+ Set-PipelineVariable -Name netOnlySid -Value ''
+
+ $operation = "create local account '$AccountName'"
+ $random = [System.Security.Cryptography.RandomNumberGenerator]::Create()
+ try {
+ $bytes = New-Object byte[] 32
+ $random.GetBytes($bytes)
+ # Fixed character classes plus 256 random bits satisfy ordinary complexity rules.
+ $password = 'Aa1!' + [Convert]::ToBase64String($bytes)
+ }
+ finally {
+ $random.Dispose()
+ }
+ Set-PipelineVariable -Name netOnlyPassword -Value $password -Secret
+ $securePassword = ConvertTo-SecureString -String $password -AsPlainText -Force
+ $password = $null
+ try {
+ $account = New-LocalUser -Name $AccountName -Password $securePassword `
+ -Description "SqlClient net-only: $AccountName" -ErrorAction Stop
+ }
+ finally {
+ $securePassword.Dispose()
+ }
+ $AccountSid = $account.SID.Value
+ Set-PipelineVariable -Name netOnlySid -Value $AccountSid
+ $sidHex = Get-AccountSidHex -Sid $account.SID
+ $loginName = "$env:COMPUTERNAME\$AccountName"
+ $quotedLogin = '[' + $loginName.Replace(']', ']]') + ']'
+
+ $operation = "create SQL Windows login '$loginName' on '$InstanceName'"
+ Invoke-NetOnlySql -LoginName $loginName -SidHex $sidHex -Query @"
+IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @login)
+ THROW 51000, 'Net-only SQL login already exists; refusing to replace it.', 1;
+CREATE LOGIN $quotedLogin FROM WINDOWS WITH DEFAULT_DATABASE = [master];
+"@
+ Set-PipelineVariable -Name netOnlyConnectionString `
+ -Value "Data Source=tcp:localhost,$Port;Initial Catalog=master;Integrated Security=true;Encrypt=false"
+ Write-Host "Created non-admin local account '$loginName' and Windows SQL login (default database master)."
+ }
+ else {
+ $operation = 'validate cleanup ownership'
+ if ($AccountName -notmatch '^netonly_[0-9a-f]{12}$') {
+ throw "Refusing cleanup of unexpected account name '$AccountName'."
+ }
+ $account = Get-LocalUser | Where-Object { $_.Name -eq $AccountName }
+ if ($AccountSid -eq ('$(' + $VariablePrefix + 'Sid)')) {
+ $AccountSid = ''
+ }
+ if ($account) {
+ if ($account.Description -ne "SqlClient net-only: $AccountName") {
+ throw "Refusing to delete local account '$AccountName': ownership description does not match."
+ }
+ if ($AccountSid -and $account.SID.Value -ne $AccountSid) {
+ throw "Refusing to delete local account '$AccountName': SID does not match setup."
+ }
+ $AccountSid = $account.SID.Value
+ }
+ $sidHex = ''
+ if ($AccountSid) {
+ $sid = New-Object System.Security.Principal.SecurityIdentifier $AccountSid
+ $sidHex = Get-AccountSidHex -Sid $sid
+ }
+ $loginName = "$env:COMPUTERNAME\$AccountName"
+ $quotedLogin = '[' + $loginName.Replace(']', ']]') + ']'
+ $operation = "clean up account '$loginName' on '$InstanceName'"
+ $failures = @()
+ try {
+ Invoke-NetOnlySql -LoginName $loginName -SidHex $sidHex -Query @"
+IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @login)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM sys.server_principals
+ WHERE name = @login AND type = 'U'
+ AND sid = CONVERT(varbinary(85), NULLIF(@sid, ''), 1))
+ THROW 51001, 'Refusing to drop net-only SQL login: Windows SID does not match setup.', 1;
+ DROP LOGIN $quotedLogin;
+END;
+"@
+ Write-Host "SQL login '$loginName' removed (or already absent)."
+ }
+ catch {
+ $failures += "SQL login cleanup on '$InstanceName' failed: $($_.Exception.Message)"
+ Write-Host "##[error]$($failures[-1])"
+ }
+ try {
+ if ($account) {
+ Remove-LocalUser -SID $account.SID -ErrorAction Stop
+ }
+ Write-Host "Local account '$AccountName' removed (or already absent)."
+ }
+ catch {
+ $failures += "Local account cleanup failed: $($_.Exception.Message)"
+ Write-Host "##[error]$($failures[-1])"
+ }
+ if ($failures.Count -gt 0) {
+ throw ($failures -join [Environment]::NewLine)
+ }
+ Set-PipelineVariable -Name netOnlyUser -Value ''
+ Set-PipelineVariable -Name netOnlySid -Value ''
+ Set-PipelineVariable -Name netOnlyDomain -Value ''
+ Set-PipelineVariable -Name netOnlyConnectionString -Value ''
+ Set-PipelineVariable -Name netOnlyPassword -Value '' -Secret
+ }
+}
+catch {
+ Write-Host "##[error]Net-only $Mode failed during '$operation': $($_.Exception.Message)"
+ throw
+}
diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs
new file mode 100644
index 0000000000..090dbccc74
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs
@@ -0,0 +1,79 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+namespace Interop.Windows.Advapi32
+{
+ internal static class Advapi32
+ {
+ internal static long GetAuthenticationId(SafeAccessTokenHandle token)
+ {
+ if (!GetTokenInformation(token, TokenInformationClass.TokenStatistics, out TokenStatistics statistics,
+ Marshal.SizeOf(), out _))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+
+ return ((long)statistics.AuthenticationId.HighPart << 32) | statistics.AuthenticationId.LowPart;
+ }
+
+ internal static bool GetIsTokenRestricted(SafeAccessTokenHandle token)
+ {
+ // FALSE means either unrestricted or failure. Clear the native last error first,
+ // including on .NET Framework, whose P/Invoke marshaller does not clear it.
+ Kernel32.Kernel32.SetLastError(0);
+ bool restricted = IsTokenRestricted(token);
+ int error = Marshal.GetLastWin32Error();
+ if (!restricted && error != 0)
+ {
+ throw new Win32Exception(error);
+ }
+
+ return restricted;
+ }
+
+ [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetTokenInformation(
+ SafeAccessTokenHandle token,
+ TokenInformationClass informationClass,
+ out TokenStatistics statistics,
+ int bufferLength,
+ out int returnLength);
+
+ [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsTokenRestricted(SafeAccessTokenHandle token);
+
+ private enum TokenInformationClass
+ {
+ TokenStatistics = 10
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Luid
+ {
+ internal uint LowPart;
+ internal int HighPart;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct TokenStatistics
+ {
+ internal Luid TokenId;
+ internal Luid AuthenticationId;
+ internal long ExpirationTime;
+ internal int TokenType;
+ internal int ImpersonationLevel;
+ internal uint DynamicCharged;
+ internal uint DynamicAvailable;
+ internal uint GroupCount;
+ internal uint PrivilegeCount;
+ internal Luid ModifiedId;
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs
index 5ff162d69e..053e99b74a 100644
--- a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs
+++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs
@@ -79,5 +79,8 @@ internal static extern int GetFullPathName(
[DllImport(DllName, SetLastError = true, ExactSpelling = true)]
internal static extern bool SetThreadErrorMode(uint dwNewMode, out uint lpOldMode);
+
+ [DllImport(DllName, ExactSpelling = true, SetLastError = true)]
+ internal static extern void SetLastError(uint error);
}
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
index 6e60f6cdbb..25d8c02029 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
@@ -1972,11 +1972,25 @@ private async Task RunWarmupLoopAsync()
// WaitHandle pool, which skips replenishment while blocking. (Warmup still
// participates in the error state on its own creations - entering it on failure and
// clearing it on success - via OpenNewInternalConnection.)
+ DbConnectionPoolIdentity? poolIdentity = Identity;
+ DbConnectionPoolIdentity? currentIdentity = poolIdentity != null && poolIdentity != DbConnectionPoolIdentity.NoIdentity
+ ? DbConnectionPoolIdentity.GetCurrent()
+ : null;
+
while (State == Running
&& !token.IsCancellationRequested
&& !ErrorOccurred
&& _connectionSlots.ReservationCount < MinPoolSize)
{
+ // Match the legacy pool: background creation must use the pool's logon session.
+ // A mismatched context skips warmup; user opens can still create on demand.
+ if (poolIdentity != null && currentIdentity != null && !poolIdentity.Equals(currentIdentity))
+ {
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ "ChannelDbConnectionPool.RunWarmupLoopAsync | INFO | {0}, Skipping warmup for a different identity.", Id);
+ break;
+ }
+
// Fresh per-attempt timeout budget based on the pool's CreationTimeout, since
// warmup has no owning Open() call to inherit a budget from. Matches the
// replenishment behavior of the legacy WaitHandle pool.
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs
index 940a10271f..6b7ff19cec 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs
@@ -4,6 +4,7 @@
using System;
using System.Security.Principal;
+using Interop.Windows.Advapi32;
namespace Microsoft.Data.SqlClient.ConnectionPool
{
@@ -17,14 +18,17 @@ internal sealed partial class DbConnectionPoolIdentity
private readonly string _sidString;
private readonly bool _isRestricted;
private readonly bool _isNetwork;
+ private readonly long _authenticationId;
private readonly int _hashCode;
- private DbConnectionPoolIdentity(string sidString, bool isRestricted, bool isNetwork)
+ private DbConnectionPoolIdentity(string sidString, bool isRestricted, bool isNetwork, long authenticationId = 0)
{
_sidString = sidString;
_isRestricted = isRestricted;
_isNetwork = isNetwork;
- _hashCode = sidString == null ? 0 : sidString.GetHashCode();
+ _authenticationId = authenticationId;
+ _hashCode = unchecked(((((sidString == null ? 0 : sidString.GetHashCode()) * 397) ^ authenticationId.GetHashCode()) * 397
+ ^ (isRestricted ? 1 : 0)) * 397 ^ (isNetwork ? 1 : 0));
}
// @TODO: Make auto-property
@@ -41,7 +45,8 @@ public override bool Equals(object value)
DbConnectionPoolIdentity that = (DbConnectionPoolIdentity)value;
result = _sidString == that._sidString &&
_isRestricted == that._isRestricted &&
- _isNetwork == that._isNetwork;
+ _isNetwork == that._isNetwork &&
+ _authenticationId == that._authenticationId;
}
return result;
@@ -54,9 +59,7 @@ public override int GetHashCode()
internal static DbConnectionPoolIdentity GetCurrent()
{
- return LocalAppContextSwitches.UseManagedNetworking
- ? GetCurrentManaged()
- : GetCurrentNative();
+ return OsConstants.IsWindows ? GetCurrentWindows() : GetCurrentManaged();
}
#if NETFRAMEWORK
@@ -84,30 +87,32 @@ private static DbConnectionPoolIdentity GetCurrentManaged()
return current;
}
- private static DbConnectionPoolIdentity GetCurrentNative()
+ private static DbConnectionPoolIdentity GetCurrentWindows()
{
DbConnectionPoolIdentity current;
using (WindowsIdentity identity = WindowsIdentity.GetCurrent())
{
- IntPtr token = identity.AccessToken.DangerousGetHandle();
SecurityIdentifier user = identity.User;
bool isNetwork = user.IsWellKnown(WellKnownSidType.NetworkSid);
string sidString = user.Value;
- // Win32NativeMethods.IsTokenRestricted will raise exception if the native call fails
- SniNativeWrapper.SniIsTokenRestricted(token, out bool isRestricted);
+ // NEW_CREDENTIALS logons retain the local SID but use different outbound credentials.
+ // Key by the logon session, not TokenId, so duplicated tokens still share a pool.
+ long authenticationId = Advapi32.GetAuthenticationId(identity.AccessToken);
+ bool isRestricted = Advapi32.GetIsTokenRestricted(identity.AccessToken);
var lastIdentity = s_lastIdentity;
if (lastIdentity != null &&
lastIdentity._sidString == sidString &&
lastIdentity._isRestricted == isRestricted &&
- lastIdentity._isNetwork == isNetwork)
+ lastIdentity._isNetwork == isNetwork &&
+ lastIdentity._authenticationId == authenticationId)
{
current = lastIdentity;
}
else
{
- current = new DbConnectionPoolIdentity(sidString, isRestricted, isNetwork);
+ current = new DbConnectionPoolIdentity(sidString, isRestricted, isNetwork, authenticationId);
}
}
s_lastIdentity = current;
@@ -115,4 +120,3 @@ private static DbConnectionPoolIdentity GetCurrentNative()
}
}
}
-
diff --git a/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs
new file mode 100644
index 0000000000..42d74bffde
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs
@@ -0,0 +1,48 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+namespace Microsoft.Data.SqlClient.Tests.Common;
+
+///
+/// Creates Windows network-only logon tokens without storing credentials in test configuration.
+///
+public static class WindowsImpersonationHelper
+{
+ private const int Logon32LogonNewCredentials = 9;
+ private const int Logon32ProviderWinnt50 = 3;
+
+ ///
+ /// Creates a new logon session with alternate outbound credentials. Windows does not validate
+ /// these credentials until network authentication, so unit tests can use placeholders.
+ ///
+ /// The outbound account name.
+ /// The outbound account's domain.
+ /// The outbound password.
+ /// A token owned by the caller, which must be disposed.
+ public static SafeAccessTokenHandle LogonNetOnly(string userName, string domain, string password)
+ {
+ if (!LogonUser(userName, domain, password, Logon32LogonNewCredentials, Logon32ProviderWinnt50, out SafeAccessTokenHandle token))
+ {
+ int error = Marshal.GetLastWin32Error();
+ token?.Dispose();
+ throw new Win32Exception(error);
+ }
+
+ return token;
+ }
+
+ [DllImport("advapi32.dll", EntryPoint = "LogonUserW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool LogonUser(
+ string userName,
+ string domain,
+ string password,
+ int logonType,
+ int logonProvider,
+ out SafeAccessTokenHandle token);
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs
new file mode 100644
index 0000000000..cd38153125
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs
@@ -0,0 +1,453 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient.Tests.Common;
+using Microsoft.Win32.SafeHandles;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Data.SqlClient.ManualTesting.Tests
+{
+ ///
+ /// Compares targetless outbound SSPI names with actual unpooled SQL authentication.
+ /// This experiment does not implement or change connection pooling.
+ ///
+ [Collection("PoolIdentityImpersonation")]
+ [Trait("Set", "3")]
+ public class OutboundCredentialNameTest
+ {
+ private readonly ITestOutputHelper _output;
+
+ /// Captures non-secret observations in published test results.
+ public OutboundCredentialNameTest(ITestOutputHelper output) => _output = output;
+
+ public static bool RunExperiment => PoolIdentityImpersonationTest.RequiresNetOnlyTests
+ || (PoolIdentityImpersonationTest.IsConfigured
+ && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_USER")));
+
+ ///
+ /// Tests two real accounts, independent same-account logons, invalid same-name credentials,
+ /// and restoration with both Open APIs and the CI-selected networking implementation.
+ ///
+ [ConditionalTheory(nameof(RunExperiment))]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task OutboundName_UnpooledSqlAuthentication(bool async)
+ {
+ AssertConfiguration();
+ await DiagnosePrequery(async);
+ string connectionString = ConnectionString();
+ string processName = CredentialName();
+ TokenStatistics processStatistics = Statistics();
+ Login process = await ReadLogin(connectionString, async);
+ using SafeAccessTokenHandle first = CreateToken(false);
+ using SafeAccessTokenHandle repeated = CreateToken(false);
+ using SafeAccessTokenHandle peer = CreateToken(true);
+ string firstId = WindowsIdentity.RunImpersonated(first, () => Statistics().AuthenticationId.ToString());
+ string repeatedId = WindowsIdentity.RunImpersonated(repeated, () => Statistics().AuthenticationId.ToString());
+ Assert.NotEqual(firstId, repeatedId);
+ Login firstLogin = await Probe(first, false, connectionString, async, "A/session1");
+ Login repeatedLogin = await Probe(repeated, false, connectionString, async, "A/session2");
+ Login peerLogin = await Probe(peer, true, connectionString, async, "B");
+ Assert.Equal(firstLogin.Sid, repeatedLogin.Sid);
+ Assert.NotEqual(firstLogin.Sid, peerLogin.Sid);
+ Assert.NotEqual(process.Sid, firstLogin.Sid);
+
+ using SafeAccessTokenHandle invalid = WindowsImpersonationHelper.LogonNetOnly(
+ User(false), Domain(false), "");
+ await WindowsIdentity.RunImpersonated(invalid, async () =>
+ {
+ AssertCredentialName(false, CredentialName());
+ SqlException failure = await Assert.ThrowsAsync(() => ReadLogin(connectionString, async));
+ Assert.NotEqual(-2, failure.Number);
+ _output.WriteLine($"A/invalid: same SSPI name, SQL rejected credentials; error={failure.Number}.");
+ });
+ await Probe(first, false, connectionString, async, "A/after-invalid");
+ Assert.Equal(processName, CredentialName());
+ Assert.Equal(processStatistics.AuthenticationId.ToString(), Statistics().AuthenticationId.ToString());
+ Login restored = await ReadLogin(connectionString, async);
+ Assert.Equal(process.Original, restored.Original);
+ Assert.Equal(process.Sid, restored.Sid);
+ _output.WriteLine($"Restoration verified; async={async}; managedSNI={DataTestUtility.UseManagedSNIOnWindows}.");
+ _output.WriteLine("Kerberos/domain aliases are uncovered: these are local Windows accounts.");
+ }
+
+ ///
+ /// Probes a SQL-SPN credential override only in a fresh disposable logon session,
+ /// observing whether a targetless name/cache predicts the actual SQL login.
+ ///
+ [ConditionalTheory(nameof(RunExperiment))]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task OutboundName_SessionCredentialOverride(bool async)
+ {
+ AssertConfiguration();
+ string connectionString = ConnectionString();
+ var builder = new SqlConnectionStringBuilder(connectionString);
+ Assert.StartsWith("tcp:localhost,", builder.DataSource, StringComparison.OrdinalIgnoreCase);
+ string target = "MSSQLSvc/localhost:" + builder.DataSource.Substring("tcp:localhost,".Length);
+ TokenStatistics process = Statistics();
+ Login processLogin = await ReadLogin(connectionString, async);
+ using SafeAccessTokenHandle token = CreateToken(false);
+ await WindowsIdentity.RunImpersonated(token, async () =>
+ {
+ TokenStatistics before = Statistics();
+ Assert.NotEqual(process.AuthenticationId.ToString(), before.AuthenticationId.ToString());
+ string cachedName = CredentialName();
+ AssertCredentialName(false, cachedName);
+ await ProbeCurrent(false, connectionString, async, "override/before");
+ if (!WriteSessionCredential(target))
+ {
+ return;
+ }
+ try
+ {
+ TokenStatistics after = Statistics();
+ string freshName = CredentialName();
+ _output.WriteLine($"Override written with CRED_PERSIST_SESSION; target={target}; "
+ + $"sameAuthenticationId={before.AuthenticationId.ToString() == after.AuthenticationId.ToString()}; "
+ + $"sameModifiedId={before.ModifiedId.ToString() == after.ModifiedId.ToString()}; "
+ + $"sameTargetlessName={string.Equals(cachedName, freshName, StringComparison.OrdinalIgnoreCase)}.");
+ Login actual = null;
+ try
+ {
+ actual = await ReadLogin(connectionString, async);
+ }
+ catch (SqlException ex)
+ {
+ _output.WriteLine($"Override SQL authentication failed; error={ex.Number}. Override selection is unverified.");
+ }
+ if (actual != null)
+ {
+ bool selectedPeer = string.Equals(actual.Original, Expected(true), StringComparison.OrdinalIgnoreCase);
+ Assert.True(selectedPeer || string.Equals(actual.Original, Expected(false), StringComparison.OrdinalIgnoreCase),
+ "SQL authenticated neither of the disposable accounts.");
+ AssertLogin(actual, selectedPeer);
+ Log("override/after", freshName, actual);
+ _output.WriteLine(selectedPeer
+ ? $"Override selected B; cachedNameMatchesSqlAccount={CredentialNameMatchesAccount(Domain(true), User(true), cachedName)}."
+ : "UNCOVERED: SQL retained A; this target override was not selected by this environment.");
+ }
+ }
+ finally
+ {
+ if (!CredDelete(target, CredentialTypeDomainPassword, 0))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "Session credential cleanup failed.");
+ }
+ }
+ await ProbeCurrent(false, connectionString, async, "override/removed");
+ });
+ Assert.Equal(process.AuthenticationId.ToString(), Statistics().AuthenticationId.ToString());
+ Assert.Equal(processLogin.Sid, (await ReadLogin(connectionString, async)).Sid);
+ }
+
+ /// Fails designated CI coverage explicitly if either disposable account is absent.
+ private static void AssertConfiguration()
+ {
+ Assert.True(PoolIdentityImpersonationTest.IsConfigured, "Account A and the TCP SQL endpoint must be configured.");
+ Assert.False(string.IsNullOrWhiteSpace(User(true)), "Account B must be configured.");
+ Assert.False(string.IsNullOrWhiteSpace(Domain(true)), "Account B's domain must be configured.");
+ Assert.False(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_PASSWORD")),
+ "Account B's secret must be configured.");
+ Assert.False(string.Equals(Expected(false), Expected(true), StringComparison.OrdinalIgnoreCase),
+ "The experiment requires two distinct disposable accounts.");
+ }
+
+ /// Builds a TCP/master connection that cannot reuse any pooled authentication.
+ private static string ConnectionString() => new SqlConnectionStringBuilder(
+ Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"))
+ {
+ Pooling = false,
+ MinPoolSize = 0,
+ ConnectRetryCount = 0,
+ ConnectTimeout = 15
+ }.ConnectionString;
+
+ /// Returns the configured username for one disposable account.
+ private static string User(bool peer) => Environment.GetEnvironmentVariable(
+ peer ? "SQLCLIENT_NETONLY_PEER_USER" : "SQLCLIENT_NETONLY_USER");
+
+ /// Returns the configured local domain for one disposable account.
+ private static string Domain(bool peer) => Environment.GetEnvironmentVariable(
+ peer ? "SQLCLIENT_NETONLY_PEER_DOMAIN" : "SQLCLIENT_NETONLY_DOMAIN");
+
+ /// Returns the expected SQL Windows login for one disposable account.
+ private static string Expected(bool peer) => Domain(peer) + "\\" + User(peer);
+
+ /// Creates a fresh outbound logon without validating or logging its secret.
+ private static SafeAccessTokenHandle CreateToken(bool peer) => WindowsImpersonationHelper.LogonNetOnly(
+ User(peer), Domain(peer), Environment.GetEnvironmentVariable(
+ peer ? "SQLCLIENT_NETONLY_PEER_PASSWORD" : "SQLCLIENT_NETONLY_PASSWORD"));
+
+ /// Queries SSPI and SQL inside the same impersonation scope, awaiting all SQL work.
+ private Task Probe(SafeAccessTokenHandle token, bool peer, string connectionString, bool async, string label) =>
+ WindowsIdentity.RunImpersonated(token, () => ProbeCurrent(peer, connectionString, async, label));
+
+ /// Isolates name-query effects using the passing regression preflight's unpooled open path.
+ private async Task DiagnosePrequery(bool async)
+ {
+ string connectionString = new SqlConnectionStringBuilder(PoolIdentityImpersonationTest.BuildConnectionString())
+ {
+ Pooling = false,
+ MinPoolSize = 0
+ }.ConnectionString;
+ Log("Control/cacheable-process", "(no SSPI query)", await ReadLogin(connectionString, async, false));
+ using SafeAccessTokenHandle control = CreateToken(false);
+ using SafeAccessTokenHandle queried = CreateToken(false);
+ string controlLogin = await WindowsIdentity.RunImpersonated(control, async () =>
+ {
+ string beforeOpen = Statistics().AuthenticationId.ToString();
+ string login = await PoolIdentityImpersonationTest.GetLogin(connectionString, async);
+ _output.WriteLine($"Control/no-SSPI: beforeOpenAuthenticationId={beforeOpen}; "
+ + $"afterOpenAuthenticationId={Statistics().AuthenticationId}; SQL={login}; async={async}.");
+ Login cached = await ReadLogin(connectionString, async, false);
+ Login recompiled = await ReadLogin(connectionString, async);
+ Log("Control/cacheable-A", "(no SSPI query)", cached);
+ Log("Control/recompiled-A", "(no SSPI query)", recompiled);
+ AssertName(Expected(false), cached.Original);
+ AssertLogin(recompiled, false);
+ return login;
+ });
+ string queriedLogin = await WindowsIdentity.RunImpersonated(queried, async () =>
+ {
+ string lookupId = Statistics().AuthenticationId.ToString();
+ string name = CredentialName();
+ string beforeOpen = Statistics().AuthenticationId.ToString();
+ string login = await PoolIdentityImpersonationTest.GetLogin(connectionString, async);
+ _output.WriteLine($"Control/acquire-query-free: SSPI={name}; lookupAuthenticationId={lookupId}; "
+ + $"beforeOpenAuthenticationId={beforeOpen}; afterOpenAuthenticationId={Statistics().AuthenticationId}; "
+ + $"SQL={login}; async={async}.");
+ Assert.Equal(lookupId, beforeOpen);
+ AssertCredentialName(false, name);
+ return login;
+ });
+ AssertName(Expected(false), controlLogin);
+ AssertName(Expected(false), queriedLogin);
+ }
+
+ /// Checks the preconnection name and the authenticated SQL name and SID.
+ private async Task ProbeCurrent(bool peer, string connectionString, bool async, string label)
+ {
+ string lookupId = Statistics().AuthenticationId.ToString();
+ string name = CredentialName();
+ AssertCredentialName(peer, name);
+ string beforeOpen = Statistics().AuthenticationId.ToString();
+ Login login = await ReadLogin(connectionString, async);
+ Log(label, name, login);
+ _output.WriteLine($"{label}: lookupAuthenticationId={lookupId}; beforeOpenAuthenticationId={beforeOpen}; "
+ + $"afterOpenAuthenticationId={Statistics().AuthenticationId}; async={async}; "
+ + $"managedSNI={DataTestUtility.UseManagedSNIOnWindows}.");
+ Assert.Equal(lookupId, beforeOpen);
+ AssertLogin(login, peer);
+ return login;
+ }
+
+ /// Checks the two observed SSPI formats against the exact configured local account.
+ private static void AssertCredentialName(bool peer, string actual) =>
+ Assert.True(CredentialNameMatchesAccount(Domain(peer), User(peer), actual),
+ $"Expected SSPI name for '{Expected(peer)}', received '{actual}'.");
+
+ /// Recognizes local SAM and user-at-machine forms without resolving domain aliases.
+ private static bool CredentialNameMatchesAccount(string domain, string user, string actual) =>
+ string.Equals(domain + "\\" + user, actual, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(user + "@" + domain, actual, StringComparison.OrdinalIgnoreCase);
+
+ /// Preserves exact account matching across the two SSPI formats observed in CI.
+ [Theory]
+ [InlineData(@"machine\account", true)]
+ [InlineData("account@machine", true)]
+ [InlineData(@"MACHINE\ACCOUNT", true)]
+ [InlineData("ACCOUNT@MACHINE", true)]
+ [InlineData(@"machine\other", false)]
+ [InlineData("other@machine", false)]
+ [InlineData(@"other\account", false)]
+ [InlineData("account@other", false)]
+ public void OutboundName_LocalAccountFormats(string actual, bool expected) =>
+ Assert.Equal(expected, CredentialNameMatchesAccount("machine", "account", actual));
+
+ /// Checks SQL SAM-form names without treating case differences as distinct accounts.
+ private static void AssertName(string expected, string actual) =>
+ Assert.True(string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase),
+ $"Expected '{expected}', received '{actual}'.");
+
+ /// Verifies both SQL name functions, Windows SID, NTLM authentication and TCP.
+ private static void AssertLogin(Login login, bool peer)
+ {
+ AssertName(Expected(peer), login.Original);
+ AssertName(Expected(peer), login.Current);
+ Assert.Equal(((SecurityIdentifier)new NTAccount(Domain(peer), User(peer))
+ .Translate(typeof(SecurityIdentifier))).Value, login.Sid);
+ Assert.Equal("NTLM", login.Authentication);
+ Assert.Equal("TCP", login.Transport);
+ }
+
+ /// Publishes only identities and protocol observations, never credentials.
+ private void Log(string label, string name, Login login) => _output.WriteLine(
+ $"{label}: SSPI={name}; ORIGINAL_LOGIN={login.Original}; SUSER_SNAME={login.Current}; "
+ + $"SUSER_SID={login.Sid}; auth_scheme={login.Authentication}; transport={login.Transport}.");
+
+ /// Reads current-session properties, normally recompiling context-sensitive scalar expressions.
+ private static async Task ReadLogin(string connectionString, bool async, bool recompile = true)
+ {
+ using var connection = new SqlConnection(connectionString);
+ if (async) await connection.OpenAsync();
+ else connection.Open();
+ using var command = new SqlCommand(
+ "SELECT ORIGINAL_LOGIN(), SUSER_SNAME(), SUSER_SID(), "
+ + "CONVERT(varchar(30), CONNECTIONPROPERTY('auth_scheme')), "
+ + "CONVERT(varchar(30), CONNECTIONPROPERTY('net_transport'))"
+ + (recompile ? " OPTION (RECOMPILE)" : ""), connection);
+ using SqlDataReader reader = async ? await command.ExecuteReaderAsync() : command.ExecuteReader();
+ Assert.True(async ? await reader.ReadAsync() : reader.Read());
+ return new Login
+ {
+ Original = reader.GetString(0),
+ Current = reader.GetString(1),
+ Sid = new SecurityIdentifier((byte[])reader.GetValue(2), 0).Value,
+ Authentication = reader.GetString(3),
+ Transport = reader.GetString(4)
+ };
+ }
+
+ /// Acquires a fresh targetless Negotiate credential under the effective identity.
+ private static string CredentialName()
+ {
+ CheckStatus(AcquireCredentialsHandle(null, "Negotiate", 2, IntPtr.Zero, IntPtr.Zero,
+ IntPtr.Zero, IntPtr.Zero, out CredentialHandle handle, out _));
+ try
+ {
+ CheckStatus(QueryCredentialsAttributes(ref handle, 1, out IntPtr name));
+ try { return Marshal.PtrToStringUni(name); }
+ finally { CheckStatus(FreeContextBuffer(name)); }
+ }
+ finally { CheckStatus(FreeCredentialsHandle(ref handle)); }
+ }
+
+ /// Reports native SSPI errors without credential data.
+ private static void CheckStatus(int status)
+ {
+ if (status != 0) throw new Win32Exception(status, $"SSPI status 0x{status:X8}.");
+ }
+
+ /// Snapshots the effective token's authentication and modification IDs.
+ private static TokenStatistics Statistics()
+ {
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ if (!GetTokenInformation(identity.AccessToken, 10, out TokenStatistics statistics,
+ Marshal.SizeOf(), out _))
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ return statistics;
+ }
+
+ ///
+ /// Writes B's credential with session-only persistence; callers must already be in a
+ /// fresh NEW_CREDENTIALS session and delete the exact target in finally.
+ ///
+ private bool WriteSessionCredential(string target)
+ {
+ byte[] bytes = Encoding.Unicode.GetBytes(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_PASSWORD"));
+ IntPtr blob = Marshal.AllocHGlobal(bytes.Length);
+ try
+ {
+ Marshal.Copy(bytes, 0, blob, bytes.Length);
+ var credential = new Credential
+ {
+ Type = CredentialTypeDomainPassword,
+ TargetName = target,
+ BlobSize = (uint)bytes.Length,
+ Blob = blob,
+ Persist = 1, // CRED_PERSIST_SESSION: never write the agent's ordinary credential store.
+ UserName = Expected(true)
+ };
+ if (CredWrite(ref credential, 0)) return true;
+ int error = Marshal.GetLastWin32Error();
+ if (error != 5 && error != 50 && error != 1312)
+ throw new Win32Exception(error, "Session credential write failed.");
+ _output.WriteLine($"UNCOVERED: session Credential Manager override unavailable; Win32={error}.");
+ return false;
+ }
+ finally
+ {
+ Array.Clear(bytes, 0, bytes.Length);
+ Marshal.Copy(bytes, 0, blob, bytes.Length);
+ Marshal.FreeHGlobal(blob);
+ }
+ }
+
+ private sealed class Login
+ {
+ internal string Original, Current, Sid, Authentication, Transport;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Luid
+ {
+ internal uint Low;
+ internal int High;
+ public override string ToString() => $"{High:X8}{Low:X8}";
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct TokenStatistics
+ {
+ internal Luid TokenId, AuthenticationId;
+ internal long Expiration;
+ internal int Type, Level;
+ internal uint Charged, Available, Groups, Privileges;
+ internal Luid ModifiedId;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CredentialHandle { internal IntPtr Lower, Upper; }
+
+ private const uint CredentialTypeDomainPassword = 2;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct Credential
+ {
+ internal uint Flags, Type;
+ internal string TargetName, Comment;
+ internal long LastWritten;
+ internal uint BlobSize;
+ internal IntPtr Blob;
+ internal uint Persist, AttributeCount;
+ internal IntPtr Attributes;
+ internal string TargetAlias, UserName;
+ }
+
+ [DllImport("secur32.dll", EntryPoint = "AcquireCredentialsHandleW", CharSet = CharSet.Unicode, ExactSpelling = true)]
+ private static extern int AcquireCredentialsHandle(string principal, string package, uint use,
+ IntPtr logonId, IntPtr authData, IntPtr getKey, IntPtr getKeyArgument,
+ out CredentialHandle handle, out long expiry);
+
+ [DllImport("secur32.dll", EntryPoint = "QueryCredentialsAttributesW", ExactSpelling = true)]
+ private static extern int QueryCredentialsAttributes(ref CredentialHandle handle, uint attribute, out IntPtr name);
+
+ [DllImport("secur32.dll", ExactSpelling = true)]
+ private static extern int FreeCredentialsHandle(ref CredentialHandle handle);
+
+ [DllImport("secur32.dll", ExactSpelling = true)]
+ private static extern int FreeContextBuffer(IntPtr buffer);
+
+ [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetTokenInformation(SafeAccessTokenHandle token, int kind,
+ out TokenStatistics statistics, int size, out int needed);
+
+ [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CredWrite(ref Credential credential, uint flags);
+
+ [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CredDelete(string target, uint type, uint flags);
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs
new file mode 100644
index 0000000000..502ce5adc4
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs
@@ -0,0 +1,345 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient.Tests.Common;
+using Microsoft.Win32.SafeHandles;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.ManualTesting.Tests
+{
+ ///
+ /// Serializes tests that switch pool implementations and exercise real Windows credentials.
+ ///
+ [CollectionDefinition("PoolIdentityImpersonation", DisableParallelization = true)]
+ public sealed class PoolIdentityImpersonationCollection
+ {
+ }
+
+ ///
+ /// Regression coverage for #4717 against a real SQL Server. Invalid-credential probes need
+ /// only integrated security; alternate-account tests read credentials from environment
+ /// variables.
+ ///
+ [Collection("PoolIdentityImpersonation")]
+ [Trait("Set", "3")]
+ public class PoolIdentityImpersonationTest
+ {
+ public static bool IsConfigured =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER"))
+ && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN"))
+ && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PASSWORD"))
+ && (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"))
+ || DataTestUtility.AreConnStringsSetup());
+
+ /// Identifies CI jobs where alternate-account coverage must execute.
+ public static bool RequiresNetOnlyTests =>
+ string.Equals(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_REQUIRED"), "true", StringComparison.OrdinalIgnoreCase);
+
+ /// Runs required CI cases even when missing configuration must be reported.
+ public static bool RunConfiguredTests => RequiresNetOnlyTests || IsConfigured;
+
+ ///
+ /// Needs only an integrated-security connection string: invalid network-only credentials
+ /// cannot open a new physical connection, so any successful open proves pool sharing.
+ ///
+ public static bool SupportsInvalidCredentialProbe =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ && DataTestUtility.AreConnStringsSetup()
+ && DataTestUtility.IsIntegratedSecuritySetup();
+
+ ///
+ /// A network-only caller with unusable credentials must not receive idle or replenished
+ /// connections authenticated as the process account.
+ ///
+ [ConditionalTheory(nameof(SupportsInvalidCredentialProbe))]
+ [InlineData(false, false, 0)]
+ [InlineData(false, true, 0)]
+ [InlineData(true, false, 0)]
+ [InlineData(true, true, 0)]
+ [InlineData(false, false, 3)]
+ [InlineData(false, true, 3)]
+ [InlineData(true, false, 3)]
+ [InlineData(true, true, 3)]
+ public async Task InvalidNetOnlyCredentials_DoNotReuseProcessConnections(bool usePoolV2, bool async, int minPoolSize)
+ {
+ using var poolVersion = new ConnectionPoolVersionScope(usePoolV2);
+ using SafeAccessTokenHandle token = CreateInvalidToken();
+ string connectionString = BuildConnectionString(minPoolSize: minPoolSize);
+ await SkipUnlessInvalidCredentialsRejected(token, connectionString, async);
+ try
+ {
+ string processLogin = await GetLogin(connectionString, async);
+ if (minPoolSize > 0)
+ {
+ // Give background replenishment time to fill the process pool.
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ }
+
+ await Assert.ThrowsAsync(
+ () => WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async)));
+ Assert.Equal(processLogin, await GetLogin(connectionString, async));
+ }
+ finally
+ {
+ using var poolConnection = new SqlConnection(connectionString);
+ SqlConnection.ClearPool(poolConnection);
+ }
+ }
+
+ ///
+ /// A failed network-only open must not place the process account's pool in its error
+ /// blocking period.
+ ///
+ [ConditionalTheory(nameof(SupportsInvalidCredentialProbe))]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task InvalidNetOnlyCredentials_FailureDoesNotBlockProcess(bool usePoolV2, bool async)
+ {
+ using var poolVersion = new ConnectionPoolVersionScope(usePoolV2);
+ using SafeAccessTokenHandle token = CreateInvalidToken();
+ string connectionString = BuildConnectionString();
+ await SkipUnlessInvalidCredentialsRejected(token, connectionString, async);
+ try
+ {
+ await Assert.ThrowsAsync(
+ () => WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async)));
+ Assert.False(string.IsNullOrEmpty(await GetLogin(connectionString, async)));
+ }
+ finally
+ {
+ using var poolConnection = new SqlConnection(connectionString);
+ SqlConnection.ClearPool(poolConnection);
+ }
+ }
+
+ ///
+ /// Connections returned under alternate outbound credentials must not be reused by the
+ /// process account after impersonation ends, for either pool and either Open API.
+ ///
+ [ConditionalTheory(nameof(RunConfiguredTests))]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task NetOnlyImpersonation_RevertsLogin(bool usePoolV2, bool async)
+ {
+ AssertConfiguration();
+ using var poolVersion = new ConnectionPoolVersionScope(usePoolV2);
+ using SafeAccessTokenHandle token = CreateToken();
+ string connectionString = BuildConnectionString();
+ string expected = await VerifyUnpooledLogins(token, connectionString, async);
+ try
+ {
+ string original = await GetLogin(connectionString, async);
+ string impersonated = await WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async));
+ Assert.Equal(expected, impersonated);
+ Assert.NotEqual(original, impersonated);
+ Assert.Equal(original, await GetLogin(connectionString, async));
+ }
+ finally
+ {
+ using var poolConnection = new SqlConnection(connectionString);
+ SqlConnection.ClearPool(poolConnection);
+ }
+ }
+
+ ///
+ /// Returning an expired connection outside impersonation must not replenish the owning
+ /// pool with process-account connections or cache a process-account login failure.
+ ///
+ [ConditionalTheory(nameof(RunConfiguredTests))]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task NetOnlyImpersonation_Replenishment_PreservesLogin(bool usePoolV2, bool async)
+ {
+ AssertConfiguration();
+ using var poolVersion = new ConnectionPoolVersionScope(usePoolV2);
+ using SafeAccessTokenHandle token = CreateToken();
+ string connectionString = BuildConnectionString(loadBalanceTimeout: 1, minPoolSize: 3);
+ string expected = await VerifyUnpooledLogins(token, connectionString, async);
+ var opened = new List();
+ try
+ {
+ SqlConnection expiring = await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async));
+ opened.Add(expiring);
+ for (int i = 0; i < 2; i++)
+ {
+ opened.Add(await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async)));
+ }
+
+ // Exceed the one-second physical connection lifetime, then trigger replenishment
+ // under the process context. Both pools now have a minimum to replenish.
+ await Task.Delay(TimeSpan.FromMilliseconds(1500));
+ expiring.Dispose();
+
+ // Hold every checkout so a correctly authenticated idle connection cannot hide a
+ // wrongly replenished one. MaxPoolSize bounds this probe at eight new checkouts.
+ for (int i = 0; i < 8; i++)
+ {
+ SqlConnection connection = await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async));
+ opened.Add(connection);
+ Assert.Equal(expected, await SelectLogin(connection, async));
+ await Task.Delay(TimeSpan.FromMilliseconds(100));
+ }
+ }
+ finally
+ {
+ using var poolConnection = new SqlConnection(connectionString);
+ SqlConnection.ClearPool(poolConnection);
+ foreach (SqlConnection connection in opened)
+ {
+ connection.Dispose();
+ }
+ }
+ }
+
+ /// Fails required CI coverage instead of skipping when provisioning is incomplete.
+ private static void AssertConfiguration()
+ {
+ Assert.True(IsConfigured, "Network-only tests require Windows, configured credentials, and a SQL Server connection.");
+ if (RequiresNetOnlyTests)
+ {
+ string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING");
+ Assert.False(string.IsNullOrWhiteSpace(configured),
+ "Required network-only tests need an explicit TCP connection string.");
+ Assert.StartsWith("tcp:", new SqlConnectionStringBuilder(configured).DataSource, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ /// Proves outbound authentication uses the configured account before testing pooling.
+ /// The alternate outbound logon token.
+ /// The connection string to probe without pooling.
+ /// Whether to use asynchronous SQL APIs.
+ /// The verified alternate SQL login.
+ private static async Task VerifyUnpooledLogins(
+ SafeAccessTokenHandle token, string connectionString, bool async)
+ {
+ var unpooled = new SqlConnectionStringBuilder(connectionString)
+ {
+ Pooling = false,
+ MinPoolSize = 0
+ };
+ string alternate = await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.ConnectionString, async));
+ string expected = $"{Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN")}\\{Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER")}";
+ Assert.True(string.Equals(expected, alternate, StringComparison.OrdinalIgnoreCase),
+ $"Expected outbound login '{expected}', but SQL Server authenticated '{alternate}'.");
+ if (RequiresNetOnlyTests)
+ {
+ string original = await GetLogin(unpooled.ConnectionString, async);
+ Assert.False(string.Equals(original, alternate, StringComparison.OrdinalIgnoreCase),
+ "The process and network-only accounts must authenticate as different SQL logins.");
+ }
+ return alternate;
+ }
+
+ /// Creates the configured network-only token without persisting credentials.
+ private static SafeAccessTokenHandle CreateToken() =>
+ WindowsImpersonationHelper.LogonNetOnly(
+ Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER"),
+ Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN"),
+ Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PASSWORD"));
+
+ ///
+ /// Creates a network-only token with placeholder credentials; NEW_CREDENTIALS logons do
+ /// not validate them until a new outbound authentication is attempted.
+ ///
+ private static SafeAccessTokenHandle CreateInvalidToken() =>
+ WindowsImpersonationHelper.LogonNetOnly("", "EXAMPLE", "");
+
+ ///
+ /// The invalid-credential probes rely on the server rejecting the placeholder outbound
+ /// credentials. Some environments (for example, loopback authentication that falls back
+ /// to the local logon session) accept them, so a successful open would not prove pool
+ /// sharing. Verify the precondition with an unpooled connection and skip otherwise.
+ ///
+ private static async Task SkipUnlessInvalidCredentialsRejected(
+ SafeAccessTokenHandle token, string connectionString, bool async)
+ {
+ var unpooled = new SqlConnectionStringBuilder(connectionString)
+ {
+ Pooling = false,
+ MinPoolSize = 0
+ };
+ try
+ {
+ await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.ConnectionString, async));
+ }
+ catch (SqlException)
+ {
+ return;
+ }
+
+ throw new Microsoft.DotNet.XUnitExtensions.SkipTestException(
+ "The server accepted placeholder network-only credentials, so pool sharing cannot be detected.");
+ }
+
+ /// Creates a unique pool group using the configured server and integrated security.
+ internal static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0)
+ {
+ string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING");
+ var builder = new SqlConnectionStringBuilder(
+ string.IsNullOrWhiteSpace(configured) ? DataTestUtility.TCPConnectionString : configured)
+ {
+ IntegratedSecurity = true,
+ Pooling = true,
+ ApplicationName = $"PoolIdentityImpersonation-{Guid.NewGuid():N}",
+ LoadBalanceTimeout = loadBalanceTimeout,
+ MinPoolSize = minPoolSize,
+ MaxPoolSize = 10,
+ ConnectTimeout = 15
+ };
+ builder.Remove("User ID");
+ builder.Remove("Password");
+ builder.Remove("Authentication");
+ return builder.ConnectionString;
+ }
+
+ /// Opens and disposes a connection after reading its authenticated SQL login.
+ internal static async Task GetLogin(string connectionString, bool async)
+ {
+ using SqlConnection connection = await Open(connectionString, async);
+ return await SelectLogin(connection, async);
+ }
+
+ /// Opens a connection with the selected API, disposing it on failure.
+ private static async Task Open(string connectionString, bool async)
+ {
+ var connection = new SqlConnection(connectionString);
+ try
+ {
+ if (async)
+ {
+ await connection.OpenAsync();
+ }
+ else
+ {
+ connection.Open();
+ }
+ return connection;
+ }
+ catch
+ {
+ connection.Dispose();
+ throw;
+ }
+ }
+
+ /// Reads the login that authenticated the physical connection.
+ private static async Task SelectLogin(SqlConnection connection, bool async)
+ {
+ using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection);
+ return (string)(async ? await command.ExecuteScalarAsync() : command.ExecuteScalar());
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs
new file mode 100644
index 0000000000..aa5c87270c
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs
@@ -0,0 +1,312 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Threading.Tasks;
+using Interop.Windows.Advapi32;
+using Microsoft.Data.Common.ConnectionString;
+using Microsoft.Data.ProviderBase;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Microsoft.Data.SqlClient.Tests.Common;
+using Microsoft.Extensions.Time.Testing;
+using Microsoft.Win32.SafeHandles;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
+{
+ ///
+ /// Guards against pooling and replenishing under the wrong network-only logon session.
+ /// No network authentication is performed; connections are supplied by a stub factory.
+ ///
+ [Collection(AppContextSwitchTestCollection.Name)]
+ public class DbConnectionPoolIdentityTest
+ {
+ public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ ///
+ /// Network-only logons keep the local SID but must have distinct pool identities, while
+ /// repeated reads and duplicated tokens from the same logon session remain equivalent.
+ ///
+ [ConditionalTheory(nameof(IsWindows))]
+ [InlineData(false)]
+ #if NET
+ [InlineData(true)]
+ #endif
+ public void GetCurrent_NetOnlyLogons_AreDistinct(bool managedNetworking)
+ {
+ using var switches = new LocalAppContextSwitchesHelper();
+ SetNetworking(switches, managedNetworking);
+ using SafeAccessTokenHandle first = CreateToken();
+ using SafeAccessTokenHandle second = CreateToken();
+ using WindowsIdentity original = WindowsIdentity.GetCurrent();
+
+ DbConnectionPoolIdentity processIdentity = DbConnectionPoolIdentity.GetCurrent();
+ DbConnectionPoolIdentity firstIdentity = WindowsIdentity.RunImpersonated(first, () =>
+ {
+ using WindowsIdentity current = WindowsIdentity.GetCurrent();
+ Assert.Equal(original.User, current.User);
+ return DbConnectionPoolIdentity.GetCurrent();
+ });
+ DbConnectionPoolIdentity secondIdentity =
+ WindowsIdentity.RunImpersonated(second, DbConnectionPoolIdentity.GetCurrent);
+
+ Assert.NotEqual(processIdentity, firstIdentity);
+ Assert.NotEqual(firstIdentity, secondIdentity);
+ Assert.Equal(processIdentity, DbConnectionPoolIdentity.GetCurrent());
+
+ WindowsIdentity.RunImpersonated(first, () =>
+ {
+ DbConnectionPoolIdentity repeated = DbConnectionPoolIdentity.GetCurrent();
+ Assert.Equal(firstIdentity, repeated);
+ Assert.Equal(firstIdentity.GetHashCode(), repeated.GetHashCode());
+ using WindowsIdentity duplicate = WindowsIdentity.GetCurrent();
+ WindowsIdentity.RunImpersonated(duplicate.AccessToken, () =>
+ Assert.Equal(firstIdentity, DbConnectionPoolIdentity.GetCurrent()));
+ });
+
+ var identities = new HashSet
+ {
+ processIdentity, firstIdentity, secondIdentity,
+ WindowsIdentity.RunImpersonated(first, DbConnectionPoolIdentity.GetCurrent)
+ };
+ Assert.Equal(3, identities.Count);
+ }
+
+ ///
+ /// Both pool implementations select separate pools for identical local SIDs belonging
+ /// to different logon sessions, and restore the original pool after impersonation ends.
+ ///
+ [ConditionalTheory(nameof(IsWindows))]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ #if NET
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ #endif
+ public void GetPool_NetOnlyLogons_SelectSeparatePools(bool usePoolV2, bool managedNetworking)
+ {
+ using var switches = new LocalAppContextSwitchesHelper();
+ switches.UseConnectionPoolV2 = usePoolV2;
+ SetNetworking(switches, managedNetworking);
+ using SafeAccessTokenHandle first = CreateToken();
+ using SafeAccessTokenHandle second = CreateToken();
+ var group = CreateGroup(minPoolSize: 0);
+ var factory = new ChannelDbConnectionPoolTest.SuccessfulSqlConnectionFactory();
+ IDbConnectionPool processPool = group.GetConnectionPool(factory);
+ IDbConnectionPool? firstPool = null;
+ IDbConnectionPool? secondPool = null;
+ try
+ {
+ firstPool = WindowsIdentity.RunImpersonated(first, () => group.GetConnectionPool(factory));
+ secondPool = WindowsIdentity.RunImpersonated(second, () => group.GetConnectionPool(factory));
+ Assert.NotSame(processPool, firstPool);
+ Assert.NotSame(firstPool, secondPool);
+ Assert.Same(processPool, group.GetConnectionPool(factory));
+ Assert.Same(firstPool, WindowsIdentity.RunImpersonated(first, () => group.GetConnectionPool(factory)));
+ if (usePoolV2)
+ {
+ Assert.IsType(firstPool);
+ }
+ else
+ {
+ Assert.IsType(firstPool);
+ }
+ }
+ finally
+ {
+ processPool.Shutdown();
+ firstPool?.Shutdown();
+ secondPool?.Shutdown();
+ }
+ }
+
+ ///
+ /// Replenishment with the wrong logon session must neither create connections nor poison
+ /// the pool; a later pass with the owning session must still reach the minimum.
+ ///
+ [ConditionalTheory(nameof(IsWindows))]
+ [InlineData(false, false, false)]
+ [InlineData(false, false, true)]
+ [InlineData(true, false, false)]
+ [InlineData(true, false, true)]
+ #if NET
+ [InlineData(false, true, false)]
+ [InlineData(false, true, true)]
+ [InlineData(true, true, false)]
+ [InlineData(true, true, true)]
+ #endif
+ public async Task Replenishment_WrongLogonSession_DoesNotCreateConnections(
+ bool usePoolV2, bool managedNetworking, bool impersonatedPool)
+ {
+ using var switches = new LocalAppContextSwitchesHelper();
+ SetNetworking(switches, managedNetworking);
+ using SafeAccessTokenHandle token = CreateToken();
+ DbConnectionPoolIdentity identity = impersonatedPool
+ ? WindowsIdentity.RunImpersonated(token, DbConnectionPoolIdentity.GetCurrent)
+ : DbConnectionPoolIdentity.GetCurrent();
+ var factory = new ChannelDbConnectionPoolTest.CountingSuccessfulConnectionFactory();
+ var group = CreateGroup(minPoolSize: 1);
+ IDbConnectionPool pool = usePoolV2
+ ? new ChannelDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider())
+ : new WaitHandleDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider());
+ try
+ {
+ await (impersonatedPool
+ ? Replenish(pool)
+ : WindowsIdentity.RunImpersonated(token, () => Replenish(pool)));
+ Assert.Equal(0, factory.CreateCount);
+ Assert.Equal(0, pool.Count);
+ Assert.False(pool.ErrorOccurred);
+
+ await (impersonatedPool
+ ? WindowsIdentity.RunImpersonated(token, () => Replenish(pool))
+ : Replenish(pool));
+ Assert.Equal(1, factory.CreateCount);
+ Assert.Equal(1, pool.IdleCount);
+ Assert.False(pool.ErrorOccurred);
+ }
+ finally
+ {
+ pool.Shutdown();
+ }
+ }
+
+ ///
+ /// Synchronous and asynchronous checkouts establish connections in the requesting
+ /// logon session for either pool implementation.
+ ///
+ [ConditionalTheory(nameof(IsWindows))]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task GetConnection_NetOnlyLogon_PreservesIdentity(bool usePoolV2, bool async)
+ {
+#if NETFRAMEWORK
+ // Load EnterpriseServices before placeholder outbound credentials affect assembly resolution.
+ _ = System.Transactions.Transaction.Current;
+#endif
+ using SafeAccessTokenHandle token = CreateToken();
+ DbConnectionPoolIdentity identity = WindowsIdentity.RunImpersonated(token, DbConnectionPoolIdentity.GetCurrent);
+ var factory = new IdentityRecordingFactory();
+ var group = CreateGroup(minPoolSize: 0);
+ IDbConnectionPool pool = usePoolV2
+ ? new ChannelDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider())
+ : new WaitHandleDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider());
+ using var owner = new SqlConnection();
+ DbConnectionInternal? connection = null;
+ try
+ {
+ connection = await WindowsIdentity.RunImpersonated(token, async () =>
+ {
+ TaskCompletionSource? completion = async
+ ? new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
+ : null;
+ bool completed = pool.TryGetConnection(owner, completion,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? result);
+ if (completed)
+ {
+ return result!;
+ }
+
+ Assert.Same(completion!.Task,
+ await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(30))));
+ return await completion.Task;
+ });
+ Assert.Equal(identity, factory.CreatedIdentity);
+ }
+ finally
+ {
+ if (connection != null)
+ {
+ pool.ReturnInternalConnection(connection, owner);
+ }
+ pool.Shutdown();
+ }
+ }
+
+ ///
+ /// Token-query failures propagate instead of collapsing identities to a default value.
+ ///
+ [ConditionalFact(nameof(IsWindows))]
+ public void TokenQueries_InvalidHandle_Throw()
+ {
+ using var token = new SafeAccessTokenHandle(IntPtr.Zero);
+ Assert.Throws(() => Advapi32.GetAuthenticationId(token));
+ Assert.Throws(() => Advapi32.GetIsTokenRestricted(token));
+ }
+
+ /// Records the identity under which a stub physical connection is created.
+ private sealed class IdentityRecordingFactory : ChannelDbConnectionPoolTest.SuccessfulSqlConnectionFactory
+ {
+ internal DbConnectionPoolIdentity? CreatedIdentity { get; private set; }
+
+ /// Captures the effective pool identity before creating a stub connection.
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options, ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, IDbConnectionPool pool,
+ DbConnection owningConnection, TimeoutTimer timeout)
+ {
+ CreatedIdentity = DbConnectionPoolIdentity.GetCurrent();
+ return base.CreateConnection(options, poolKey, poolGroupProviderInfo, pool, owningConnection, timeout);
+ }
+ }
+
+ /// Creates a network-only token without usable network credentials.
+ /// A token belonging to a new logon session, owned by the caller.
+ private static SafeAccessTokenHandle CreateToken() =>
+ WindowsImpersonationHelper.LogonNetOnly("", "EXAMPLE", "");
+
+ /// Chooses the networking mode where that switch exists.
+ /// The scope restoring the cached switch after the test.
+ /// Whether to use managed networking on Windows.
+ private static void SetNetworking(LocalAppContextSwitchesHelper switches, bool managedNetworking)
+ {
+ #if NET
+ switches.UseManagedNetworking = managedNetworking;
+ #endif
+ }
+
+ /// Constructs a group that pools integrated-authentication connections by identity.
+ /// The minimum number of connections to replenish.
+ /// An isolated pool group for the test.
+ private static DbConnectionPoolGroup CreateGroup(int minPoolSize) =>
+ new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;Integrated Security=true;"),
+ new ConnectionPoolKey("IdentityTest", null, null, null, null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: true, minPoolSize: minPoolSize, maxPoolSize: 5,
+ creationTimeout: 15000, loadBalanceTimeout: 0, hasTransactionAffinity: false, idleTimeout: 0));
+
+ ///
+ /// Executes an actual background replenishment pass and awaits its completion rather
+ /// than using sleeps to infer that no connection was created.
+ ///
+ /// The pool whose background callback is exercised.
+ /// A task completing when the replenishment pass finishes.
+ private static async Task Replenish(IDbConnectionPool pool)
+ {
+ Task work;
+ if (pool is ChannelDbConnectionPool channel)
+ {
+ channel.RequestWarmup();
+ work = channel.WarmupLoopTask!;
+ }
+ else
+ {
+ MethodInfo callback = typeof(WaitHandleDbConnectionPool).GetMethod(
+ "PoolCreateRequest", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ work = Task.Run(() => callback.Invoke(pool, new object?[] { null }));
+ }
+
+ Assert.Same(work, await Task.WhenAny(work, Task.Delay(TimeSpan.FromSeconds(30))));
+ await work;
+ }
+ }
+}