diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index f6cf392f6c..7b94f25904 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -161,6 +161,16 @@ Located in `ConnectionPool/`: - Pool groups manage pools for different connection strings ### Authentication Providers +The provider registry, authentication configuration handlers and `SqlAuthenticationInitializer` +live in `Microsoft.Data.SqlClient.Extensions/Abstractions`, targeting `net462;netstandard2.0;net10.0`. +The driver forwards their public types and delegates authentication lookup directly. +Abstractions' cached feature switches and separate capability guards keep explicit registration +AOT-safe while removing reflective configuration and Azure discovery from trimmed/AOT publishes. +Abstractions owns its authentication `AzureExtensionLoader`; it does not include sources from +SqlClient or Azure. The driver independently loads Azure for attestation, without processing +authentication configuration or consulting authentication discovery switches. Keep the two +loaders' assembly identity checks consistent. + Located in `SSPI/` and authentication-related files: - Windows Authentication (SSPI/Kerberos) - SQL Server Authentication diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md index 006bfa7e92..996255ad8a 100644 --- a/.github/instructions/features.instructions.md +++ b/.github/instructions/features.instructions.md @@ -243,7 +243,8 @@ AppContext switches allow runtime behavior changes without modifying connection | Switch Name | Default | Description | |-------------|---------|-------------| | `Switch.Microsoft.Data.SqlClient.DisableTNIRByDefaultInConnectionString` | `false` | Disables Transparent Network IP Resolution by default | -| `Switch.Microsoft.Data.SqlClient.EnableAppConfig` | `true` | .NET only. Controls whether SqlClient reads app.config: configurable retry logic, authentication providers and switch overrides. See [Trimming](#trimming) | +| `Switch.Microsoft.Data.SqlClient.EnableAppConfig` | `true` | Controls authentication configuration in Abstractions and .NET-only driver retry/switch overrides. Trimmed/AOT authentication paths are removed automatically. See [Trimming](#trimming) | +| `Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery` | `true` | Controls reflective Azure authentication discovery; trimmed/AOT .NET 10 publishes remove this guarded path | | `Switch.Microsoft.Data.SqlClient.EnableMultiSubnetFailoverByDefault` | `false` | Sets `MultiSubnetFailover=true` as the default for all connections | | `Switch.Microsoft.Data.SqlClient.EnableTransactionIsolationLevelReset` | `false` | Resets a changed session transaction isolation level to `READ COMMITTED` before a pooled connection is reused | | `Switch.Microsoft.Data.SqlClient.EnableUserAgent` | varies | Controls sending user agent information to SQL Server | @@ -270,8 +271,14 @@ configuration, and may register providers in code instead. - **Partial configuration:** the driver applies the valid settings and uses defaults for the rest. - **Invalid entries:** an invalid `useWamBroker` value is traced and ignored, keeping the default and other valid settings. Configuration-reading errors (`ConfigurationErrorsException`) are - traced and produce empty settings without trying legacy. Other exception types propagate; - initializer and provider validation or activation can still fail. + traced and produce empty settings without trying legacy. Other exception types reach registry + bootstrap, which traces recoverable failures and continues with enabled Azure discovery. +- **Activation failures:** recoverable initializer failures are traced without preventing the + configured provider list from being applied. Failed provider entries are traced and skipped, + and later entries are still attempted. Enabled Azure discovery runs afterward using valid + option overrides and fills only unregistered methods. Successful configuration registrations + retain precedence. Fatal activation errors propagate. Configuration and discovery are attempted + once; restart after correcting configuration to retry failed entries. - **Internal handler metadata mismatches:** assert in Debug. In Release, treat the affected string setting or provider list as empty while retaining other valid settings. Omitting an optional XML attribute uses the handler's default and is not a metadata mismatch. @@ -284,11 +291,9 @@ configuration, and may register providers in code instead. To diagnose unexpected provider selection, turn on `SqlClientEventSource` tracing. Skipped or invalid settings are reported there. -These loading policies apply to the new Abstractions loader; the driver continues using its -existing reader until the registry migration in #4821. - -On .NET, `Switch.Microsoft.Data.SqlClient.EnableAppConfig` controls whether configuration -is read at all. See [Trimming](#trimming). +These policies apply during registry bootstrap through the Abstractions loader. +`Switch.Microsoft.Data.SqlClient.EnableAppConfig` gates authentication configuration reading +on .NET and .NET Framework without changing parsing policy. See [Trimming](#trimming). ### Usage Example ```csharp @@ -315,9 +320,17 @@ Setting `EnableAppConfig` to `false` at publish time removes the configuration r ``` -Setting it only at run time, with `AppContext.SetSwitch` or `runtimeconfig.json`, stops the reading but leaves the trim warnings. On .NET Framework the switch has no effect, since trimming does not apply there. +Setting it only at run time, with `AppContext.SetSwitch` or `runtimeconfig.json`, stops the reading but cannot remove code at publish time. On .NET Framework, the switch disables authentication configuration loading in Abstractions; the driver's retry configuration and switch-override readers remain .NET-only. Trimming does not apply to .NET Framework. ### Guidelines for Adding New Switches +Authentication gates live in Abstractions' `AuthenticationFeatureSwitches.cs`, separately from +the driver's `LocalAppContextSwitches.cs`. `EnableAppConfig` and `EnableAzureExtensionDiscovery` +default to `true` and cache their first value. Separate capability guards remove authentication configuration, +initializer loading and Azure discovery automatically in trimmed/AOT .NET 10 publishes. +Explicit provider registration remains available. Authentication's EnableAppConfig gate also +applies on .NET Framework; the driver's retry/switch-override readers remain .NET-only. +Runtime AppContext changes do not affect publish-time trimming. + 1. Define the switch name constant in `LocalAppContextSwitches.cs` 2. Add a cached property with lazy evaluation pattern (see existing switches) 3. Default to `false` — the switch should opt-in to the new behavior diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index fcd970c549..19976367fd 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -91,7 +91,8 @@ extend the driver's runtime support below .NET Framework 4.6.2 or .NET 10. | Driver reference and unsupported-platform assemblies | `net462;net10.0;netstandard2.0` | | Existing dual-target tests, stress projects, and their shared helpers | `net462;net10.0` | | Modern-only projects, including performance tests | `net10.0` | -| AKV Provider, Abstractions, Logging, and standard-only test utilities | `netstandard2.0` only | +| Abstractions | `net462;netstandard2.0;net10.0` | +| AKV Provider, Logging, and standard-only test utilities | `netstandard2.0` only | | Azure extensions | `net462;netstandard2.0` | | Microsoft.SqlServer.Server (independently versioned; unchanged here) | `net46;netstandard2.0` | | PackageCompatibility tool and tests (xUnit v3) | `net481;net10.0` | @@ -196,6 +197,25 @@ PowerShell script tests. The tool test targets use their own runner options rath test parameters below; see the [PackageCompatibility](tools/PackageCompatibility/README.md) and [PackageValidator](tools/PackageValidator/README.md) guides. +Authentication registry, callback, switch, formatter and exception tests are ordinary xUnit tests +under `src/Microsoft.Data.SqlClient.Extensions/Abstractions/test`. Run this focused target on Linux: + +```bash +dotnet build build.proj -t:TestAbstractions -p:TestFramework=net10.0 -p:ReferenceType=Project +``` + +Signed Abstractions builds use the repository test public key for their `InternalsVisibleTo` +grant in Package mode. Pass `-p:ReferenceType=Package` together with `SigningKeyPath` and +`TestSigningKeyPath` to run signed tests. `TestAbstractions` prepares the Logging package unless +`SkipDependencyPack=true` and forwards the selected reference mode, versions and signing keys. +The test project still references Abstractions directly; its Logging dependency follows the +selected reference mode. Signed Project builds intentionally omit test access. + +The executable current/legacy app.config compatibility harness is deferred to +[#4817](https://github.com/dotnet/SqlClient/pull/4817); its scenario project and test target are +not available in this layer. Executing `net462` tests requires Windows; a Linux cross-build is +not runtime validation. + > [!TIP] > In project-reference mode, test targets automatically build their dependencies. In package mode, > prepare the referenced packages first; see [Package Mode Builds](#package-mode-builds). diff --git a/build.proj b/build.proj index dc740a3db2..1a7d90cf28 100644 --- a/build.proj +++ b/build.proj @@ -344,7 +344,7 @@ @@ -1094,18 +1095,29 @@ - + + PackLogging + + - AbstractionsTests-$(OS) $(LogFilePrefix)-$(TestFramework) "$(DotnetPath)dotnet" test "$(AbstractionsTestProjectPath)" -p:Configuration=$(Configuration) + $(SigningKeyPathArgument) + $(TestSigningKeyPathArgument) + + + $(BuildNumberArgument) + $(BuildSuffixArgument) + $(PackageVersionSqlClientArgument) + $(FileVersionSqlClientArgument) + + + $(ReferenceTypeArgument) + $(TestBlameArgument) $(TestCodeCoverageArgument) $(TestFiltersArgument) diff --git a/eng/pipelines/scripts/tests/Build-Orchestration.Tests.ps1 b/eng/pipelines/scripts/tests/Build-Orchestration.Tests.ps1 index 3a12367828..2707703f78 100644 --- a/eng/pipelines/scripts/tests/Build-Orchestration.Tests.ps1 +++ b/eng/pipelines/scripts/tests/Build-Orchestration.Tests.ps1 @@ -213,11 +213,14 @@ Describe 'supported target frameworks' { Frameworks = 'net46;netstandard2.0' Projects = @('src\Microsoft.SqlServer.Server\Microsoft.SqlServer.Server.csproj') } + @{ + Frameworks = 'net462;netstandard2.0;net10.0' + Projects = @('src\Microsoft.Data.SqlClient.Extensions\Abstractions\src\Abstractions.csproj') + } @{ Frameworks = 'netstandard2.0' Projects = @( 'src\Microsoft.Data.SqlClient.Internal\Logging\src\Logging.csproj' - 'src\Microsoft.Data.SqlClient.Extensions\Abstractions\src\Abstractions.csproj' 'src\Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider\src\Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj' 'src\Microsoft.Data.SqlClient\tests\tools\Microsoft.Data.SqlClient.TestUtilities\Microsoft.Data.SqlClient.TestUtilities.csproj' 'src\Microsoft.Data.SqlClient\tests\tools\TDS\TDS\TDS.csproj' @@ -617,6 +620,65 @@ Describe 'build.proj dependency packing' { } } +Describe 'Abstractions test orchestration' { + It 'forwards Package mode, versions and signing keys to the test command' { + $arguments = Get-ChildArguments 'TestAbstractions' @( + '-p:ReferenceType=Package', + '-p:PackageVersionSqlClient=8.1.2-review', + '-p:FileVersionSqlClient=8.1.2.42', + '-p:BuildNumber=42', + '-p:BuildSuffix=ci', + '-p:SigningKeyPath=driver-key.snk', + '-p:TestSigningKeyPath=test-key.snk' + ) + $testStart = [Array]::IndexOf($arguments, 'test') + $testStart | Should -BeGreaterOrEqual 0 + $testArguments = $arguments[$testStart..($arguments.Count - 1)] + + $testArguments | Should -Contain '-p:ReferenceType=Package' + $testArguments | Should -Contain '-p:SqlClientPackageVersion=8.1.2-review' + $testArguments | Should -Contain '-p:SqlClientFileVersion=8.1.2.42' + $testArguments | Should -Contain '-p:BuildNumber=42' + $testArguments | Should -Contain '-p:BuildSuffix=ci' + $testArguments | Should -Contain '-p:SigningKeyPath=driver-key.snk' + $testArguments | Should -Contain '-p:TestSigningKeyPath=test-key.snk' + } + + It 'packs Logging before testing in Package mode with the same version and signing key' { + $arguments = Get-ChildArguments 'TestAbstractions' @( + '-p:ReferenceType=Package', + '-p:PackageVersionSqlClient=8.1.2-review', + '-p:SigningKeyPath=driver-key.snk' + ) + $packStart = [Array]::IndexOf($arguments, 'pack') + $testStart = [Array]::IndexOf($arguments, 'test') + $packStart | Should -BeGreaterOrEqual 0 + $testStart | Should -BeGreaterThan $packStart + $packArguments = $arguments[$packStart..($testStart - 1)] + + (Split-Path $packArguments[1] -Leaf) | Should -Be 'Logging.csproj' + $packArguments | Should -Contain '-p:SqlClientPackageVersion=8.1.2-review' + $packArguments | Should -Contain '-p:SigningKeyPath=driver-key.snk' + } + + It 'does not pack Logging with ' -ForEach @( + @{ Property = '-p:ReferenceType=Project' } + @{ Property = '-p:SkipDependencyPack=true' } + ) { + $properties = @($Property) + if ($Property -eq '-p:SkipDependencyPack=true') { + $properties += '-p:ReferenceType=Package' + } + $arguments = Get-ChildArguments 'TestAbstractions' $properties + + $arguments | Should -Contain 'test' + $arguments | Should -Not -Contain 'pack' + if ($Property -eq '-p:SkipDependencyPack=true') { + $arguments | Should -Contain '-p:ReferenceType=Package' + } + } +} + Describe 'test project family versions' { It 'uses the forwarded family version for central dependencies in ' -ForEach @( @{ Target = 'BuildSqlClientTestsFunctional' } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationInitializer.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationInitializer.xml new file mode 100644 index 0000000000..c332685621 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationInitializer.xml @@ -0,0 +1,37 @@ + + + + + Initializes authentication providers configured in app.config. + + Configured initializers run synchronously during registry bootstrap while the registry lock + is held. Calls to and + on the current thread are supported. + Do not synchronously wait for another thread that calls either method: that thread blocks + on the registry lock, causing a deadlock. + Recoverable initialization or registration failures stop this initializer and are traced, + but later configured providers and enabled Azure discovery are still attempted. + Fatal failures propagate. Earlier registrations and initializer side effects are not + rolled back. Enable tracing before the first registry access to capture these failures; + failed initialization is not retried until the application restarts. + + + + Creates an authentication initializer. + + + Registers application-configured authentication providers. + + When invoked by registry bootstrap, this method executes while the registry lock is held. + Calls to and + on the current thread are supported. + Do not synchronously wait for another thread that calls either method. The other thread + blocks on the registry lock, causing a deadlock. + + + + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml index 7848aaec1a..9677769292 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml @@ -129,7 +129,31 @@ See the LICENSE file in the project root for more information. Gets an authentication provider by method. The authentication method. - The authentication provider or if not found. + The authentication provider or if no provider is registered. + + Bootstraps the registry on first access. Configuration and reflective Azure discovery + are unavailable in trimmed and NativeAOT .NET 10 publishes; explicitly register providers + with SetProvider. Recoverable registry initialization failures emit diagnostic traces + through Microsoft.Data.SqlClient.EventSource with the Trace keyword (0x2). + Enable tracing before the first registry access to capture configuration-reading, + initializer/provider activation and Azure-discovery failures. Recoverable failures in + these steps are traced and handled without caching them for later diagnostic replay. + Only unexpected recoverable bootstrap failures are cached and logged on subsequent + accesses, including when tracing is enabled after the first failure. + Lookups still return successfully registered providers. + Register an application provider as a workaround, or correct the reported configuration + or package version problem and restart the application to retry bootstrap. + + Registry bootstrap and provider replacement callbacks execute while a registry lock is + held. Same-thread calls to and are + supported. If a callback changes the registration for the same method, replacement + rechecks precedence and retries callbacks against the current registration; callbacks + may therefore run more than once. Code executing under that lock must not synchronously + wait for another thread that calls either method. The other thread blocks on the lock, + causing a deadlock. + During bootstrap, a reentrant lookup sees only the providers registered so far. + + Sets an authentication provider by method. @@ -138,6 +162,31 @@ See the LICENSE file in the project root for more information. if the operation succeeded; otherwise, (for example, the existing provider disallows overriding). + + Works directly under NativeAOT. Configuration providers and configured initializers + cannot be overwritten by application registrations. Application registrations replace + the discovered Azure default. Outside configured initializer execution, a null provider, + unsupported method or recoverable provider callback failure returns false and emits diagnostic traces + through Microsoft.Data.SqlClient.EventSource with the Trace keyword (0x2). + During a configured initializer, registration errors propagate to the configuration + application step and stop that initializer. Recoverable initializer failures are traced, + and later configured providers and enabled Azure discovery are still attempted. + Failed provider entries are traced and skipped without blocking later entries. + Fatal failures propagate. Failed registrations do not replace the existing provider. + Unexpected recoverable bootstrap failures are cached and traced, but application + registration remains available without retrying bootstrap. Existing configuration + registrations retain precedence. Correct the reported cause and restart the application + to retry failed configuration or discovery. + + Registry bootstrap and provider replacement callbacks execute while a registry lock is + held. Same-thread calls to and are + supported. If a callback changes the registration for the same method, replacement + rechecks precedence and retries callbacks against the current registration; callbacks + may therefore run more than once. Code executing under that lock must not synchronously + wait for another thread that calls either method. The other thread blocks on the lock, + causing a deadlock. + + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProviderConfigurationSection.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProviderConfigurationSection.xml index b3f303ad75..3b8a3db2ac 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProviderConfigurationSection.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProviderConfigurationSection.xml @@ -19,6 +19,34 @@ See the LICENSE file in the project root for more information. This sealed handler derives directly from . Referencing this handler in an app.config section declaration is supported. + + The current SqlClientAuthenticationProviders section takes precedence. + An incompatible current handler is traced and ignored, allowing this legacy section + to be used. Configuration-reading errors produce empty settings without fallback. + + + Configuration reading uses a relaxed policy that traces and ignores configuration-reading, + handler, schema and broker-value errors. Ignoring these errors may select unintended + providers or provider settings. Recoverable initializer activation or registration + failures stop that initializer, but later configured providers are still attempted. + Recoverable provider-entry failures are traced and skipped without blocking later + entries or enabled Azure discovery. Fatal failures propagate. + Missing sections and empty optional attributes remain valid. + + + Enable tracing before the first registry access to capture configuration and discovery + failures. Failed entries are not retried on subsequent accesses. + Only unexpected recoverable bootstrap failures are cached and traced again. + Subsequent calls to + can still return successfully + registered providers, and can + register application providers without retrying bootstrap. Configuration registrations + retain precedence. Correct the configuration and restart the application to retry + configuration or discovery. + + + Earlier registrations and initializer/provider callback side effects are not rolled back. + @@ -44,6 +72,11 @@ See the LICENSE file in the project root for more information. runtime parses it with . The Web Account Manager broker preference, or an empty string when unset. + + An unset or empty value preserves the Azure provider's default broker behavior. + Non-empty values must parse as a Boolean. Invalid values are traced and ignored, + preserving the provider's default broker behavior and other valid settings. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlClientAuthenticationProviderConfigurationSection.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlClientAuthenticationProviderConfigurationSection.xml index 32b93057ec..0b3b637cb2 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlClientAuthenticationProviderConfigurationSection.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlClientAuthenticationProviderConfigurationSection.xml @@ -19,6 +19,32 @@ See the LICENSE file in the project root for more information. This sealed handler derives directly from . Referencing this handler in an app.config section declaration is supported. + + This section takes precedence over SqlAuthenticationProviders. + An incompatible handler is traced and ignored, allowing the legacy section to be used. + Configuration-reading errors produce empty settings without fallback. + + + Configuration reading uses a relaxed policy that traces and ignores configuration-reading, + handler, schema and broker-value errors. Ignoring these errors may select unintended + providers or provider settings. Recoverable initializer activation or registration + failures stop that initializer, but later configured providers are still attempted. + Recoverable provider-entry failures are traced and skipped without blocking later + entries or enabled Azure discovery. Fatal failures propagate. + Missing sections and empty optional attributes remain valid. + + + Enable tracing before the first registry access to capture configuration and discovery + failures. Failed entries are not retried on subsequent accesses. + Only unexpected recoverable bootstrap failures are cached and traced again. + Subsequent calls to + can still return successfully + registered providers, and can + register application providers without retrying bootstrap. Configuration registrations + retain precedence. Correct the configuration and restart the application to retry + configuration or discovery. Earlier registrations and initializer/provider callback + side effects are not rolled back. + @@ -44,6 +70,11 @@ See the LICENSE file in the project root for more information. runtime parses it with . The Web Account Manager broker preference, or an empty string when unset. + + An unset or empty value preserves the Azure provider's default broker behavior. + Non-empty values must parse as a Boolean. Invalid values are traced and ignored, + preserving the provider's default broker behavior and other valid settings. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj index 355cdf5ea0..1f8f9921fd 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj @@ -2,7 +2,9 @@ Microsoft.Data.SqlClient.Extensions.Abstractions - net462;netstandard2.0 + + net462;netstandard2.0;net10.0 + true diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationFeatureSwitches.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationFeatureSwitches.cs new file mode 100644 index 0000000000..126dfb2883 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationFeatureSwitches.cs @@ -0,0 +1,125 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Microsoft.Data.SqlClient; + +/// +/// Defines authentication bootstrap switches and capability guards so configuration loading, Azure +/// extension discovery can be excluded from trimmed or AOT builds. +/// +/// +/// Configuration and discovery default to enabled to preserve existing runtime behavior. Each +/// switch is read on first access and cached for the lifetime of the process. Publish-time switch +/// values allow trimming tools to remove guarded reflection paths; changing an AppContext switch at +/// runtime does not change what was retained during publishing. +/// +internal static class AuthenticationFeatureSwitches +{ + #region Feature Switches + + /// + /// Gets whether authentication providers and initializers may be loaded from app.config. + /// + /// + /// Defaults to true when the AppContext switch is unset. + /// + // Associates this property with the named switch so trimming tools can substitute its + // publish-time value. + [FeatureSwitchDefinition("Switch.Microsoft.Data.SqlClient.EnableAppConfig")] + internal static bool EnableAppConfig => + AcquireAndReturn( + "Switch.Microsoft.Data.SqlClient.EnableAppConfig", + ref s_enableAppConfig, + defaultValue: true); + + /// + /// Gets whether authentication bootstrap may discover the Azure extension provider through + /// reflection. + /// + /// + /// Defaults to true when the AppContext switch is unset. + /// + // Associates this property with the named switch so trimming tools can substitute its + // publish-time value. + [FeatureSwitchDefinition("Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery")] + internal static bool EnableAzureExtensionDiscovery => + AcquireAndReturn( + "Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery", + ref s_enableAzureExtensionDiscovery, + defaultValue: true); + + #endregion + + #region Trimming Guards + + /// + /// Guards the reflection-based authentication configuration and initializer loading path. + /// + /// + /// Follows EnableAppConfig. + /// + // Marks this property as guarding calls requiring unreferenced code. Keep it separate from the + // switch definition so trimming removes the guarded path without a publish-time switch value. + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + // Marks this property as guarding calls requiring dynamic code. + [FeatureGuard(typeof(RequiresDynamicCodeAttribute))] + // Permits a switch-backed guard because trimming replaces this capability guard with false. + [UnconditionalSuppressMessage("Trimming", "IL4000", + Justification = "The separate AppContext feature switch backs this guard; trimming replaces it with false.")] + internal static bool IsAppConfigSupported => EnableAppConfig; + + /// + /// Guards reflection-based Azure extension discovery and provider construction. + /// + /// + /// Follows EnableAzureExtensionDiscovery. + /// + // Marks this property as guarding calls requiring unreferenced code. Keep it separate from the + // switch definition so trimming removes the guarded path without a publish-time switch value. + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + // Marks this property as guarding calls requiring dynamic code. + [FeatureGuard(typeof(RequiresDynamicCodeAttribute))] + // Permits a switch-backed guard because trimming replaces this capability guard with false. + [UnconditionalSuppressMessage("Trimming", "IL4000", + Justification = "The separate AppContext feature switch backs this guard; trimming replaces it with false.")] + internal static bool IsAzureExtensionDiscoverySupported => EnableAzureExtensionDiscovery; + + #endregion + + #region Helpers + + // The possible states of a feature switch. + internal enum SwitchValue : byte + { + None = 0, + True = 1, + False = 2 + } + + // Cached feature switch values. + internal static SwitchValue s_enableAppConfig; + internal static SwitchValue s_enableAzureExtensionDiscovery; + + // Acquires the feature switch value from AppContext if it hasn't been cached yet. Switches + // that aren't set will assume the default value. + private static bool AcquireAndReturn( + string switchName, ref SwitchValue switchValue, bool defaultValue) + { + // Short circuit if we've already cached the switch value. + if (switchValue != SwitchValue.None) + { + return switchValue == SwitchValue.True; + } + + // Acquire the switch value from AppContext. If not present, use the default value. + bool enabled = + AppContext.TryGetSwitch(switchName, out bool acquiredValue) ? acquiredValue : defaultValue; + + switchValue = enabled ? SwitchValue.True : SwitchValue.False; + return enabled; + } + #endregion +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AzureExtensionLoader.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AzureExtensionLoader.cs new file mode 100644 index 0000000000..a315ae59d5 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AzureExtensionLoader.cs @@ -0,0 +1,80 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Data.SqlClient.Internal; + +namespace Microsoft.Data.SqlClient; + +/// Loads the optional Azure assembly for authentication discovery in Abstractions. +/// +/// SqlClient owns an independent attestation loader so attestation does not depend on +/// authentication configuration, initializers or discovery switches. Keep their assembly +/// identity checks consistent. +/// +internal static class AzureExtensionLoader +{ + internal const string AssemblyName = "Microsoft.Data.SqlClient.Extensions.Azure"; + + private static readonly byte[] s_publicKeyToken = [0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5]; + + /// Loads the extension and checks its identity in signed builds. + /// The caller name for diagnostics. + /// The trusted assembly, or null when missing, unloadable or untrusted. + [RequiresUnreferencedCode("Azure authentication discovery loads an assembly by name.")] + internal static Assembly? Load(string caller) => Load(caller, static assemblyName => + { +#if STRONG_NAME_SIGNING + return Assembly.Load(assemblyName); +#else + return Assembly.Load(assemblyName.Name!); +#endif + }); + + [RequiresUnreferencedCode("Azure authentication discovery loads an assembly by name.")] + internal static Assembly? Load(string caller, Func assemblyLoader) + { + try + { + var qualifiedName = new AssemblyName(AssemblyName); +#if STRONG_NAME_SIGNING + SqlClientEventSource.Log.TryTraceEvent( + "{0}: Attempting to load Azure extension assembly={1} with expected public key token={2}", + caller, AssemblyName, BitConverter.ToString(s_publicKeyToken).Replace("-", "")); + + qualifiedName.SetPublicKeyToken(s_publicKeyToken); + var assembly = assemblyLoader(qualifiedName); + +#if !NETFRAMEWORK + // Framework binding enforces the token; .NET and .NET Standard need this check. + byte[]? actualToken = assembly.GetName().GetPublicKeyToken(); + if (actualToken is null || !actualToken.SequenceEqual(s_publicKeyToken)) + { + SqlClientEventSource.Log.TryTraceEvent( + "{0}: Azure extension assembly={1} has an unexpected public key token", + caller, assembly.GetName()); + return null; + } +#endif +#else + SqlClientEventSource.Log.TryTraceEvent( + "{0}: Attempting to load Azure extension assembly={1} without strong name " + + "verification; ensure this assembly is from a trusted source", + caller, AssemblyName); + var assembly = assemblyLoader(qualifiedName); +#endif + return assembly; + } + catch (Exception ex) + when ((ex is FileNotFoundException or FileLoadException or BadImageFormatException) + && ExceptionHelpers.IsRecoverableException(ex)) + { + SqlClientEventSource.Log.TryTraceEvent( + "{0}: Azure extension assembly={1} not found or not loadable; {2}: {3}", + caller, AssemblyName, ex.GetType().Name, ex.Message); + return null; + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/ExceptionHelpers.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/ExceptionHelpers.cs new file mode 100644 index 0000000000..79e47e3842 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/ExceptionHelpers.cs @@ -0,0 +1,59 @@ +// 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. + +namespace Microsoft.Data.SqlClient; + +/// Provides exception-handling policy for authentication activation and registration. +internal static class ExceptionHelpers +{ + /// Determines whether handling a failure would mask a fatal runtime error. + /// The failure, including nested and aggregate exceptions. + /// False if any nested failure is fatal or cannot be examined safely. + internal static bool IsRecoverableException(Exception exception) + { + static bool IsFatal(Exception ex) => + ex is OutOfMemoryException or StackOverflowException or + AccessViolationException or ThreadAbortException; + + if (IsFatal(exception)) + { + return false; + } + + try + { + Stack pending = new(); + while (true) + { + if (exception is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + pending.Push(inner); + } + } + else if (exception.InnerException is Exception inner) + { + pending.Push(inner); + } + + if (pending.Count == 0) + { + return true; + } + + exception = pending.Pop(); + if (IsFatal(exception)) + { + return false; + } + } + } + catch (Exception ex) when (ex is OutOfMemoryException or StackOverflowException) + { + // Exception-tree traversal may itself exhaust memory; never recover in that case. + return false; + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/PackageReadme.md b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/PackageReadme.md index 5bc2817060..90503fba43 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/PackageReadme.md +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/PackageReadme.md @@ -18,6 +18,64 @@ This package supports: - .NET Framework 4.6.2 (uses the framework's `System.Configuration` assembly) - .NET Standard 2.0 (compatible with .NET Framework 4.6.1+, .NET Core 2.0+, and .NET 5+) +- .NET 10.0 (trim/AOT-analyzed authentication registration) + +## Authentication registration + +The provider registry and bootstrap live in this package. `GetProvider` and `SetProvider` +work directly, including under NativeAOT, without loading SqlClient through reflection. +Configuration providers and configured initializers take precedence over application +registrations; application registrations take precedence over the discovered Azure default. +Recoverable configuration failures are traced without preventing later configured providers, +enabled Azure discovery or application registration. Successful configuration registrations +retain precedence; discovered Azure defaults fill only unregistered methods. Null providers, +unsupported methods and throwing registration callbacks return `false`, without replacing +the existing provider. These failures are traced through `Microsoft.Data.SqlClient.EventSource` +(Informational level, Trace keyword `0x2`) with corrective guidance. +Configuration and optional discovery are attempted once. Failed entries are not retried on later +accesses. Register an application provider as a workaround, or correct the cause and restart the +application to retry configuration and discovery. + +Untrimmed applications retain app.config loading and automatic Azure discovery. These switches +default to `true` and are cached independently on first access for the lifetime of the process: + +- `Switch.Microsoft.Data.SqlClient.EnableAppConfig` +- `Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery` + +Automatic Azure discovery requires the `ActiveDirectoryAuthenticationProviderOptions` constructor +from a compatible `Microsoft.Data.SqlClient.Extensions.Azure` package, even when no overrides are +configured. Discovery does not fall back to parameterless or string-based provider constructors. +An extension missing the options contract is traced with upgrade guidance: use version 7.0.2 or +later with that contract and keep SqlClient family package versions compatible. + +### Authentication compatibility considerations + +Moving authentication registration from the driver to Abstractions preserves the public method +signatures, but is not a behavior-neutral change. Compared with the previous driver-owned registry: + +| Area | Compatibility difference | +|---|---| +| Configured initializer precedence | Providers registered during a configured initializer's `Initialize()` now receive configuration precedence. Application `SetProvider` calls can no longer replace them; explicit configuration entries were already protected. Change the configuration or initializer instead. | +| Configuration-provider replacement callbacks | Providers listed in app.config's `` collection now use the same replacement callbacks as `SetProvider`, including calls made by an initializer. Previously, configuration assignments bypassed these callbacks. Replacing an existing provider for the same authentication method invokes the old provider's `BeforeUnload` and the replacement's `BeforeLoad` before publishing the replacement. Registering for a method with no existing provider invokes neither callback. | +| Older Azure extensions | Automatic discovery no longer falls back to parameterless or string-based constructors. An older extension without the options-based contract may stop supplying the default provider. Upgrade to a compatible extension with that contract, or explicitly register a provider. This change does not remove those public constructors. | +| .NET Framework configuration switch | `EnableAppConfig=false` now disables authentication configuration on .NET Framework, where it previously remained enabled. The driver's retry configuration and switch-override readers remain .NET-only. | +| Lookup synchronization | `GetProvider` now takes the registry lock used by bootstrap and replacement `BeforeLoad`/`BeforeUnload` callbacks. Slow callbacks delay other lookups and connection logins. Keep callbacks short: synchronously waiting for another thread that accesses the registry can deadlock. | +| Bootstrap failure reporting | Driver access previously could expose the manager's cached type-initialization exception. Now, if no usable provider or access-token callback is available, the driver's `Open` and `OpenAsync` report missing-provider guidance as a `SqlException`, rather than the previous missing-provider `ArgumentException`. Detailed bootstrap and Azure loading causes remain in Abstractions' EventSource traces, not a diagnostic API or the connection exception's inner exception. | +| Configuration failure handling | A failed manager static initialization previously prevented recovery through that manager. Recoverable initializer and provider failures are now traced and skipped; later configured providers and enabled Azure discovery are still attempted. Successful configuration registrations retain precedence, and application registration and lookup remain usable. Failed configuration is not retried until the application restarts. Fatal activation errors still propagate. | + +Strict configuration parsing is not introduced: configuration reading retains the relaxed policy +described below. New capabilities include registration without loading SqlClient, explicit +registration under trimming/NativeAOT, and the independent `EnableAzureExtensionDiscovery` switch. + +Set switches before first registry access. On .NET 10 trimmed and NativeAOT publishes, both +reflective paths are removed automatically. Explicitly construct and register a provider for +each required authentication method before opening connections. The Azure provider is supplied +by `Microsoft.Data.SqlClient.Extensions.Azure`. Authentication discovery loads the optional Azure +assembly within Abstractions. The driver independently loads it for Azure Attestation, without +processing authentication configuration or consulting the authentication discovery switch. +Both loaders enforce the same assembly identity checks in signed builds. +Publish-time overrides use +`RuntimeHostConfigurationOption` with `Trim="true"`; runtime changes cannot affect trimming. ## Authentication configuration @@ -38,7 +96,7 @@ For the .NET Standard target, `System.Configuration.ConfigurationManager` is a t compile dependency required by the handlers' configuration base types. The .NET Framework target uses the framework's `System.Configuration` instead, without introducing a ConfigurationManager package dependency. Authentication initialization and provider loading -remain in the driver, and the modern section continues to take precedence over the legacy one. +are owned by this package, and the modern section takes precedence over the legacy one. ## Configuration loading @@ -64,14 +122,43 @@ string setting or provider list is treated as empty while other valid settings a Omitting optional XML attributes does not cause a metadata mismatch. Loading only reads settings. It doesn't create or activate initializers or providers. -Other exception types propagate, and initializer or provider validation and activation -can still fail later. +Other exception types reach registry bootstrap, which traces recoverable failures and continues +with Azure discovery if enabled. Initializer and provider validation and activation happen later. Use `SqlClientEventSource` tracing to see settings that were skipped or ignored. -These policies apply to the new Abstractions loader; the driver continues using its existing -reader until the authentication registry migration in -[dotnet/SqlClient#4821](https://github.com/dotnet/SqlClient/pull/4821). +> **Warning:** Relaxed loading and parsing can permit the driver to install and configure +> unexpected providers or provider settings. Ignoring broken +> authentication configuration can select legacy providers, allow default providers to be +> installed, or retain default broker settings instead of the application's intended settings. +> Tracing these errors does not prevent those unintended configurations. + +As in the driver, all `ConfigurationErrorsException` failures are traced and ignored, even +when they wrap fatal failures. Other exception types propagate to registry bootstrap. +Reading settings does not activate initializers or providers; registry bootstrap applies +the resulting settings afterward. + +A declared modern section takes precedence even when its element is absent from app.config. +These policies apply to registry bootstrap through the Abstractions loader. +The `EnableAppConfig` switch gates configuration reading; it does not change parsing policy. + +`SqlAuthenticationInitializer` is also defined in Abstractions and forwarded from SqlClient, +preserving existing compiled consumers and initializer declarations. + +If an explicitly configured initializer or provider cannot be loaded, instantiated, or applied, +the recoverable failure is traced and remaining configured providers are still attempted. A failed +initializer is not resumed, and a failed provider entry is skipped. Valid Azure option overrides +and successful registrations are retained. Azure discovery runs afterward whenever supported and +enabled, even after configuration reading or activation fails; it cannot replace an existing +registration. Fatal activation errors still propagate. Earlier registrations and +initializer/provider callback side effects are not rolled back. + +Missing sections and empty optional settings remain valid. +Registration errors inside a configured initializer stop that initializer, rather than being +converted to an ignorable `false` result. Configuration application traces the failure and continues +with the configured provider list. Successful registrations are traced +with their authentication method, provider type and precedence tier; refused registrations are +traced separately and do not produce success events. Merge the following example into the application's app.config, replacing `ExampleAuthenticationProvider, ExampleApp` with the assembly-qualified name of an diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationConfiguration.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationConfiguration.cs index a498f8d2b8..ce5fdeeae1 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationConfiguration.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationConfiguration.cs @@ -30,11 +30,16 @@ internal sealed class SqlAuthenticationConfiguration /// internal IReadOnlyList Providers { get; } = Array.Empty(); + /// Creates empty settings without retaining configuration reflection in AOT bootstrap. + internal SqlAuthenticationConfiguration() + { + } + /// /// Reads either supported handler's metadata, or creates empty settings when no section is supplied. /// /// The selected section, or null for no authentication overrides. - internal SqlAuthenticationConfiguration(ConfigurationSection? section = null) + internal SqlAuthenticationConfiguration(ConfigurationSection? section) { if (section is null) { @@ -83,7 +88,7 @@ internal SqlAuthenticationConfiguration(ConfigurationSection? section = null) if (section.ElementInformation.Properties["providers"]?.Value is ProviderSettingsCollection providers) { - // Snapshot entries, but defer method validation and activation to preserve fail-fast order. + // Snapshot entries, but defer method validation and activation to the registry. Providers = providers.Cast() .Select(provider => new ProviderSettings(provider.Name, provider.Type)).ToArray(); } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationInitializer.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationInitializer.cs new file mode 100644 index 0000000000..85026cc480 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationInitializer.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Microsoft.Data.SqlClient; + +/// +public abstract class SqlAuthenticationInitializer +{ + /// + protected SqlAuthenticationInitializer() { } + + /// + public abstract void Initialize(); +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs deleted file mode 100644 index 5007384465..0000000000 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ /dev/null @@ -1,198 +0,0 @@ -// 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.Reflection; -using System.Runtime.InteropServices; -using Microsoft.Data.SqlClient.Internal; - -namespace Microsoft.Data.SqlClient; - -/// -// -// This part of the SqlAuthenticationProvider class implements the static -// GetProvider and SetProvider methods by reflection into the Microsoft.Data.SqlClient -// package's SqlAuthenticationProviderManager class, if that assembly is present. -// -public abstract partial class SqlAuthenticationProvider -{ - /// - /// This class implements the static GetProvider and SetProvider methods by - /// using reflection to call into the Microsoft.Data.SqlClient package's - /// SqlAuthenticationProviderManager class, if that assembly is present. - /// - private static class Internal - { - /// - /// Our handle to the reflected GetProvider() method. - /// - private static readonly MethodInfo? _getProvider = null; - - /// - /// Our handle to the reflected SetProvider() method. - /// - private static readonly MethodInfo? _setProvider = null; - - /// - /// Static construction performs the reflection lookups. - /// - static Internal() - { - const string assemblyName = "Microsoft.Data.SqlClient"; - - // If the MDS package is present, load its - // SqlAuthenticationProviderManager class and get/set methods. - try - { - // Try to load the MDS assembly. - var assembly = Assembly.Load(assemblyName); - - if (assembly is null) - { - Log($"MDS assembly={assemblyName} not found; " + - "Get/SetProvider() will not function"); - return; - } - - // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/39845): - // Verify the assembly is signed by us? - - // Look for the manager class. - const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; - Type? manager = assembly.GetType(className); - - if (manager is null) - { - Log($"MDS auth manager manager class={className} not found; " + - "Get/SetProvider() will not function"); - return; - } - - // Get handles to the get/set static methods. - _getProvider = manager.GetMethod( - "GetProvider", - BindingFlags.NonPublic | BindingFlags.Static); - - if (_getProvider is null) - { - Log($"MDS GetProvider() method not found; " + - "GetProvider() will not function"); - } - - _setProvider = manager.GetMethod( - "SetProvider", - BindingFlags.NonPublic | BindingFlags.Static); - - if (_setProvider is null) - { - Log($"MDS SetProvider() method not found; " + - "SetProvider() will not function"); - } - } - // All of these exceptions mean we couldn't find the get/set - // methods. - catch (Exception ex) - when (ex is AmbiguousMatchException - or BadImageFormatException - or FileLoadException - or FileNotFoundException) - { - Log($"MDS assembly={assemblyName} not found or not usable; " + - $"Get/SetProvider() will not function: {ex} "); - } - // Any other exceptions are fatal. - } - - /// - /// Call the reflected GetProvider method. - /// - /// - /// The authentication method whose provider to get. - /// - /// - /// Returns null if reflection failed or any exceptions occur. - /// Otherwise, returns as the reflected method does. - /// - internal static SqlAuthenticationProvider? GetProvider( - SqlAuthenticationMethod authenticationMethod) - { - if (_getProvider is null) - { - return null; - } - - try - { - return _getProvider.Invoke(null, [authenticationMethod]) - as SqlAuthenticationProvider; - } - catch (Exception ex) - when (ex is InvalidOperationException - or MemberAccessException - or MethodAccessException - or NotSupportedException - or TargetInvocationException) - { - Log($"GetProvider() invocation failed: " + - $"{ex.GetType().Name}: {ex.Message}"); - return null; - } - } - - /// - /// Call the reflected SetProvider method. - /// - /// - /// The authentication method whose provider to set. - /// - /// - /// The provider to set. - /// - /// - /// Returns false if reflection failed, invocation fails, or any - /// exceptions occur. Otherwise, returns as the reflected method - /// does. - /// - internal static bool SetProvider( - SqlAuthenticationMethod authenticationMethod, - SqlAuthenticationProvider provider) - { - if (_setProvider is null) - { - return false; - } - - try - { - bool? result = - _setProvider.Invoke(null, [authenticationMethod, provider]) - as bool?; - - if (!result.HasValue) - { - Log($"SetProvider() invocation returned null; " + - "translating to false"); - return false; - } - - return result.Value; - } - catch (Exception ex) - when (ex is InvalidOperationException - or MemberAccessException - or MethodAccessException - or NotSupportedException - or TargetInvocationException) - { - Log($"SetProvider() invocation failed: " + - $"{ex.GetType().Name}: {ex.Message}"); - return false; - } - } - - private static void Log(string message) - { - SqlClientEventSource.Log.TryTraceEvent("SqlAuthenticationProvider.Internal | {0}", message); - } - } -} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs index 86c045efc0..1a9c3139f7 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs @@ -21,25 +21,17 @@ public virtual void BeforeUnload(SqlAuthenticationMethod authenticationMethod) { /// - // - // We would like to deprecate this method in favour of - // SqlAuthenticationProviderManager.GetProvider(). - // public static SqlAuthenticationProvider? GetProvider( SqlAuthenticationMethod authenticationMethod) { - return Internal.GetProvider(authenticationMethod); + return SqlAuthenticationProviderRegistry.GetProvider(authenticationMethod); } /// - // - // We would like to deprecate this method in favour of - // SqlAuthenticationProviderManager.SetProvider(). - // public static bool SetProvider( SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider) { - return Internal.SetProvider(authenticationMethod, provider); + return SqlAuthenticationProviderRegistry.SetProvider(authenticationMethod, provider); } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProviderRegistry.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProviderRegistry.cs new file mode 100644 index 0000000000..f6ac1125d0 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProviderRegistry.cs @@ -0,0 +1,624 @@ +// 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.Configuration; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; +using Microsoft.Data.SqlClient.Internal; + +namespace Microsoft.Data.SqlClient; + +/// +/// Owns authentication provider registration and bootstrap in Abstractions so the public +/// registration API can access providers directly, including under NativeAOT. +/// +/// +/// Serializes registry access and gives configuration registrations precedence over application +/// registrations and discovered Azure defaults. Recoverable configuration failures are traced +/// without preventing later configured providers, Azure discovery or application registrations. +/// +internal sealed class SqlAuthenticationProviderRegistry +{ + // TODO: Localize exception messages in a later PR. + + #region Private Fields + + /// + /// Synchronizes bootstrap, provider lookup, registration and replacement callbacks. + /// + private readonly object _sync = new(); + + /// Indicates bootstrap is executing, allowing reentrant registry access. + private bool _bootstrapInProgress; + + /// + /// Indicates that the configured initializer callback is executing. + /// + private bool _invokingConfiguredInitializer; + + /// Indicates that all enabled bootstrap steps were attempted. + private bool _bootstrapCompleted; + + /// + /// Preserves the original bootstrap failure for subsequent registry accesses. + /// + private ExceptionDispatchInfo? _bootstrapFailure; + + /// + /// The shared registry, available before bootstrap invokes a configured initializer. + /// + private static readonly SqlAuthenticationProviderRegistry Instance = new(); + + /// Provider registrations indexed by the authentication methods they serve. + private readonly Dictionary _providers = new(); + + /// Registration precedence, ordered from lowest to highest. + internal enum RegistrationTier + { + /// + /// A discovered Azure provider that cannot replace an existing registration. + /// + Default, + /// An application registration that can replace a discovered default. + Application, + /// + /// A configured provider or initializer registration that application code cannot override. + /// + Config + } + + private sealed class ProviderRegistration(SqlAuthenticationProvider provider, RegistrationTier tier) + { + internal SqlAuthenticationProvider Provider { get; } = provider; + internal RegistrationTier Tier { get; } = tier; + } + + /// Validated authentication settings owned by this registry instance. + private SqlAuthenticationConfiguration _configuration = new(); + + #endregion + + #region Internal Methods + + /// + /// Gets the registered provider for an authentication method after ensuring bootstrap. + /// + /// The authentication method to look up. + /// + /// The provider, or null if none is registered, including after a recoverable bootstrap failure. + /// + /// + /// Recoverable failures, including cached bootstrap failures, emit actionable diagnostic + /// traces. + /// + internal static SqlAuthenticationProvider? GetProvider( + SqlAuthenticationMethod authenticationMethod) + { + // Keep lookup synchronized with bootstrap and replacement callbacks. + lock (Instance._sync) + { + try + { + Instance.EnsureBootstrapped(); + } + catch (Exception e) when (ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProvider.GetProvider bootstrap failed for {0}; checking " + + "registered providers. Check " + + "app.config provider and initializer type names, public parameterless " + + "constructors, initializer implementations and supported authentication " + + "methods. Upgrade all SqlClient family packages together to the same " + + "version. Bootstrap failures are cached; register an application provider " + + "with SetProvider, or correct the cause and restart the application. Error: {1}", + authenticationMethod, e); + } + return Instance._providers.TryGetValue(authenticationMethod, out ProviderRegistration? value) + ? value.Provider + : null; + } + } + + /// + /// Registers an authentication provider with application or configured-initializer precedence. + /// + /// Authentication method. + /// Authentication provider. + /// + /// True if registration succeeds; false on recoverable argument or callback + /// errors, or if an existing registration has higher precedence. + /// + /// + /// Recoverable failures and precedence refusals emit diagnostic traces. Registrations made + /// by a configured initializer receive configuration precedence rather than application + /// precedence. + /// Registration errors during a configured initializer stop that initializer. Configuration + /// application traces the failure and continues with the remaining configured providers. + /// After bootstrap, application-provider support validation runs outside the registry lock. + /// + internal static bool SetProvider( + SqlAuthenticationMethod authenticationMethod, + SqlAuthenticationProvider provider) + { + try + { + // Establish configuration precedence before registering an application provider. + try + { + Instance.EnsureBootstrapped(); + } + catch (Exception e) + when (!Instance._invokingConfiguredInitializer && + ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProvider.SetProvider bootstrap failed for {0}; continuing " + + "with application registration. Bootstrap will not be retried; existing " + + "configuration registrations retain precedence. Error: {1}", + authenticationMethod, e); + } + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + // Outside bootstrap, support validation must allow cross-thread registry lookups. + return Instance.Register(authenticationMethod, provider); + } + catch (Exception e) + when (!Instance._invokingConfiguredInitializer && + ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProvider.SetProvider failed for {0}; returning false. Pass a " + + "non-null provider that supports this method and fix any throwing IsSupported, " + + "BeforeLoad or BeforeUnload callbacks. Error: {1}", + authenticationMethod, e); + return false; + } + } + + /// + /// Discovers and registers Azure defaults using the supplied assembly loader. + /// + /// + /// The assembly loader; production uses to validate + /// identity in signed builds. + /// + /// + /// Recoverable discovery failures are traced without failing registry bootstrap; fatal runtime + /// errors propagate. + /// + [RequiresUnreferencedCode("Azure extension discovery loads types and constructors by name.")] + [RequiresDynamicCode("Azure extension discovery constructs providers dynamically.")] + internal void LoadAzureExtension(Func loadAssembly) + { + try + { + Assembly? assembly = loadAssembly(); + if (assembly is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Azure extension assembly={0} not found; no default Active Directory " + + "provider installed", + AzureExtensionLoader.AssemblyName); + return; + } + + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Azure extension assembly={0} found; attempting to set as default provider for " + + "all Active Directory authentication methods", + assembly.GetName()); + + // Resolve by name to keep the Azure extension optional, without a static dependency. + const string className = + "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider"; + Type? type = assembly.GetType(className); + + if (type is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Azure extension does not contain class={0}; no default Active Directory " + + "provider installed", + className); + + return; + } + + // Require the modern options contract rather than probing older constructors or types. + const string optionsTypeName = + "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; + Type optionsType = assembly.GetType(optionsTypeName) + ?? throw new TypeLoadException( + $"Azure authentication discovery requires '{optionsTypeName}'. Upgrade the " + + "'Microsoft.Data.SqlClient.Extensions.Azure' package to version 7.0.2 or " + + "later with the options-based provider contract, and keep SqlClient family " + + "package versions compatible."); + + SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider( + type, + optionsType, + _configuration.ApplicationClientId, + _configuration.UseWamBroker); + + if (instance is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Failed to instantiate Azure extension class={0}; no default Active " + + "Directory provider installed", + className); + + return; + } + + // Share one provider across methods, filling only unregistered methods at Default tier. + Register(SqlAuthenticationMethod.ActiveDirectoryIntegrated, instance, RegistrationTier.Default); + #pragma warning disable 0618 // Type or member is obsolete + Register(SqlAuthenticationMethod.ActiveDirectoryPassword, instance, RegistrationTier.Default); + #pragma warning restore 0618 // Type or member is obsolete + Register(SqlAuthenticationMethod.ActiveDirectoryInteractive, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryMSI, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryDefault, instance, RegistrationTier.Default); + Register(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, instance, RegistrationTier.Default); + + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Attempted to register Azure extension class={0} as the default for Active " + + "Directory authentication methods; existing registrations were preserved", + className); + } + catch (Exception ex) when (ExceptionHelpers.IsRecoverableException(ex)) + { + // Optional discovery failures must not poison bootstrap or block explicit registration. + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderRegistry) + + ": Azure extension assembly={0} not found or not usable; default registration " + + "did not complete. Error: {1}", + AzureExtensionLoader.AssemblyName, + ex); + } + } + + /// + /// Reflectively constructs an Azure authentication provider using the configured overrides. + /// + /// The Azure authentication provider type to instantiate. + /// The required Azure options type. + /// + /// The application client ID override, or null to retain the default. + /// + /// The WAM broker override, or null to retain the default. + /// + /// The constructed provider, or null if reflective construction does not produce an + /// authentication provider. + /// + /// + /// Always uses the options constructor, preserving option defaults when no overrides are + /// configured. + /// Incompatible Azure contracts are logged during discovery without installing a default + /// provider. + /// + /// + /// The extension lacks the required options constructor. + /// + /// + /// The options type lacks a configured property. + /// + [RequiresUnreferencedCode("Azure provider constructors and properties are discovered by name.")] + [RequiresDynamicCode("Azure providers are constructed dynamically.")] + internal static SqlAuthenticationProvider? CreateAzureAuthenticationProvider( + Type providerType, + Type optionsType, + string? applicationClientId, + bool? useWamBroker) + { + // Require the options constructor even when there are no configured overrides. + ConstructorInfo optionsCtor = providerType.GetConstructor([optionsType]) + ?? throw new MissingMethodException( + providerType.FullName, $".ctor({optionsType.FullName})"); + // Start with the extension's defaults and change only explicitly configured options. + object? options = Activator.CreateInstance(optionsType); + + if (applicationClientId is not null) + { + PropertyInfo applicationClientIdProperty = + optionsType.GetProperty("ApplicationClientId") + ?? throw new MissingMemberException(optionsType.FullName, "ApplicationClientId"); + applicationClientIdProperty.SetValue(options, applicationClientId); + } + + if (useWamBroker is bool useWam) + { + PropertyInfo useWamBrokerProperty = optionsType.GetProperty("UseWamBroker") + ?? throw new MissingMemberException(optionsType.FullName, "UseWamBroker"); + useWamBrokerProperty.SetValue(options, useWam); + } + + return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; + } + + #endregion + + #region Bootstrap, Configuration and Registration Methods + + /// + /// Runs the enabled configuration and Azure discovery steps on first access. + /// + /// + /// The section reader, or null to use the process-wide ConfigurationManager. + /// + /// + /// The Azure assembly loader, or null to use the production loader with identity validation. + /// + /// + /// Reentrant initializer registrations can use the registry while bootstrap is in progress. + /// Recoverable configuration failures are traced without preventing Azure discovery. + /// Completed bootstrap does not retry failed configuration or optional discovery. + /// Unexpected recoverable bootstrap failures are cached and rethrown internally. + /// + internal void EnsureBootstrapped( + Func? getSection = null, + Func? loadAzureAssembly = null) + { + lock (_sync) + { + // Cached failures take precedence over in-progress or completed bootstrap state. + _bootstrapFailure?.Throw(); + // Initializers can reenter on this thread; do not recursively bootstrap the registry. + if (_bootstrapCompleted || _bootstrapInProgress) + { + return; + } + _bootstrapInProgress = true; + try + { + // Capability guards let trimming/AOT remove these reflective bootstrap paths. + // Load configuration first so Azure discovery receives its option overrides. + if (AuthenticationFeatureSwitches.IsAppConfigSupported) + { + try + { + ApplyConfiguration(SqlAuthenticationConfiguration.Load(getSection)); + } + catch (Exception e) when (ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "Authentication configuration could not be loaded or applied; " + + "continuing with Azure discovery if enabled. Correct app.config " + + "and restart the application to retry configuration. Error: {0}", e); + } + } + if (AuthenticationFeatureSwitches.IsAzureExtensionDiscoverySupported) + { + LoadAzureExtension(loadAzureAssembly ?? LoadAzureExtensionAssembly); + } + _bootstrapCompleted = true; + } + catch (Exception e) when (ExceptionHelpers.IsRecoverableException(e)) + { + // Cache partial-bootstrap failures without losing their original stack. + SqlClientEventSource.Log.TryTraceEvent( + "Authentication bootstrap failed: {0}", e); + _bootstrapFailure = ExceptionDispatchInfo.Capture(e); + throw; + } + finally + { + _bootstrapInProgress = false; + } + } + } + + /// Loads the Azure extension with the same identity checks as attestation. + /// The trusted Azure assembly, or null if it is unavailable. + [RequiresUnreferencedCode("Azure extension discovery loads an assembly by name.")] + private static Assembly? LoadAzureExtensionAssembly() => + AzureExtensionLoader.Load(nameof(SqlAuthenticationProviderRegistry)); + + /// + /// Applies Azure options, invokes a configured initializer and registers configured providers. + /// + /// + /// The validated settings to retain for Azure discovery and provider activation. + /// + /// + /// Initializer and provider registrations receive configuration precedence. Recoverable + /// initializer or provider failures are traced, and remaining entries are still attempted. + /// Earlier registrations and callback effects are not rolled back. Fatal errors propagate. + /// + [RequiresUnreferencedCode("Configured initializer and provider types are loaded by name.")] + [RequiresDynamicCode("Configured initializer and providers are constructed dynamically.")] + internal void ApplyConfiguration(SqlAuthenticationConfiguration configuration) + { + // Retain the settings before callbacks run so reentrant discovery sees their overrides. + _configuration = configuration; + string initializerTypeName = configuration.InitializerType; + IReadOnlyList providers = configuration.Providers; + + // Run the initializer first so explicit configured provider entries take final precedence. + if (!string.IsNullOrEmpty(initializerTypeName)) + { + try + { + var initializerType = Type.GetType(initializerTypeName, throwOnError: true)!; + var initializer = Activator.CreateInstance(initializerType) + as SqlAuthenticationInitializer + ?? throw new InvalidOperationException( + "The configured initializer must create a non-null " + + "SqlAuthenticationInitializer instance."); + + // Only registrations made during this callback inherit Config precedence. + _invokingConfiguredInitializer = true; + try + { + initializer.Initialize(); + } + finally + { + _invokingConfiguredInitializer = false; + } + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | Created user-defined " + + "SqlAuthenticationInitializer."); + } + catch (Exception e) when (ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | Could not create or run configured " + + "authentication initializer '{0}'; continuing with configured providers. " + + "Check the type name, public parameterless constructor and initializer " + + "implementation. Error: {1}", initializerTypeName, e); + } + } + else + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | No user-defined " + + "SqlAuthenticationInitializer found."); + } + + // These entries remain configuration-owned after the initializer callback has returned. + if (providers.Count > 0) + { + foreach (ProviderSettings providerSettings in providers) + { + try + { + SqlAuthenticationMethod authentication = + SqlAuthenticationConfiguration.AuthenticationEnumFromString( + providerSettings.Name); + var providerType = Type.GetType(providerSettings.Type, throwOnError: true)!; + var provider = Activator.CreateInstance(providerType) as SqlAuthenticationProvider + ?? throw new InvalidOperationException( + "The configured provider must create a non-null " + + "SqlAuthenticationProvider instance."); + if (!provider.IsSupported(authentication)) + { + throw new NotSupportedException( + $"Configured authentication provider '{providerSettings.Type}' does not " + + $"support method '{providerSettings.Name}'."); + } + + if (!Register(authentication, provider, RegistrationTier.Config)) + { + throw new InvalidOperationException( + $"Configured authentication provider '{providerSettings.Type}' could not " + + $"be registered for method '{providerSettings.Name}'."); + } + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | Added user-defined auth provider: {0} " + + "for authentication {1}.", + providerSettings.Type, authentication); + } + catch (Exception e) when (ExceptionHelpers.IsRecoverableException(e)) + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | Could not apply configured " + + "authentication provider '{0}' for method '{1}'; skipping this entry. " + + "Check the type name, public parameterless constructor, supported " + + "method and registration callbacks. Error: {2}", + providerSettings.Type, providerSettings.Name, e); + } + } + } + else + { + SqlClientEventSource.Log.TryTraceEvent( + "SqlAuthenticationProviderRegistry | No user-defined auth providers."); + } + } + + /// + /// Validates and publishes a provider registration according to its precedence. + /// + /// The authentication method to register. + /// The provider that must support the method. + /// + /// The registration precedence; defaults to application precedence, promoted to configuration + /// precedence while invoking the configured initializer. + /// + /// True if registered; false if precedence prevents replacement. + /// + /// Validates support before acquiring the registry lock; bootstrap may already hold that lock. + /// Precedence checks, replacement callbacks and publication remain synchronized. Replacement + /// invokes + /// the old provider's BeforeUnload and the new provider's BeforeLoad before updating the + /// registration; first registrations do not invoke these callbacks. Reentrant changes to the + /// same method restart precedence checks and callbacks against the current registration. + /// Callback failures do not publish the replacement. + /// + /// + /// The provider does not support the authentication method. + /// + internal bool Register( + SqlAuthenticationMethod authenticationMethod, + SqlAuthenticationProvider provider, + RegistrationTier tier = RegistrationTier.Application) + { + // Initializers use the public API, but their registrations belong to configuration. + // Leave explicitly supplied Default and Config tiers unchanged. + if (tier == RegistrationTier.Application && _invokingConfiguredInitializer) + { + tier = RegistrationTier.Config; + } + + // After bootstrap, validate outside the lock to permit cross-thread registry lookups. + if (!provider.IsSupported(authenticationMethod)) + { + throw new NotSupportedException( + $"Authentication provider '{provider.GetType().FullName}' does not support " + + $"method '{authenticationMethod}'."); + } + lock (_sync) + { + while (true) + { + if (_providers.TryGetValue(authenticationMethod, out ProviderRegistration? registration)) + { + // Equal Application/Config tiers may replace; defaults never replace a registration. + if (registration.Tier > tier || + (tier == RegistrationTier.Default && registration.Tier == tier)) + { + SqlClientEventSource.Log.TryTraceEvent( + "Authentication registration refused for {0}: existing tier {1} takes " + + "precedence over {2}. To change a configuration-owned provider, update " + + "app.config or its initializer rather than overriding it with an application " + + "registration.", + authenticationMethod, registration.Tier, tier); + + return false; + } + + registration.Provider.BeforeUnload(authenticationMethod); + if (!ReferenceEquals(_providers[authenticationMethod], registration)) + { + continue; + } + + provider.BeforeLoad(authenticationMethod); + // Entry identity also detects re-registration of the same provider instance. + if (!ReferenceEquals(_providers[authenticationMethod], registration)) + { + continue; + } + } + // Publish provider and precedence together only after replacement callbacks succeed. + _providers[authenticationMethod] = new ProviderRegistration(provider, tier); + SqlClientEventSource.Log.TryTraceEvent( + "Authentication provider registered for {0}: provider {1}, tier {2}.", + authenticationMethod, provider.GetType().FullName, tier); + return true; + } + } + } + + #endregion +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/TrimmingAttributes.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/TrimmingAttributes.cs index ab233940c3..2db957f1c6 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/TrimmingAttributes.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/TrimmingAttributes.cs @@ -6,6 +6,10 @@ namespace System.Diagnostics.CodeAnalysis; +// The C# compiler emits these uses as ordinary custom-attribute metadata in the netstandard2.0 +// assembly. It does not implement feature switching, trimming or AOT behavior for them; capable +// analysis and publishing tools interpret the metadata. The .NET Framework runtime does not. + /// /// Provides the trimming annotation missing from .NET Standard 2.0 and .NET Framework so shared code can mark /// members that may fail when unreferenced code is removed. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationApplyTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationApplyTest.cs new file mode 100644 index 0000000000..378f0c9b4f --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationApplyTest.cs @@ -0,0 +1,504 @@ +// 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.Configuration; +using Tier = Microsoft.Data.SqlClient.SqlAuthenticationProviderRegistry.RegistrationTier; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// +/// Verifies best-effort configured activation and precedence on isolated registries. +/// +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] +public class AuthenticationConfigurationApplyTest : IDisposable +{ + /// + /// Exposes the current test's context to reflectively activated fixtures whose parameterless + /// constructors cannot receive it. References the same object as . + /// Activation must remain synchronous on the test thread; disposal clears this reference. + /// + [ThreadStatic] + private static Context? s_context; + + /// + /// Owns fresh state for each xUnit test instance. The constructor publishes this same object + /// through so activated fixtures can access it without the test instance. + /// + private readonly Context _context = new(); + + public AuthenticationConfigurationApplyTest() => s_context = _context; + + public void Dispose() => s_context = null; + + private static Context Current => s_context + ?? throw new InvalidOperationException("No configuration test context is active."); + + /// + /// Missing configuration does not install providers or reserve configuration precedence. + /// + [Fact] + public void ApplyConfigurationAllowsMissingSection() + { + // Empty parsed settings must not activate anything or reserve a configuration-owned slot. + Apply(null); + + Assert.Empty(_context.Events); + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new FirstProvider())); + } + + /// + /// Both handlers run the initializer first and let explicit entries replace its providers. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ApplyConfigurationOrdersInitializerAndProviders(bool modern) + { + // The explicit provider must replace the provider registered by Initialize, not vice versa. + var section = CreateSection(modern); + section.ElementInformation.Properties["initializerType"].Value = + typeof(Initializer).AssemblyQualifiedName; + AddProvider(section, "Active Directory Integrated", typeof(SecondProvider)); + + Apply(section); + + // Callback order proves which provider won; lower tiers must not replace that result. + Assert.Equal(new[] + { + "construct:initializer", "initialize", "construct:first", + "construct:second", "unload:first", "load:second" + }, _context.Events); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ThirdProvider())); + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ThirdProvider(), Tier.Config)); + Assert.Equal(new[] { "unload:second", "load:third" }, + _context.Events.Skip(_context.Events.Count - 2)); + } + + /// + /// Initializer callback registrations inherit Config precedence without explicit entries. + /// + [Fact] + public void ApplyConfigurationPromotesInitializerRegistrations() + { + // No explicit entry exists, so only the callback-context promotion can confer Config tier. + var section = CreateSection(); + section.ElementInformation.Properties["initializerType"].Value = + typeof(Initializer).AssemblyQualifiedName; + + Apply(section); + + // Both application registrations and Azure defaults must preserve the initializer's choice. + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new SecondProvider())); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new SecondProvider(), Tier.Default)); + } + + /// + /// Registrations in an initializer constructor are not promoted before Initialize begins. + /// + [Fact] + public void ApplyConfigurationDoesNotPromoteInitializerConstructorRegistrations() + { + // Construction precedes the Initialize callback and must not inherit its precedence. + var section = CreateSection(); + section.ElementInformation.Properties["initializerType"].Value = + typeof(ConstructorRegisteringInitializer).AssemblyQualifiedName; + + Apply(section); + + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new SecondProvider())); + Assert.Equal(new[] { "construct:first", "construct:second", "unload:first", "load:second" }, + _context.Events); + } + + /// + /// Initializer failures are traced without blocking provider activation or leaking initializer precedence. + /// + [Theory] + [InlineData("construct:initializer")] + [InlineData("initialize")] + [InlineData("after:initialize")] + public void ApplyConfigurationContinuesAfterInitializerFailure(string failureAt) + { + // Seed a Config registration to detect a callback-context flag left set after failure. + var existing = CreateSection(); + AddProvider(existing, "Active Directory Integrated", typeof(FirstProvider)); + Apply(existing); + _context.Events.Clear(); + _context.FailureAt = failureAt; + var section = CreateSection(); + section.ElementInformation.Properties["initializerType"].Value = + typeof(Initializer).AssemblyQualifiedName; + AddProvider(section, "Active Directory Interactive", typeof(SecondProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => + message.Contains(typeof(Initializer).AssemblyQualifiedName!) && + message.Contains(_context.Failure.Message)); + Assert.Contains("construct:second", _context.Events); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryInteractive, new ThirdProvider())); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ThirdProvider())); + } + + /// + /// Earlier providers survive a failing entry, and later entries still acquire configuration precedence. + /// + [Theory] + [InlineData("construct:second")] + [InlineData("support:second")] + public void ApplyConfigurationPreservesPartialState(string failureAt) + { + // A failing middle entry must not prevent the final entry from being applied. + _context.FailureAt = failureAt; + var section = CreateSection(); + AddProvider(section, "Active Directory Integrated", typeof(FirstProvider)); + AddProvider(section, "Active Directory Interactive", typeof(SecondProvider)); + AddProvider(section, "Active Directory Default", typeof(ThirdProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => + message.Contains(typeof(SecondProvider).AssemblyQualifiedName!) && + message.Contains("Active Directory Interactive") && + message.Contains(_context.Failure.Message)); + Assert.Equal(new[] { "construct:first", "construct:second", "construct:third" }, + _context.Events); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ThirdProvider())); + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryInteractive, new ThirdProvider())); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryDefault, new ThirdProvider())); + } + + /// + /// Unknown methods are traced and skipped before construction without skipping later providers. + /// + [Theory] + [InlineData("unknown")] + [InlineData("SqlPassword")] + [InlineData("1")] + public void ApplyConfigurationRejectsUnknownMethodsBeforeActivation(string method) + { + // Put the invalid name first to distinguish validation from provider-constructor failure. + var section = CreateSection(); + AddProvider(section, method, typeof(FirstProvider)); + AddProvider(section, "Active Directory Default", typeof(SecondProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => message.Contains(method) && + message.Contains(typeof(FirstProvider).AssemblyQualifiedName!)); + Assert.Equal(new[] { "construct:second" }, _context.Events); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryDefault, new ThirdProvider())); + } + + /// + /// A provider rejecting its configured method is skipped without preventing later entries. + /// + [Fact] + public void ApplyConfigurationRejectsUnsupportedProvider() + { + // A recognized method still requires the activated provider to explicitly support it. + var section = CreateSection(); + AddProvider(section, "Active Directory Integrated", typeof(UnsupportedProvider)); + AddProvider(section, "Active Directory Default", typeof(SecondProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => + message.Contains(typeof(UnsupportedProvider).AssemblyQualifiedName!) && + message.Contains("Active Directory Integrated")); + Assert.Equal(new[] { "construct:unsupported", "construct:second" }, _context.Events); + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new FirstProvider())); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryDefault, new ThirdProvider())); + } + + /// + /// Failed replacement callbacks preserve the old provider while subsequent entries are applied. + /// + [Theory] + [InlineData("unload:first")] + [InlineData("load:second")] + public void ApplyConfigurationContinuesAfterCallbackFailure(string failureAt) + { + // Replacing an existing configured provider reaches both unload and load failure paths. + var existing = CreateSection(); + AddProvider(existing, "Active Directory Integrated", typeof(FirstProvider)); + Apply(existing); + _context.Events.Clear(); + _context.FailureAt = failureAt; + var section = CreateSection(); + AddProvider(section, "Active Directory Integrated", typeof(SecondProvider)); + AddProvider(section, "Active Directory Default", typeof(ThirdProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => + message.Contains(typeof(SecondProvider).AssemblyQualifiedName!) && + message.Contains(_context.Failure.Message)); + Assert.Equal(failureAt == "unload:first" + ? new[] { "construct:second", "unload:first", "construct:third" } + : new[] { "construct:second", "unload:first", "load:second", "construct:third" }, + _context.Events); + _context.FailureAt = null; + // A successful replacement must still unload the original, proving failed publication. + Assert.True(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ThirdProvider(), Tier.Config)); + Assert.Equal(new[] { "unload:first", "load:third" }, + _context.Events.Skip(_context.Events.Count - 2)); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryDefault, new ThirdProvider())); + } + + /// + /// Missing types or parameterless constructors are traced without skipping later providers. + /// + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void ApplyConfigurationReportsTypeAndConstructorFailures( + bool initializer, bool missingType) + { + // Keep resolution errors distinct from activation errors for each configured role. + string typeName = missingType ? "Missing.AuthenticationType" + : (initializer ? typeof(ParameterizedInitializer) : typeof(ParameterizedProvider)) + .AssemblyQualifiedName!; + var section = CreateSection(); + if (initializer) + { + section.ElementInformation.Properties["initializerType"].Value = typeName; + } + else + { + GetProviders(section).Add(new ProviderSettings("Active Directory Integrated", typeName)); + } + AddProvider(section, "Active Directory Default", typeof(ThirdProvider)); + + using var listener = new AuthenticationTraceListener(); + Apply(section); + + Assert.Contains(listener.Messages, message => + message.Contains(typeName) && + message.Contains(missingType ? nameof(TypeLoadException) : nameof(MissingMethodException))); + Assert.Equal(new[] { "construct:third" }, _context.Events); + Assert.False(_context.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryDefault, new FirstProvider())); + } + + /// + /// Configured option overrides flow from this registry into the discovered Azure provider. + /// + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + [InlineData(null, "true")] + [InlineData(null, "false")] + [InlineData("configured-client", null)] + [InlineData("configured-client", "")] + [InlineData("configured-client", "true")] + [InlineData("configured-client", "false")] + [InlineData("configured-client", " TRUE ")] + public void ApplyConfigurationPassesOptionsToAzureDiscovery(string? clientId, string? broker) + { + // Exercise absent overrides separately from explicit false and mixed-case true values. + var section = CreateSection(); + if (clientId is not null) + { + section.ElementInformation.Properties["applicationClientId"].Value = clientId; + } + if (broker is not null) + { + section.ElementInformation.Properties["useWamBroker"].Value = broker; + } + ActiveDirectoryAuthenticationProviderOptions? actual = null; + ThrowingProvider.OnConstructed = options => actual = options; + try + { + Apply(section); + _context.Registry.LoadAzureExtension(() => typeof(ThrowingProvider).Assembly); + + // Discovery must use this registry's settings, not the shared singleton's values. + Assert.NotNull(actual); + Assert.Equal(string.IsNullOrEmpty(clientId) ? null : clientId, + actual.ApplicationClientId); + Assert.Equal(!string.IsNullOrEmpty(broker) && bool.Parse(broker), actual.UseWamBroker); + } + finally + { + // The simulated Azure provider is shared with other serialized discovery tests. + ThrowingProvider.OnConstructed = null; + } + } + + /// + /// Parses the section before asking the isolated registry to activate its entries. + /// + /// The handler under test, or null for absent configuration. + private void Apply(ConfigurationSection? section) => + _context.Registry.ApplyConfiguration(new SqlAuthenticationConfiguration(section)); + + /// + /// Creates a fresh handler without accessing the process-wide configuration system. + /// + /// Whether to use the preferred rather than legacy handler. + /// The empty configuration section. + private static ConfigurationSection CreateSection( + bool modern = true) => + modern ? new SqlClientAuthenticationProviderConfigurationSection() + : new SqlAuthenticationProviderConfigurationSection(); + + /// Adds a constructible provider entry using its assembly-qualified name. + /// The configuration under test. + /// The configured authentication-method name. + /// The configured provider type. + private static void AddProvider( + ConfigurationSection section, string method, Type type) => + GetProviders(section).Add(new ProviderSettings(method, type.AssemblyQualifiedName)); + + /// Reads the shared provider collection from either independent sealed handler. + /// The current or legacy handler. + /// The handler's provider entries. + private static ProviderSettingsCollection GetProviders(ConfigurationSection section) => + Assert.IsType( + section.ElementInformation.Properties["providers"].Value); + + /// + /// Holds per-test state shared with types activated by the configuration loader. + /// + private sealed class Context + { + internal readonly SqlAuthenticationProviderRegistry Registry = new(); + internal readonly List Events = new(); + internal readonly Exception Failure = + new InvalidOperationException("Test activation failure"); + internal string? FailureAt; + + /// + /// Records activation/callback order and raises the selected test failure. + /// + /// The activation or callback being executed. + internal void Record(string action) + { + Events.Add(action); + FailIfRequested(action); + } + + /// + /// Fails a selected operation without imposing a support-check count contract. + /// + /// The operation being executed. + internal void FailIfRequested(string action) + { + if (FailureAt == action) + { + throw Failure; + } + } + } + + /// + /// Registers through the same instance method used by public initializer registrations. + /// + public sealed class Initializer : SqlAuthenticationInitializer + { + public Initializer() => Current.Record("construct:initializer"); + + public override void Initialize() + { + Current.Record("initialize"); + Assert.True(Current.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new FirstProvider())); + Current.FailIfRequested("after:initialize"); + } + } + + /// + /// Distinguishes initializer construction from the narrower Initialize callback context. + /// + public sealed class ConstructorRegisteringInitializer : SqlAuthenticationInitializer + { + public ConstructorRegisteringInitializer() => + Assert.True(Current.Registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new FirstProvider())); + + public override void Initialize() { } + } + + /// + /// Records construction and replacement callbacks without acquiring real tokens. + /// + public abstract class Provider : SqlAuthenticationProvider + { + private readonly string _name; + + protected Provider(string name) + { + _name = name; + Current.Record("construct:" + name); + } + + public override bool IsSupported(SqlAuthenticationMethod method) + { + Current.FailIfRequested("support:" + _name); + return true; + } + + public override void BeforeLoad(SqlAuthenticationMethod method) => + Current.Record("load:" + _name); + + public override void BeforeUnload(SqlAuthenticationMethod method) => + Current.Record("unload:" + _name); + + public override Task AcquireTokenAsync( + SqlAuthenticationParameters parameters) => throw new NotSupportedException(); + } + + /// Supplies the first configured or initializer-registered provider. + public sealed class FirstProvider() : Provider("first"); + + /// Supplies a replacement or later failing configured provider. + public sealed class SecondProvider() : Provider("second"); + + /// Detects whether entries after a failure are still activated. + public sealed class ThirdProvider() : Provider("third"); + + /// Rejects the configured method before registration can publish it. + public sealed class UnsupportedProvider() : Provider("unsupported") + { + public override bool IsSupported(SqlAuthenticationMethod method) => false; + } + + /// Lacks the parameterless constructor required for configured providers. + public sealed class ParameterizedProvider : Provider + { + public ParameterizedProvider(int value) : base("parameterized") => _ = value; + } + + /// Lacks the parameterless constructor required for configured initializers. + public sealed class ParameterizedInitializer : SqlAuthenticationInitializer + { + public ParameterizedInitializer(int value) => _ = value; + + public override void Initialize() { } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationBootstrapTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationBootstrapTest.cs new file mode 100644 index 0000000000..5251a2997f --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationBootstrapTest.cs @@ -0,0 +1,267 @@ +// 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.Configuration; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// +/// Verifies bootstrap lifecycle with isolated section readers and Azure assembly loaders. +/// +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] +public class AuthenticationConfigurationBootstrapTest : IDisposable +{ + private readonly AuthenticationFeatureSwitchesHelper _switches = new() + { + EnableAppConfig = true, + EnableAzureExtensionDiscovery = false + }; + + public void Dispose() => _switches.Dispose(); + + /// + /// Successful bootstrap applies configuration once and never invokes later readers. + /// + [Fact] + public void EnsureBootstrappedLoadsConfigurationOnce() + { + // A reader that fails on reuse distinguishes cached success from repeated bootstrap. + var registry = new SqlAuthenticationProviderRegistry(); + int reads = 0; + + registry.EnsureBootstrapped(_ => + { + reads++; + return new SqlClientAuthenticationProviderConfigurationSection(); + }); + registry.EnsureBootstrapped(_ => throw new InvalidOperationException( + "Completed bootstrap must not read configuration.")); + + Assert.Equal(1, reads); + } + + /// + /// Recoverable reading and activation failures are tolerated without retrying configuration. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnsureBootstrappedToleratesConfigurationFailureWithoutRetry(bool retrieval) + { + var registry = new SqlAuthenticationProviderRegistry(); + int reads = 0; + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["initializerType"].Value = + typeof(object).AssemblyQualifiedName; + var expected = new InvalidOperationException("Test section retrieval failure"); + + using var listener = new AuthenticationTraceListener(); + registry.EnsureBootstrapped(_ => + { + reads++; + if (retrieval) + { + throw expected; + } + return section; + }); + registry.EnsureBootstrapped(_ => + throw new InvalidOperationException("Completed bootstrap must not retry.")); + + Assert.Contains(listener.Messages, message => message.Contains(retrieval + ? expected.Message + : typeof(object).AssemblyQualifiedName!)); + Assert.Equal(1, reads); + } + + /// + /// Azure discovery follows recoverable config failures, preserves valid options and providers, + /// and still respects the discovery switch. + /// + [Theory] + [InlineData("read", true)] + [InlineData("read", false)] + [InlineData("initializer", true)] + [InlineData("initializer", false)] + [InlineData("provider", true)] + [InlineData("provider", false)] + public void EnsureBootstrappedDiscoversAzureAfterConfigurationFailure( + string failureAt, bool discovery) + { + _switches.EnableAzureExtensionDiscovery = discovery; + var registry = new SqlAuthenticationProviderRegistry(); + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["applicationClientId"].Value = "configured-client"; + section.ElementInformation.Properties["useWamBroker"].Value = "true"; + if (failureAt == "initializer") + { + section.ElementInformation.Properties["initializerType"].Value = + typeof(object).AssemblyQualifiedName; + } + section.Providers.Add(new ProviderSettings( + "Active Directory Integrated", typeof(ConfiguredProvider).AssemblyQualifiedName)); + if (failureAt == "provider") + { + section.Providers.Add(new ProviderSettings( + "Active Directory Interactive", typeof(object).AssemblyQualifiedName)); + } + section.Providers.Add(new ProviderSettings( + "Active Directory Service Principal", typeof(ConfiguredProvider).AssemblyQualifiedName)); + int reads = 0; + int loads = 0; + ActiveDirectoryAuthenticationProviderOptions? options = null; + ThrowingProvider.OnConstructed = value => options = value; + using var listener = new AuthenticationTraceListener(); + try + { + registry.EnsureBootstrapped(_ => + { + reads++; + if (failureAt == "read") + { + throw new InvalidOperationException("Test configuration retrieval failure"); + } + return section; + }, () => + { + loads++; + return typeof(ThrowingProvider).Assembly; + }); + registry.EnsureBootstrapped( + _ => throw new InvalidOperationException("Configuration must not be retried."), + () => throw new InvalidOperationException("Discovery must not be retried.")); + + Assert.Equal(1, reads); + Assert.Equal(discovery ? 1 : 0, loads); + if (discovery) + { + Assert.NotNull(options); + Assert.Equal(failureAt == "read" ? null : "configured-client", + options.ApplicationClientId); + Assert.Equal(failureAt != "read", options.UseWamBroker); + } + else + { + Assert.Null(options); + } + Assert.Contains(listener.Messages, message => message.Contains(failureAt == "read" + ? "Test configuration retrieval failure" + : typeof(object).AssemblyQualifiedName!)); + if (failureAt != "read") + { + // Configuration wins over application registrations and discovered defaults. + Assert.False(registry.Register( + SqlAuthenticationMethod.ActiveDirectoryIntegrated, new ConfiguredProvider())); + Assert.False(registry.Register( + SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, new ConfiguredProvider())); + } + Assert.Equal(!discovery, registry.Register( + SqlAuthenticationMethod.ActiveDirectoryInteractive, new ConfiguredProvider(), + SqlAuthenticationProviderRegistry.RegistrationTier.Default)); + } + finally + { + ThrowingProvider.OnConstructed = null; + } + } + + /// + /// Fatal errors escape uncached and release the in-progress state in the finally block. + /// + [Fact] + public void EnsureBootstrappedDoesNotCacheFatalFailure() + { + // Simulate a fatal runtime failure without exhausting memory in the test process. + _switches.EnableAzureExtensionDiscovery = true; + var registry = new SqlAuthenticationProviderRegistry(); + var expected = new OutOfMemoryException("Test fatal configuration failure"); + bool discoveryAttempted = false; + + Assert.Same(expected, Assert.Throws(() => + registry.EnsureBootstrapped(_ => throw expected, () => + { + discoveryAttempted = true; + return typeof(ThrowingProvider).Assembly; + }))); + Assert.False(discoveryAttempted); + // A second attempt must actually read, proving the in-progress flag was released. + int reads = 0; + registry.EnsureBootstrapped(_ => + { + reads++; + return new SqlClientAuthenticationProviderConfigurationSection(); + }); + + Assert.Equal(1, reads); + } + + /// + /// Same-thread reentry returns immediately rather than recursively loading configuration. + /// + [Fact] + public void EnsureBootstrappedAllowsReentrantAccess() + { + // Reenter while the outer reader holds the bootstrap lock on this same thread. + var registry = new SqlAuthenticationProviderRegistry(); + int reads = 0; + + registry.EnsureBootstrapped(_ => + { + reads++; + registry.EnsureBootstrapped(_ => + throw new InvalidOperationException("Reentry must not recursively bootstrap.")); + return new SqlClientAuthenticationProviderConfigurationSection(); + }); + + Assert.Equal(1, reads); + } + + /// + /// Disabling configuration prevents even a failing reader from being invoked. + /// + [Fact] + public void EnsureBootstrappedHonorsConfigurationSwitch() + { + // Override only the cached switch so process-wide AppContext state remains untouched. + _switches.EnableAppConfig = false; + + new SqlAuthenticationProviderRegistry().EnsureBootstrapped(_ => + throw new InvalidOperationException("Disabled configuration must not be read.")); + } + + /// Relaxed parsing completes bootstrap after configuration reading or broker-value errors. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnsureBootstrappedToleratesConfigurationErrors(bool retrieval) + { + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["useWamBroker"].Value = "invalid"; + int reads = 0; + var registry = new SqlAuthenticationProviderRegistry(); + + registry.EnsureBootstrapped(_ => + { + reads++; + if (retrieval) + { + throw new ConfigurationErrorsException("Test section retrieval failure"); + } + return section; + }); + registry.EnsureBootstrapped(_ => throw new InvalidOperationException( + "Successful relaxed bootstrap must not retry.")); + + Assert.Equal(1, reads); + } + + /// Provides a valid configured registration without acquiring real tokens. + public sealed class ConfiguredProvider : SqlAuthenticationProvider + { + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + + public override Task AcquireTokenAsync( + SqlAuthenticationParameters parameters) => throw new NotSupportedException(); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFailureTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFailureTest.cs new file mode 100644 index 0000000000..89ed1923dd --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFailureTest.cs @@ -0,0 +1,125 @@ +// 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.Configuration; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Verifies best-effort configuration application never masks fatal errors. +public class AuthenticationConfigurationFailureTest +{ + /// Malformed broker values are ignored without skipping configured provider activation. + [Theory] + [InlineData("invalid")] + [InlineData("1")] + [InlineData(" ")] + public void InvalidBrokerValueDoesNotPreventProviderActivation(string value) + { + // The fatal provider proves activation is attempted after the invalid broker is ignored. + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["useWamBroker"].Value = value; + section.Providers.Add(new ProviderSettings( + "Active Directory Interactive", typeof(FailingProvider).AssemblyQualifiedName)); + + var settings = new SqlAuthenticationConfiguration(section); + Assert.Null(settings.UseWamBroker); + Exception failure = ApplyInvalidConfiguration(section); + Assert.IsType(failure.GetBaseException()); + } + + /// Absent sections and incompatible handlers both allow fallback in relaxed parsing. + [Theory] + [InlineData("appSettings", false)] + [InlineData("SqlClientAuthenticationConfigurationFailureTest_Missing", true)] + public void AbsentOrMismatchedSectionReturnsNull(string name, bool missing) + { + // Compare a genuinely missing section with an existing handler for another schema. + Assert.Equal(missing, ConfigurationManager.GetSection(name) is null); + Assert.Null(SqlAuthenticationConfiguration.FetchConfigurationSection( + name, typeof(SqlClientAuthenticationProviderConfigurationSection))); + } + + /// Fatal initializer and provider failures propagate without an argument wrapper. + [Theory] + [InlineData(typeof(FailingInitializer))] + [InlineData(typeof(FailingProvider))] + public void FatalConfigurationFailuresAreNotWrapped(Type type) + { + // Fail through either reflective construction or the initializer callback. + var section = new SqlClientAuthenticationProviderConfigurationSection(); + if (typeof(SqlAuthenticationInitializer).IsAssignableFrom(type)) + { + section.ElementInformation.Properties["initializerType"].Value = type.AssemblyQualifiedName; + } + else + { + section.Providers.Add(new ProviderSettings( + "Active Directory Integrated", type.AssemblyQualifiedName)); + } + + // Fatal errors may retain reflection wrappers, but must not gain a configuration wrapper. + Exception failure = ApplyInvalidConfiguration(section); + Assert.IsNotType(failure); + Assert.IsType(failure.GetBaseException()); + } + + /// Null or incompatible activation results do not prevent reaching later fatal failures. + [Theory] + [InlineData(typeof(int?), true)] + [InlineData(typeof(int?), false)] + [InlineData(typeof(object), true)] + [InlineData(typeof(object), false)] + public void InvalidActivationContinuesToLaterProviders(Type type, bool initializer) + { + // Nullable activation produces null; object activation produces an incompatible instance. + var section = new SqlClientAuthenticationProviderConfigurationSection(); + if (initializer) + { + section.ElementInformation.Properties["initializerType"].Value = type.AssemblyQualifiedName; + } + else + { + section.Providers.Add(new ProviderSettings( + "Active Directory Integrated", type.AssemblyQualifiedName)); + } + + // A recoverable activation error must not prevent this later constructor from running. + section.Providers.Add(new ProviderSettings( + "Active Directory Interactive", typeof(FailingProvider).AssemblyQualifiedName)); + + Exception failure = ApplyInvalidConfiguration(section); + Assert.IsType(failure.GetBaseException()); + } + + /// Applies invalid configuration to an isolated registry and captures its failure. + /// The invalid section, without modifying process-wide configuration. + /// The failure escaping configuration application. + private static Exception ApplyInvalidConfiguration(ConfigurationSection section) + { + return Assert.ThrowsAny(() => + new SqlAuthenticationProviderRegistry().ApplyConfiguration( + new SqlAuthenticationConfiguration(section))); + } + + /// Simulates a fatal initializer callback without actually exhausting memory. + private sealed class FailingInitializer : SqlAuthenticationInitializer + { + public FailingInitializer() { } + + public override void Initialize() => + throw new OutOfMemoryException("Test fatal initializer failure"); + } + + /// Simulates a fatal provider constructor without actually exhausting memory. + private sealed class FailingProvider : SqlAuthenticationProvider + { + public FailingProvider() => + throw new OutOfMemoryException("Test fatal provider failure"); + + public override bool IsSupported(SqlAuthenticationMethod method) => true; + + public override Task AcquireTokenAsync( + SqlAuthenticationParameters parameters) => throw new NotSupportedException(); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFileTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFileTest.cs index 789d920103..6567ed4f4e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFileTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationFileTest.cs @@ -176,6 +176,28 @@ SqlAuthenticationConfiguration Load() => SqlAuthenticationConfiguration.Load(nam }); } + /// A real mismatched modern declaration falls back despite having a matching schema. + [Fact] + public void WrongModernHandlerFallsBackToLegacy() + { + WithConfiguration( + """ +
+
+ """, + $""" + <{Modern} applicationClientId="wrong-handler" /> + <{Legacy} applicationClientId="legacy-client" /> + """, + configuration => + { + var settings = SqlAuthenticationConfiguration.Load(configuration.GetSection); + Assert.Equal("legacy-client", settings.ApplicationClientId); + }); + } + /// Creates and deletes an isolated configuration file without changing process configuration. /// Handler declarations in configSections. /// Configuration section elements. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationParsingTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationParsingTest.cs index af0453f2b8..1c12bad35b 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationParsingTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationConfigurationParsingTest.cs @@ -36,7 +36,7 @@ public void InvalidBrokerValuePreservesOtherSettings(string value) } /// - /// A caller-owned default configuration preserves provider defaults without loading app.config. + /// Empty bootstrap settings preserve provider defaults without rooting section parsing. /// [Fact] public void DefaultConstructorCreatesEmptyConfiguration() diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesHelper.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesHelper.cs new file mode 100644 index 0000000000..9bdf4e2ffb --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesHelper.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 SwitchValue = Microsoft.Data.SqlClient.AuthenticationFeatureSwitches.SwitchValue; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// +/// Snapshots, overrides and restores authentication switch caches without changing AppContext. +/// Use within the serialized authentication feature switch test collection. +/// +internal sealed class AuthenticationFeatureSwitchesHelper : IDisposable +{ + private readonly SwitchValue _enableAppConfigOriginal = AuthenticationFeatureSwitches.s_enableAppConfig; + private readonly SwitchValue _enableAzureExtensionDiscoveryOriginal = AuthenticationFeatureSwitches.s_enableAzureExtensionDiscovery; + + /// Overrides the app.config cache; null clears it so the next read acquires the runtime value or default. + internal bool? EnableAppConfig + { + get => AuthenticationFeatureSwitches.EnableAppConfig; + set => AuthenticationFeatureSwitches.s_enableAppConfig = ToCachedValue(value); + } + + /// Overrides the Azure discovery cache; null clears it so the next read acquires the runtime value or default. + internal bool? EnableAzureExtensionDiscovery + { + get => AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery; + set => AuthenticationFeatureSwitches.s_enableAzureExtensionDiscovery = ToCachedValue(value); + } + + /// Restores the exact cached states captured at construction, including uninitialized values. + public void Dispose() + { + AuthenticationFeatureSwitches.s_enableAppConfig = _enableAppConfigOriginal; + AuthenticationFeatureSwitches.s_enableAzureExtensionDiscovery = _enableAzureExtensionDiscoveryOriginal; + } + + /// Converts a nullable override to the corresponding cached switch state. + /// The override, or null to clear the cache. + /// The cached state represented by the override. + private static SwitchValue ToCachedValue(bool? value) => value switch + { + true => SwitchValue.True, + false => SwitchValue.False, + null => SwitchValue.None + }; +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesTest.cs new file mode 100644 index 0000000000..8958e4776b --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationFeatureSwitchesTest.cs @@ -0,0 +1,101 @@ +// 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. + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Serializes cached switch overrides against authentication bootstrap in other tests. +[CollectionDefinition(Name, DisableParallelization = true)] +public class AuthenticationFeatureSwitchTestCollection +{ + public const string Name = "Authentication feature switches"; +} + +/// Verifies authentication switch defaults, cached overrides and test-helper isolation. +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] +public class AuthenticationFeatureSwitchesTest +{ + /// Authentication tests must leave the process-wide switches unset and manipulate only their caches. + [Fact] + public void AppContextSwitchesAreUnset() + { + // Other tests may override caches, but must never leave global AppContext values behind. + Assert.False(AppContext.TryGetSwitch("Switch.Microsoft.Data.SqlClient.EnableAppConfig", out _)); + Assert.False(AppContext.TryGetSwitch("Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery", out _)); + } + + /// Clearing cached values reads the enabled defaults without changing process-wide AppContext switches. + [Fact] + public void UnsetSwitchesDefaultToEnabled() + { + // Clear both caches to exercise first-read defaults rather than a previous test's values. + using var switches = new AuthenticationFeatureSwitchesHelper(); + switches.EnableAppConfig = null; + switches.EnableAzureExtensionDiscovery = null; + + Assert.False(AppContext.TryGetSwitch("Switch.Microsoft.Data.SqlClient.EnableAppConfig", out _)); + Assert.False(AppContext.TryGetSwitch("Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery", out _)); + Assert.True(AuthenticationFeatureSwitches.EnableAppConfig); + Assert.True(AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery); + Assert.True(AuthenticationFeatureSwitches.IsAppConfigSupported); + Assert.True(AuthenticationFeatureSwitches.IsAzureExtensionDiscoverySupported); + } + + /// The app.config switch and guard honor cached overrides without setting the AppContext switch. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnableAppConfigAndGuardHonorCachedValue(bool cachedValue) + { + // The guard must follow the cached feature switch without publishing an AppContext value. + using var switches = new AuthenticationFeatureSwitchesHelper(); + const string switchName = "Switch.Microsoft.Data.SqlClient.EnableAppConfig"; + Assert.False(AppContext.TryGetSwitch(switchName, out _)); + switches.EnableAppConfig = cachedValue; + + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.EnableAppConfig); + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.EnableAppConfig); + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.IsAppConfigSupported); + Assert.False(AppContext.TryGetSwitch(switchName, out _)); + } + + /// The Azure discovery switch and guard honor cached overrides without setting the AppContext switch. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnableAzureExtensionDiscoveryAndGuardHonorCachedValue(bool cachedValue) + { + // Keep discovery's capability guard and runtime switch consistent for either override. + using var switches = new AuthenticationFeatureSwitchesHelper(); + const string switchName = "Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery"; + Assert.False(AppContext.TryGetSwitch(switchName, out _)); + switches.EnableAzureExtensionDiscovery = cachedValue; + + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery); + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery); + Assert.Equal(cachedValue, AuthenticationFeatureSwitches.IsAzureExtensionDiscoverySupported); + Assert.False(AppContext.TryGetSwitch(switchName, out _)); + } + + /// Disposal restores both original cached values after nested overrides, including an uninitialized cache. + [Fact] + public void HelperRestoresCachedValues() + { + // Nest helpers to verify disposal restores uninitialized caches, not just Boolean values. + using var original = new AuthenticationFeatureSwitchesHelper(); + original.EnableAppConfig = null; + original.EnableAzureExtensionDiscovery = false; + using (var overrides = new AuthenticationFeatureSwitchesHelper()) + { + overrides.EnableAppConfig = false; + overrides.EnableAzureExtensionDiscovery = true; + Assert.False(AuthenticationFeatureSwitches.EnableAppConfig); + Assert.True(AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery); + } + + // Restoring an uninitialized cache must restore default-enabled evaluation. + Assert.True(AuthenticationFeatureSwitches.EnableAppConfig); + Assert.False(AuthenticationFeatureSwitches.EnableAzureExtensionDiscovery); + } + +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationRegistryTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationRegistryTest.cs new file mode 100644 index 0000000000..045b6fbdb8 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationRegistryTest.cs @@ -0,0 +1,512 @@ +// 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.Collections; +using System.Collections.Concurrent; +using System.Diagnostics.Tracing; +using System.Reflection; +using System.Runtime.ExceptionServices; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Validates errors and callback compatibility of the direct provider registry. +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] +public class AuthenticationRegistryTest +{ + /// Application registration remains usable after failure without overriding successful configuration registrations. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CachedBootstrapFailureAllowsApplicationRegistration(bool configuredProvider) + { + var registry = Assert.IsType( + typeof(SqlAuthenticationProviderRegistry).GetField( + "Instance", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)); + FieldInfo failureField = typeof(SqlAuthenticationProviderRegistry).GetField( + "_bootstrapFailure", BindingFlags.Instance | BindingFlags.NonPublic)!; + var providers = Assert.IsAssignableFrom( + typeof(SqlAuthenticationProviderRegistry).GetField( + "_providers", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(registry)); + const SqlAuthenticationMethod method = SqlAuthenticationMethod.ActiveDirectoryDefault; + object? originalFailure = failureField.GetValue(registry); + object? originalProvider = providers[method]; + var failure = ExceptionDispatchInfo.Capture(new ArgumentException( + "Test configured initializer failure", new InvalidOperationException("Test activation failure"))); + using var listener = new AuthenticationTraceListener(); + providers.Remove(method); + failureField.SetValue(registry, failure); + try + { + Assert.Null(SqlAuthenticationProvider.GetProvider(method)); + var provider = new Provider(true); + var configured = new Provider(true); + if (configuredProvider) + { + Assert.True(registry.Register( + method, configured, SqlAuthenticationProviderRegistry.RegistrationTier.Config)); + } + + Assert.Equal(!configuredProvider, SqlAuthenticationProvider.SetProvider(method, provider)); + SqlAuthenticationProvider expected = configuredProvider ? configured : provider; + Assert.Same(expected, SqlAuthenticationProvider.GetProvider(method)); + Assert.Same(failure, failureField.GetValue(registry)); + Assert.False(SqlAuthenticationProvider.SetProvider(method, new Provider(false))); + Assert.Same(expected, SqlAuthenticationProvider.GetProvider(method)); + Assert.Contains(listener.Messages, message => + message.Contains("Test configured initializer failure") && + message.Contains("application registration")); + } + finally + { + failureField.SetValue(registry, originalFailure); + if (originalProvider is null) + { + providers.Remove(method); + } + else + { + providers[method] = originalProvider; + } + } + } + + /// An installed legacy Azure contract reports the required upgrade rather than a missing package. + [Fact] + public void LegacyAzureContractIsTracedWithUpgradeGuidance() + { + using var listener = new AuthenticationTraceListener(); + var registry = new SqlAuthenticationProviderRegistry(); + + registry.LoadAzureExtension(() => new LegacyAzureAssembly()); + + Assert.Contains(listener.Messages, message => + message.Contains("ActiveDirectoryAuthenticationProviderOptions") && + message.Contains("Upgrade") && message.Contains("7.0.2")); + Assert.True(registry.Register(SqlAuthenticationMethod.ActiveDirectoryDefault, new Provider(true))); + } + + /// Discovery reports attempts, not installation, when existing defaults reject it. + [Fact] + public void AzureDiscoveryTraceDoesNotClaimRejectedRegistrationsSucceeded() + { + // Populate every default slot, then observe a second discovery where all attempts lose. + var registry = new SqlAuthenticationProviderRegistry(); + using (var firstListener = new AuthenticationTraceListener()) + { + registry.LoadAzureExtension(() => typeof(ThrowingProvider).Assembly); + Assert.Contains( + "Authentication provider registered for ActiveDirectoryIntegrated: provider " + + "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider, tier Default.", + firstListener.Messages); + } + + using var listener = new AuthenticationTraceListener(); + registry.LoadAzureExtension(() => typeof(ThrowingProvider).Assembly); + + // Rejections may be traced, but none may be reported as successful installation. + Assert.Contains(listener.Messages, message => + message.Contains("existing tier Default takes precedence over Default")); + Assert.DoesNotContain(listener.Messages, message => + message.StartsWith("Authentication provider registered for ", StringComparison.Ordinal)); + Assert.Contains( + "SqlAuthenticationProviderRegistry: Attempted to register Azure extension " + + "class=Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider as the default " + + "for Active Directory authentication methods; existing registrations were preserved", + listener.Messages); + } + + /// Deferred trace formatting preserves configuration diagnostics and broker values. + [Theory] + [InlineData("true", "Received user-defined UseWamBroker=True.")] + [InlineData("false", "Received user-defined UseWamBroker=False.")] + [InlineData("", "No user-defined UseWamBroker found.")] + public void ConfigurationTraceMessagesPreserveText(string brokerValue, string brokerMessage) + { + using var listener = new AuthenticationTraceListener(); + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["useWamBroker"].Value = brokerValue; + + // Avoid changing process-wide configuration or registering providers in the shared registry. + new SqlAuthenticationProviderRegistry().ApplyConfiguration( + new SqlAuthenticationConfiguration(section)); + + // Values are traced during parsing; activation diagnostics remain the registry's concern. + Assert.Contains("SqlAuthenticationConfiguration | " + brokerMessage, listener.Messages); + Assert.Contains( + "SqlAuthenticationConfiguration | No user-defined Application Client Id found.", + listener.Messages); + Assert.Contains( + "SqlAuthenticationProviderRegistry | No user-defined SqlAuthenticationInitializer found.", + listener.Messages); + Assert.Contains( + "SqlAuthenticationProviderRegistry | No user-defined auth providers.", + listener.Messages); + } + + /// Registration errors escape the initializer callback for configuration application to trace. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InitializerRegistrationFailuresAreNotSwallowed(bool nullProvider) + { + // Complete shared bootstrap before simulating the narrower configured-initializer context. + SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.NotSpecified); + var registry = Assert.IsType( + typeof(SqlAuthenticationProviderRegistry).GetField( + "Instance", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)); + FieldInfo invoking = typeof(SqlAuthenticationProviderRegistry).GetField( + "_invokingConfiguredInitializer", BindingFlags.Instance | BindingFlags.NonPublic)!; + bool original = Assert.IsType(invoking.GetValue(registry)); + + // Simulate callback context on the shared registry, restoring it even if an assertion fails. + invoking.SetValue(registry, true); + try + { + Exception? failure = Record.Exception(() => SqlAuthenticationProvider.SetProvider( + SqlAuthenticationMethod.NotSpecified, nullProvider ? null! : new Provider(false))); + if (nullProvider) + { + Assert.IsType(failure); + } + else + { + Assert.IsType(failure); + } + } + finally + { + invoking.SetValue(registry, original); + } + } + + /// Unsupported registrations preserve the false result and explain how to fix the provider. + [Fact] + public void UnsupportedProviderReturnsFalseAndLogs() + { + // Outside initialization, a support rejection must return false and emit a trace. + using var listener = new AuthenticationTraceListener(); + Assert.False(SqlAuthenticationProvider.SetProvider( + SqlAuthenticationMethod.NotSpecified, new Provider(false))); + Assert.Contains(listener.Messages, message => + message.Contains("SetProvider") && message.Contains("NotSpecified") && + message.Contains("returning false") && message.Contains("supports")); + } + + /// Null registrations preserve the false result and explain the required argument. + [Fact] + public void NullProviderReturnsFalseAndLogs() + { + // Null is a caller error, but outside initialization registration remains non-throwing. + using var listener = new AuthenticationTraceListener(); + Assert.False(SqlAuthenticationProvider.SetProvider( + SqlAuthenticationMethod.NotSpecified, null!)); + Assert.Contains(listener.Messages, message => + message.Contains("SetProvider") && message.Contains("returning false") && + message.Contains("non-null")); + } + + /// Provider callback failures are logged without replacing the existing registration. + [Theory] + [InlineData("IsSupported")] + [InlineData("BeforeLoad")] + [InlineData("BeforeUnload")] + public void CallbackFailureReturnsFalseAndPreservesProvider(string callback) + { + // Place the failure on the old or replacement provider according to callback ownership. + using var listener = new AuthenticationTraceListener(); + var first = new ThrowingProvider(callback == "BeforeUnload" ? callback : null); + var replacement = new ThrowingProvider(callback == "BeforeUnload" ? null : callback); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, first)); + + // Publication must wait until support validation and both replacement callbacks succeed. + Assert.False(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, replacement)); + + Assert.Same(first, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.NotSpecified)); + Assert.Contains(listener.Messages, message => + message.Contains("SetProvider") && message.Contains(callback) && + message.Contains("returning false") && message.Contains("callbacks")); + first.Throw = false; + // A later valid replacement must succeed; the failed attempt must not claim registration. + Assert.Single(listener.Messages, message => + message.StartsWith( + "Authentication provider registered for NotSpecified:", StringComparison.Ordinal)); + var valid = new Provider(true); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, valid)); + Assert.Same(valid, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.NotSpecified)); + } + + /// The registry preserves initial insertion and replacement callback behavior. + [Fact] + public void ReplacementInvokesCallbacks() + { + // First insertion has no replacement callbacks; replacing it unloads old and loads new. + using var listener = new AuthenticationTraceListener(); + var first = new Provider(true); + var second = new Provider(true); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, first)); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, second)); + Assert.Equal(1, first.Unloads); + Assert.Equal(1, second.Loads); + Assert.Same(second, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.SqlPassword)); + Assert.Contains( + "Authentication provider registered for SqlPassword: provider " + + typeof(Provider).FullName + ", tier Application.", + listener.Messages); + } + + /// Fatal failures in later aggregate entries propagate from every registration callback without replacing the provider. + [Theory] + [InlineData("IsSupported")] + [InlineData("BeforeLoad")] + [InlineData("BeforeUnload")] + public void AggregateCallbackFailurePropagatesAndPreservesProvider(string callback) + { + // Put a fatal error after an ordinary aggregate entry to defeat first-inner-only checks. + var failure = new AggregateException( + new InvalidOperationException("Ordinary failure"), new OutOfMemoryException("Test fatal failure")); + var first = new ThrowingProvider(callback == "BeforeUnload" ? callback : null, failure); + var replacement = new ThrowingProvider(callback == "BeforeUnload" ? null : callback, failure); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, first)); + + try + { + Assert.Same(failure, Assert.Throws(() => + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, replacement))); + Assert.Same(first, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.NotSpecified)); + } + finally + { + // Restore a harmless shared provider even when an assertion fails. + first.Throw = false; + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.NotSpecified, new Provider(true))); + } + } + + /// Application-provider support checks can wait for another thread to look up the existing provider. + [Fact] + public void SupportValidationAllowsCrossThreadLookup() + { + // IsSupported waits for another thread's lookup, exposing any lock held around validation. + var first = new Provider(true); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, first)); + var replacement = new LookupProvider(); + + bool registered = SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, replacement); + + // The worker must see the old provider before the replacement is published. + Assert.NotNull(replacement.Lookup); + Assert.True(replacement.Lookup.Wait(TimeSpan.FromSeconds(10)), "Cross-thread lookup did not complete."); + Assert.True(registered, "Support validation blocked a cross-thread registry lookup."); + Assert.Same(first, replacement.Lookup.Result); + Assert.Equal(1, replacement.SupportChecks); + Assert.Same(replacement, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.SqlPassword)); + } + + /// Nested providers are unloaded before a reentrant outer replacement is published. + [Theory] + [InlineData("BeforeLoad")] + [InlineData("BeforeUnload")] + public void ReentrantReplacementUnloadsNestedProvider(string callback) + { + var nested = new Provider(true); + bool nestedRegistrationAttempted = false; + bool nestedRegistrationSucceeded = false; + void RegisterNested(SqlAuthenticationMethod method) + { + if (!nestedRegistrationAttempted) + { + // Set the guard before reentering callbacks so the retry can converge. + nestedRegistrationAttempted = true; + nestedRegistrationSucceeded = SqlAuthenticationProvider.SetProvider(method, nested); + } + } + + var first = new Provider(true, beforeUnload: callback == "BeforeUnload" ? RegisterNested : null); + var replacement = new Provider(true, beforeLoad: callback == "BeforeLoad" ? RegisterNested : null); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, first)); + + try + { + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, replacement)); + + Assert.True(nestedRegistrationAttempted); + Assert.True(nestedRegistrationSucceeded); + Assert.Same(replacement, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.SqlPassword)); + Assert.Equal(1, nested.Loads); + // The nested provider must be cleaned up even though the outer registration wins. + Assert.Equal(1, nested.Unloads); + } + finally + { + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, new Provider(true))); + } + } + + /// Re-registering the same provider instance still requires replacement callbacks to retry. + [Fact] + public void ReentrantReplacementDetectsSameProviderReregistration() + { + var first = new Provider(true); + bool registeredAgain = false; + var replacement = new Provider(true, beforeLoad: method => + { + if (!registeredAgain) + { + registeredAgain = true; + Assert.True(SqlAuthenticationProvider.SetProvider(method, first)); + } + }); + // Force the populated-slot path, then exclude its setup callback from the measured loads. + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, new Provider(true))); + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, first)); + first.Loads = 0; + + try + { + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, replacement)); + + Assert.True(registeredAgain); + Assert.Equal(1, first.Loads); + // The outer attempt, nested registration and retry each unload the original provider. + Assert.Equal(3, first.Unloads); + Assert.Same(replacement, SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.SqlPassword)); + } + finally + { + Assert.True(SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.SqlPassword, new Provider(true))); + } + } + + /// Nested configuration registrations retain precedence over an outer application replacement. + [Theory] + [InlineData("BeforeLoad")] + [InlineData("BeforeUnload")] + public void ReentrantReplacementPreservesHigherPrecedence(string callback) + { + // Keep Config-tier registrations out of the process-wide registry used by other tests. + var registry = new SqlAuthenticationProviderRegistry(); + var nested = new Provider(true); + bool registeredNested = false; + void RegisterNested(SqlAuthenticationMethod method) + { + if (!registeredNested) + { + registeredNested = true; + Assert.True(registry.Register( + method, nested, SqlAuthenticationProviderRegistry.RegistrationTier.Config)); + } + } + + var first = new Provider(true, beforeUnload: callback == "BeforeUnload" ? RegisterNested : null); + var replacement = new Provider(true, beforeLoad: callback == "BeforeLoad" ? RegisterNested : null); + Assert.True(registry.Register(SqlAuthenticationMethod.SqlPassword, first)); + + // Rechecking precedence must leave the nested Config provider registered and loaded. + Assert.False(registry.Register(SqlAuthenticationMethod.SqlPassword, replacement)); + Assert.True(registeredNested); + Assert.Equal(1, nested.Loads); + Assert.Equal(0, nested.Unloads); + Assert.False(registry.Register(SqlAuthenticationMethod.SqlPassword, new Provider(true))); + } + + private sealed class Provider( + bool supported, + Action? beforeLoad = null, + Action? beforeUnload = null) : SqlAuthenticationProvider + { + internal int Loads; + internal int Unloads; + public override bool IsSupported(SqlAuthenticationMethod method) => supported; + public override void BeforeLoad(SqlAuthenticationMethod method) + { + Loads++; + beforeLoad?.Invoke(method); + } + public override void BeforeUnload(SqlAuthenticationMethod method) + { + Unloads++; + beforeUnload?.Invoke(method); + } + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) => + throw new NotSupportedException(); + } + + private sealed class ThrowingProvider(string? callback, Exception? failure = null) : SqlAuthenticationProvider + { + internal bool Throw { get; set; } = true; + + public override bool IsSupported(SqlAuthenticationMethod method) + { + ThrowIfRequested(nameof(IsSupported)); + return true; + } + + public override void BeforeLoad(SqlAuthenticationMethod method) => + ThrowIfRequested(nameof(BeforeLoad)); + + public override void BeforeUnload(SqlAuthenticationMethod method) => + ThrowIfRequested(nameof(BeforeUnload)); + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) => + throw new NotSupportedException(); + + private void ThrowIfRequested(string name) + { + if (Throw && callback == name) + { + throw failure ?? new InvalidOperationException("Test callback failure: " + name); + } + } + } + + private sealed class LookupProvider : SqlAuthenticationProvider + { + internal Task? Lookup { get; private set; } + internal int SupportChecks { get; private set; } + + public override bool IsSupported(SqlAuthenticationMethod method) + { + SupportChecks++; + Lookup = Task.Run(() => SqlAuthenticationProvider.GetProvider(method)); + return Lookup.Wait(TimeSpan.FromSeconds(10)); + } + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) => + throw new NotSupportedException(); + } + + private sealed class LegacyAzureAssembly : Assembly + { + public override AssemblyName GetName() => + new("Microsoft.Data.SqlClient.Extensions.Azure") { Version = new Version(1, 0, 0, 0) }; + + public override Type? GetType(string name) => + name == "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider" + ? typeof(ThrowingProvider) + : null; + } + +} + +/// Collects authentication trace messages for serialized registry and configuration tests. +internal sealed class AuthenticationTraceListener : EventListener +{ + internal ConcurrentQueue Messages { get; } = new(); + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (eventSource.Name == "Microsoft.Data.SqlClient.EventSource") + { + EnableEvents(eventSource, EventLevel.Informational, (EventKeywords)2); + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (eventData.EventId == 3 && eventData.Payload?[0] is string message) + { + Messages.Enqueue(message); + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionDiscoveryTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionDiscoveryTest.cs new file mode 100644 index 0000000000..19d4a469bf --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionDiscoveryTest.cs @@ -0,0 +1,125 @@ +// 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.Reflection; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test +{ + /// Verifies Azure discovery handles ordinary constructor failures but propagates fatal errors. + [Collection(AuthenticationFeatureSwitchTestCollection.Name)] + public class AzureExtensionDiscoveryTest + { + /// Exercises the discovery catch filter with direct or reflection-wrapped constructor exceptions. + [Theory] + [InlineData(typeof(InvalidOperationException), true)] + [InlineData(typeof(OutOfMemoryException), false)] + [InlineData(typeof(StackOverflowException), false)] + [InlineData(typeof(AccessViolationException), false)] + [InlineData(typeof(ThreadAbortException), false)] + public void ProviderConstructorFailureHonorsRecoverability(Type exceptionType, bool recoverable) + { + // Throw a known exception from the simulated Azure provider's reflective constructor. + Exception failure = (Exception)Activator.CreateInstance(exceptionType, nonPublic: true)!; + ThrowingProvider.ConstructorFailure = failure; + try + { + var registry = new SqlAuthenticationProviderRegistry(); + if (recoverable) + { + registry.LoadAzureExtension(() => typeof(ThrowingProvider).Assembly); + } + else + { + Exception? constructor = Record.Exception( + () => registry.LoadAzureExtension(() => typeof(ThrowingProvider).Assembly)); + // .NET Framework propagates some fatal constructor exceptions without a reflection wrapper. + Assert.Same(failure, constructor is TargetInvocationException invocation + ? invocation.InnerException + : constructor); + } + } + finally + { + // Keep constructor failure injection from affecting subsequent discovery tests. + ThrowingProvider.ConstructorFailure = null; + } + } + + /// Assembly loading failures use the same catch policy as reflective provider construction. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AssemblyLoaderFailureHonorsRecoverability(bool recoverable) + { + // Missing optional assemblies are recoverable; reflection-wrapped fatal errors are not. + Exception failure = recoverable + ? new FileNotFoundException("Test Azure assembly is absent.") + : new TargetInvocationException(new OutOfMemoryException()); + var registry = new SqlAuthenticationProviderRegistry(); + if (recoverable) + { + registry.LoadAzureExtension(() => throw failure); + } + else + { + Assert.Same(failure, Assert.Throws( + () => registry.LoadAzureExtension(() => throw failure))); + } + } + + /// Recoverable loading failures are handled even when they are not reflection or binding errors. + [Theory] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(IOException))] + [InlineData(typeof(UnauthorizedAccessException))] + public void RecoverableAssemblyLoaderFailureIsHandled(Type exceptionType) + { + // Optional discovery must tolerate ordinary loader errors beyond binding exceptions. + Exception failure = (Exception)Activator.CreateInstance(exceptionType)!; + var registry = new SqlAuthenticationProviderRegistry(); + + registry.LoadAzureExtension(() => throw failure); + } + } + + /// Simulates Azure provider constructor failures without depending on the Azure package. + public class ThrowingProvider : SqlAuthenticationProvider + { + internal static Exception? ConstructorFailure { get; set; } + internal static Action? OnConstructed { get; set; } + + public ThrowingProvider(ActiveDirectoryAuthenticationProviderOptions options) + { + if (ConstructorFailure is Exception failure) + { + throw failure; + } + OnConstructed?.Invoke(options); + } + + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) => + throw new NotSupportedException(); + } +} + +namespace Microsoft.Data.SqlClient +{ + /// Provides the exact type name required by discovery; test behavior resides in ThrowingProvider. + public sealed class ActiveDirectoryAuthenticationProvider : Extensions.Abstractions.Test.ThrowingProvider + { + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) + : base(options) + { + } + } + + /// Provides the options contract required by reflective Azure discovery. + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + public string? ApplicationClientId { get; set; } + public bool UseWamBroker { get; set; } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionLoaderTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionLoaderTest.cs new file mode 100644 index 0000000000..5cb78e2b8d --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureExtensionLoaderTest.cs @@ -0,0 +1,61 @@ +// 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.Reflection; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Verifies optional assembly loading without a dependency on the Azure extension. +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] +public class AzureExtensionLoaderTest +{ + /// A missing extension does not prevent explicit authentication registration. + [Fact] + public void MissingAzureExtensionReturnsNull() + { + Assert.Null(AzureExtensionLoader.Load(nameof(AzureExtensionLoaderTest))); + } + + /// Assembly loading ignores recoverable binding errors but preserves fatal inner failures. + [Theory] + [InlineData("FileNotFound", false)] + [InlineData("FileNotFound", true)] + [InlineData("FileLoad", false)] + [InlineData("FileLoad", true)] + [InlineData("BadImageFormat", false)] + [InlineData("BadImageFormat", true)] + public void AssemblyResolutionPreservesFatalFailures(string failureType, bool fatal) + { + Exception inner = fatal + ? new OutOfMemoryException("Test fatal assembly resolution failure") + : new InvalidOperationException("Test recoverable assembly resolution failure"); + Exception failure = failureType switch + { + "FileNotFound" => new FileNotFoundException("Test assembly not found", inner), + "FileLoad" => new FileLoadException("Test assembly load failure", inner), + "BadImageFormat" => new BadImageFormatException("Test invalid assembly image", inner), + _ => throw new ArgumentOutOfRangeException(nameof(failureType)), + }; + bool loadAttempted = false; + Assembly LoadAssembly(AssemblyName assemblyName) + { + Assert.Equal(AzureExtensionLoader.AssemblyName, assemblyName.Name); + loadAttempted = true; + throw failure; + } + + if (fatal) + { + Exception actual = Assert.ThrowsAny(() => + AzureExtensionLoader.Load(nameof(AzureExtensionLoaderTest), LoadAssembly)); + Assert.Same(inner, actual.GetBaseException()); + } + else + { + Assert.Null(AzureExtensionLoader.Load(nameof(AzureExtensionLoaderTest), LoadAssembly)); + } + + Assert.True(loadAttempted); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureProviderConstructionTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureProviderConstructionTest.cs new file mode 100644 index 0000000000..2971f2ca0f --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AzureProviderConstructionTest.cs @@ -0,0 +1,50 @@ +// 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. + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Verifies Azure discovery constructs providers using the supported options contract. +public class AzureProviderConstructionTest +{ + public sealed class StubOptions + { + public string? ApplicationClientId { get; set; } + public bool UseWamBroker { get; set; } + } + + public sealed class StubProvider : SqlAuthenticationProvider + { + public StubOptions Options { get; } + + public StubProvider(StubOptions options) => Options = options; + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) + => Task.FromResult(new SqlAuthenticationToken("stub", DateTimeOffset.UtcNow.AddMinutes(5))); + + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + } + + /// Discovery always uses options, preserving defaults unless configuration overrides them. + [Theory] + [InlineData(null, null)] + [InlineData("app-123", null)] + [InlineData(null, false)] + [InlineData(null, true)] + [InlineData("app-123", false)] + [InlineData("app-123", true)] + public void CreateAzureAuthenticationProvider_UsesOptionsCtor(string? applicationClientId, bool? useWamBroker) + { + // The Options constructor is the sole discovery contract, including with no overrides. + var instance = SqlAuthenticationProviderRegistry.CreateAzureAuthenticationProvider( + typeof(StubProvider), + typeof(StubOptions), + applicationClientId, + useWamBroker); + + // Unspecified values must retain option defaults rather than being forcibly overwritten. + var stub = Assert.IsType(instance); + Assert.Equal(applicationClientId, stub.Options.ApplicationClientId); + Assert.Equal(useWamBroker ?? false, stub.Options.UseWamBroker); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/ExceptionHelpersTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/ExceptionHelpersTest.cs new file mode 100644 index 0000000000..164bd8c056 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/ExceptionHelpersTest.cs @@ -0,0 +1,55 @@ +// 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.Reflection; + +namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; + +/// Verifies registration and activation do not mask fatal failures through wrappers. +public class ExceptionHelpersTest +{ + /// Fatal runtime failures remain non-recoverable, directly and through reflection. + [Theory] + [InlineData(typeof(OutOfMemoryException))] + [InlineData(typeof(StackOverflowException))] + [InlineData(typeof(AccessViolationException))] + [InlineData(typeof(ThreadAbortException))] + public void FatalFailuresAreNotRecoverable(Type type) + { + var failure = (Exception)Activator.CreateInstance(type, nonPublic: true)!; + + Assert.False(ExceptionHelpers.IsRecoverableException(failure)); + Assert.False(ExceptionHelpers.IsRecoverableException( + new TargetInvocationException(new InvalidOperationException("Wrapper", failure)))); + } + + /// All aggregate branches are examined even after traversing ordinary subtrees. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FatalAggregateBranchIsNotRecoverable(bool fatalFirst) + { + var fatal = new OutOfMemoryException("Simulated fatal activation failure"); + var ordinary = new AggregateException(new FormatException("Ordinary failure"), + new ArgumentException("Ordinary failure")); + var aggregate = fatalFirst + ? new AggregateException(fatal, ordinary) + : new AggregateException(ordinary, fatal); + + Assert.False(ExceptionHelpers.IsRecoverableException(new TargetInvocationException(aggregate))); + } + + /// Ordinary nested failures and empty aggregates remain recoverable. + [Fact] + public void OrdinaryFailuresAreRecoverable() + { + Assert.True(ExceptionHelpers.IsRecoverableException(new AggregateException())); + Assert.True(ExceptionHelpers.IsRecoverableException( + new ArgumentException("Wrapper", new FormatException("Ordinary failure")))); + Assert.True(ExceptionHelpers.IsRecoverableException(new AggregateException( + new InvalidOperationException("Ordinary failure"), + new TargetInvocationException(new AggregateException( + new FormatException("Ordinary failure")))))); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs index c5872a8391..a80674bb15 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs @@ -6,6 +6,8 @@ namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test; +/// Registration no longer depends on reflective access to a SqlClient assembly. +[Collection(AuthenticationFeatureSwitchTestCollection.Name)] public class SqlAuthenticationProviderTest { #region Test Setup @@ -25,8 +27,7 @@ public SqlAuthenticationProviderTest() #region Tests /// - /// Test that GetProvider fails predictably when the MDS assembly can't be - /// found. + /// Without SqlClient, a method starts without a provider and supports direct registration and lookup. /// [Theory] #pragma warning disable CS0618 // Type or member is obsolete @@ -40,35 +41,15 @@ public SqlAuthenticationProviderTest() [InlineData(SqlAuthenticationMethod.ActiveDirectoryMSI)] [InlineData(SqlAuthenticationMethod.ActiveDirectoryDefault)] [InlineData(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity)] - public void GetProvider_NoMdsAssembly(SqlAuthenticationMethod method) + public void GetAndSetProvider_NoMdsAssembly(SqlAuthenticationMethod method) { - // GetProvider() should return null when the MDS assembly can't be - // found. + // Abstractions must bootstrap and own registrations without loading the driver assembly. Assert.Null(SqlAuthenticationProvider.GetProvider(method)); - } - /// - /// Test that SetProvider fails predictably when the MDS assembly can't be - /// found. - /// - [Theory] - #pragma warning disable CS0618 // Type or member is obsolete - [InlineData(SqlAuthenticationMethod.ActiveDirectoryPassword)] - #pragma warning restore CS0618 // Type or member is obsolete - [InlineData(SqlAuthenticationMethod.ActiveDirectoryIntegrated)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryInteractive)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryMSI)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryDefault)] - [InlineData(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity)] - public void SetProvider_NoMdsAssembly(SqlAuthenticationMethod method) - { - // SetProvider() should return false when the MDS assembly can't be - // found. - Assert.False( - SqlAuthenticationProvider.SetProvider(method, new Provider())); + // A successful direct registration must be returned unchanged for the selected method. + var provider = new Provider(); + Assert.True(SqlAuthenticationProvider.SetProvider(method, provider)); + Assert.Same(provider, SqlAuthenticationProvider.GetProvider(method)); } #endregion diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs index 321c1ae2e4..7f19e1e3ee 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs @@ -2,20 +2,87 @@ // 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.Collections; +using System.Configuration; +using System.Reflection; + namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; [Collection(SqlAuthenticationProviderCollection.Name)] public class DefaultAuthProviderTests { + /// Authentication and attestation resolve the same optional Azure assembly. + [Fact] + public void IndependentLoadersResolveSameAzureAssembly() + { + Assembly expected = typeof(ActiveDirectoryAuthenticationProvider).Assembly; + foreach (Assembly assembly in new[] + { + typeof(SqlAuthenticationProvider).Assembly, + typeof(SqlConnection).Assembly + }) + { + Type loader = Assert.IsAssignableFrom( + assembly.GetType("Microsoft.Data.SqlClient.AzureExtensionLoader")); + MethodInfo load = Assert.IsAssignableFrom( + loader.GetMethod("Load", BindingFlags.NonPublic | BindingFlags.Static, + binder: null, types: new[] { typeof(string) }, modifiers: null)); + + Assert.Same(expected, load.Invoke(null, new object[] { nameof(DefaultAuthProviderTests) })); + } + } + + /// + /// The installed Azure package fills unregistered methods after configuration failures + /// without replacing a valid later configuration entry. + /// + [Fact] + public void AzureDiscoveryContinuesAfterConfigurationFailures() + { + var section = new SqlClientAuthenticationProviderConfigurationSection(); + section.ElementInformation.Properties["initializerType"].Value = + typeof(object).AssemblyQualifiedName; + section.Providers.Add(new ProviderSettings( + "Active Directory Integrated", typeof(object).AssemblyQualifiedName)); + section.Providers.Add(new ProviderSettings( + "Active Directory Service Principal", typeof(ConfiguredProvider).AssemblyQualifiedName)); + Type registryType = typeof(SqlAuthenticationProvider).Assembly.GetType( + "Microsoft.Data.SqlClient.SqlAuthenticationProviderRegistry", throwOnError: true)!; + object registry = Activator.CreateInstance(registryType, nonPublic: true)!; + MethodInfo bootstrap = registryType.GetMethod( + "EnsureBootstrapped", BindingFlags.Instance | BindingFlags.NonPublic)!; + + bootstrap.Invoke(registry, new object?[] { new Func(_ => section), null }); + + var providers = Assert.IsAssignableFrom( + registryType.GetField("_providers", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(registry)); + foreach (var expected in new[] + { + (Method: SqlAuthenticationMethod.ActiveDirectoryIntegrated, + ProviderType: typeof(ActiveDirectoryAuthenticationProvider)), + (Method: SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, + ProviderType: typeof(ConfiguredProvider)), + (Method: SqlAuthenticationMethod.ActiveDirectoryDefault, + ProviderType: typeof(ActiveDirectoryAuthenticationProvider)), + }) + { + object? registration = providers[expected.Method]; + Assert.NotNull(registration); + object? provider = registration.GetType() + .GetProperty("Provider", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(registration); + Assert.IsType(expected.ProviderType, provider); + } + } + // Verify that our auth provider has been installed for all AAD/Entra // authentication methods, and not for any other methods. // // Note that this isn't testing anything in the Azure package. It actually - // tests the static constructor of the SqlAuthenticationProviderManager - // class in the MDS package and the static GetProvider() and SetProvider() - // methods of the SqlAuthenticationProvider class in the Abstractions - // package. We're testing this here because this test project uses both of - // those packages, and this is a convenient place to put such a test. + // tests registry bootstrap and the static GetProvider() and SetProvider() + // methods in Abstractions. We're testing this here because this project + // includes the real Azure provider for discovery. // // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/41888): // Move this test to a more appropriate location once we have one. @@ -63,5 +130,15 @@ public void AuthProviderInstalled() break; } } + + } + + /// Provides configuration-owned authentication without making a token request. + public sealed class ConfiguredProvider : SqlAuthenticationProvider + { + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + + public override Task AcquireTokenAsync( + SqlAuthenticationParameters parameters) => throw new NotSupportedException(); } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 529653c909..fc7c78021a 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -88,7 +88,7 @@ public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() /// Mirrors the previous test for the /// constructor: a caller (or app.config) that sets only ApplicationClientId and skips /// UseWamBroker must get the documented default of . This is - /// the contract SqlAuthenticationProviderManager relies on when reflecting onto the + /// the contract the Abstractions authentication registry relies on when reflecting onto the /// Options ctor and only forwarding the properties that were explicitly configured. ///
[Fact] diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index b6e24e814d..e87539fad6 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -38,15 +38,6 @@ public enum SortOrder Descending = 1 } -/// -public abstract class SqlAuthenticationInitializer -{ - /// - protected SqlAuthenticationInitializer() { } - /// - public abstract void Initialize(); -} - /// #if NET public class SqlBatch : System.Data.Common.DbBatch diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AzureAttestationTokenValidatorBinding.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AzureAttestationTokenValidatorBinding.cs index 5d89e8ded3..3397322ab2 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AzureAttestationTokenValidatorBinding.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AzureAttestationTokenValidatorBinding.cs @@ -101,7 +101,8 @@ internal AzureAttestationTokenValidatorBinding( /// /// The extension is loaded once per load of this assembly. If the application loads /// SqlClient into more than one AssemblyLoadContext, each copy loads the extension - /// separately, as the authentication provider manager does. + /// separately. This uses the driver's own loader, independently of authentication + /// configuration and discovery in Abstractions. /// internal static AzureAttestationTokenValidatorBinding? Instance => s_instance.Value; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs index e421ad5758..0e2c534e33 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs @@ -3019,10 +3019,10 @@ private SqlFedAuthToken GetFedAuthToken(SqlFedAuthInfo fedAuthInfo) // Username to use in error messages. string? username = null; - SqlAuthenticationProvider? authProvider = SqlAuthenticationProviderManager.GetProvider(ConnectionOptions.Authentication); + SqlAuthenticationProvider? authProvider = SqlAuthenticationProvider.GetProvider(ConnectionOptions.Authentication); if (authProvider == null && _accessTokenCallback == null) { - throw SQL.CannotFindAuthProvider(ConnectionOptions.Authentication); + throw SQL.CannotFindAuthProvider(ConnectionOptions.Authentication, this); } // We will perform retries if the provider indicates an error that diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs deleted file mode 100644 index a49ac86acd..0000000000 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ /dev/null @@ -1,560 +0,0 @@ -// 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.Concurrent; -using System.Collections.Generic; -using System.Configuration; -using System.IO; -using System.Reflection; -using Microsoft.Data.SqlClient.Internal; - -#nullable enable - -namespace Microsoft.Data.SqlClient -{ - internal sealed class SqlAuthenticationProviderManager - { - [Obsolete("ActiveDirectoryPassword is deprecated, use a more secure authentication method. See https://aka.ms/SqlClientEntraIDAuthentication for more details.")] - private const string ActiveDirectoryPassword = "active directory password"; - private const string ActiveDirectoryIntegrated = "active directory integrated"; - private const string ActiveDirectoryInteractive = "active directory interactive"; - private const string ActiveDirectoryServicePrincipal = "active directory service principal"; - private const string ActiveDirectoryDeviceCodeFlow = "active directory device code flow"; - private const string ActiveDirectoryManagedIdentity = "active directory managed identity"; - private const string ActiveDirectoryMSI = "active directory msi"; - private const string ActiveDirectoryDefault = "active directory default"; - private const string ActiveDirectoryWorkloadIdentity = "active directory workload identity"; - - static SqlAuthenticationProviderManager() - { - TryReadConfigurationSection(out AuthenticationConfiguration? configurationSection); - - Instance = new SqlAuthenticationProviderManager(configurationSection); - - // If our Azure extensions package is present, use its authentication provider as our - // default. - try - { - // Try to load our Azure extension. - var assembly = AzureExtensionLoader.Load(nameof(SqlAuthenticationProviderManager)); - - if (assembly is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Azure extension assembly={0} not usable; " + - "no default Active Directory provider installed", - AzureExtensionLoader.AssemblyName); - return; - } - - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Azure extension assembly={0} found; " + - "attempting to set as default provider for all Active " + - "Directory authentication methods", - assembly.GetName()); - - // Look for the authentication provider class. - const string className = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider"; - Type? type = assembly.GetType(className); - - if (type is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Azure extension does not contain class={0}; " + - "no default Active Directory provider installed", - className); - - return; - } - - // Try to instantiate it. Behavior depends on what the app - // configured in : - // * Neither applicationClientId nor useWamBroker -> use the - // parameterless constructor (defaults to the SqlClient - // first-party app id and enables WAM brokering on Windows). - // * applicationClientId only -> prefer the - // (ActiveDirectoryAuthenticationProviderOptions) constructor - // when the Azure extension exposes it; otherwise fall back - // to the legacy (string applicationClientId) constructor so - // older Azure extension versions keep working. - // * useWamBroker (with or without applicationClientId) -> - // requires the (Options) constructor because there is no - // positional analog. If the Azure extension is too old to - // expose Options, throw to surface the misconfiguration. - const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; - Type? optionsType = assembly.GetType(optionsTypeName); - - SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider( - type, - optionsType, - Instance._applicationClientId, - Instance._useWamBroker); - - if (instance is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Failed to instantiate Azure extension class={0}; " + - "no default Active Directory provider installed", - className); - - return; - } - - // We successfully instantiated the provider, so set it as the - // default for all Active Directory authentication methods. - // - // Note that SetProvider() will refuse to clobber an application - // specified provider, so these defaults will only be applied - // for methods that do not already have a provider. - SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, instance); - #pragma warning disable 0618 // Type or member is obsolete - SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, instance); - #pragma warning restore 0618 // Type or member is obsolete - SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, instance); - SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, instance); - - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Azure extension class={0} installed as " + - "provider for all Active Directory authentication methods", - className); - } - // All of these exceptions mean we couldn't find or instantiate the - // Azure extension's authentication provider, in which case we - // simply have no default and the app must provide one if they - // attempt to use Active Directory authentication. - catch (Exception ex) - when (ex is - AmbiguousMatchException or - ArgumentException or - BadImageFormatException or - FileLoadException or - FileNotFoundException or - MemberAccessException or - MethodAccessException or - MissingMethodException or - NotSupportedException or - TargetInvocationException or - TypeInitializationException or - TypeLoadException) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - ": Azure extension assembly={0} not found or " + - "not usable; no default provider installed; " + - "{1}: {2}", - AzureExtensionLoader.AssemblyName, - ex.GetType().Name, - ex.Message); - } - // Any other exceptions are fatal. - } - - private static readonly SqlAuthenticationProviderManager Instance; - - private readonly HashSet _authenticationsWithAppSpecifiedProvider = new(); - private readonly ConcurrentDictionary _providers = new(); - private readonly SqlClientLogger _sqlAuthLogger = new SqlClientLogger(); - private readonly string? _applicationClientId = null; - - // Optional override for ActiveDirectoryAuthenticationProviderOptions.UseWamBroker - // read from the app.config attribute. - // null means the app did not configure the value, in which case we leave the - // provider's default behavior (WAM is implied by the SqlClient first-party app id and - // off otherwise) untouched. - private readonly bool? _useWamBroker = null; - - /// - /// Constructor. - /// - private SqlAuthenticationProviderManager(AuthenticationConfiguration? configSection) - { - var methodName = "Ctor"; - - if (configSection == null) - { - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "Neither SqlClientAuthenticationProviders nor SqlAuthenticationProviders configuration section found."); - return; - } - - if (!string.IsNullOrEmpty(configSection.ApplicationClientId)) - { - _applicationClientId = configSection.ApplicationClientId; - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "Received user-defined Application Client Id"); - } - else - { - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined Application Client Id found."); - } - - if (!string.IsNullOrEmpty(configSection.UseWamBroker)) - { - if (bool.TryParse(configSection.UseWamBroker, out bool useWamBroker)) - { - _useWamBroker = useWamBroker; - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, $"Received user-defined UseWamBroker={useWamBroker}."); - } - else - { - _sqlAuthLogger.LogError(nameof(SqlAuthenticationProviderManager), methodName, $"Ignoring user-defined UseWamBroker='{configSection.UseWamBroker}': not a valid boolean."); - } - } - else - { - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined UseWamBroker found."); - } - - // Create user-defined auth initializer, if any. - if (!string.IsNullOrEmpty(configSection.InitializerType)) - { - try - { - var initializerType = Type.GetType(configSection.InitializerType, true); - if (initializerType is not null) - { - var initializer = (SqlAuthenticationInitializer?)Activator.CreateInstance(initializerType); - if (initializer is not null) - { - initializer.Initialize(); - } - } - } - catch (Exception e) - { - throw SQL.CannotCreateSqlAuthInitializer(configSection.InitializerType, e); - } - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "Created user-defined SqlAuthenticationInitializer."); - } - else - { - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined SqlAuthenticationInitializer found."); - } - - // add user-defined providers, if any. - if (configSection.Providers != null && configSection.Providers.Count > 0) - { - foreach (ProviderSettings providerSettings in configSection.Providers) - { - SqlAuthenticationMethod authentication = AuthenticationEnumFromString(providerSettings.Name); - SqlAuthenticationProvider? provider; - try - { - var providerType = Type.GetType(providerSettings.Type, true); - if (providerType is null) - { - continue; - } - provider = (SqlAuthenticationProvider?)Activator.CreateInstance(providerType); - } - catch (Exception e) - { - throw SQL.CannotCreateAuthProvider(authentication.ToString(), providerSettings.Type, e); - } - if (provider is null) - { - continue; - } - if (!provider.IsSupported(authentication)) - { - throw SQL.UnsupportedAuthenticationByProvider(authentication.ToString(), providerSettings.Type); - } - - _providers[authentication] = provider; - _authenticationsWithAppSpecifiedProvider.Add(authentication); - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, string.Format("Added user-defined auth provider: {0} for authentication {1}.", providerSettings?.Type, authentication)); - } - } - else - { - _sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, "No user-defined auth providers."); - } - } - - /// - /// Get an authentication provider by method. - /// - /// Authentication method. - /// Authentication provider or null if not found. - internal static SqlAuthenticationProvider? GetProvider(SqlAuthenticationMethod authenticationMethod) - { - return Instance._providers.TryGetValue(authenticationMethod, out SqlAuthenticationProvider? value) ? value : null; - } - - // Reflectively constructs the Azure extension's ActiveDirectoryAuthenticationProvider, - // selecting the constructor that matches what the app configured. Extracted from the - // static initializer so it can be unit-tested with stub provider/options shapes. - // - // Returns null when no compatible constructor is available (e.g. a custom assembly - // that lacks both the (Options) and (string) ctors). - // - // Throws InvalidOperationException when useWamBroker is configured but the Azure - // extension is too old to expose ActiveDirectoryAuthenticationProviderOptions; that - // signals user-actionable misconfiguration and intentionally escapes the static ctor's - // catch-when filter so it surfaces as a TypeInitializationException. - internal static SqlAuthenticationProvider? CreateAzureAuthenticationProvider( - Type providerType, - Type? optionsType, - string? applicationClientId, - bool? useWamBroker) - { - if (applicationClientId is null && useWamBroker is null) - { - return Activator.CreateInstance(providerType) as SqlAuthenticationProvider; - } - - ConstructorInfo? optionsCtor = optionsType is null - ? null - : providerType.GetConstructor([optionsType]); - - if (useWamBroker is bool useWam) - { - if (optionsType is null || optionsCtor is null) - { - throw SQL.UseWamBrokerRequiresAzureExtensionUpgrade(); - } - - var options = Activator.CreateInstance(optionsType); - if (options is null) - { - return null; - } - - if (applicationClientId is not null) - { - optionsType.GetProperty("ApplicationClientId") - ?.SetValue(options, applicationClientId); - } - optionsType.GetProperty("UseWamBroker") - ?.SetValue(options, useWam); - - return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; - } - - // applicationClientId-only: prefer Options when the extension exposes it, - // otherwise fall back to the legacy (string) ctor for backward compatibility - // with older Azure extension versions. - if (optionsType is not null && optionsCtor is not null) - { - var options = Activator.CreateInstance(optionsType); - if (options is null) - { - return null; - } - optionsType.GetProperty("ApplicationClientId") - ?.SetValue(options, applicationClientId); - return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; - } - - ConstructorInfo? legacyCtor = providerType.GetConstructor([typeof(string)]); - if (legacyCtor is not null) - { - return legacyCtor.Invoke([applicationClientId]) as SqlAuthenticationProvider; - } - - return null; - } - - /// - /// Set an authentication provider by method. - /// - /// Authentication method. - /// Authentication provider. - /// - /// True if succeeded, false on any errors or if the authentication method has already - /// been claimed via app configuration. - /// - internal static bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider) - { - if (!provider.IsSupported(authenticationMethod)) - { - throw SQL.UnsupportedAuthenticationByProvider(authenticationMethod.ToString(), provider.GetType().Name); - } - var methodName = "SetProvider"; - if (Instance._authenticationsWithAppSpecifiedProvider.Contains(authenticationMethod)) - { - Instance._sqlAuthLogger.LogError(nameof(SqlAuthenticationProviderManager), methodName, $"Failed to add provider {GetProviderType(provider)} because a user-defined provider with type {GetProviderType(Instance._providers[authenticationMethod])} already existed for authentication {authenticationMethod}."); - - // The app has already specified a Provider for this - // authentication method, so we won't override it. - return false; - } - Instance._providers.AddOrUpdate( - authenticationMethod, - provider, - (SqlAuthenticationMethod key, SqlAuthenticationProvider oldProvider) => - { - if (oldProvider != null) - { - oldProvider.BeforeUnload(authenticationMethod); - } - - provider.BeforeLoad(authenticationMethod); - - Instance._sqlAuthLogger.LogInfo(nameof(SqlAuthenticationProviderManager), methodName, $"Added auth provider {GetProviderType(provider)}, overriding existed provider {GetProviderType(oldProvider)} for authentication {authenticationMethod}."); - return provider; - }); - return true; - } - - /// - /// Reads the authentication provider configuration section from app.config, - /// unless app.config reading is disabled. - /// - /// - /// The section that was found, or null if there is none or app.config was not read. - /// - /// - /// True when app.config was read, false when the EnableAppConfig switch is - /// disabled and it was not. - /// - /// - /// Separated from the static constructor so that it can be tested: the - /// constructor runs once per process, before any test can observe it. - /// - internal static bool TryReadConfigurationSection(out AuthenticationConfiguration? configurationSection) - { - configurationSection = null; - - if (!LocalAppContextSwitches.EnableAppConfig) - { - return false; - } - - try - { - configurationSection = ReadConfigurationSection(ConfigurationManager.GetSection); - } - catch (ConfigurationErrorsException e) - { - // Don't throw an error for invalid config files - SqlClientEventSource.Log.TryTraceEvent("static SqlAuthenticationProviderManager: Unable to load custom SqlAuthenticationProviders or SqlClientAuthenticationProviders. ConfigurationManager failed to load due to configuration errors: {0}", e); - } - - return true; - } - - internal static AuthenticationConfiguration? ReadConfigurationSection(Func getSection) - { - var current = FetchConfigurationSection( - SqlClientAuthenticationProviderConfigurationSection.Name, getSection); - if (current is not null) - { - return new AuthenticationConfiguration( - current.ApplicationClientId, current.InitializerType, current.UseWamBroker, current.Providers); - } - - var legacy = FetchConfigurationSection( - SqlAuthenticationProviderConfigurationSection.Name, getSection); - return legacy is null - ? null - : new AuthenticationConfiguration( - legacy.ApplicationClientId, legacy.InitializerType, legacy.UseWamBroker, legacy.Providers); - } - - /// - /// Fetches provided configuration section from app.config file. - /// Does not support reading from appsettings.json yet. - /// - /// - /// - /// Reads a section from the application or an isolated configuration file. - /// - private static T? FetchConfigurationSection(string name, Func getSection) where T : class - { - Type t = typeof(T); - - // TODO: Support reading configuration from appsettings.json for .NET runtime applications. - object? section = getSection(name); - if (section != null) - { - if (section is ConfigurationSection configSection && configSection.GetType() == t) - { - return (T)section; - } - else - { - SqlClientEventSource.Log.TraceEvent("Found a custom {0} configuration but it is not of type {1}.", name, t.FullName); - } - } - return default; - } - - private static SqlAuthenticationMethod AuthenticationEnumFromString(string authentication) - { - switch (authentication.ToLowerInvariant()) - { - case ActiveDirectoryIntegrated: - return SqlAuthenticationMethod.ActiveDirectoryIntegrated; - #pragma warning disable 0618 // Type or member is obsolete - case ActiveDirectoryPassword: - return SqlAuthenticationMethod.ActiveDirectoryPassword; - #pragma warning restore 0618 // Type or member is obsolete - case ActiveDirectoryInteractive: - return SqlAuthenticationMethod.ActiveDirectoryInteractive; - case ActiveDirectoryServicePrincipal: - return SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; - case ActiveDirectoryDeviceCodeFlow: - return SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow; - case ActiveDirectoryManagedIdentity: - return SqlAuthenticationMethod.ActiveDirectoryManagedIdentity; - case ActiveDirectoryMSI: - return SqlAuthenticationMethod.ActiveDirectoryMSI; - case ActiveDirectoryDefault: - return SqlAuthenticationMethod.ActiveDirectoryDefault; - case ActiveDirectoryWorkloadIdentity: - return SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity; - default: - throw SQL.UnsupportedAuthentication(authentication); - } - } - - private static string GetProviderType(SqlAuthenticationProvider? provider) - { - if (provider is null) - { - return "null"; - } - return provider.GetType().FullName ?? "unknown"; - } - - // Temporary bridge for the current driver reader. Remove with this manager in #4821, - // when the Abstractions registry takes over using SqlAuthenticationConfiguration. - internal sealed class AuthenticationConfiguration - { - internal AuthenticationConfiguration( - string applicationClientId, - string initializerType, - string useWamBroker, - ProviderSettingsCollection providers) - { - ApplicationClientId = applicationClientId; - InitializerType = initializerType; - UseWamBroker = useWamBroker; - Providers = providers; - } - - internal string ApplicationClientId { get; } - internal string InitializerType { get; } - internal string UseWamBroker { get; } - internal ProviderSettingsCollection Providers { get; } - } - } - - /// - public abstract class SqlAuthenticationInitializer - { - /// - public abstract void Initialize(); - } -} diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index 513baeb12d..d3199aba8f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -9,10 +9,7 @@ using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; using System.Text; -using System.Threading; -using System.Threading.Tasks; using System.Transactions; using Interop.Common.Sni; using Microsoft.Data.Common; @@ -248,41 +245,17 @@ internal static Exception NonLocalSSEInstance() // SQL.ActiveDirectoryAuth // - internal static Exception UnsupportedAuthentication(string authentication) - { - return ADP.NotSupported(StringsHelper.GetString(Strings.SQL_UnsupportedAuthentication, authentication)); - } - internal static Exception UnsupportedAuthenticationSpecified(SqlAuthenticationMethod authentication) { return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_UnsupportedAuthenticationSpecified, authentication)); } - internal static Exception CannotCreateAuthProvider(string authentication, string type, Exception e) - { - return ADP.Argument(StringsHelper.GetString(Strings.SQL_CannotCreateAuthProvider, authentication, type), e); - } - - internal static Exception CannotCreateSqlAuthInitializer(string type, Exception e) - { - return ADP.Argument(StringsHelper.GetString(Strings.SQL_CannotCreateAuthInitializer, type), e); - } - - internal static Exception UnsupportedAuthenticationByProvider(string authentication, string type) - { - return ADP.NotSupported(StringsHelper.GetString(Strings.SQL_UnsupportedAuthenticationByProvider, type, authentication)); - } - - internal static Exception UseWamBrokerRequiresAzureExtensionUpgrade() - { - return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_UseWamBrokerRequiresAzureExtensionUpgrade)); - } - - internal static Exception CannotFindAuthProvider(SqlAuthenticationMethod authentication) + internal static SqlException CannotFindAuthProvider( + SqlAuthenticationMethod authentication, SqlConnectionInternal internalConnection) { string authName = authentication.ToString(); - return authentication switch + string message = authentication switch { #pragma warning disable 0618 SqlAuthenticationMethod.ActiveDirectoryPassword or @@ -295,9 +268,14 @@ SqlAuthenticationMethod.ActiveDirectoryManagedIdentity or SqlAuthenticationMethod.ActiveDirectoryMSI or SqlAuthenticationMethod.ActiveDirectoryDefault or SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity - => ADP.Argument(StringsHelper.GetString(Strings.SQL_CannotFindActiveDirectoryAuthProvider, authName)), - _ => ADP.Argument(StringsHelper.GetString(Strings.SQL_CannotFindAuthProvider, authName)), + => StringsHelper.GetString(Strings.SQL_CannotFindActiveDirectoryAuthProvider, authName), + _ => StringsHelper.GetString(Strings.SQL_CannotFindAuthProvider, authName), }; + SqlErrorCollection errors = new(); + errors.Add(new SqlError(0, 0, TdsEnums.MIN_ERROR_CLASS, + internalConnection?.ConnectionOptions.DataSource, message, "", 0)); + return SqlException.CreateException( + errors, null, internalConnection, innerException: null, batchCommand: null); } internal static Exception ParameterDirectionInvalidForOptimizedBinding(string paramName) @@ -2166,4 +2144,3 @@ public static MethodInfo GetPromotedToken } } } - diff --git a/src/Microsoft.Data.SqlClient/src/PackageReadme.md b/src/Microsoft.Data.SqlClient/src/PackageReadme.md index b876fa20dc..17325444ef 100644 --- a/src/Microsoft.Data.SqlClient/src/PackageReadme.md +++ b/src/Microsoft.Data.SqlClient/src/PackageReadme.md @@ -95,6 +95,42 @@ Install-Package Microsoft.Data.SqlClient.Extensions.Azure This package provides the `ActiveDirectoryAuthenticationProvider`, which uses `Azure.Identity` credential types supplied by [Azure.Core](https://www.nuget.org/packages/Azure.Core) to handle token acquisition, caching, and credential management. +The authentication registry and `SqlAuthenticationInitializer` now live in +`Microsoft.Data.SqlClient.Extensions.Abstractions`; existing driver type names are forwarded. +`SqlAuthenticationProvider.GetProvider` and `SetProvider` work directly without a reflective +bridge to the driver. Configuration registrations take precedence over application registrations, +which take precedence over discovered Azure defaults. + +In trimmed and NativeAOT .NET 10 applications, explicitly construct and register a provider for +each required method before opening connections: reflective Azure discovery and authentication +app.config loading are removed. Untrimmed applications retain both paths. The switches +`Switch.Microsoft.Data.SqlClient.EnableAppConfig` and +`Switch.Microsoft.Data.SqlClient.EnableAzureExtensionDiscovery` default to `true` and are cached +on first access. Recoverable initialization failures emit diagnostic guidance through +`Microsoft.Data.SqlClient.EventSource` (Informational level, Trace keyword `0x2`). +Bootstrap failures are cached without blocking explicit application registration or lookup of +successfully registered providers. Register a provider as a workaround, or correct the cause and +restart the application to retry bootstrap. Detailed causes remain in Abstractions traces rather +than the connection exception. + +#### Authentication compatibility considerations + +Public registration method signatures are unchanged, but moving the registry to Abstractions +introduces these observable differences from the previous driver-owned registry: + +| Area | Compatibility difference | +|---|---| +| Configured initializer precedence | Providers registered during a configured initializer's `Initialize()` now have configuration precedence and cannot be replaced by application `SetProvider` calls. Explicit configuration entries were already protected. Change the configuration or initializer instead. | +| Older Azure extensions | Automatic discovery now requires the options-based provider contract and no longer falls back to parameterless or string-based constructors. An older extension may stop supplying a default provider. Upgrade to a compatible extension with the options contract, or explicitly register a provider. The older public constructors are not removed by this change. | +| .NET Framework configuration switch | `EnableAppConfig=false` now disables authentication configuration on .NET Framework too, where it previously remained enabled. The driver's retry configuration and switch-override readers remain .NET-only. | +| Lookup synchronization | Registry lookups now take the same lock as bootstrap and replacement `BeforeLoad`/`BeforeUnload` callbacks. Slow callbacks delay other lookups and connection logins. Keep callbacks short: waiting synchronously for another thread that accesses the registry can deadlock. | +| Bootstrap failure reporting | Driver access previously could expose the manager's cached type-initialization exception. Now, if no usable provider or access-token callback is available, `Open` and `OpenAsync` report missing-provider guidance as a `SqlException`, rather than the previous missing-provider `ArgumentException`. Detailed causes remain in Abstractions EventSource traces, not a diagnostic API or the connection exception's inner exception. | +| Recovery after failed bootstrap | Unlike a failed manager static initialization, a recoverable cached bootstrap failure no longer prevents explicit application registration or lookup of successfully registered providers. Failed configuration is not retried, and successful configuration registrations retain precedence. Restart after correcting the cause to retry bootstrap. | + +Strict configuration parsing is not introduced: configuration reading retains its relaxed error +policy. Registration without loading the driver, explicit registration under trimming/NativeAOT, +and the independent `EnableAzureExtensionDiscovery` switch are new capabilities. + With this package reference, you can continue to use Entra ID authentication modes directly in your connection string: ```csharp diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index a254c36e64..3cc0549931 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -607,10 +607,10 @@ Cannot find an authentication provider for '{0}'. - Cannot find an authentication provider for '{0}'. Install the 'Microsoft.Data.SqlClient.Extensions.Azure' NuGet package (https://www.nuget.org/packages/Microsoft.Data.SqlClient.Extensions.Azure) to use Active Directory (Entra ID) authentication methods. + Cannot find an authentication provider for '{0}'. Register a provider with SqlAuthenticationProvider.SetProvider, or use a compatible 'Microsoft.Data.SqlClient.Extensions.Azure' NuGet package with automatic discovery enabled. Discovery is unavailable in trimmed and NativeAOT applications. Check Microsoft.Data.SqlClient.EventSource traces (Informational level, Trace keyword 0x2) for configuration or Azure extension loading failures. - Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to at least v1.1.0 that includes this type. + Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to at least v7.0.2 that includes this type. Parameter '{0}' cannot be null or empty. diff --git a/src/Microsoft.Data.SqlClient/src/TypeForwards.Abstractions.cs b/src/Microsoft.Data.SqlClient/src/TypeForwards.Abstractions.cs index eeaf2dfb51..86ce61707d 100644 --- a/src/Microsoft.Data.SqlClient/src/TypeForwards.Abstractions.cs +++ b/src/Microsoft.Data.SqlClient/src/TypeForwards.Abstractions.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Data.SqlClient.SqlAuthenticationMethod))] +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Data.SqlClient.SqlAuthenticationInitializer))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Data.SqlClient.SqlAuthenticationParameters))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Data.SqlClient.SqlAuthenticationProvider))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Data.SqlClient.SqlAuthenticationProviderException))] diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs index dc6d5b3d7a..002514daaf 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs @@ -52,15 +52,14 @@ private void InvalidCombinationCheck(SqlCredential credential) /// /// Tests whether a dummy SQL Auth provider is registered due to - /// configuration in an app.config file. Only .NET Framework reads - /// from the app.config file, so this test is only valid for that - /// runtime. + /// configuration in an app.config file. This test host supplies the + /// configuration only on .NET Framework. A dedicated process harness for + /// untrimmed .NET configuration loading is deferred to #4817. /// /// See the app.config file in the same directory as this file. /// - /// .NET (Core) reads similar configuration from appsettings.json, but - /// our SqlAuthenticationProviderManager does not currently support - /// that configuration source. + /// The Abstractions authentication registry does not currently support + /// appsettings.json as a configuration source. /// [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))] public async Task IsDummySqlAuthenticationProviderSetByDefault() diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationConfigurationTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationConfigurationTest.cs index 3323c70241..1671f9823c 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationConfigurationTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationConfigurationTest.cs @@ -3,16 +3,123 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; +using System.Collections; using System.Configuration; using System.IO; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Microsoft.SqlServer.TDS.PreLogin; +using Microsoft.SqlServer.TDS.Servers; using Xunit; namespace Microsoft.Data.SqlClient.UnitTests; -/// Verifies real configuration files activate forwarded handlers and preserve reader behavior. +/// Verifies real configuration files activate driver-forwarded handlers and preserve metadata. +[Collection(AppContextSwitchTestCollection.Name)] public class AuthenticationConfigurationTest { + /// Open and OpenAsync report missing providers as SqlException, including after cached bootstrap failure. + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ConnectionErrorDirectsUsersToRegistrationAndTraces(bool cachedFailure, bool openAsync) + { + const SqlAuthenticationMethod method = SqlAuthenticationMethod.ActiveDirectoryDefault; + SqlAuthenticationProvider.GetProvider(method); + Type registryType = typeof(SqlAuthenticationProvider).Assembly.GetType( + "Microsoft.Data.SqlClient.SqlAuthenticationProviderRegistry", throwOnError: true)!; + object registry = registryType.GetField("Instance", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + FieldInfo failureField = registryType.GetField("_bootstrapFailure", BindingFlags.Instance | BindingFlags.NonPublic)!; + var providers = Assert.IsAssignableFrom( + registryType.GetField("_providers", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(registry)); + object? originalFailure = failureField.GetValue(registry); + object? originalProvider = providers[method]; + providers.Remove(method); + failureField.SetValue(registry, cachedFailure + ? ExceptionDispatchInfo.Capture(new ArgumentException("Test configured initializer failure")) + : null); + try + { + using var server = new TdsServer(new TdsServerArguments + { + FedAuthRequiredPreLoginOption = TdsPreLoginFedAuthRequiredOption.FedAuthRequired, + }); + server.Start(); + var builder = new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Authentication = method, + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false, + ConnectTimeout = 5, + ConnectRetryCount = 0, + }; + using var connection = new SqlConnection(builder.ConnectionString); + + SqlException failure = openAsync + ? await Assert.ThrowsAsync(() => connection.OpenAsync()) + : Assert.Throws(() => connection.Open()); + Assert.Contains("SetProvider", failure.Message); + Assert.Contains("Microsoft.Data.SqlClient.EventSource", failure.Message); + Assert.Contains("compatible", failure.Message); + Assert.Null(failure.InnerException); + var error = Assert.IsType(Assert.Single(failure.Errors)); + Assert.Equal(builder.DataSource, error.Server); + Assert.NotEqual(Guid.Empty, failure.ClientConnectionId); + } + finally + { + failureField.SetValue(registry, originalFailure); + if (originalProvider is null) + { + providers.Remove(method); + } + else + { + providers[method] = originalProvider; + } + } + } + + /// The driver-forwarded registry honors disabled authentication configuration on both runtimes. + [Fact] + public void DisabledAppConfigDoesNotReadAuthenticationConfiguration() + { + Assembly abstractions = typeof(SqlAuthenticationProvider).Assembly; + Type switches = abstractions.GetType("Microsoft.Data.SqlClient.AuthenticationFeatureSwitches", throwOnError: true)!; + Type registryType = abstractions.GetType("Microsoft.Data.SqlClient.SqlAuthenticationProviderRegistry", throwOnError: true)!; + FieldInfo configSwitch = switches.GetField("s_enableAppConfig", BindingFlags.Static | BindingFlags.NonPublic)!; + FieldInfo discoverySwitch = switches.GetField("s_enableAzureExtensionDiscovery", BindingFlags.Static | BindingFlags.NonPublic)!; + object? originalConfig = configSwitch.GetValue(null); + object? originalDiscovery = discoverySwitch.GetValue(null); + try + { + configSwitch.SetValue(null, Enum.Parse(configSwitch.FieldType, "False")); + discoverySwitch.SetValue(null, Enum.Parse(discoverySwitch.FieldType, "False")); + object registry = Activator.CreateInstance(registryType, nonPublic: true)!; + MethodInfo bootstrap = registryType.GetMethod( + "EnsureBootstrapped", BindingFlags.Instance | BindingFlags.NonPublic)!; + int reads = 0; + Func reader = _ => + { + reads++; + throw new InvalidOperationException("Disabled authentication configuration must not be read."); + }; + + bootstrap.Invoke(registry, new object?[] { reader, null }); + + Assert.Equal(0, reads); + } + finally + { + configSwitch.SetValue(null, originalConfig); + discoverySwitch.SetValue(null, originalDiscovery); + } + } + /// Both driver-qualified declarations load the sealed handler and retain every configured value. [Theory] [InlineData("SqlAuthenticationProviders", typeof(SqlAuthenticationProviderConfigurationSection))] @@ -28,16 +135,17 @@ public void DriverQualifiedSectionPreservesValues(string sectionName, Type handl ", configuration => { - object section = configuration.GetSection(sectionName); + var section = Assert.IsAssignableFrom( + configuration.GetSection(sectionName)); Assert.Equal(handlerType, section.GetType()); Assert.Equal("Microsoft.Data.SqlClient.Extensions.Abstractions", section.GetType().Assembly.GetName().Name); - var settings = SqlAuthenticationProviderManager.ReadConfigurationSection(configuration.GetSection); - Assert.NotNull(settings); - Assert.Equal("configured-client", settings.ApplicationClientId); - Assert.Equal("Example.Initializer, Example", settings.InitializerType); - Assert.Equal("false", settings.UseWamBroker); - var provider = Assert.Single(settings.Providers); + Assert.Equal("configured-client", section.ElementInformation.Properties["applicationClientId"].Value); + Assert.Equal("Example.Initializer, Example", section.ElementInformation.Properties["initializerType"].Value); + Assert.Equal("false", section.ElementInformation.Properties["useWamBroker"].Value); + var providers = Assert.IsType( + section.ElementInformation.Properties["providers"].Value); + var provider = Assert.Single(providers); var providerSettings = Assert.IsType(provider); Assert.Equal("active directory default", providerSettings.Name); Assert.Equal("Example.Provider, Example", providerSettings.Type); @@ -56,85 +164,16 @@ public void DriverQualifiedSectionPreservesDefaults(string sectionName, Type han $"<{sectionName} />", configuration => { - var settings = SqlAuthenticationProviderManager.ReadConfigurationSection(configuration.GetSection); - Assert.NotNull(settings); - Assert.Equal(string.Empty, settings.ApplicationClientId); - Assert.Equal(string.Empty, settings.InitializerType); - Assert.Equal(string.Empty, settings.UseWamBroker); - Assert.Empty(settings.Providers); + var section = Assert.IsAssignableFrom( + configuration.GetSection(sectionName)); + Assert.Equal(string.Empty, section.ElementInformation.Properties["applicationClientId"].Value); + Assert.Equal(string.Empty, section.ElementInformation.Properties["initializerType"].Value); + Assert.Equal(string.Empty, section.ElementInformation.Properties["useWamBroker"].Value); + Assert.Empty(Assert.IsType( + section.ElementInformation.Properties["providers"].Value)); }); } - /// The modern section takes precedence without reading the legacy section. - [Fact] - public void ModernSectionTakesPrecedence() - { - WithConfiguration( - @"
-
", - @" - ", - configuration => - { - List reads = new(); - var settings = SqlAuthenticationProviderManager.ReadConfigurationSection(name => - { - reads.Add(name); - return configuration.GetSection(name); - }); - Assert.NotNull(settings); - Assert.Equal("modern-client", settings.ApplicationClientId); - Assert.Equal(new[] { "SqlClientAuthenticationProviders" }, reads); - }); - } - - /// When neither section exists, the reader checks both names and returns no settings. - [Fact] - public void MissingSectionsReturnNull() - { - WithConfiguration(string.Empty, string.Empty, configuration => - { - List reads = new(); - var settings = SqlAuthenticationProviderManager.ReadConfigurationSection(name => - { - reads.Add(name); - return configuration.GetSection(name); - }); - Assert.Null(settings); - Assert.Equal(new[] { "SqlClientAuthenticationProviders", "SqlAuthenticationProviders" }, reads); - }); - } - - /// A mismatched modern handler is ignored rather than accepted solely because its schema matches. - [Fact] - public void WrongModernHandlerFallsBackToLegacy() - { - WithConfiguration( - @"
-
", - @" - ", - configuration => - { - var settings = SqlAuthenticationProviderManager.ReadConfigurationSection(configuration.GetSection); - Assert.NotNull(settings); - Assert.Equal("legacy-client", settings.ApplicationClientId); - }); - } - - /// A malformed modern section still fails parsing instead of silently falling back. - [Fact] - public void MalformedModernSectionDoesNotFallBack() - { - WithConfiguration( - @"
-
", - @" - ", - configuration => Assert.Throws(() => - SqlAuthenticationProviderManager.ReadConfigurationSection(configuration.GetSection))); - } - /// Loads an isolated configuration file and removes it even when an assertion fails. /// Configuration section declarations. /// Configuration section values. diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/EnableAppConfigSwitchTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/EnableAppConfigSwitchTest.cs index 4399fb0098..4afed03f74 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/EnableAppConfigSwitchTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/EnableAppConfigSwitchTest.cs @@ -12,7 +12,8 @@ namespace Microsoft.Data.SqlClient.UnitTests; /// /// Tests that the EnableAppConfig switch gates every app.config reader: the /// configurable retry logic providers, the switch overrides applied during -/// static initialization, and the authentication provider section. +/// static initialization. A dedicated process harness for authentication +/// configuration is deferred to #4817. /// [Collection(AppContextSwitchTestCollection.Name)] public class EnableAppConfigSwitchTest @@ -85,25 +86,6 @@ public void SwitchOverridesAreReadOnlyWhenEnabled(bool enabled) Assert.Equal(enabled, LocalAppContextSwitches.ApplyAppConfigSwitchOverrides()); } - /// - /// The switch gates the authentication provider section read during static - /// initialization. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public void AuthenticationProvidersAreReadOnlyWhenEnabled(bool enabled) - { - using LocalAppContextSwitchesHelper switchesHelper = new(); - switchesHelper.EnableAppConfig = enabled; - - Assert.Equal( - enabled, - SqlAuthenticationProviderManager.TryReadConfigurationSection(out SqlAuthenticationProviderManager.AuthenticationConfiguration? section)); - - // No test host supplies the section, so it is null either way. - Assert.Null(section); - } } #endif diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs deleted file mode 100644 index 08089abe98..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs +++ /dev/null @@ -1,272 +0,0 @@ -// 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.Threading.Tasks; -using Xunit; - -namespace Microsoft.Data.SqlClient.UnitTests; - -public class SqlAuthenticationProviderManagerTests -{ - private class Provider : SqlAuthenticationProvider - { - public override Task AcquireTokenAsync( - SqlAuthenticationParameters parameters) - { - return Task.FromResult( - new SqlAuthenticationToken( - "SampleAccessToken", DateTimeOffset.UtcNow.AddMinutes(5))); - } - - public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) - { - return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow; - } - } - - // Verify that we can get and set providers via both the Abstractions - // package and Manager class interchangeably. - // - // This tests the dynamic assembly loading code in the Abstractions - // package. - [Fact] - public void Abstractions_And_Manager_GetSetProvider_Equivalent() - { - // Set via Manager, get via both. - Provider provider1 = new(); - - Assert.True( - SqlAuthenticationProviderManager.SetProvider( - // GOTCHA: On .NET Framework, the dummy provider is already - // registered as the default provider for Interactive, so we - // use DeviceCodeFlow instead. - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - provider1)); - - Assert.Same( - provider1, - SqlAuthenticationProviderManager.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - Assert.Same( - provider1, - SqlAuthenticationProvider.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - // Set via Abstractions, get via both. - Provider provider2 = new(); - - Assert.True( - SqlAuthenticationProvider.SetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - provider2)); - - Assert.Same( - provider2, - SqlAuthenticationProviderManager.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - Assert.Same( - provider2, - SqlAuthenticationProvider.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - } - - // Regression: the manager's static initializer reflectively constructs the Azure extension's - // ActiveDirectoryAuthenticationProvider. That class has overlapping 1-arg constructors - // ((string) and (ProviderOptions)), so calling Activator.CreateInstance(type, [null]) used - // to throw AmbiguousMatchException -- which surfaced as TypeInitializationException from - // GetProvider and broke every AD-authenticated connection. Calling GetProvider for an AD - // method must succeed (returning either the registered provider or null) and must not throw. - [Fact] - public void GetProvider_ForActiveDirectoryMethod_DoesNotThrow() - { - foreach (SqlAuthenticationMethod method in new[] - { - SqlAuthenticationMethod.ActiveDirectoryIntegrated, - #pragma warning disable CS0618 // ActiveDirectoryPassword is obsolete. - SqlAuthenticationMethod.ActiveDirectoryPassword, - #pragma warning restore CS0618 - SqlAuthenticationMethod.ActiveDirectoryInteractive, - SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, - SqlAuthenticationMethod.ActiveDirectoryMSI, - SqlAuthenticationMethod.ActiveDirectoryDefault, - SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, - }) - { - // No assertion on the value -- the provider may or may not be installed depending on - // whether the Azure extension is on disk. We only assert no throw (which is what a - // TypeInitializationException from the static initializer would do). - _ = SqlAuthenticationProviderManager.GetProvider(method); - } - } - - // CreateAzureAuthenticationProvider tests ---------------------------------------------- - // - // Each Stub* container mimics one shape the real Azure extension might expose: - // * StubModern - both a (string) ctor and an (Options) ctor. - // * StubLegacy - only the (string) ctor; no Options type at all. - // * StubMinimal - only a parameterless ctor. - // - // The helper takes a Type directly, so these stubs do not need any particular full name. - - public class StubProviderBase : SqlAuthenticationProvider - { - public string? CapturedApplicationClientId; - public bool? CapturedUseWamBroker; - public bool ParameterlessCtorUsed; - public bool StringCtorUsed; - public bool OptionsCtorUsed; - - public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) - => Task.FromResult(new SqlAuthenticationToken("stub", DateTimeOffset.UtcNow.AddMinutes(5))); - - public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; - } - - public static class StubModern - { - public sealed class ActiveDirectoryAuthenticationProviderOptions - { - public string? ApplicationClientId { get; set; } - public bool UseWamBroker { get; set; } - } - - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - - public ActiveDirectoryAuthenticationProvider(string applicationClientId) - { - StringCtorUsed = true; - CapturedApplicationClientId = applicationClientId; - } - - public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) - { - OptionsCtorUsed = true; - CapturedApplicationClientId = options.ApplicationClientId; - CapturedUseWamBroker = options.UseWamBroker; - } - } - } - - public static class StubLegacy - { - // No Options type defined -- mimics older Azure extension versions. - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - - public ActiveDirectoryAuthenticationProvider(string applicationClientId) - { - StringCtorUsed = true; - CapturedApplicationClientId = applicationClientId; - } - } - } - - public static class StubMinimal - { - // Parameterless only -- mimics a hypothetical extension with no 1-arg ctors at all. - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - } - } - - [Fact] - public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterlessCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: null, - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.ParameterlessCtorUsed); - Assert.False(stub.StringCtorUsed); - Assert.False(stub.OptionsCtorUsed); - Assert.Null(stub.CapturedApplicationClientId); - Assert.Null(stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOptionsCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: "app-123", - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.OptionsCtorUsed); - Assert.False(stub.StringCtorUsed); - Assert.Equal("app-123", stub.CapturedApplicationClientId); - Assert.Equal(false, stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBackToStringCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: "legacy-456", - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.StringCtorUsed); - Assert.False(stub.OptionsCtorUsed); - Assert.False(stub.ParameterlessCtorUsed); - Assert.Equal("legacy-456", stub.CapturedApplicationClientId); - Assert.Null(stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_ReturnsNull() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubMinimal.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: "no-ctor", - useWamBroker: null); - - Assert.Null(instance); - } - - [Fact] - public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws() - { - InvalidOperationException ex = Assert.Throws(() => - SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: null, - useWamBroker: true)); - - Assert.Contains("ActiveDirectoryAuthenticationProviderOptions", ex.Message); - Assert.Contains("Microsoft.Data.SqlClient.Extensions.Azure", ex.Message); - } - - [Fact] - public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_UsesOptionsCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: "app-789", - useWamBroker: true); - - var stub = Assert.IsType(instance); - Assert.True(stub.OptionsCtorUsed); - Assert.Equal("app-789", stub.CapturedApplicationClientId); - Assert.Equal(true, stub.CapturedUseWamBroker); - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlUtilTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlUtilTests.cs index 1cbfee5ef5..696e826bf6 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlUtilTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlUtilTests.cs @@ -23,9 +23,11 @@ public class SqlUtilTests [InlineData(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity)] public void CannotFindAuthProvider_ActiveDirectoryMethod_ContainsPackageGuidance(SqlAuthenticationMethod authMethod) { - Exception exception = SQL.CannotFindAuthProvider(authMethod); + Exception exception = SQL.CannotFindAuthProvider(authMethod, internalConnection: null); - Assert.IsType(exception); + var sqlException = Assert.IsType(exception); + Assert.Single(sqlException.Errors); + Assert.Null(sqlException.InnerException); Assert.Contains(authMethod.ToString(), exception.Message); Assert.Contains("Microsoft.Data.SqlClient.Extensions.Azure", exception.Message); } @@ -35,9 +37,11 @@ public void CannotFindAuthProvider_ActiveDirectoryMethod_ContainsPackageGuidance [InlineData(SqlAuthenticationMethod.SqlPassword)] public void CannotFindAuthProvider_NonActiveDirectoryMethod_DoesNotContainPackageGuidance(SqlAuthenticationMethod authMethod) { - Exception exception = SQL.CannotFindAuthProvider(authMethod); + Exception exception = SQL.CannotFindAuthProvider(authMethod, internalConnection: null); - Assert.IsType(exception); + var sqlException = Assert.IsType(exception); + Assert.Single(sqlException.Errors); + Assert.Null(sqlException.InnerException); Assert.Contains(authMethod.ToString(), exception.Message); Assert.DoesNotContain("Microsoft.Data.SqlClient.Extensions.Azure", exception.Message); } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/TypeForwardTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/TypeForwardTests.cs index ed11feb494..5f9e27237a 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/TypeForwardTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/TypeForwardTests.cs @@ -16,6 +16,7 @@ public class TypeForwardTests /// Type forwards preserve resolution through the original assembly name. [Theory] [InlineData("Microsoft.Data.SqlClient.SqlAuthenticationMethod", true)] + [InlineData("Microsoft.Data.SqlClient.SqlAuthenticationInitializer", false)] [InlineData("Microsoft.Data.SqlClient.SqlAuthenticationParameters", false)] [InlineData("Microsoft.Data.SqlClient.SqlAuthenticationProvider", false)] [InlineData("Microsoft.Data.SqlClient.SqlAuthenticationProviderException", false)]