From 0dd8542ee6f7e33d7b187183214d77c61940cba7 Mon Sep 17 00:00:00 2001
From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com>
Date: Fri, 21 Aug 2026 11:34:33 -0300
Subject: [PATCH 1/5] Add assembly signing for
Microsoft.Data.SqlClient.Extensions.Azure
Sign the Azure package and its test assembly on internal Package-mode builds.
Azure references Abstractions and Logging, both of which are signed earlier in
this stack, so the reference closure is complete.
- Thread isInternalBuild from the CI core into the Azure stage, pack job and
test jobs, downloading the driver and test signing keys as needed.
- Add a signed InternalsVisibleTo grant for the Azure test assembly, which
needs internal access for the WAM broker tests, and sign that assembly when a
test key is supplied.
- Thread signing arguments into the build.proj TestAzure target.
- Rename the test job's buildArguments variable to dotnetBuildOpts; Azure
Pipelines exposes variables as environment variables and the dotnet CLI
injects BUILDARGUMENTS into dotnet build.
---
.../Azure/test/Azure.Test.csproj | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj
index 9b294a4f98..90d340dc04 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj
@@ -20,12 +20,18 @@
$(TestSigningKeyPath)
+
+
+
+ true
+ $(TestSigningKeyPath)
+
+
enableenable
-
From e1f0618f980c607a9195cf449ee92e3165a01f78 Mon Sep 17 00:00:00 2001
From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com>
Date: Tue, 9 Jun 2026 09:58:57 -0300
Subject: [PATCH 2/5] AOT-safe authentication provider discovery
Gate the reflection-based config/Azure-extension provider discovery behind a
feature switch so the trimmer can remove it, and relocate the provider registry
into the shared Abstractions assembly. The public surface stays
SqlAuthenticationProvider.GetProvider/SetProvider (no new public type).
- Registry: move into Abstractions as internal AuthenticationProviderRegistry
(one ConcurrentDictionary of a ProviderEntry record struct; IsExternalInit
polyfill for netstandard2.0); delete the Abstractions->core reflection bridge.
- Bootstrapper: replace SqlAuthenticationProviderManager with an internal,
core-side AuthenticationBootstrapper that lazily (Lazy) discovers
config-driven and Azure-extension providers and seeds the registry, triggered
once from SqlConnectionInternal.GetFedAuthToken.
- AOT: feature switch
Microsoft.Data.SqlClient.EnableReflectionBasedAuthenticationProviderDiscovery
([FeatureSwitchDefinition] on .NET 9+, ILLink.Substitutions.xml on .NET 8)
trims LoadAzureExtensionProvider when disabled; AotCompatibility tool
validates Native AOT publish and trimming.
- Behavior: app-config ("permanent") providers take precedence and are
last-in-wins; BeforeLoad fires on first registration; lifecycle callbacks are
isolated from exceptions and documented as idempotent.
- Tests: registry and bootstrapper tests reorganized -- UnitTests covers the
Azure-absent and stub paths, Azure.Test covers the real provider (global vs
non-global split); serialized collection renamed to SqlAuthenticationProviderGlobal.
---
aot-auth-provider-proposal.md | 374 ++++++++++++
.../doc/SqlAuthenticationProvider.xml | 10 +
.../Abstractions/src/Abstractions.csproj | 13 +-
.../src/AuthenticationProviderRegistry.cs | 256 ++++++++
.../Abstractions/src/IsExternalInit.cs | 18 +
.../src/SqlAuthenticationProvider.Internal.cs | 198 ------
.../src/SqlAuthenticationProvider.cs | 12 +-
.../Abstractions/src/Strings.Designer.cs | 80 +++
.../Abstractions/src/Strings.cs.resx | 18 +
.../Abstractions/src/Strings.de.resx | 18 +
.../Abstractions/src/Strings.es.resx | 18 +
.../Abstractions/src/Strings.fr.resx | 18 +
.../Abstractions/src/Strings.it.resx | 18 +
.../Abstractions/src/Strings.ja.resx | 18 +
.../Abstractions/src/Strings.ko.resx | 18 +
.../Abstractions/src/Strings.pl.resx | 18 +
.../Abstractions/src/Strings.pt-BR.resx | 18 +
.../Abstractions/src/Strings.resx | 123 ++++
.../Abstractions/src/Strings.ru.resx | 18 +
.../Abstractions/src/Strings.tr.resx | 18 +
.../Abstractions/src/Strings.zh-Hans.resx | 18 +
.../Abstractions/src/Strings.zh-Hant.resx | 18 +
.../AuthenticationProviderRegistryTest.cs | 463 +++++++++++++++
.../test/SqlAuthenticationProviderTest.cs | 86 ++-
.../Azure/test/AADAuthenticationTests.cs | 2 +-
.../AuthenticationBootstrapperGlobalTests.cs | 113 ++++
.../test/AuthenticationBootstrapperTests.cs | 148 +++++
.../Azure/test/DefaultAuthProviderTests.cs | 67 ---
...AuthenticationProviderGlobalCollection.cs} | 4 +-
.../Azure/test/WamBrokerTests.cs | 44 +-
.../ref/Microsoft.Data.SqlClient.cs | 5 +
.../src/Microsoft.Data.SqlClient.csproj | 14 +-
...nager.cs => AuthenticationBootstrapper.cs} | 562 +++++++++---------
.../Connection/SqlConnectionInternal.cs | 7 +-
.../Data/SqlClient/LocalAppContextSwitches.cs | 107 +++-
.../src/Resources/ILLink.Substitutions.xml | 54 +-
.../Common/LocalAppContextSwitchesHelper.cs | 25 +-
.../FunctionalTests/AADAuthenticationTests.cs | 26 -
.../DummySqlAuthenticationProvider.cs | 28 -
...soft.Data.SqlClient.FunctionalTests.csproj | 5 -
.../SqlAuthenticationProviderManagerTests.cs | 75 ---
.../tests/FunctionalTests/app.config | 14 -
.../Microsoft.Data.SqlClient.UnitTests.csproj | 3 +
....cs => AuthenticationBootstrapperTests.cs} | 242 +++++---
.../DummySqlAuthenticationProvider.cs | 23 +
.../SqlClient/ILLinkSubstitutionsTests.cs | 52 ++
.../SqlClient/LocalAppContextSwitchesTest.cs | 2 +
.../tests/UnitTests/app.config | 11 +
.../AotCompatibility/AotCompatibility.csproj | 55 ++
tools/AotCompatibility/Directory.Build.props | 6 +
.../AotCompatibility/Directory.Packages.props | 10 +
tools/AotCompatibility/Program.cs | 194 ++++++
tools/AotCompatibility/README.md | 115 ++++
53 files changed, 2956 insertions(+), 924 deletions(-)
create mode 100644 aot-auth-provider-proposal.md
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/IsExternalInit.cs
delete mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.Designer.cs
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.cs.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.de.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.es.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.fr.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.it.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ja.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ko.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pl.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pt-BR.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ru.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.tr.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hans.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hant.resx
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationProviderRegistryTest.cs
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperTests.cs
delete mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs
rename src/Microsoft.Data.SqlClient.Extensions/Azure/test/{SqlAuthenticationProviderCollection.cs => SqlAuthenticationProviderGlobalCollection.cs} (79%)
rename src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/{SqlAuthenticationProviderManager.cs => AuthenticationBootstrapper.cs} (60%)
delete mode 100644 src/Microsoft.Data.SqlClient/tests/FunctionalTests/DataCommon/DummySqlAuthenticationProvider.cs
delete mode 100644 src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderManagerTests.cs
delete mode 100644 src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config
rename src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/{SqlAuthenticationProviderManagerTests.cs => AuthenticationBootstrapperTests.cs} (55%)
create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/DummySqlAuthenticationProvider.cs
create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/ILLinkSubstitutionsTests.cs
create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
create mode 100644 tools/AotCompatibility/AotCompatibility.csproj
create mode 100644 tools/AotCompatibility/Directory.Build.props
create mode 100644 tools/AotCompatibility/Directory.Packages.props
create mode 100644 tools/AotCompatibility/Program.cs
create mode 100644 tools/AotCompatibility/README.md
diff --git a/aot-auth-provider-proposal.md b/aot-auth-provider-proposal.md
new file mode 100644
index 0000000000..6ef1b868ee
--- /dev/null
+++ b/aot-auth-provider-proposal.md
@@ -0,0 +1,374 @@
+# AOT-Safe Authentication Provider Registration
+
+## Problem
+
+Issue [#4193](https://github.com/dotnet/SqlClient/issues/4193): Entra ID authentication is broken under NativeAOT in SqlClient v7.0.
+
+In v6.x, `ActiveDirectoryAuthenticationProvider` lived in the core `Microsoft.Data.SqlClient` assembly and was instantiated with a direct `new` call in `SqlAuthenticationProviderManager.SetDefaultAuthProviders()`. The AOT compiler could trace the entire type hierarchy statically.
+
+In v7.0, the provider was moved to the separate `Microsoft.Data.SqlClient.Extensions.Azure` package. The manager now discovers it at runtime via `Assembly.Load` + `Activator.CreateInstance`. Under NativeAOT, the linker has no static reference to follow and trims the assembly, silently leaving all Active Directory auth methods without a provider.
+
+The public `SqlAuthenticationProvider.SetProvider()` API (in the Abstractions package) also fails under AOT because it uses reflection to call the internal `SqlAuthenticationProviderManager` in the core assembly.
+
+**There is no AOT-safe way to register an authentication provider in v7.0.** None of the reflection code paths carry `[RequiresUnreferencedCode]` or `[RequiresDynamicCode]` annotations, so the AOT compiler emits zero warnings.
+
+## PR #4195 Proposal (Callback Bridge)
+
+PR [#4195](https://github.com/dotnet/SqlClient/pull/4195) introduces a callback-based bridge:
+
+1. **`SqlAuthenticationProvider.RegisterProviderManager(getProvider, setProvider)`** — Called by `SqlAuthenticationProviderManager`'s static constructor to wire up direct (non-reflection) delegates. Providers registered via `SetProvider` before the manager initializes are buffered in a pending dictionary and replayed. Marked `[EditorBrowsable(Never)]`.
+
+2. **`ActiveDirectoryAuthenticationProvider.RegisterAsDefault()`** — AOT-safe entry point that registers the MSAL-based provider for all 9 AD auth methods. Applications call this early in `Main()`.
+
+3. **`LoadAzureExtensionProvider()`** — Extracted from the static constructor and annotated with `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`.
+
+### AOT usage
+
+```csharp
+// Top of Main(), before opening any connection:
+ActiveDirectoryAuthenticationProvider.RegisterAsDefault();
+
+// Then connect normally:
+using var connection = new SqlConnection(
+ "Server=myserver.database.windows.net;Database=mydb;" +
+ "Authentication=Active Directory Managed Identity;");
+await connection.OpenAsync();
+```
+
+### Concerns with this approach
+
+- Adds a new cross-assembly callback/buffering mechanism with subtle ordering requirements (register-before-manager-init gets buffered, register-after goes direct).
+- `RegisterProviderManager()` is public API surface (even if hidden with `[EditorBrowsable(Never)]`) that exists only as internal plumbing.
+- Thread safety of the pending-provider buffer is informal (plain `Dictionary` without synchronization, safe in practice but not formally).
+- The reflection-based `SqlAuthenticationProvider.Internal` class remains the primary code path; the callback bridge is layered on top.
+- Does not address the root cause: `SqlAuthenticationProviderManager` is the natural owner of provider registration but is invisible to consumers.
+
+## Alternative Proposal (Make Manager Public)
+
+Instead of adding bridge infrastructure, make the existing `SqlAuthenticationProviderManager` class public and expose its `GetProvider`/`SetProvider` methods directly.
+
+### Changes
+
+#### 1. Make `SqlAuthenticationProviderManager` public
+
+| Current | Proposed |
+|---------|----------|
+| `internal sealed class SqlAuthenticationProviderManager` | `public sealed class SqlAuthenticationProviderManager` |
+| `internal static SqlAuthenticationProvider? GetProvider(...)` | `public static SqlAuthenticationProvider? GetProvider(...)` |
+| `internal static bool SetProvider(...)` | `public static bool SetProvider(...)` |
+
+#### 2. Expose `ApplicationClientId`
+
+Add a public read-only property:
+
+```csharp
+public static string? ApplicationClientId => Instance._applicationClientId;
+```
+
+The manager already reads this from config (`SqlClientAuthenticationProviders` / `SqlAuthenticationProviders` configuration sections). Exposing it lets AOT apps pass it to the `ActiveDirectoryAuthenticationProvider` constructor without reimplementing config parsing.
+
+#### 3. Deprecate `SqlAuthenticationProvider.GetProvider`/`SetProvider`
+
+These static methods on the Abstractions class exist only to bridge into the manager via reflection. With the manager now public, they are redundant:
+
+```csharp
+[Obsolete("Use SqlAuthenticationProviderManager.GetProvider() instead.")]
+public static SqlAuthenticationProvider? GetProvider(SqlAuthenticationMethod authenticationMethod)
+
+[Obsolete("Use SqlAuthenticationProviderManager.SetProvider() instead.")]
+public static bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider)
+```
+
+The code comments in the current source already state this intent:
+> *"We would like to deprecate this method in favour of SqlAuthenticationProviderManager.GetProvider()."*
+
+#### 4. Guard reflection code with a feature switch
+
+Use `[FeatureSwitchDefinition]` (.NET 9+) to let the trimmer eliminate the reflection-based Azure extension discovery entirely in AOT builds:
+
+```csharp
+[FeatureSwitchDefinition(
+ "Microsoft.Data.SqlClient.EnableReflectionBasedProviderDiscovery")]
+internal static bool EnableReflectionBasedProviderDiscovery =>
+ AppContext.TryGetSwitch(
+ "Microsoft.Data.SqlClient.EnableReflectionBasedProviderDiscovery",
+ out bool enabled)
+ ? enabled
+ : true; // ON by default for non-AOT
+```
+
+Guard the reflection block in the static constructor:
+
+```csharp
+if (EnableReflectionBasedProviderDiscovery)
+{
+ LoadAzureExtensionProvider(); // Assembly.Load + Activator.CreateInstance
+}
+```
+
+When an AOT app sets the switch to `false` (via `RuntimeHostConfigurationOption` with `Trim="true"`), the trimmer substitutes the property with constant `false` and eliminates the entire reflection branch.
+
+#### 5. Annotate reflection paths
+
+Apply `[RequiresUnreferencedCode]` and `[RequiresDynamicCode]` to the extracted `LoadAzureExtensionProvider()` method so the AOT analyzer warns even without the feature switch.
+
+#### 6. Update ref assembly
+
+Add `SqlAuthenticationProviderManager` to `src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs`:
+
+```csharp
+public sealed class SqlAuthenticationProviderManager
+{
+ public static string? ApplicationClientId { get { throw null; } }
+ public static SqlAuthenticationProvider? GetProvider(
+ SqlAuthenticationMethod authenticationMethod) { throw null; }
+ public static bool SetProvider(
+ SqlAuthenticationMethod authenticationMethod,
+ SqlAuthenticationProvider provider) { throw null; }
+}
+```
+
+### AOT usage
+
+```csharp
+// Top of Main(), before opening any connection:
+var provider = new ActiveDirectoryAuthenticationProvider(
+ SqlAuthenticationProviderManager.ApplicationClientId);
+
+SqlAuthenticationProviderManager.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider);
+SqlAuthenticationProviderManager.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDefault, provider);
+// ... etc. for each needed auth method
+```
+
+No reflection. No `Assembly.Load`. No `Activator.CreateInstance`. Fully AOT-safe.
+
+## Alternative Proposal (Move Registry Into Abstractions)
+
+Both proposals above leave the registry in the core `Microsoft.Data.SqlClient`
+assembly and bridge into it — PR #4195 via a callback, "Make Manager Public" via
+new public API plus a still-present reflection fallback. This proposal removes the
+Abstractions→SqlClient reflection at its root by relocating the *provider registry*
+to the assembly both sides already share.
+
+### Root cause
+
+The reflection exists only because the dependency arrow points **SqlClient →
+Abstractions**, yet the registry *state* lives in SqlClient. Abstractions therefore
+cannot call the manager directly without a cycle, so it reflects. Move the registry
+singleton into a layer **both** assemblies reference and both can call concrete,
+strongly-typed APIs against the same static state — no `MethodInfo.Invoke`, no
+`Assembly.Load`, AOT/trim-safe.
+
+The manager's surface is typed on `SqlAuthenticationProvider` and
+`SqlAuthenticationMethod`, which already live in Abstractions. So the registry must
+sit *at or above* those types — and Abstractions is exactly that layer.
+
+### Option A — Move the registry core into Abstractions (recommended)
+
+The manager can't move wholesale: its static constructor depends on SqlClient-only
+facilities (`System.Configuration` section parsing, the Azure extension
+`Assembly.Load` + public-key-token check, `SqlAuthenticationInitializer`,
+`SqlClientLogger`, `SQL.*` error helpers). The move is therefore a **split**:
+
+1. **Registry core → Abstractions** (`internal sealed`): the
+ `ConcurrentDictionary`, `GetProvider`, `SetProvider`,
+ `IsSupported` enforcement, the app-specified-provider tracking set, and the
+ singleton `Instance`. Logging switches to `SqlClientEventSource` (already in the
+ Logging package that Abstractions references); the unsupported-method error
+ throws `SqlAuthenticationProviderException` instead of
+ `SQL.UnsupportedAuthenticationByProvider`. A new internal
+ `SetAppSpecifiedProvider(method, provider)` records config/Azure-seeded entries
+ that user `SetProvider` may not override.
+
+2. **Bootstrap/orchestration → stays in SqlClient** under a new internal
+ `SqlAuthenticationProviderBootstrapper` (so there is no duplicate
+ `SqlAuthenticationProviderManager` type). It parses config and loads the Azure
+ default provider, pushing results into the Abstractions singleton via
+ `SetAppSpecifiedProvider`, triggered once on the connection auth path.
+
+3. **Abstractions public wrappers call the concrete manager.**
+ `SqlAuthenticationProvider.GetProvider`/`SetProvider` invoke the moved manager
+ directly, and `SqlAuthenticationProvider.Internal.cs` (the entire reflection
+ bridge) is **deleted**. SqlClient reads providers during connection auth through
+ the public `SqlAuthenticationProvider.GetProvider`, and seeds the (overridable)
+ Azure default through the public `SqlAuthenticationProvider.SetProvider` — neither
+ needs internal access.
+
+4. **`[InternalsVisibleTo("Microsoft.Data.SqlClient")]`** is added to Abstractions for
+ **one reason only**: the config-specified-provider precedence. A provider declared
+ in `app.config` must be registered as non-overridable (so a later user
+ `SetProvider` returns `false` instead of replacing it), and that marking —
+ `SetAppSpecifiedProvider` — is intentionally *not* part of the public surface. The
+ general get/set path does **not** need `InternalsVisibleTo`; only the bootstrapper's
+ app-specified seeding does. If that precedence rule were dropped or reworked,
+ `InternalsVisibleTo` would be unnecessary and SqlClient could rely entirely on the
+ public API. The manager itself stays internal — no public API or ref-assembly
+ change.
+
+> **Note (porting to 7.0):** Adding `[InternalsVisibleTo("Microsoft.Data.SqlClient")]`
+> to Abstractions depends on the build/signing changes in PR
+> [#4369](https://github.com/dotnet/SqlClient/pull/4369). To take this AOT fix onto the
+> 7.0 branch, those changes would need to be ported there first. (This applies only if
+> the config-precedence seeding hook is kept; see point 4.)
+
+### Option B — New package *below* Abstractions (rejected)
+
+The literal reading of "put the registry in a Logging-style package beneath
+Abstractions" does not pay off. Abstractions consists of **exactly six files, all of
+them auth contracts** (`SqlAuthenticationProvider`, `SqlAuthenticationMethod`,
+`SqlAuthenticationParameters`, `SqlAuthenticationToken`,
+`SqlAuthenticationProviderException`, and the reflection bridge that gets deleted
+anyway). Because the manager is typed on those contracts, a package *below*
+Abstractions would have to take the contracts down with it — leaving Abstractions an
+**empty type-forwarding shim** that re-exports the moved types solely to preserve its
+published NuGet surface.
+
+In other words, Abstractions already *is* the low "auth-contracts" package this idea
+imagines living beneath; there is no separate higher layer to sit under. Option B is
+Option A plus a pointless empty shim and a churn of relocated public types and
+`TypeForwards.Abstractions.cs` entries. **Rejected** in favor of Option A.
+
+### Considered: declarations in Abstractions, definitions in a higher intermediate package (rejected)
+
+A tempting variation is to keep `SqlAuthenticationProvider.GetProvider`/`SetProvider`
+*declared* in Abstractions but put their *definitions* (the registry) in an
+intermediate package that sits **above** Abstractions and **below** SqlClient/Azure —
+so the heavy code never ships in the low contract package. This is **not possible** in
+.NET for the same type.
+
+- **A method body lives in the same assembly as its declaring type.** There is no
+ cross-assembly header/implementation split; `partial` methods and classes must be in
+ the same compilation. `SqlAuthenticationProvider` — with all its method bodies —
+ exists in exactly one assembly at runtime.
+
+- **Type forwarding only relocates downward.** A forwarder must reference the definer:
+
+ ```csharp
+ [assembly: TypeForwardedTo(typeof(SqlAuthenticationProvider))] // needs a ref to the definer
+ ```
+
+ Today this works because **SqlClient (high) forwards down to Abstractions (low)** —
+ SqlClient already references Abstractions, so it is acyclic. The proposed topology
+ needs Abstractions (low) to forward **up** to the intermediate, which would require
+ Abstractions to reference the intermediate while the intermediate references
+ Abstractions for the contract types — a **circular assembly reference**. Not allowed.
+
+ > **General principle:** a type's definition can live *at or below* Abstractions,
+ > never above it.
+
+- **`static abstract` interface members don't apply.** They can be implemented in a
+ different assembly, but require a .NET 7+ runtime (not `netstandard2.0`) and dispatch
+ only through a generic type parameter — not a plain `SqlAuthenticationProvider.GetProvider()`
+ call.
+
+The only way to genuinely place the implementation in a higher package is **dependency
+inversion via a registration slot**: keep the public statics defined in Abstractions as
+trivial relays to an injected `ISqlAuthProviderRegistry` (or delegates) that a higher
+assembly registers at startup. That is exactly the **PR #4195 callback bridge minus the
+reflection** — strongly typed, AOT-safe, and `netstandard2.0`-compatible — but it
+reintroduces the registration handshake and its ordering/buffering concerns, which is
+the complexity Option A exists to avoid.
+
+### AOT impact
+
+This directly eliminates the Abstractions→SqlClient reflection path that the Problem
+section calls out (the public `SetProvider` failing under AOT). It does **not** by
+itself fix the SqlClient→Azure discovery reflection — that remains and would still be
+handled by the feature-switch + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`
+annotations from the "Make Manager Public" proposal, applied to the relocated
+bootstrapper. The two proposals compose: this one removes one reflection edge
+structurally; the annotations/feature-switch trim the other.
+
+### Changes
+
+| Current | Proposed |
+|---------|----------|
+| `SqlAuthenticationProviderManager` in core assembly (`internal`) | Registry core moved to Abstractions (`internal`), bootstrap relocated to `SqlAuthenticationProviderBootstrapper` in core |
+| `SqlAuthenticationProvider.Internal` reflection bridge | Deleted |
+| `SqlAuthenticationProvider.GetProvider`/`SetProvider` reflect into core | Call the concrete manager directly |
+| Abstractions `InternalsVisibleTo`: Test only | Add `Microsoft.Data.SqlClient` (only for the config-precedence seeding hook) |
+| Default Azure provider force-loaded via reflection | Seeded by SqlClient bootstrap when a connection authenticates |
+
+### Tradeoffs
+
+- **No new public API.** Unlike the other two proposals, nothing new is exposed;
+ `SqlAuthenticationProvider.GetProvider`/`SetProvider` keep their signatures.
+- **Behavior change (accepted):** user `SetProvider` now works even if the core
+ assembly isn't loaded (the provider sits in the shared singleton until SqlClient
+ reads it). The default Azure provider appears only once SqlClient's bootstrap runs
+ — i.e., when a connection actually authenticates — rather than being eagerly
+ force-loaded. This removes the last `Assembly.Load` from the get/set path.
+- **Larger structural change** to where the registry lives, but a small, contained
+ one (Abstractions is six files). Bootstrap logic and config/Azure semantics are
+ unchanged in behavior, only relocated.
+
+### Assembly size & performance
+
+Relocating the registry core shifts code from `Microsoft.Data.SqlClient` into
+`Microsoft.Data.SqlClient.Extensions.Abstractions`: the former shrinks by roughly the
+amount the latter grows. Because `Abstractions` is a hard dependency of `SqlClient`
+and is never deployed without it, the two assemblies always ship together — so the
+**total on-disk and in-memory footprint is unchanged** (net zero, minus the deleted
+reflection bridge).
+
+There is **no runtime, efficiency, or performance penalty**. Quite the opposite:
+
+- **Faster, not slower** — get/set become direct virtual/static calls instead of
+ `MethodInfo.Invoke`, and the one-time `Assembly.Load` + reflection lookup in the
+ static constructor is removed.
+- **No extra indirection** — the call simply crosses an assembly boundary that the
+ JIT already inlines/resolves like any other; there is no marshalling or bridge.
+- **No new dependencies** — Abstractions already references the Logging package it
+ needs for `SqlClientEventSource`; nothing new is pulled in.
+- **Smaller metadata** — deleting `SqlAuthenticationProvider.Internal` and the
+ reflection plumbing removes code from the shipped product.
+
+### Scope
+
+- **In scope**: relocate the registry core into Abstractions; delete the reflection
+ bridge; add `InternalsVisibleTo`; relocate config/Azure bootstrap into a SqlClient
+ `SqlAuthenticationProviderBootstrapper`; preserve app-specified-provider precedence.
+- **Out of scope**: making the manager public (covered by the prior proposal),
+ the SqlClient→Azure discovery reflection (compose with the feature-switch/annotation
+ approach), other AOT issues (#1947).
+
+## Comparison
+
+| Aspect | PR #4195 (Callback Bridge) | Public Manager | Move Into Abstractions |
+|--------|---------------------------|----------------|------------------------|
+| New public API surface | `RegisterProviderManager()` on Abstractions, `RegisterAsDefault()` on Azure | `GetProvider`/`SetProvider`/`ApplicationClientId` on Manager | None (manager stays internal) |
+| Reflection in Abstractions→SqlClient | Remains (deprecated path still used) | Deprecated, no longer needed | Removed (manager moved into Abstractions) |
+| Reflection in SqlClient→Azure | Annotated, still runs | Guarded by feature switch, trimmable | Remains; compose with feature switch/annotations |
+| Buffering/ordering complexity | Pending-provider dictionary with replay | None | None |
+| Thread safety concerns | Informal (plain Dictionary) | None (existing ConcurrentDictionary) | None (existing ConcurrentDictionary) |
+| Lines of new code | ~300 | Minimal (visibility changes + feature switch + ref assembly) | Moderate (relocate registry + bootstrapper) |
+| Backwards compatible | Yes | Yes (additive public API, deprecations only) | Yes (signatures unchanged; default-seeding timing differs) |
+| Works without code changes for non-AOT | Yes | Yes (reflection discovery unchanged by default) | Yes (config/Azure bootstrap unchanged by default) |
+| AOT app code | `ActiveDirectoryAuthenticationProvider.RegisterAsDefault()` | Explicit `SetProvider` calls (more verbose but transparent) | Explicit `SetProvider` calls (same as Public Manager) |
+
+## Zero-Code-Change AOT: Why No Proposal Supports It
+
+None of the proposals allow AOT apps to use Entra ID authentication without any startup code changes. All require explicit provider registration:
+
+- **PR #4195**: `ActiveDirectoryAuthenticationProvider.RegisterAsDefault()`
+- **Public Manager**: `SqlAuthenticationProviderManager.SetProvider(...)` calls
+- **Move Into Abstractions**: `SqlAuthenticationProvider.SetProvider(...)` calls (registry relocation removes the Abstractions→SqlClient reflection, but the app must still touch the provider type)
+
+The fundamental issue is that under NativeAOT, the trimmer removes code that's only reachable via reflection. Without a static reference from the app to `ActiveDirectoryAuthenticationProvider`, the trimmer has no reason to keep it. Some mechanism needs the app to "touch" the provider type.
+
+### Alternatives considered
+
+| Approach | Why it doesn't work |
+|----------|-------------------|
+| **`[ModuleInitializer]`** in the Azure extension assembly that self-registers | Module initializers of trimmed assemblies don't run — same chicken-and-egg problem. |
+| **Source generator** that emits registration code when it detects the Azure extension package reference | Could work, but significantly more complex to ship and maintain. |
+| **ILLink/trimmer root descriptor XML** shipped in the Azure extension NuGet package | Preserves the type, but `Assembly.Load`/`Activator.CreateInstance` in the manager's static constructor are still AOT-hostile. Combining with the feature switch left enabled defeats much of the point of AOT — you'd preserve reflection infrastructure and hope it works rather than using statically traceable code. |
+
+One line of startup code is the standard .NET pattern for AOT-compatible service registration (similar to how `System.Text.Json` requires explicit `JsonSerializerContext` registration under AOT). It's the expected tradeoff.
+
+## Scope
+
+- **In scope**: `SqlAuthenticationProviderManager` visibility, `ApplicationClientId` property, deprecations on `SqlAuthenticationProvider`, feature switch for reflection, AOT annotations, ref assembly update.
+- **Out of scope**: Removing reflection entirely (backwards compat), changing `SqlAuthenticationProvider.Internal` beyond deprecation (Abstractions targets `netstandard2.0`), other AOT issues (#1947).
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml
index 7848aaec1a..ce19c6de12 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml
@@ -99,6 +99,8 @@ See the LICENSE file in the project root for more information.
Avoid performing long-waiting tasks in this method, since it can block other threads from accessing the provider registry.
+ This method must be idempotent. It may be invoked more than once for the same provider instance, and more than once for a single call, because registration uses a concurrent registry whose update logic may run multiple times under contention.
+
This method must not throw.
The authentication method.
@@ -108,6 +110,8 @@ See the LICENSE file in the project root for more information.
For example, this method is called when a different provider with the same authentication method overrides this provider in the SQL authentication provider registry. Avoid performing long-waiting task in this method, since it can block other threads from accessing the provider registry.
+ This method must be idempotent. It may be invoked more than once for the same provider instance, and more than once for a single call, because registration uses a concurrent registry whose update logic may run multiple times under contention.
+
This method must not throw.
The authentication method.
@@ -130,6 +134,9 @@ 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.
+
+ This is the canonical way to retrieve a registered authentication provider.
+ Sets an authentication provider by method.
@@ -138,6 +145,9 @@ See the LICENSE file in the project root for more information.
if the operation succeeded; otherwise, (for example, the existing provider disallows overriding).
+
+ This is the canonical way to register an authentication provider.
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj
index 8826080981..3b7c99ac5a 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj
@@ -34,9 +34,13 @@
-
+
+
+
+
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
new file mode 100644
index 0000000000..49780cc763
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
@@ -0,0 +1,256 @@
+// 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 Microsoft.Data.SqlClient.Internal;
+
+namespace Microsoft.Data.SqlClient;
+
+///
+/// Holds the registry of instances keyed by
+/// . This is the shared store that backs the public
+/// and
+/// methods.
+///
+///
+/// Providers fall into two categories:
+///
+///
+///
+/// Permanent providers, registered via (e.g. from an
+/// application's configuration). These take precedence and cannot be overridden by a later
+/// call to .
+///
+///
+///
+///
+/// Overridable providers, registered via . These can be replaced
+/// by subsequent calls, but never override a permanent provider.
+///
+///
+///
+///
+internal sealed class AuthenticationProviderRegistry
+{
+ #region Private Fields
+
+ ///
+ /// The singleton instance backing the public static
+ /// and
+ /// accessors.
+ ///
+ ///
+ /// Production code uses this shared instance. Tests can instead construct an isolated
+ /// instance via the internal constructor to avoid mutating global state.
+ ///
+ internal static AuthenticationProviderRegistry Instance { get; } = new();
+
+ ///
+ /// A registered provider together with whether it was registered as permanent (via
+ /// ) and therefore not overridable by .
+ ///
+ /// The registered provider. Never .
+ /// Whether the provider must not be overridden by .
+ private readonly record struct ProviderEntry(SqlAuthenticationProvider Provider, bool IsPermanent);
+
+ ///
+ /// The registered providers keyed by authentication method. Each entry records whether the
+ /// provider was registered as permanent (e.g. application specified); permanent providers are
+ /// not overridable via .
+ ///
+ private readonly ConcurrentDictionary _providers = new();
+
+ ///
+ /// Control-flow sentinel used to abort 's update factory when it
+ /// would replace a permanent provider. Never escapes .
+ ///
+ private sealed class PermanentProviderException : Exception
+ {
+ }
+
+ ///
+ /// Sentinel thrown from 's update factory to abort the
+ /// AddOrUpdate call when it would replace a permanent provider. It never escapes
+ /// .
+ ///
+ private static readonly PermanentProviderException s_permanentProviderException = new();
+
+ #endregion
+
+ #region Construction
+
+ ///
+ /// Initializes a new, empty registry. Production code uses the shared ;
+ /// the constructor is exposed to tests so they can exercise registry behavior in isolation.
+ ///
+ internal AuthenticationProviderRegistry()
+ {
+ }
+
+ #endregion
+
+ #region Internal API
+
+ ///
+ /// Gets the provider registered for the given authentication method, or
+ /// if none is registered.
+ ///
+ internal SqlAuthenticationProvider? GetProvider(SqlAuthenticationMethod authenticationMethod)
+ {
+ return _providers.TryGetValue(authenticationMethod, out ProviderEntry entry)
+ ? entry.Provider
+ : null;
+ }
+
+ ///
+ /// Registers an overridable provider for the given authentication method.
+ ///
+ ///
+ /// if the provider was registered; if a
+ /// permanent provider is already registered for the authentication method.
+ ///
+ ///
+ /// The provider does not support the given authentication method.
+ ///
+ internal bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider)
+ {
+ if (!provider.IsSupported(authenticationMethod))
+ {
+ throw new NotSupportedException(
+ string.Format(
+ AbstractionsStrings.SQL_UnsupportedAuthenticationByProvider,
+ provider.GetType().Name,
+ authenticationMethod.ToString()));
+ }
+
+ try
+ {
+ _providers.AddOrUpdate(
+ authenticationMethod,
+ // addValueFactory: no provider is registered for this method yet.
+ (SqlAuthenticationMethod key) =>
+ {
+ InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
+
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | Added auth provider {0} for authentication {1}.",
+ GetProviderType(provider),
+ key);
+
+ return new ProviderEntry(provider, IsPermanent: false);
+ },
+ // updateValueFactory: a provider is already registered for this method.
+ (SqlAuthenticationMethod key, ProviderEntry existing) =>
+ {
+ // Permanent providers cannot be replaced.
+ if (existing.IsPermanent)
+ {
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | Failed to add provider {0} because a " +
+ "permanent provider with type {1} already existed for authentication {2}.",
+ GetProviderType(provider),
+ GetProviderType(existing.Provider),
+ key);
+
+ // A permanent provider was specified for this authentication method, so we
+ // won't override it. Abort the update; SetProvider catches this below.
+ throw s_permanentProviderException;
+ }
+
+ InvokeProviderCallback(existing.Provider, existing.Provider.BeforeUnload, key, nameof(SqlAuthenticationProvider.BeforeUnload));
+ InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
+
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | Added auth provider {0}, overriding " +
+ "existing provider {1} for authentication {2}.",
+ GetProviderType(provider),
+ GetProviderType(existing.Provider),
+ key);
+
+ return new ProviderEntry(provider, IsPermanent: false);
+ });
+ }
+ catch (PermanentProviderException)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Registers a permanent provider for the given authentication method. Permanent providers
+ /// take precedence and cannot be overridden by .
+ ///
+ ///
+ /// Callers are responsible for verifying that the provider supports the authentication method
+ /// before registering it.
+ ///
+ /// This is a last-in-wins operation: a later call for the
+ /// same authentication method unconditionally replaces any previously registered provider
+ /// (permanent or not). Only is blocked by an existing permanent
+ /// provider; itself always overwrites.
+ ///
+ ///
+ internal void SetPermanentProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider)
+ {
+ _providers[authenticationMethod] = new ProviderEntry(provider, IsPermanent: true);
+ }
+
+ #endregion
+
+ #region Private Helpers
+
+ ///
+ /// Returns a human-readable type name for the given provider, for use in trace messages.
+ ///
+ /// The provider to describe, or .
+ ///
+ /// The provider's full type name; "null" if is
+ /// ; or "unknown" if the type name is unavailable.
+ ///
+ private static string GetProviderType(SqlAuthenticationProvider? provider)
+ {
+ if (provider is null)
+ {
+ return "null";
+ }
+ return provider.GetType().FullName ?? "unknown";
+ }
+
+ ///
+ /// Invokes a provider lifecycle callback ( or
+ /// ), isolating the registry from a
+ /// misbehaving provider: any exception the callback throws is logged and swallowed so it
+ /// cannot corrupt registration.
+ ///
+ /// The provider whose callback is being invoked (used for logging).
+ /// The callback to invoke.
+ /// The authentication method passed to the callback.
+ /// The callback name, used in trace messages.
+ private static void InvokeProviderCallback(
+ SqlAuthenticationProvider provider,
+ Action callback,
+ SqlAuthenticationMethod authenticationMethod,
+ string callbackName)
+ {
+ try
+ {
+ callback(authenticationMethod);
+ }
+ catch (Exception ex)
+ {
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | {0} threw for provider {1} with " +
+ "authentication {2}; ignoring: {3}",
+ callbackName,
+ GetProviderType(provider),
+ authenticationMethod,
+ ex);
+ }
+ }
+
+ #endregion
+}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/IsExternalInit.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/IsExternalInit.cs
new file mode 100644
index 0000000000..225b0e3860
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/IsExternalInit.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.
+
+#if !NET
+
+namespace System.Runtime.CompilerServices;
+
+///
+/// Polyfill for the marker type the C# compiler requires to emit init-only setters
+/// (used by records and init-only properties). It is provided by the BCL on .NET, but not on
+/// the netstandard2.0 / .NET Framework targets this assembly supports, so we define it here.
+///
+internal static class IsExternalInit
+{
+}
+
+#endif
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..595106a294 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 AuthenticationProviderRegistry.Instance.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 AuthenticationProviderRegistry.Instance.SetProvider(authenticationMethod, provider);
}
}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.Designer.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.Designer.cs
new file mode 100644
index 0000000000..c07b958067
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.Designer.cs
@@ -0,0 +1,80 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Microsoft.Data.SqlClient
+{
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class AbstractionsStrings
+ {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal AbstractionsStrings()
+ {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager
+ {
+ get
+ {
+ if (object.ReferenceEquals(resourceMan, null))
+ {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.Data.SqlClient.Strings", typeof(AbstractionsStrings).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture
+ {
+ get
+ {
+ return resourceCulture;
+ }
+ set
+ {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The provider '{0}' does not support authentication '{1}'..
+ ///
+ internal static string SQL_UnsupportedAuthenticationByProvider
+ {
+ get
+ {
+ return ResourceManager.GetString("SQL_UnsupportedAuthenticationByProvider", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.cs.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.cs.resx
new file mode 100644
index 0000000000..ed789746b9
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.cs.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Zprostředkovatel {0} nepodporuje ověřování {1}.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.de.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.de.resx
new file mode 100644
index 0000000000..bebed6aad9
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.de.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Die Authentifizierung "{1}" wird vom Anbieter "{0}" nicht unterstützt.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.es.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.es.resx
new file mode 100644
index 0000000000..eb9a12c8d1
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.es.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ El proveedor "{0}" no admite la autenticación "{1}".
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.fr.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.fr.resx
new file mode 100644
index 0000000000..499e11b9e7
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.fr.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Le fournisseur '{0}' ne prend pas en charge l'authentification '{1}'.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.it.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.it.resx
new file mode 100644
index 0000000000..d15ff92cd4
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.it.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Il provider '{0}' non supporta l'autenticazione '{1}'.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ja.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ja.resx
new file mode 100644
index 0000000000..6db9db6080
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ja.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ プロバイダー '{0}' では認証 '{1}' はサポートされていません。
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ko.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ko.resx
new file mode 100644
index 0000000000..c153160993
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ko.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ '{0}' 공급자에서 '{1}' 인증을 지원하지 않습니다.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pl.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pl.resx
new file mode 100644
index 0000000000..58aa1ddc94
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pl.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Dostawca „{0}” nie obsługuje uwierzytelniania „{1}”.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pt-BR.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pt-BR.resx
new file mode 100644
index 0000000000..cc0343939e
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.pt-BR.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ O provedor '{0}' não dá suporte à autenticação '{1}'.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.resx
new file mode 100644
index 0000000000..44856c63c5
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ The provider '{0}' does not support authentication '{1}'.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ru.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ru.resx
new file mode 100644
index 0000000000..30c30793d9
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.ru.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Поставщик "{0}" не поддерживает проверку подлинности "{1}".
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.tr.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.tr.resx
new file mode 100644
index 0000000000..359aced0bd
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.tr.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ '{0}' adlı sağlayıcı, '{1}' kimlik doğrulamasını desteklemiyor.
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hans.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hans.resx
new file mode 100644
index 0000000000..26cfda9a6e
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hans.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 提供程序“{0}”不支持身份验证“{1}”。
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hant.resx b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hant.resx
new file mode 100644
index 0000000000..8505a12ec6
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Strings.zh-Hant.resx
@@ -0,0 +1,18 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 提供者 '{0}' 不支援驗證 '{1}'。
+
+
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationProviderRegistryTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationProviderRegistryTest.cs
new file mode 100644
index 0000000000..742ba2b5d8
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/AuthenticationProviderRegistryTest.cs
@@ -0,0 +1,463 @@
+// 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;
+
+///
+/// Tests for the AuthenticationProviderRegistry class.
+///
+/// Each test exercises an isolated registry instance (constructed via the internal constructor)
+/// so there is no shared global state and the tests are safe to run in parallel.
+///
+public class AuthenticationProviderRegistryTest
+{
+ #region GetProvider
+
+ ///
+ /// GetProvider returns null when no provider has been registered for the specified
+ /// authentication method.
+ ///
+ [Fact]
+ public void GetProvider_ReturnsNull_WhenNoProviderRegistered()
+ {
+ AuthenticationProviderRegistry registry = new();
+
+ Assert.Null(registry.GetProvider(SqlAuthenticationMethod.SqlPassword));
+ }
+
+ ///
+ /// GetProvider returns null for NotSpecified, which is never a valid registration target.
+ ///
+ [Fact]
+ public void GetProvider_ReturnsNull_ForNotSpecified()
+ {
+ AuthenticationProviderRegistry registry = new();
+
+ Assert.Null(registry.GetProvider(SqlAuthenticationMethod.NotSpecified));
+ }
+
+ ///
+ /// Getting an existing provider works.
+ ///
+ [Fact]
+ public void GetProvider_ReturnsSameInstance_AfterSetProvider()
+ {
+ AuthenticationProviderRegistry registry = new();
+ DeviceCodeProvider provider = new();
+
+ Assert.True(
+ registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow,
+ provider));
+
+ Assert.Same(
+ provider,
+ registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow));
+ }
+
+ #endregion
+
+ #region SetProvider - Basic
+
+ ///
+ /// SetProvider throws NullReferenceException when a null provider is passed (current behavior,
+ /// not a validated argument).
+ ///
+ [Fact]
+ public void SetProvider_ThrowsOnNullProvider()
+ {
+ AuthenticationProviderRegistry registry = new();
+
+ Assert.Throws(() =>
+ registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow,
+ null!));
+ }
+
+ ///
+ /// SetProvider throws NotSupportedException when the provider does not support the specified
+ /// authentication method, and the message names both the provider type and the method.
+ ///
+ [Fact]
+ public void SetProvider_ThrowsOnUnsupportedMethod()
+ {
+ AuthenticationProviderRegistry registry = new();
+
+ // DeviceCodeProvider only supports DeviceCodeFlow.
+ DeviceCodeProvider provider = new();
+
+ NotSupportedException ex = Assert.Throws(() =>
+ registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive,
+ provider));
+
+ Assert.Contains(nameof(DeviceCodeProvider), ex.Message);
+ Assert.Contains(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive.ToString(),
+ ex.Message);
+ }
+
+ ///
+ /// SetProvider replaces a previously registered provider for the same authentication method.
+ ///
+ [Fact]
+ public void SetProvider_ReplacesExistingProvider()
+ {
+ AuthenticationProviderRegistry registry = new();
+ DeviceCodeProvider provider1 = new();
+ DeviceCodeProvider provider2 = new();
+
+ Assert.True(
+ registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow,
+ provider1));
+
+ Assert.Same(
+ provider1,
+ registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow));
+
+ // Replace with provider2.
+ Assert.True(
+ registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow,
+ provider2));
+
+ Assert.Same(
+ provider2,
+ registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow));
+ }
+
+ ///
+ /// Distinct providers registered for distinct methods are keyed independently: each method
+ /// returns its own provider, and a method that was never registered returns null. This guards
+ /// against mis-keying or cross-method bleed in the backing store.
+ ///
+ [Fact]
+ public void SetProvider_DistinctProvidersPerMethod_AreKeyedIndependently()
+ {
+ AuthenticationProviderRegistry registry = new();
+
+ AllMethodsProvider integrated = new();
+ AllMethodsProvider interactive = new();
+ AllMethodsProvider deviceCode = new();
+
+ Assert.True(registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, integrated));
+ Assert.True(registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, interactive));
+ Assert.True(registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, deviceCode));
+
+ // Each method returns its own provider -- no cross-talk.
+ Assert.Same(integrated, registry.GetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated));
+ Assert.Same(interactive, registry.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive));
+ Assert.Same(deviceCode, registry.GetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow));
+
+ // A method that was never registered returns null.
+ Assert.Null(registry.GetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal));
+ }
+
+ #endregion
+
+
+ #region SetProvider - Lifecycle callbacks
+
+ ///
+ /// The first registration for a method invokes BeforeLoad (immediately before the provider is
+ /// added to the registry) but not BeforeUnload (there is no prior provider to unload).
+ ///
+ [Fact]
+ public void SetProvider_FirstRegistration_InvokesBeforeLoad_NotBeforeUnload()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ RecordingProvider provider = new();
+
+ Assert.True(registry.SetProvider(method, provider));
+ Assert.Same(provider, registry.GetProvider(method));
+
+ Assert.Equal([method], provider.BeforeLoadCalls);
+ Assert.Empty(provider.BeforeUnloadCalls);
+ }
+
+ ///
+ /// An exception thrown by BeforeLoad during the first registration (the add path, with no prior
+ /// provider to override) is swallowed; the provider is still registered and SetProvider
+ /// succeeds.
+ ///
+ [Fact]
+ public void SetProvider_FirstRegistration_BeforeLoadThrows_IsSwallowed_AndRegistrationSucceeds()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ RecordingProvider provider = new(throwFromCallbacks: true);
+
+ // No prior provider exists, so this takes the add path; BeforeLoad throws but is swallowed.
+ Assert.True(registry.SetProvider(method, provider));
+ Assert.Same(provider, registry.GetProvider(method));
+
+ // The throwing BeforeLoad was actually invoked (before it threw); BeforeUnload was not
+ // (there was nothing to unload).
+ Assert.Equal([method], provider.BeforeLoadCalls);
+ Assert.Empty(provider.BeforeUnloadCalls);
+ }
+
+ ///
+ /// Replacing an existing provider invokes BeforeUnload on the old provider and BeforeLoad on
+ /// the new provider, each for the affected method.
+ ///
+ [Fact]
+ public void SetProvider_Replace_InvokesBeforeUnloadOnOld_AndBeforeLoadOnNew()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ RecordingProvider oldProvider = new();
+ RecordingProvider newProvider = new();
+
+ Assert.True(registry.SetProvider(method, oldProvider));
+
+ // We see BeforeLoad called on oldProvider.
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Empty(oldProvider.BeforeUnloadCalls);
+
+ Assert.True(registry.SetProvider(method, newProvider));
+
+ // We see BeforeUnload called on oldProvider, and its BeforeLoad calls are not repeated.
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Equal([method], oldProvider.BeforeUnloadCalls);
+
+ // We see BeforeLoad called on newProvider.
+ Assert.Equal([method], newProvider.BeforeLoadCalls);
+ Assert.Empty(newProvider.BeforeUnloadCalls);
+
+ Assert.Same(newProvider, registry.GetProvider(method));
+ }
+
+ ///
+ /// An exception thrown by the old provider's BeforeUnload callback is swallowed; the new
+ /// provider is still registered and SetProvider succeeds.
+ ///
+ [Fact]
+ public void SetProvider_Replace_BeforeUnloadThrows_IsSwallowed_AndRegistrationSucceeds()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ RecordingProvider oldProvider = new(throwFromCallbacks: true);
+ RecordingProvider newProvider = new();
+
+ Assert.True(registry.SetProvider(method, oldProvider));
+
+ // We see BeforeLoad called on oldProvider.
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Empty(oldProvider.BeforeUnloadCalls);
+
+ // oldProvider.BeforeUnload throws, but the override still succeeds.
+ Assert.True(registry.SetProvider(method, newProvider));
+ Assert.Same(newProvider, registry.GetProvider(method));
+
+ // The throwing BeforeUnload was actually invoked (before it threw).
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Equal([method], oldProvider.BeforeUnloadCalls);
+
+ // The new provider's BeforeLoad still ran.
+ Assert.Equal([method], newProvider.BeforeLoadCalls);
+ Assert.Empty(newProvider.BeforeUnloadCalls);
+ }
+
+ ///
+ /// An exception thrown by the new provider's BeforeLoad callback is swallowed; the new provider
+ /// is still registered and SetProvider succeeds.
+ ///
+ [Fact]
+ public void SetProvider_Replace_BeforeLoadThrows_IsSwallowed_AndRegistrationSucceeds()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ RecordingProvider oldProvider = new();
+ RecordingProvider newProvider = new(throwFromCallbacks: true);
+
+ Assert.True(registry.SetProvider(method, oldProvider));
+
+ // We see BeforeLoad called on oldProvider.
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Empty(oldProvider.BeforeUnloadCalls);
+
+ // newProvider.BeforeLoad throws, but the override still succeeds.
+ Assert.True(registry.SetProvider(method, newProvider));
+ Assert.Same(newProvider, registry.GetProvider(method));
+
+ // The old provider's BeforeUnload still ran.
+ Assert.Equal([method], oldProvider.BeforeLoadCalls);
+ Assert.Equal([method], oldProvider.BeforeUnloadCalls);
+
+ // The throwing BeforeLoad was actually invoked (before it threw).
+ Assert.Equal([method], newProvider.BeforeLoadCalls);
+ Assert.Empty(newProvider.BeforeUnloadCalls);
+ }
+
+ #endregion
+
+ #region SetPermanentProvider
+
+ ///
+ /// SetPermanentProvider registers the provider, and GetProvider then returns
+ /// that same instance.
+ ///
+ [Fact]
+ public void SetPermanentProvider_ThenGetProvider_ReturnsSameInstance()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity;
+
+ AllMethodsProvider provider = new();
+ registry.SetPermanentProvider(method, provider);
+
+ Assert.Same(provider, registry.GetProvider(method));
+ }
+
+ ///
+ /// A permanently registered provider takes precedence: a subsequent user
+ /// SetProvider call for the same method returns false and does not replace
+ /// it.
+ ///
+ [Fact]
+ public void SetPermanentProvider_TakesPrecedence_OverUserSetProvider()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity;
+
+ AllMethodsProvider permanent = new();
+ registry.SetPermanentProvider(method, permanent);
+
+ Assert.Same(permanent, registry.GetProvider(method));
+
+ // A user attempt to override the permanent provider fails.
+ AllMethodsProvider userProvider = new();
+ Assert.False(registry.SetProvider(method, userProvider));
+
+ // The permanent provider is still in place.
+ Assert.Same(permanent, registry.GetProvider(method));
+ }
+
+ ///
+ /// SetPermanentProvider is last-in-wins: a later call for the same method
+ /// unconditionally replaces the previously registered permanent provider,
+ /// and the replacement remains non-overridable by SetProvider.
+ ///
+ [Fact]
+ public void SetPermanentProvider_LastInWins()
+ {
+ AuthenticationProviderRegistry registry = new();
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity;
+
+ AllMethodsProvider first = new();
+ AllMethodsProvider second = new();
+
+ registry.SetPermanentProvider(method, first);
+ registry.SetPermanentProvider(method, second);
+
+ // The second permanent registration replaced the first.
+ Assert.Same(second, registry.GetProvider(method));
+
+ // The replacement is still permanent: a user SetProvider is refused.
+ Assert.False(registry.SetProvider(method, new AllMethodsProvider()));
+ Assert.Same(second, registry.GetProvider(method));
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// A dummy provider that supports all authentication methods.
+ ///
+ private sealed class AllMethodsProvider : SqlAuthenticationProvider
+ {
+ ///
+ public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true;
+
+ ///
+ public override Task AcquireTokenAsync(
+ SqlAuthenticationParameters parameters)
+ => throw new NotImplementedException();
+ }
+
+ ///
+ /// A dummy provider that only supports ActiveDirectoryDeviceCodeFlow.
+ ///
+ private sealed class DeviceCodeProvider : SqlAuthenticationProvider
+ {
+ ///
+ public override bool IsSupported(SqlAuthenticationMethod authenticationMethod)
+ => authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
+
+ ///
+ public override Task AcquireTokenAsync(
+ SqlAuthenticationParameters parameters)
+ => Task.FromResult(
+ new SqlAuthenticationToken(
+ "SampleAccessToken", DateTimeOffset.UtcNow.AddMinutes(5)));
+ }
+
+ ///
+ /// A provider that supports all methods and records every BeforeLoad and
+ /// BeforeUnload invocation. When constructed with throwFromCallbacks,
+ /// each callback throws after recording, so tests can verify the registry
+ /// both invokes the callback and isolates its failure.
+ ///
+ private sealed class RecordingProvider : SqlAuthenticationProvider
+ {
+ private readonly bool _throwFromCallbacks;
+
+ public RecordingProvider(bool throwFromCallbacks = false)
+ => _throwFromCallbacks = throwFromCallbacks;
+
+ public List BeforeLoadCalls { get; } = new();
+
+ public List BeforeUnloadCalls { get; } = new();
+
+ ///
+ public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true;
+
+ ///
+ public override void BeforeLoad(SqlAuthenticationMethod authenticationMethod)
+ {
+ BeforeLoadCalls.Add(authenticationMethod);
+ if (_throwFromCallbacks)
+ {
+ throw new InvalidOperationException("BeforeLoad failed.");
+ }
+ }
+
+ ///
+ public override void BeforeUnload(SqlAuthenticationMethod authenticationMethod)
+ {
+ BeforeUnloadCalls.Add(authenticationMethod);
+ if (_throwFromCallbacks)
+ {
+ throw new InvalidOperationException("BeforeUnload failed.");
+ }
+ }
+
+ ///
+ public override Task AcquireTokenAsync(
+ SqlAuthenticationParameters parameters)
+ => throw new NotImplementedException();
+ }
+
+ #endregion
+}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs
index c5872a8391..362a707bce 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/test/SqlAuthenticationProviderTest.cs
@@ -6,6 +6,11 @@
namespace Microsoft.Data.SqlClient.Extensions.Abstractions.Test;
+///
+/// Tests for the public static API, which delegates to
+/// the shared AuthenticationProviderRegistry.Instance within the Abstractions assembly.
+/// Registry behavior in isolation is covered by AuthenticationProviderRegistryTest.
+///
public class SqlAuthenticationProviderTest
{
#region Test Setup
@@ -15,7 +20,8 @@ public class SqlAuthenticationProviderTest
///
public SqlAuthenticationProviderTest()
{
- // Confirm that the MDS assembly is indeed not present.
+ // Confirm that the MDS assembly is indeed not present. This proves the
+ // registry operates purely within the Abstractions assembly.
Assert.Throws(
() => Assembly.Load("Microsoft.Data.SqlClient"));
}
@@ -25,50 +31,35 @@ public SqlAuthenticationProviderTest()
#region Tests
///
- /// Test that GetProvider fails predictably when the MDS assembly can't be
- /// found.
+ /// The public static API delegates to the shared
+ /// , so reads and writes through the
+ /// public API and the shared registry instance observe the same backing store.
///
- [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 GetProvider_NoMdsAssembly(SqlAuthenticationMethod method)
+ [Fact]
+ public void PublicApi_DelegatesToSharedInstance()
{
- // GetProvider() should return null when the MDS assembly can't be
- // found.
- Assert.Null(SqlAuthenticationProvider.GetProvider(method));
- }
+ // Use a method that no other test registers on the shared instance, so this cannot
+ // interfere with other tests running in the same class.
+ const SqlAuthenticationMethod method =
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
- ///
- /// 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()));
+ DeviceCodeProvider provider = new();
+
+ Assert.True(SqlAuthenticationProvider.SetProvider(method, provider));
+
+ Assert.Same(provider, SqlAuthenticationProvider.GetProvider(method));
+
+ // The public API and the shared registry instance agree.
+ Assert.Same(provider, AuthenticationProviderRegistry.Instance.GetProvider(method));
+
+ // Replacing via the internal API is reflected through both the public API and the shared
+ // registry instance, confirming they observe the same backing store.
+ DeviceCodeProvider replacement = new();
+
+ Assert.True(AuthenticationProviderRegistry.Instance.SetProvider(method, replacement));
+
+ Assert.Same(replacement, SqlAuthenticationProvider.GetProvider(method));
+ Assert.Same(replacement, AuthenticationProviderRegistry.Instance.GetProvider(method));
}
#endregion
@@ -76,22 +67,25 @@ public void SetProvider_NoMdsAssembly(SqlAuthenticationMethod method)
#region Helpers
///
- /// A dummy provider that supports all authentication methods.
+ /// A dummy provider that only supports ActiveDirectoryDeviceCodeFlow.
///
- private sealed class Provider : SqlAuthenticationProvider
+ private sealed class DeviceCodeProvider : SqlAuthenticationProvider
{
///
public override bool IsSupported(
SqlAuthenticationMethod authenticationMethod)
{
- return true;
+ return authenticationMethod ==
+ SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
}
///
public override Task AcquireTokenAsync(
SqlAuthenticationParameters parameters)
{
- throw new NotImplementedException();
+ return Task.FromResult(
+ new SqlAuthenticationToken(
+ "SampleAccessToken", DateTimeOffset.UtcNow.AddMinutes(5)));
}
}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs
index e90dcce506..e24b7ceaab 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs
@@ -10,7 +10,7 @@
namespace Microsoft.Data.SqlClient.Extensions.Azure.Test;
// These tests were moved from MDS FunctionalTests AADAuthenticationTests.cs.
-[Collection("SqlAuthenticationProvider")]
+[Collection("SqlAuthenticationProviderGlobal")]
public class AADAuthenticationTests
{
[Fact]
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
new file mode 100644
index 0000000000..efd48b3046
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
@@ -0,0 +1,113 @@
+// 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.Azure.Test;
+
+///
+/// Tests for the SqlClient-internal AuthenticationBootstrapper that run the full bootstrap
+/// and so mutate the process-wide AuthenticationProviderRegistry.Instance. They are
+/// serialized via the shared SqlAuthenticationProvider collection.
+///
+///
+/// Like , these require the Azure extension assembly
+/// to be present (guaranteed only by this test project). The non-global, side-effect-free tests
+/// live in .
+///
+[Collection("SqlAuthenticationProviderGlobal")]
+public class AuthenticationBootstrapperGlobalTests
+{
+ public AuthenticationBootstrapperGlobalTests()
+ {
+ // Precondition: the Azure extension assembly must be present for these tests to be
+ // meaningful. This is what distinguishes this project from the core UnitTests.
+ Assert.NotNull(Assembly.Load("Microsoft.Data.SqlClient.Extensions.Azure"));
+ }
+
+ // Verify that the bootstrapper installs the Azure auth provider for all AAD/Entra
+ // authentication methods, and not for any other methods.
+ //
+ // This project configures neither applicationClientId nor useWamBroker (it has no app.config
+ // overrides), so the bootstrapper constructs the Azure extension's
+ // ActiveDirectoryAuthenticationProvider via its parameterless constructor and registers that
+ // single instance for every Active Directory method.
+ [Fact]
+ public void Bootstrap_InstallsAzureProvider_ForAllActiveDirectoryMethods()
+ {
+ // Under the lazy-bootstrap model the SqlClient bootstrapper only runs on first federated
+ // authentication. Force it to run so the Azure extension provider is discovered and
+ // registered.
+ //
+ // GOTCHA: This modifies global state.
+ Bootstrap();
+
+ // Iterate over all authentication methods rather than specifying them via Theory data so
+ // that we detect any new methods that don't meet our expectations.
+ #if NET
+ var methods = Enum.GetValues();
+ #else
+ var methods = Enum.GetValues(typeof(SqlAuthenticationMethod)).Cast();
+ #endif
+
+ foreach (var method in methods)
+ {
+ SqlAuthenticationProvider? provider = SqlAuthenticationProvider.GetProvider(method);
+
+ switch (method)
+ {
+ #pragma warning disable 0618 // Type or member is obsolete
+ case SqlAuthenticationMethod.ActiveDirectoryPassword:
+ #pragma warning restore 0618 // Type or member is obsolete
+ case SqlAuthenticationMethod.ActiveDirectoryIntegrated:
+ case SqlAuthenticationMethod.ActiveDirectoryInteractive:
+ case SqlAuthenticationMethod.ActiveDirectoryServicePrincipal:
+ case SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow:
+ case SqlAuthenticationMethod.ActiveDirectoryManagedIdentity:
+ case SqlAuthenticationMethod.ActiveDirectoryMSI:
+ case SqlAuthenticationMethod.ActiveDirectoryDefault:
+ case SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity:
+ {
+ Assert.NotNull(provider);
+ Assert.IsType(provider);
+ break;
+ }
+ default:
+ // There is either no provider installed, or it is not ours.
+ if (provider is not null)
+ {
+ Assert.IsNotType(provider);
+ }
+ break;
+ }
+ }
+ }
+
+ // Forces the MDS bootstrapper to run by invoking its internal static Bootstrap() method via
+ // reflection. This project does not have InternalsVisibleTo from Microsoft.Data.SqlClient, so
+ // the method cannot be called directly.
+ //
+ // NOTE: This perturbs GLOBAL state -- Bootstrap() seeds the process-wide
+ // AuthenticationProviderRegistry.Instance (installing the Azure provider for the AD methods).
+ // That is why this class lives in the [Collection("SqlAuthenticationProviderGlobal")] collection,
+ // which serializes it with the other tests that mutate the shared registry.
+ //
+ // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/41888):
+ // Once PR #4385 completes (signing Azure/Azure.Test for internal Package-mode CI builds), grant
+ // this project InternalsVisibleTo from Microsoft.Data.SqlClient and replace this reflection
+ // with a direct call to AuthenticationBootstrapper.Bootstrap().
+ private static void Bootstrap()
+ {
+ Type? bootstrapper = Type.GetType(
+ "Microsoft.Data.SqlClient.AuthenticationBootstrapper, Microsoft.Data.SqlClient");
+ Assert.NotNull(bootstrapper);
+
+ MethodInfo? bootstrap = bootstrapper!.GetMethod(
+ "Bootstrap",
+ BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
+ Assert.NotNull(bootstrap);
+
+ bootstrap!.Invoke(null, null);
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperTests.cs
new file mode 100644
index 0000000000..cbeecaf66c
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperTests.cs
@@ -0,0 +1,148 @@
+// 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.Azure.Test;
+
+///
+/// Tests for the MDS-internal AuthenticationBootstrapper that require the Azure extension
+/// assembly to be present but do NOT mutate global state. These exercise
+/// CreateAzureAuthenticationProvider's constructor selection against the real Azure provider,
+/// so they need no [Collection] serialization.
+///
+///
+/// This is the only test project that references — and therefore guarantees the presence of —
+/// Microsoft.Data.SqlClient.Extensions.Azure, so the bootstrapper's Azure-extension
+/// discovery can be exercised for real here. The core UnitTests project, where the Azure extension
+/// is absent, covers the bootstrapper's Azure-absent paths instead.
+///
+/// The global-state-mutating tests (which run the full bootstrap) live in
+/// (AuthenticationBootstrapperGlobalTests.cs).
+///
+public class AuthenticationBootstrapperTests
+{
+ public AuthenticationBootstrapperTests()
+ {
+ // Precondition: the Azure extension assembly must be present for these tests to be
+ // meaningful. This is what distinguishes this project from the core UnitTests.
+ Assert.NotNull(Assembly.Load("Microsoft.Data.SqlClient.Extensions.Azure"));
+ }
+
+ // CreateAzureAuthenticationProvider -- constructor selection against the REAL Azure extension.
+ //
+ // These tests drive the same logic the bootstrapper runs inside LoadAzureExtensionProvider,
+ // using the applicationClientId / useWamBroker values that the
+ // app.config section would produce. Because the real Azure extension always exposes
+ // ActiveDirectoryAuthenticationProviderOptions, the legacy-(string) fallback, the
+ // "no compatible ctor -> null", and the "useWamBroker but Options missing -> throw" paths are
+ // NOT reachable here; those are covered with stubs in the core UnitTests.
+
+ // The SqlClient first-party application client id hard-coded in the provider (always enables
+ // WAM broker).
+ private const string SqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38";
+
+ // A fixed stand-in for a caller-/config-supplied application id, distinct from the first-party id.
+ private const string TestCustomAppId = "11111111-2222-3333-4444-555555555555";
+
+ // No config (applicationClientId and useWamBroker both unset) -> parameterless ctor -> the
+ // first-party id, which enables WAM broker.
+ [Fact]
+ public void CreateAzureProvider_NoConfig_UsesParameterlessCtor()
+ {
+ var provider = Assert.IsType(
+ CreateAzureAuthenticationProvider(applicationClientId: null, useWamBroker: null));
+
+ Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId);
+ Assert.True(provider.UseWamBroker);
+ }
+
+ // applicationClientId only -> Options ctor; UseWamBroker stays at its default (false) for a
+ // caller-supplied id.
+ [Fact]
+ public void CreateAzureProvider_AppClientIdOnly_UsesOptionsCtor_WamDisabled()
+ {
+ var provider = Assert.IsType(
+ CreateAzureAuthenticationProvider(applicationClientId: TestCustomAppId, useWamBroker: null));
+
+ Assert.Equal(TestCustomAppId, provider.ApplicationClientId);
+ Assert.False(provider.UseWamBroker);
+ }
+
+ // applicationClientId + useWamBroker=true -> Options ctor; both are forwarded.
+ [Fact]
+ public void CreateAzureProvider_AppClientIdAndUseWamBrokerTrue_UsesOptionsCtor_WamEnabled()
+ {
+ var provider = Assert.IsType(
+ CreateAzureAuthenticationProvider(applicationClientId: TestCustomAppId, useWamBroker: true));
+
+ Assert.Equal(TestCustomAppId, provider.ApplicationClientId);
+ Assert.True(provider.UseWamBroker);
+ }
+
+ // applicationClientId + useWamBroker=false -> Options ctor; the explicit opt-out is honored.
+ [Fact]
+ public void CreateAzureProvider_AppClientIdAndUseWamBrokerFalse_UsesOptionsCtor_WamDisabled()
+ {
+ var provider = Assert.IsType(
+ CreateAzureAuthenticationProvider(applicationClientId: TestCustomAppId, useWamBroker: false));
+
+ Assert.Equal(TestCustomAppId, provider.ApplicationClientId);
+ Assert.False(provider.UseWamBroker);
+ }
+
+ // useWamBroker=true with no applicationClientId -> Options ctor; the id falls back to the
+ // first-party id, which enables WAM broker.
+ [Fact]
+ public void CreateAzureProvider_UseWamBrokerOnly_UsesOptionsCtor_WamEnabled()
+ {
+ var provider = Assert.IsType(
+ CreateAzureAuthenticationProvider(applicationClientId: null, useWamBroker: true));
+
+ Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId);
+ Assert.True(provider.UseWamBroker);
+ }
+
+ // Invokes the MDS-internal AuthenticationBootstrapper.CreateAzureAuthenticationProvider via
+ // reflection, passing the REAL Azure provider and options types. Unwraps the reflection wrapper
+ // so a test observes the real exception type, if any.
+ //
+ // NOTE: This reflection is only needed because this project does not have InternalsVisibleTo
+ // from Microsoft.Data.SqlClient. This call has no global side effects -- it just returns a new
+ // provider instance.
+ //
+ // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/41888):
+ // Once PR #4385 completes (signing Azure/Azure.Test for internal Package-mode CI builds), grant
+ // this project InternalsVisibleTo from Microsoft.Data.SqlClient and replace this reflection
+ // with a direct call to AuthenticationBootstrapper.CreateAzureAuthenticationProvider.
+ private static SqlAuthenticationProvider? CreateAzureAuthenticationProvider(
+ string? applicationClientId,
+ bool? useWamBroker)
+ {
+ Type? bootstrapper = Type.GetType(
+ "Microsoft.Data.SqlClient.AuthenticationBootstrapper, Microsoft.Data.SqlClient");
+ Assert.NotNull(bootstrapper);
+
+ MethodInfo? method = bootstrapper!.GetMethod(
+ "CreateAzureAuthenticationProvider",
+ BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ try
+ {
+ return (SqlAuthenticationProvider?)method!.Invoke(
+ null,
+ [
+ typeof(ActiveDirectoryAuthenticationProvider),
+ typeof(ActiveDirectoryAuthenticationProviderOptions),
+ applicationClientId,
+ useWamBroker,
+ ]);
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ throw ex.InnerException;
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs
deleted file mode 100644
index fa426141c9..0000000000
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs
+++ /dev/null
@@ -1,67 +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.
-
-namespace Microsoft.Data.SqlClient.Extensions.Azure.Test;
-
-[Collection("SqlAuthenticationProvider")]
-public class DefaultAuthProviderTests
-{
- // 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.
- //
- // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/41888):
- // Move this test to a more appropriate location once we have one.
- //
- [Fact]
- public void AuthProviderInstalled()
- {
- // Iterate over all authentication methods rather than specifying them
- // via Theory data so that we detect any new methods that don't meet
- // our expectations.
- #if NET
- var methods = Enum.GetValues();
- #else
- var methods = Enum.GetValues(typeof(SqlAuthenticationMethod)).Cast();
- #endif
-
- foreach (var method in methods)
- {
- SqlAuthenticationProvider? provider =
- SqlAuthenticationProvider.GetProvider(method);
-
- switch (method)
- {
- #pragma warning disable 0618 // Type or member is obsolete
- case SqlAuthenticationMethod.ActiveDirectoryPassword:
- #pragma warning restore 0618 // Type or member is obsolete
- case SqlAuthenticationMethod.ActiveDirectoryIntegrated:
- case SqlAuthenticationMethod.ActiveDirectoryInteractive:
- case SqlAuthenticationMethod.ActiveDirectoryServicePrincipal:
- case SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow:
- case SqlAuthenticationMethod.ActiveDirectoryManagedIdentity:
- case SqlAuthenticationMethod.ActiveDirectoryMSI:
- case SqlAuthenticationMethod.ActiveDirectoryDefault:
- case SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity:
- Assert.NotNull(provider);
- Assert.IsType(provider);
- break;
-
- default:
- // There is either no provider installed, or it is not ours.
- if (provider is not null)
- {
- Assert.IsNotType(provider);
- }
- break;
- }
- }
- }
-}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderGlobalCollection.cs
similarity index 79%
rename from src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs
rename to src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderGlobalCollection.cs
index 2bf23f7873..892239d990 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderGlobalCollection.cs
@@ -8,7 +8,7 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test;
/// Defines a test collection that serializes execution of test classes
/// which mutate the global registry.
///
-[CollectionDefinition("SqlAuthenticationProvider")]
-public class SqlAuthenticationProviderCollection
+[CollectionDefinition("SqlAuthenticationProviderGlobal")]
+public class SqlAuthenticationProviderGlobalCollection
{
}
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs
index 9b2aa5dcf7..ad9a625d1b 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs
@@ -6,7 +6,6 @@
namespace Microsoft.Data.SqlClient.Extensions.Azure.Test;
-[Collection("SqlAuthenticationProvider")]
public class WamBrokerTests
{
// The SqlClient first-party application client id that is hard-coded in the provider.
@@ -88,7 +87,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 AuthenticationBootstrapper relies on when reflecting onto the
/// Options ctor and only forwarding the properties that were explicitly configured.
///
[Fact]
@@ -272,45 +271,4 @@ public void Ctor_Options_Null_ThrowsArgumentNullException()
Assert.Throws(
() => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProviderOptions)null!));
}
-
- ///
- /// Registering an instance via must not
- /// wrap or replace the instance, so its WAM broker setting survives registration.
- ///
- ///
- /// Provider registration mutates global state shared across this test class collection
- /// (and any other test that depends on the default provider being installed). Save and
- /// restore the original provider in a finally block to keep cross-test isolation.
- ///
- [Fact]
- public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting()
- {
- var provider = new ActiveDirectoryAuthenticationProvider(
- new ActiveDirectoryAuthenticationProviderOptions
- {
- ApplicationClientId = TestCustomAppId,
- UseWamBroker = true,
- });
-
- SqlAuthenticationProvider? original =
- SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive);
- try
- {
- SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider);
-
- var retrieved = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive)
- as ActiveDirectoryAuthenticationProvider;
- Assert.NotNull(retrieved);
- Assert.Same(provider, retrieved);
- Assert.Equal(TestCustomAppId, retrieved!.ApplicationClientId);
- Assert.True(retrieved.UseWamBroker);
- }
- finally
- {
- if (original is not null)
- {
- SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, original);
- }
- }
- }
}
diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
index 266d0b9a1d..3b9a7a6989 100644
--- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
+++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
@@ -2,6 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+// NOTE: This ref assembly intentionally does not use #nullable annotations.
+// The implementation source uses nullable (e.g. string?, SqlAuthenticationProvider?)
+// but the ref/notsupported projects omit them for consistency with the existing
+// codebase convention and to avoid GenAPI nullable attribute complications.
+
namespace Microsoft.Data.SqlClient;
///
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
index c81f18031b..2399524cdc 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
@@ -198,14 +198,14 @@
-
-
-
-
+
+
+
+
+
- ILLink.Substitutions.xml
-
+ LogicalName="ILLink.Substitutions.xml"
+ Condition="'$(TargetFramework)' != 'net462'" />
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
similarity index 60%
rename from src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs
rename to src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
index 082a5599ce..f5fd0adf45 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
@@ -3,9 +3,10 @@
// See the LICENSE file in the project root for more information.
using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
using System.Configuration;
+#if NET
+using System.Diagnostics.CodeAnalysis;
+#endif
using System.IO;
using System.Reflection;
using Microsoft.Data.SqlClient.Internal;
@@ -14,50 +15,252 @@
namespace Microsoft.Data.SqlClient
{
- internal sealed class SqlAuthenticationProviderManager
+ ///
+ /// Seeds an with the authentication providers
+ /// discovered from application configuration and from the optional Azure extension assembly.
+ ///
+ ///
+ ///
+ /// Production code uses a lazily-created singleton that seeds the shared
+ /// . Because the singleton is only
+ /// created when a federated/Active Directory connection first authenticates, the (reflection
+ /// based) config and Azure-extension discovery is deferred until it is actually needed.
+ ///
+ ///
+ /// Call to force that one-time initialization. It accesses the lazy
+ /// singleton, whose factory runs exactly once in a thread-safe manner. Constructing a
+ /// bootstrapper directly does not run that factory, so it has no effect on the shared registry.
+ ///
+ ///
+ /// Tests can instead construct an isolated bootstrapper (which seeds a fresh registry) to
+ /// inspect discovered providers without mutating global state.
+ ///
+ ///
+ internal sealed class AuthenticationBootstrapper
{
- [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";
-
- // The name of our Azure extension assembly.
- private const string azureAssemblyName = "Microsoft.Data.SqlClient.Extensions.Azure";
-
- // The public key token of our Azure extension assembly, used to avoid loading imposter
- // assemblies.
- private static readonly byte[] s_azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ];
-
- static SqlAuthenticationProviderManager()
+ // The production singleton. Its factory seeds the shared AuthenticationProviderRegistry.
+ // Instance, and runs exactly once, only when Value is first accessed (via Bootstrap()).
+ // Constructing a bootstrapper directly does not touch this field, so it has no effect on
+ // the shared registry - keeping isolated-registry callers (e.g. tests) free of global state.
+ private static readonly Lazy s_instance =
+ new(static () => new AuthenticationBootstrapper(AuthenticationProviderRegistry.Instance));
+
+ // The registry this bootstrapper seeds. Production uses the shared singleton registry;
+ // tests can inject an isolated registry to avoid mutating global state.
+ private readonly AuthenticationProviderRegistry _registry;
+
+ // Our logging instance.
+ private readonly SqlClientLogger _sqlAuthLogger = new();
+
+ // Application client ID read from the app.config configuration section, or null if none
+ // was configured.
+ private 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 bool? _useWamBroker = null;
+
+ ///
+ /// Creates a bootstrapper that seeds the supplied registry, running config-driven and
+ /// Azure extension provider discovery.
+ ///
+ internal AuthenticationBootstrapper(AuthenticationProviderRegistry registry)
+ {
+ _registry = registry;
+
+ // Config-driven auth providers, initializers, and application client ID all use
+ // reflection (Type.GetType / Activator.CreateInstance) and are incompatible with AOT.
+ // Only read the config section and load the Azure extension when reflection-based
+ // discovery is enabled.
+ if (LocalAppContextSwitches.EnableReflectionBasedAuthenticationProviderDiscovery)
+ {
+ LoadConfiguration();
+ LoadAzureExtensionProvider();
+ }
+ else
+ {
+ _sqlAuthLogger.LogInfo(
+ nameof(AuthenticationBootstrapper),
+ "Ctor",
+ "Reflection-based provider discovery is disabled; skipping app.config " +
+ "authentication provider configuration.");
+ }
+ }
+
+ ///
+ /// Forces the one-time initialization that seeds the shared authentication provider
+ /// registry. Accessing the lazy singleton's value runs its factory exactly once, in a
+ /// thread-safe manner; subsequent calls are a cheap no-op.
+ ///
+ internal static void Bootstrap()
{
- SqlAuthenticationProviderConfigurationSection? configurationSection = null;
+ _ = s_instance.Value;
+ }
+
+ ///
+ /// Gets the application client ID read from the app.config configuration section,
+ /// or if none was configured.
+ ///
+ internal string? ApplicationClientId => _applicationClientId;
+
+ ///
+ /// Reads the app.config configuration section and registers config-driven initializers and
+ /// authentication providers. Uses reflection (Type.GetType / Activator.CreateInstance) and
+ /// is not compatible with NativeAOT trimming.
+ ///
+ #if NET
+ [RequiresUnreferencedCode(
+ "Config-driven auth providers and initializers use Type.GetType and Activator.CreateInstance. " +
+ "For AOT applications, register providers explicitly via SetProvider().")]
+ [RequiresDynamicCode(
+ "Config-driven auth providers and initializers use Activator.CreateInstance. " +
+ "For AOT applications, register providers explicitly via SetProvider().")]
+ #endif
+ private void LoadConfiguration()
+ {
+ SqlClientEventSource.Log.TryTraceEvent("AuthenticationBootstrapper | Loading authentication provider configuration from app.config.");
+
+ SqlAuthenticationProviderConfigurationSection? configSection = null;
try
{
// New configuration section "SqlClientAuthenticationProviders" for Microsoft.Data.SqlClient accepted to avoid conflicts with older one.
- configurationSection = FetchConfigurationSection(SqlClientAuthenticationProviderConfigurationSection.Name);
- if (configurationSection == null)
+ configSection = FetchConfigurationSection(SqlClientAuthenticationProviderConfigurationSection.Name);
+ if (configSection == null)
{
// If configuration section is not yet found, try with old Configuration Section name for backwards compatibility
- configurationSection = FetchConfigurationSection(SqlAuthenticationProviderConfigurationSection.Name);
+ configSection = FetchConfigurationSection(SqlAuthenticationProviderConfigurationSection.Name);
}
}
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);
+ SqlClientEventSource.Log.TryTraceEvent("static AuthenticationBootstrapper: Unable to load custom SqlAuthenticationProviders or SqlClientAuthenticationProviders. ConfigurationManager failed to load due to configuration errors: {0}", e);
+ }
+
+ var methodName = "Ctor";
+
+ if (configSection == null)
+ {
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, "Neither SqlClientAuthenticationProviders nor SqlAuthenticationProviders configuration section found.");
+ return;
}
- Instance = new SqlAuthenticationProviderManager(configurationSection);
+ if (!string.IsNullOrEmpty(configSection.ApplicationClientId))
+ {
+ _applicationClientId = configSection.ApplicationClientId;
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, "Received user-defined Application Client Id");
+ }
+ else
+ {
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), 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(AuthenticationBootstrapper), methodName, $"Received user-defined UseWamBroker={useWamBroker}.");
+ }
+ else
+ {
+ _sqlAuthLogger.LogError(nameof(AuthenticationBootstrapper), methodName, $"Ignoring user-defined UseWamBroker='{configSection.UseWamBroker}': not a valid boolean.");
+ }
+ }
+ else
+ {
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), 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(AuthenticationBootstrapper), methodName, "Created user-defined SqlAuthenticationInitializer.");
+ }
+ else
+ {
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), 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);
+ }
+
+ // Register as a permanent (application-specified) provider so it cannot be
+ // overridden by the Azure extension default or by a later SetProvider call.
+ _registry.SetPermanentProvider(authentication, provider);
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, string.Format("Added user-defined auth provider: {0} for authentication {1}.", providerSettings?.Type, authentication));
+ }
+ }
+ else
+ {
+ _sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, "No user-defined auth providers.");
+ }
+ }
+
+ ///
+ /// Attempts to load the Azure extension authentication provider via
+ /// reflection. This method uses Assembly.Load and Activator.CreateInstance
+ /// and is not compatible with NativeAOT trimming.
+ ///
+ #if NET
+ [RequiresUnreferencedCode(
+ "Azure extension provider discovery uses Assembly.Load and Activator.CreateInstance. " +
+ "For AOT applications, register providers explicitly via SetProvider().")]
+ [RequiresDynamicCode(
+ "Azure extension provider discovery uses Activator.CreateInstance. " +
+ "For AOT applications, register providers explicitly via SetProvider().")]
+ #endif
+ private void LoadAzureExtensionProvider()
+ {
+ // The name of our Azure extension assembly.
+ const string azureAssemblyName = "Microsoft.Data.SqlClient.Extensions.Azure";
- // If our Azure extensions package is present, use its authentication provider as our
- // default.
try
{
// Try to load our Azure extension.
@@ -66,15 +269,18 @@ static SqlAuthenticationProviderManager()
// When strong-name signing is enabled, build a fully-qualified AssemblyName
// that includes the expected public key token.
+ // The public key token of our Azure extension assembly, used to avoid loading
+ // imposter assemblies.
+ byte[] azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ];
+
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Attempting to load Azure extension assembly={0} with " +
- "expected public key token={1}",
- azureAssemblyName,
- BitConverter.ToString(s_azurePublicKeyToken).Replace("-", ""));
+ nameof(AuthenticationBootstrapper) +
+ $": Attempting to load Azure extension assembly={azureAssemblyName} with " +
+ "expected public key token=" +
+ BitConverter.ToString(azurePublicKeyToken).Replace("-", ""));
var qualifiedName = new AssemblyName(azureAssemblyName);
- qualifiedName.SetPublicKeyToken(s_azurePublicKeyToken);
+ qualifiedName.SetPublicKeyToken(azurePublicKeyToken);
// The .NET Framework runtime will enforce the token during binding, causing Load()
// to throw. This prevents an untrusted assembly from being loaded and having its
@@ -93,14 +299,13 @@ static SqlAuthenticationProviderManager()
{
byte[]? actualToken = assembly.GetName().GetPublicKeyToken();
- if (actualToken is null || !actualToken.AsSpan().SequenceEqual(s_azurePublicKeyToken))
+ if (actualToken is null || !actualToken.AsSpan().SequenceEqual(azurePublicKeyToken))
{
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension assembly={0} has an " +
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension assembly={assembly.GetName()} has an " +
"unexpected public key token; " +
- "no default Active Directory provider installed",
- assembly.GetName());
+ "no default Active Directory provider installed");
return;
}
}
@@ -109,10 +314,9 @@ static SqlAuthenticationProviderManager()
#else
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Attempting to load Azure extension assembly={0} without " +
- "strong name verification; ensure this assembly is from a trusted source",
- azureAssemblyName);
+ nameof(AuthenticationBootstrapper) +
+ $": Attempting to load Azure extension assembly={azureAssemblyName} without " +
+ "strong name verification; ensure this assembly is from a trusted source");
var assembly = Assembly.Load(azureAssemblyName);
@@ -121,19 +325,17 @@ static SqlAuthenticationProviderManager()
if (assembly is null)
{
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension assembly={0} not found; " +
- "no default Active Directory provider installed",
- azureAssemblyName);
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension assembly={azureAssemblyName} not found; " +
+ "no default Active Directory provider installed");
return;
}
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension assembly={0} found; " +
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension assembly={assembly.GetName()} found; " +
"attempting to set as default provider for all Active " +
- "Directory authentication methods",
- assembly.GetName());
+ "Directory authentication methods");
// Look for the authentication provider class.
const string className = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider";
@@ -142,10 +344,9 @@ static SqlAuthenticationProviderManager()
if (type is null)
{
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension does not contain class={0}; " +
- "no default Active Directory provider installed",
- className);
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension does not contain class={className}; " +
+ "no default Active Directory provider installed");
return;
}
@@ -170,16 +371,15 @@ static SqlAuthenticationProviderManager()
SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider(
type,
optionsType,
- Instance._applicationClientId,
- Instance._useWamBroker);
+ _applicationClientId,
+ _useWamBroker);
if (instance is null)
{
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Failed to instantiate Azure extension class={0}; " +
- "no default Active Directory provider installed",
- className);
+ nameof(AuthenticationBootstrapper) +
+ $": Failed to instantiate Azure extension class={className}; " +
+ "no default Active Directory provider installed");
return;
}
@@ -190,23 +390,22 @@ static SqlAuthenticationProviderManager()
// 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);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, instance);
#pragma warning disable 0618 // Type or member is obsolete
- SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, instance);
+ _registry.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);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, instance);
+ _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, instance);
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension class={0} installed as " +
- "provider for all Active Directory authentication methods",
- className);
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension class={className} installed as " +
+ "provider for all Active Directory authentication methods");
}
// All of these exceptions mean we couldn't find or instantiate the
// Azure extension's authentication provider, in which case we
@@ -228,147 +427,14 @@ TypeInitializationException or
TypeLoadException)
{
SqlClientEventSource.Log.TryTraceEvent(
- nameof(SqlAuthenticationProviderManager) +
- ": Azure extension assembly={0} not found or " +
+ nameof(AuthenticationBootstrapper) +
+ $": Azure extension assembly={azureAssemblyName} not found or " +
"not usable; no default provider installed; " +
- "{1}: {2}",
- azureAssemblyName,
- ex.GetType().Name,
- ex.Message);
+ $"{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(SqlAuthenticationProviderConfigurationSection? 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.
@@ -443,55 +509,10 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS
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;
- }
-
///
/// Fetches provided configuration section from app.config file.
/// Does not support reading from appsettings.json yet.
///
- ///
- ///
- ///
private static T? FetchConfigurationSection(string name) where T : class
{
Type t = typeof(T);
@@ -516,39 +537,30 @@ private static SqlAuthenticationMethod AuthenticationEnumFromString(string authe
{
switch (authentication.ToLowerInvariant())
{
- case ActiveDirectoryIntegrated:
+ case "active directory integrated":
return SqlAuthenticationMethod.ActiveDirectoryIntegrated;
#pragma warning disable 0618 // Type or member is obsolete
- case ActiveDirectoryPassword:
+ case "active directory password":
return SqlAuthenticationMethod.ActiveDirectoryPassword;
#pragma warning restore 0618 // Type or member is obsolete
- case ActiveDirectoryInteractive:
+ case "active directory interactive":
return SqlAuthenticationMethod.ActiveDirectoryInteractive;
- case ActiveDirectoryServicePrincipal:
+ case "active directory service principal":
return SqlAuthenticationMethod.ActiveDirectoryServicePrincipal;
- case ActiveDirectoryDeviceCodeFlow:
+ case "active directory device code flow":
return SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow;
- case ActiveDirectoryManagedIdentity:
+ case "active directory managed identity":
return SqlAuthenticationMethod.ActiveDirectoryManagedIdentity;
- case ActiveDirectoryMSI:
+ case "active directory msi":
return SqlAuthenticationMethod.ActiveDirectoryMSI;
- case ActiveDirectoryDefault:
+ case "active directory default":
return SqlAuthenticationMethod.ActiveDirectoryDefault;
- case ActiveDirectoryWorkloadIdentity:
+ case "active directory workload identity":
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";
- }
}
///
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 ec210e407a..e20af8f170 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
@@ -2786,7 +2786,12 @@ private SqlFedAuthToken GetFedAuthToken(SqlFedAuthInfo fedAuthInfo)
// Username to use in error messages.
string? username = null;
- SqlAuthenticationProvider? authProvider = SqlAuthenticationProviderManager.GetProvider(ConnectionOptions.Authentication);
+ // Ensure config-driven and Azure extension providers have been discovered and
+ // registered before we look one up. This is a one-time, lazy initialization that
+ // only runs the first time a federated/Active Directory connection authenticates.
+ AuthenticationBootstrapper.Bootstrap();
+
+ SqlAuthenticationProvider? authProvider = SqlAuthenticationProvider.GetProvider(ConnectionOptions.Authentication);
if (authProvider == null && _accessTokenCallback == null)
{
throw SQL.CannotFindAuthProvider(ConnectionOptions.Authentication);
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
index 06bf6c4f0e..3c2438a131 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
@@ -155,6 +155,14 @@ internal static class LocalAppContextSwitches
///
private const string UseMinimumLoginTimeoutString =
"Switch.Microsoft.Data.SqlClient.UseOneSecFloorInTimeoutCalculationDuringLogin";
+
+ ///
+ /// The name of the app context switch that controls whether
+ /// AuthenticationBootstrapper uses reflection to discover and load
+ /// the Azure extension authentication provider at startup.
+ ///
+ internal const string EnableReflectionBasedAuthenticationProviderDiscoveryString =
+ "Microsoft.Data.SqlClient.EnableReflectionBasedAuthenticationProviderDiscovery";
#endregion
@@ -260,9 +268,9 @@ private enum SwitchValue : byte
#if NET
///
- /// The cached value of the UseManagedNetworking switch.
+ /// The cached value of the UseManagedNetworkingOnWindows switch.
///
- private static SwitchValue s_useManagedNetworking = SwitchValue.None;
+ private static SwitchValue s_useManagedNetworkingOnWindows = SwitchValue.None;
#endif
///
@@ -270,6 +278,11 @@ private enum SwitchValue : byte
///
private static SwitchValue s_useMinimumLoginTimeout = SwitchValue.None;
+ ///
+ /// The cached value of the EnableReflectionBasedAuthenticationProviderDiscovery switch.
+ ///
+ private static SwitchValue s_enableReflectionBasedAuthenticationProviderDiscovery = SwitchValue.None;
+
#endregion
#region Static Initialization
@@ -614,38 +627,44 @@ public static bool UseCompatibilityAsyncBehaviour
#if NET
///
- /// When set to true, .NET on Windows will use the managed SNI
- /// implementation instead of the native SNI implementation.
+ /// Returns whether to use the managed SNI implementation. On non-Windows
+ /// platforms this always returns (native SNI is not
+ /// available). On Windows it delegates to the
+ /// Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows
+ /// AppContext switch (default ).
///
/// ILLink.Substitutions.xml allows the unused SNI implementation to be
- /// trimmed away when the corresponding AppContext switch is set at compile
- /// time. In such cases, this property will return a constant value, even if
- /// the AppContext switch is set or reset at runtime. See the
- /// ILLink.Substitutions.xml resource file for details.
+ /// trimmed away when the AppContext switch is set at publish time. The
+ /// trimmer substitutes which is
+ /// guarded by the platform check here, so the substitution is safe on all
+ /// platforms.
///
/// The default value of this switch is false on Windows and true on non-Windows platforms.
///
- public static bool UseManagedNetworking
+ public static bool UseManagedNetworking =>
+ !OperatingSystem.IsWindows() || UseManagedNetworkingOnWindows;
+
+ ///
+ /// Returns the value of the UseManagedNetworkingOnWindows AppContext switch.
+ /// This property is the trimmer substitution target — callers should use
+ /// which includes the platform guard.
+ ///
+ private static bool UseManagedNetworkingOnWindows
{
get
{
- if (!OsConstants.IsWindows)
- {
- return true;
- }
-
- if (s_useManagedNetworking != SwitchValue.None)
+ if (s_useManagedNetworkingOnWindows != SwitchValue.None)
{
- return s_useManagedNetworking == SwitchValue.True;
+ return s_useManagedNetworkingOnWindows == SwitchValue.True;
}
if (AppContext.TryGetSwitch(UseManagedNetworkingOnWindowsString, out bool returnedValue) && returnedValue)
{
- s_useManagedNetworking = SwitchValue.True;
+ s_useManagedNetworkingOnWindows = SwitchValue.True;
return true;
}
- s_useManagedNetworking = SwitchValue.False;
+ s_useManagedNetworkingOnWindows = SwitchValue.False;
return false;
}
}
@@ -669,6 +688,58 @@ public static bool UseManagedNetworking
defaultValue: true,
ref s_useMinimumLoginTimeout);
+ ///
+ /// When set to true (the default), AuthenticationBootstrapper will
+ /// use reflection (Assembly.Load + Activator.CreateInstance) to discover
+ /// and load the Azure extension authentication provider at startup.
+ ///
+ /// AOT applications should set this to false and register providers
+ /// explicitly via SqlAuthenticationProvider.SetProvider().
+ ///
+ /// The default value of this switch is true.
+ ///
+ ///
+ ///
+ /// This switch can be consumed in two ways:
+ ///
+ ///
+ /// 1. Runtime AppContext switch (non-AOT apps):
+ /// Set via AppContext.SetSwitch("Microsoft.Data.SqlClient.EnableReflectionBasedAuthenticationProviderDiscovery", false)
+ /// or in runtimeconfig.json. Must be set before the first SqlConnection is opened
+ /// (i.e. before AuthenticationBootstrapper's static constructor runs).
+ /// The reflection code remains present in the assembly but is not called.
+ ///
+ ///
+ /// 2. Trimmer/AOT feature switch (published AOT apps):
+ /// Set in the consuming app's .csproj:
+ ///
+ /// <RuntimeHostConfigurationOption
+ /// Include="Microsoft.Data.SqlClient.EnableReflectionBasedAuthenticationProviderDiscovery"
+ /// Value="false"
+ /// Trim="true" />
+ ///
+ /// At publish time, the trimmer substitutes this property with constant false
+ /// (via [FeatureSwitchDefinition] on .NET 9+ and ILLink.Substitutions.xml
+ /// on .NET 8+). The dead if (false) branch is eliminated, and all unreachable
+ /// reflection code (Assembly.Load, Activator.CreateInstance, exception
+ /// filters) is removed from the final binary.
+ ///
+ ///
+ /// The two approaches differ in that the runtime switch leaves reflection IL in the
+ /// binary (just skips calling it), while the trimmer switch physically removes the
+ /// code — eliminating AOT warnings and reducing binary size.
+ ///
+ ///
+#if NET9_0_OR_GREATER
+ [System.Diagnostics.CodeAnalysis.FeatureSwitchDefinition(
+ EnableReflectionBasedAuthenticationProviderDiscoveryString)]
+#endif
+ internal static bool EnableReflectionBasedAuthenticationProviderDiscovery =>
+ AcquireAndReturn(
+ EnableReflectionBasedAuthenticationProviderDiscoveryString,
+ defaultValue: true,
+ ref s_enableReflectionBasedAuthenticationProviderDiscovery);
+
#endregion
#region Helpers
diff --git a/src/Microsoft.Data.SqlClient/src/Resources/ILLink.Substitutions.xml b/src/Microsoft.Data.SqlClient/src/Resources/ILLink.Substitutions.xml
index e8577c7ae0..bf3478b984 100644
--- a/src/Microsoft.Data.SqlClient/src/Resources/ILLink.Substitutions.xml
+++ b/src/Microsoft.Data.SqlClient/src/Resources/ILLink.Substitutions.xml
@@ -1,9 +1,57 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
index 49ad2712ec..bf5b5945a2 100644
--- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
+++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
@@ -66,6 +66,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable
private readonly bool? _useManagedNetworkingOriginal;
#endif
private readonly bool? _useMinimumLoginTimeoutOriginal;
+ private readonly bool? _enableReflectionBasedAuthenticationProviderDiscoveryOriginal;
#endregion
@@ -131,11 +132,13 @@ public LocalAppContextSwitchesHelper()
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_useManagedNetworkingOriginal =
- GetSwitchValue("s_useManagedNetworking");
+ GetSwitchValue("s_useManagedNetworkingOnWindows");
}
#endif
_useMinimumLoginTimeoutOriginal =
GetSwitchValue("s_useMinimumLoginTimeout");
+ _enableReflectionBasedAuthenticationProviderDiscoveryOriginal =
+ GetSwitchValue("s_enableReflectionBasedAuthenticationProviderDiscovery");
}
catch
{
@@ -174,7 +177,7 @@ public void Dispose()
"s_useLegacyFailoverAlternationOnLoginSqlErrors",
_useLegacyFailoverAlternationOnLoginSqlErrorsOriginal);
SetSwitchValue(
- "s_legacyRowVersionNullBehavior",
+ "s_legacyRowVersionNullBehavior",
_legacyRowVersionNullBehaviorOriginal);
SetSwitchValue(
"s_legacyVarTimeZeroScaleBehaviour",
@@ -207,13 +210,16 @@ public void Dispose()
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
SetSwitchValue(
- "s_useManagedNetworking",
+ "s_useManagedNetworkingOnWindows",
_useManagedNetworkingOriginal);
}
#endif
SetSwitchValue(
"s_useMinimumLoginTimeout",
_useMinimumLoginTimeoutOriginal);
+ SetSwitchValue(
+ "s_enableReflectionBasedAuthenticationProviderDiscovery",
+ _enableReflectionBasedAuthenticationProviderDiscoveryOriginal);
}
finally
{
@@ -372,7 +378,7 @@ public bool? UseOverallConnectTimeoutForPoolWait
#if NET
///
- /// Get or set the UseManagedNetworking switch value.
+ /// Get or set the UseManagedNetworkingOnWindows switch value.
///
///
/// The underlying s_useManagedNetworking field only exists in the SqlClient
@@ -387,7 +393,7 @@ public bool? UseOverallConnectTimeoutForPoolWait
public bool? UseManagedNetworking
{
get => GetSwitchPropertyValue(nameof(UseManagedNetworking));
- set => SetSwitchValue("s_useManagedNetworking", value);
+ set => SetSwitchValue("s_useManagedNetworkingOnWindows", value);
}
#endif
@@ -400,6 +406,15 @@ public bool? UseMinimumLoginTimeout
set => SetSwitchValue("s_useMinimumLoginTimeout", value);
}
+ ///
+ /// Get or set the EnableReflectionBasedAuthenticationProviderDiscovery switch value.
+ ///
+ public bool? EnableReflectionBasedAuthenticationProviderDiscovery
+ {
+ get => GetSwitchValue("s_enableReflectionBasedAuthenticationProviderDiscovery");
+ set => SetSwitchValue("s_enableReflectionBasedAuthenticationProviderDiscovery", value);
+ }
+
#endregion
#region Helpers
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs
index dc6d5b3d7a..13e41e2d90 100644
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs
+++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AADAuthenticationTests.cs
@@ -4,8 +4,6 @@
using System;
using System.Security;
-using System.Threading.Tasks;
-using Microsoft.Data.SqlClient.FunctionalTests.DataCommon;
using Xunit;
namespace Microsoft.Data.SqlClient.Tests
@@ -49,29 +47,5 @@ private void InvalidCombinationCheck(SqlCredential credential)
Assert.Throws(() => connection.AccessToken = "SampleAccessToken");
}
}
-
- ///
- /// 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.
- ///
- /// 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.
- ///
- [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))]
- public async Task IsDummySqlAuthenticationProviderSetByDefault()
- {
- var provider = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive);
-
- Assert.NotNull(provider);
- Assert.IsType(provider);
-
- var token = await provider.AcquireTokenAsync(null);
- Assert.Equal(token.AccessToken, DummySqlAuthenticationProvider.DUMMY_TOKEN_STR);
- }
}
}
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/DataCommon/DummySqlAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/DataCommon/DummySqlAuthenticationProvider.cs
deleted file mode 100644
index c68baf63eb..0000000000
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/DataCommon/DummySqlAuthenticationProvider.cs
+++ /dev/null
@@ -1,28 +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;
-
-namespace Microsoft.Data.SqlClient.FunctionalTests.DataCommon
-{
- ///
- /// Dummy class to override default Sql Authentication provider in functional tests.
- /// This type returns a dummy access token and is only used for registration test from app.config file.
- /// Since no actual connections are intended to be made in Functional tests,
- /// this type is added by default to validate config file registration scenario.
- ///
- public class DummySqlAuthenticationProvider : SqlAuthenticationProvider
- {
- public static string DUMMY_TOKEN_STR = "dummy_access_token";
-
- public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters)
- => Task.FromResult(new SqlAuthenticationToken(DUMMY_TOKEN_STR, new DateTimeOffset(DateTime.Now.AddHours(2))));
-
- public override bool IsSupported(SqlAuthenticationMethod authenticationMethod)
- {
- return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive;
- }
- }
-}
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj
index e22d6430d9..eb107e6ec7 100644
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj
+++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/Microsoft.Data.SqlClient.FunctionalTests.csproj
@@ -26,11 +26,6 @@
-
-
- Always
-
-
PreserveNewest
xunit.runner.json
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderManagerTests.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderManagerTests.cs
deleted file mode 100644
index 0d276624c9..0000000000
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlAuthenticationProviderManagerTests.cs
+++ /dev/null
@@ -1,75 +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 Microsoft.Data.SqlClient.FunctionalTests.DataCommon;
-using Xunit;
-
-namespace Microsoft.Data.SqlClient.Tests
-{
- public class SqlAuthenticationProviderManagerTests
- {
- // The FunctionalTests project employs a .NET Framework app.config file
- // that configures a dummy authentication provider for
- // ActiveDirectoryInteractive authentication. Verify that this is
- // respected.
- [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))]
- public void DefaultAuthenticationProviders_AppConfig()
- {
- // The provider for ActiveDirectoryInteractive should be our dummy
- // provider.
- Assert.IsType(
- SqlAuthenticationProvider.GetProvider(
- SqlAuthenticationMethod.ActiveDirectoryInteractive));
-
- // There should be no provider for other methods. Spot-check a few.
- Assert.Null(SqlAuthenticationProvider.GetProvider(
- #pragma warning disable CS0618 // Type or member is obsolete
- SqlAuthenticationMethod.ActiveDirectoryPassword));
- #pragma warning restore CS0618 // Type or member is obsolete
-
- Assert.Null(SqlAuthenticationProvider.GetProvider(
- SqlAuthenticationMethod.ActiveDirectoryManagedIdentity));
- }
-
- // Verify that the dummy provider installed via app.config cannot be replaced.
- [ConditionalFact(typeof(TestUtility), nameof(TestUtility.IsNetFramework))]
- public void DefaultAuthenticationProviders_NoReplace()
- {
- // The provider for ActiveDirectoryInteractive should be our dummy
- // provider.
- Assert.IsType(
- SqlAuthenticationProvider.GetProvider(
- SqlAuthenticationMethod.ActiveDirectoryInteractive));
-
- // Try to add another provider for ActiveDirectoryInteractive.
- bool setResult = SqlAuthenticationProvider.SetProvider(
- SqlAuthenticationMethod.ActiveDirectoryInteractive,
- new TestProvider());
-
- // The set should have failed.
- Assert.False(setResult);
-
- // The dummy provider is still installed.
- Assert.IsType(
- SqlAuthenticationProvider.GetProvider(
- SqlAuthenticationMethod.ActiveDirectoryInteractive));
- }
-
- private class TestProvider : SqlAuthenticationProvider
- {
- public override async Task AcquireTokenAsync(
- SqlAuthenticationParameters parameters)
- {
- throw new NotImplementedException();
- }
-
- public override bool IsSupported(SqlAuthenticationMethod authenticationMethod)
- {
- return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive;
- }
- }
- }
-}
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config
deleted file mode 100644
index 9fc08c65a7..0000000000
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/app.config
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
index 4111f701f4..b67112447c 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
@@ -24,6 +24,9 @@
TrueResources.resx
+
+ Always
+ PreserveNewest
xunit.runner.json
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
similarity index 55%
rename from src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs
rename to src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
index 08089abe98..5d961a475f 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
@@ -3,106 +3,34 @@
// See the LICENSE file in the project root for more information.
using System;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
+using Microsoft.DotNet.XUnitExtensions;
using Xunit;
namespace Microsoft.Data.SqlClient.UnitTests;
-public class SqlAuthenticationProviderManagerTests
+///
+/// Tests for , the core-side
+/// component that discovers config-driven and Azure extension authentication
+/// providers and seeds them into the Abstractions registry.
+///
+public class AuthenticationBootstrapperTests
{
- private class Provider : SqlAuthenticationProvider
+ // The Azure extension assembly is intentionally NOT referenced by this project (nor by the
+ // core driver), so these tests exercise the bootstrapper's Azure-ABSENT behavior: stub-based
+ // constructor selection (CreateAzureAuthenticationProvider_*) and config-driven providers.
+ // The Azure-PRESENT behavior is covered by
+ // Microsoft.Data.SqlClient.Extensions.Azure.Test.AuthenticationBootstrapperTests, the only
+ // test project that references (and therefore guarantees the presence of) the Azure extension.
+ public AuthenticationBootstrapperTests()
{
- 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);
- }
+ // Precondition: confirm the Azure extension is not present in this test context, so the
+ // Azure-absent assumptions in these tests hold.
+ Assert.Throws(
+ () => Assembly.Load("Microsoft.Data.SqlClient.Extensions.Azure"));
}
// CreateAzureAuthenticationProvider tests ----------------------------------------------
@@ -182,7 +110,7 @@ public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase
[Fact]
public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterlessCtor()
{
- var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubModern.ActiveDirectoryAuthenticationProvider),
typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions),
applicationClientId: null,
@@ -199,7 +127,7 @@ public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterles
[Fact]
public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOptionsCtor()
{
- var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubModern.ActiveDirectoryAuthenticationProvider),
typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions),
applicationClientId: "app-123",
@@ -215,7 +143,7 @@ public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOpt
[Fact]
public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBackToStringCtor()
{
- var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubLegacy.ActiveDirectoryAuthenticationProvider),
optionsType: null,
applicationClientId: "legacy-456",
@@ -232,7 +160,7 @@ public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBack
[Fact]
public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_ReturnsNull()
{
- var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubMinimal.ActiveDirectoryAuthenticationProvider),
optionsType: null,
applicationClientId: "no-ctor",
@@ -245,7 +173,7 @@ public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_Returns
public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws()
{
InvalidOperationException ex = Assert.Throws(() =>
- SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubLegacy.ActiveDirectoryAuthenticationProvider),
optionsType: null,
applicationClientId: null,
@@ -258,7 +186,7 @@ public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws
[Fact]
public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_UsesOptionsCtor()
{
- var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider(
+ var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider(
typeof(StubModern.ActiveDirectoryAuthenticationProvider),
typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions),
applicationClientId: "app-789",
@@ -269,4 +197,120 @@ public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_Uses
Assert.Equal("app-789", stub.CapturedApplicationClientId);
Assert.Equal(true, stub.CapturedUseWamBroker);
}
+
+ // ApplicationClientId tests ------------------------------------------------------------
+
+ ///
+ /// Verifies that ApplicationClientId is accessible and returns null when
+ /// no app.config section is present (non-Framework targets).
+ ///
+ [ConditionalFact(nameof(IsNotNetFramework))]
+ public void ApplicationClientId_IsNull_WhenNoConfig()
+ {
+ // On non-Framework targets there is no app.config, so the property
+ // should be null.
+ AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ Assert.Null(bootstrapper.ApplicationClientId);
+ }
+
+ // The UnitTests project has an app.config that configures a dummy
+ // authentication provider for ActiveDirectoryInteractive and sets
+ // applicationClientId. The following tests verify this on .NET Framework.
+
+ ///
+ /// Verifies that ApplicationClientId is read from the app.config section.
+ ///
+ [ConditionalFact(nameof(IsNetFramework))]
+ public void ApplicationClientId_ReadFromAppConfig()
+ {
+ // The app.config sets applicationClientId="f3e3a0a0-1234-5678-9abc-def012345678"
+ AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ Assert.Equal(
+ "f3e3a0a0-1234-5678-9abc-def012345678",
+ bootstrapper.ApplicationClientId);
+ }
+
+ ///
+ /// Verifies that the dummy provider from app.config is registered for
+ /// ActiveDirectoryInteractive and that no other methods have providers.
+ ///
+ [ConditionalFact(nameof(IsNetFramework))]
+ public void DefaultAuthenticationProviders_AppConfig()
+ {
+ AuthenticationProviderRegistry registry = new();
+ _ = new AuthenticationBootstrapper(registry);
+
+ foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod)))
+ {
+ var provider = registry.GetProvider(method);
+
+ if (method == SqlAuthenticationMethod.ActiveDirectoryInteractive)
+ {
+ Assert.IsType(provider);
+ }
+ else
+ {
+ Assert.Null(provider);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that the app.config-registered dummy provider can acquire a token.
+ ///
+ [ConditionalFact(nameof(IsNetFramework))]
+ public async Task DefaultAuthenticationProvider_AcquiresToken()
+ {
+ AuthenticationProviderRegistry registry = new();
+ _ = new AuthenticationBootstrapper(registry);
+
+ var provider = registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive);
+ Assert.NotNull(provider);
+ var token = await provider.AcquireTokenAsync(null!);
+ Assert.Equal(DummySqlAuthenticationProvider.DummyAccessToken, token.AccessToken);
+ }
+
+ ///
+ /// Verifies that the app.config-registered provider cannot be replaced.
+ ///
+ [ConditionalFact(nameof(IsNetFramework))]
+ public void DefaultAuthenticationProviders_NoReplace()
+ {
+ AuthenticationProviderRegistry registry = new();
+ _ = new AuthenticationBootstrapper(registry);
+
+ Assert.IsType(
+ registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive));
+
+ bool setResult = registry.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive,
+ new InteractiveProvider());
+
+ Assert.False(setResult);
+
+ Assert.IsType(
+ registry.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryInteractive));
+ }
+
+ private static bool IsNetFramework =>
+ RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework");
+
+ private static bool IsNotNetFramework => !IsNetFramework;
+
+ private class InteractiveProvider : SqlAuthenticationProvider
+ {
+ public override Task AcquireTokenAsync(
+ SqlAuthenticationParameters parameters)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override bool IsSupported(SqlAuthenticationMethod authenticationMethod)
+ {
+ return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive;
+ }
+ }
}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/DummySqlAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/DummySqlAuthenticationProvider.cs
new file mode 100644
index 0000000000..2367a04630
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/DummySqlAuthenticationProvider.cs
@@ -0,0 +1,23 @@
+// 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;
+
+namespace Microsoft.Data.SqlClient.UnitTests;
+
+///
+/// Dummy authentication provider registered via app.config for .NET Framework
+/// unit tests. Returns a dummy token and only supports ActiveDirectoryInteractive.
+///
+public class DummySqlAuthenticationProvider : SqlAuthenticationProvider
+{
+ public const string DummyAccessToken = "dummy_access_token";
+
+ public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters)
+ => Task.FromResult(new SqlAuthenticationToken(DummyAccessToken, new DateTimeOffset(DateTime.Now.AddHours(2))));
+
+ public override bool IsSupported(SqlAuthenticationMethod authenticationMethod)
+ => authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive;
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/ILLinkSubstitutionsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/ILLinkSubstitutionsTests.cs
new file mode 100644
index 0000000000..3380d7c9ab
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/ILLinkSubstitutionsTests.cs
@@ -0,0 +1,52 @@
+// 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 Xunit;
+
+namespace Microsoft.Data.SqlClient.UnitTests;
+
+///
+/// Verifies that the correct ILLink substitution files are embedded in the
+/// assembly for the current platform. This catches regressions in the csproj
+/// conditional embedding logic.
+///
+public class ILLinkSubstitutionsTests
+{
+ private static readonly string[] s_resourceNames =
+ typeof(SqlConnection).Assembly.GetManifestResourceNames();
+
+#if NETFRAMEWORK
+ ///
+ /// On .NET Framework the trimmer is not supported, so the
+ /// substitution file must NOT be embedded.
+ ///
+ [Fact]
+ public void Assembly_DoesNotContainSubstitutions()
+ {
+ Assert.DoesNotContain("ILLink.Substitutions.xml", s_resourceNames);
+ }
+#else
+ ///
+ /// The cross-platform substitution file (auth provider feature switch and
+ /// UseManagedNetworkingOnWindows) must always be present on .NET
+ /// (non-Framework) builds regardless of OS.
+ ///
+ [Fact]
+ public void Assembly_ContainsSubstitutions()
+ {
+ Assert.Contains("ILLink.Substitutions.xml", s_resourceNames);
+ }
+
+ ///
+ /// There should be no separate Windows-only substitution file. All entries
+ /// are now in the unified cross-platform ILLink.Substitutions.xml since the
+ /// UseManagedNetworking property includes a platform guard.
+ ///
+ [Fact]
+ public void Assembly_DoesNotContainWindowsSubstitutions()
+ {
+ Assert.DoesNotContain("ILLink.Substitutions.Windows.xml", s_resourceNames);
+ }
+#endif
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
index ff70c17f4b..4942aa9d21 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
@@ -44,6 +44,7 @@ public void TestDefaultAppContextSwitchValues()
switchesHelper.UseConnectionPoolV2 = null;
switchesHelper.UseLegacyIdleTimeoutBehavior = null;
switchesHelper.UseMinimumLoginTimeout = null;
+ switchesHelper.EnableReflectionBasedAuthenticationProviderDiscovery = null;
#if NET
switchesHelper.GlobalizationInvariantMode = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@@ -69,6 +70,7 @@ public void TestDefaultAppContextSwitchValues()
Assert.False(switchesHelper.IgnoreServerProvidedFailoverPartner);
Assert.False(switchesHelper.UseLegacyFailoverAlternationOnLoginSqlErrors);
Assert.False(switchesHelper.EnableMultiSubnetFailoverByDefault);
+ Assert.True(switchesHelper.EnableReflectionBasedAuthenticationProviderDiscovery);
#if NET
Assert.False(switchesHelper.GlobalizationInvariantMode);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config b/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
new file mode 100644
index 0000000000..72f7f0d22e
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/AotCompatibility/AotCompatibility.csproj b/tools/AotCompatibility/AotCompatibility.csproj
new file mode 100644
index 0000000000..464cc68e4b
--- /dev/null
+++ b/tools/AotCompatibility/AotCompatibility.csproj
@@ -0,0 +1,55 @@
+
+
+
+ Exe
+ net8.0;net9.0;net10.0
+
+ Microsoft.Data.SqlClient.Tools.AotCompatibility
+
+ enable
+ enable
+ latest
+
+
+ true
+ true
+
+
+ true
+ false
+ false
+ true
+ true
+
+
+
+
+ false
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/AotCompatibility/Directory.Build.props b/tools/AotCompatibility/Directory.Build.props
new file mode 100644
index 0000000000..1dc46a758a
--- /dev/null
+++ b/tools/AotCompatibility/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/tools/AotCompatibility/Directory.Packages.props b/tools/AotCompatibility/Directory.Packages.props
new file mode 100644
index 0000000000..b73fddeddd
--- /dev/null
+++ b/tools/AotCompatibility/Directory.Packages.props
@@ -0,0 +1,10 @@
+
+
+
+
+ true
+
+
+
+
+
diff --git a/tools/AotCompatibility/Program.cs b/tools/AotCompatibility/Program.cs
new file mode 100644
index 0000000000..2b0f07d7e7
--- /dev/null
+++ b/tools/AotCompatibility/Program.cs
@@ -0,0 +1,194 @@
+// 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.
+
+//
+// AOT Compatibility Test Application
+//
+// This application validates that Microsoft.Data.SqlClient can be published
+// as a Native AOT binary when the reflection-based authentication provider
+// discovery is disabled via the feature switch.
+//
+// Usage:
+// dotnet publish -c Release
+//
+// After successful publish, this app can be run to verify that explicit
+// provider registration works correctly without reflection.
+//
+
+using Microsoft.Data.SqlClient;
+
+Console.WriteLine("AOT Compatibility Test");
+Console.WriteLine("======================");
+Console.WriteLine();
+
+// Verify that the feature switch disabled reflection-based discovery.
+// In an AOT build, the trimmer will have substituted the property with
+// constant false and eliminated LoadAzureExtensionProvider() entirely.
+Console.WriteLine("Feature switch checks:");
+bool switchFound = AppContext.TryGetSwitch(
+ "Microsoft.Data.SqlClient.EnableReflectionBasedAuthenticationProviderDiscovery",
+ out bool reflectionEnabled);
+if (!switchFound)
+{
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine(" WARNING: Feature switch not found in AppContext.");
+ Console.WriteLine(" The switch is set via RuntimeHostConfigurationOption at publish time.");
+ Console.WriteLine(" If running via 'dotnet run' (JIT mode), this is expected.");
+ Console.ResetColor();
+}
+Console.WriteLine($" EnableReflectionBasedAuthenticationProviderDiscovery: {reflectionEnabled}");
+Console.WriteLine();
+
+Console.WriteLine("SqlAuthenticationProvider API checks:");
+
+// Register a provider explicitly (the AOT-safe way).
+string clientId = Guid.NewGuid().ToString();
+var provider = new ActiveDirectoryAuthenticationProvider(clientId);
+Console.WriteLine($" ApplicationClientId: {clientId}");
+
+bool registered = SqlAuthenticationProvider.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDefault, provider);
+Console.WriteLine($" SetProvider(Default): {registered}");
+
+registered = SqlAuthenticationProvider.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider);
+Console.WriteLine($" SetProvider(ManagedIdentity): {registered}");
+
+registered = SqlAuthenticationProvider.SetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider);
+Console.WriteLine($" SetProvider(WorkloadIdentity): {registered}");
+
+// Verify we can retrieve the registered provider.
+var retrieved = SqlAuthenticationProvider.GetProvider(
+ SqlAuthenticationMethod.ActiveDirectoryDefault);
+Console.WriteLine($" GetProvider(Default): {retrieved?.GetType().Name ?? "(null)"}");
+Console.WriteLine();
+
+// Verify SqlConnection can be constructed (no actual connection needed).
+Console.WriteLine("SqlConnection construction:");
+try
+{
+ using var connection = new SqlConnection(
+ "Server=localhost;Database=test;Encrypt=false;");
+ Console.WriteLine($" Created successfully (State={connection.State})");
+}
+catch (Exception ex)
+{
+ Console.WriteLine($" Construction failed: {ex.Message}");
+ return 1;
+}
+
+Console.WriteLine();
+
+// Check the ILC map file for trimming verification.
+// The map file is generated alongside the native binary during publish.
+// At runtime we can look for it relative to the executable.
+Console.WriteLine("Trimming verification (ILC map file):");
+var exePath = Environment.ProcessPath;
+if (exePath is not null)
+{
+ var exeDir = Path.GetDirectoryName(exePath)!;
+ var exeName = Path.GetFileNameWithoutExtension(exePath);
+
+ // Map file is in obj////native/.map.xml
+ // But at runtime we only have the publish dir. Check if it was copied or
+ // look in the well-known obj path relative to the project directory.
+ // For simplicity, search upward from exe for the map file.
+ var mapFile = Path.Combine(exeDir, $"{exeName}.map.xml");
+
+ // If not next to the binary, try the obj path (when running from the project dir)
+ if (!File.Exists(mapFile))
+ {
+ // Try to find it via the well-known native output path pattern
+ var projectDir = FindProjectDir(exeDir);
+ if (projectDir is not null)
+ {
+ try
+ {
+ var candidates = new DirectoryInfo(projectDir)
+ .EnumerateFiles($"{exeName}.map.xml", new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true
+ });
+ var first = candidates.FirstOrDefault();
+ if (first is not null)
+ {
+ mapFile = first.FullName;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($" Warning: Could not search for map file: {ex.Message}");
+ }
+ }
+ }
+
+ if (File.Exists(mapFile))
+ {
+ // Stream the file line-by-line to avoid loading a potentially huge ILC map
+ // file entirely into memory.
+ bool hasLoadAzure = false;
+ foreach (var line in File.ReadLines(mapFile))
+ {
+ if (line.Contains("LoadAzureExtensionProvider", StringComparison.Ordinal))
+ {
+ hasLoadAzure = true;
+ break;
+ }
+ }
+
+ Console.WriteLine($" Map file: {mapFile}");
+ Console.WriteLine($" Contains LoadAzureExtensionProvider: {hasLoadAzure}");
+
+ if (!reflectionEnabled && hasLoadAzure)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine(" FAIL: Reflection code was NOT trimmed!");
+ Console.ResetColor();
+ return 1;
+ }
+ else if (!reflectionEnabled && !hasLoadAzure)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine(" PASS: Reflection code was successfully trimmed.");
+ Console.ResetColor();
+ }
+ else if (reflectionEnabled && hasLoadAzure)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine(" PASS: Reflection code is present (as expected with discovery enabled).");
+ Console.ResetColor();
+ }
+ else
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine(" WARN: Reflection code absent despite discovery being enabled.");
+ Console.ResetColor();
+ }
+ }
+ else
+ {
+ Console.WriteLine($" Map file not found (expected after 'dotnet publish').");
+ Console.WriteLine(" Skipping trimming verification.");
+ }
+}
+
+Console.WriteLine();
+Console.WriteLine("All AOT compatibility checks passed.");
+return 0;
+
+static string? FindProjectDir(string startDir)
+{
+ var dir = startDir;
+ while (dir is not null)
+ {
+ if (Directory.GetFiles(dir, "*.csproj").Length > 0)
+ {
+ return dir;
+ }
+ dir = Path.GetDirectoryName(dir);
+ }
+ return null;
+}
diff --git a/tools/AotCompatibility/README.md b/tools/AotCompatibility/README.md
new file mode 100644
index 0000000000..103f6672d9
--- /dev/null
+++ b/tools/AotCompatibility/README.md
@@ -0,0 +1,115 @@
+# AOT Compatibility Test App
+
+This application validates that `Microsoft.Data.SqlClient` can be published as a
+Native AOT binary when the reflection-based authentication provider discovery is
+disabled via the feature switch.
+
+## What it validates
+
+1. **Feature switch propagation** — `RuntimeHostConfigurationOption` with
+ `Trim="true"` correctly disables reflection-based provider discovery.
+2. **Native AOT publish** — The app publishes successfully as a fully native
+ binary without linker errors.
+3. **Explicit provider registration** — `SqlAuthenticationProvider.SetProvider()`
+ and `GetProvider()` work correctly at runtime in an AOT context.
+4. **SqlConnection construction** — Basic `SqlConnection` object creation works
+ without reflection.
+
+## Usage
+
+### Build (JIT mode, for quick iteration)
+
+```bash
+dotnet build
+dotnet run
+```
+
+### Publish as Native AOT
+
+```bash
+dotnet publish -c Release -f net9.0 -r linux-x64
+./bin/Release/net9.0/linux-x64/publish/AotCompatibility
+```
+
+On Windows:
+
+```cmd
+dotnet publish -c Release -f net9.0 -r win-x64
+bin\Release\net9.0\win-x64\publish\AotCompatibility.exe
+```
+
+### Publish with reflection enabled (to confirm trimmer warnings appear)
+
+```bash
+dotnet publish -c Release -f net9.0 -r linux-x64 -p:EnableReflectionBasedAuthProviderDiscovery=true
+```
+
+When `EnableReflectionBasedAuthProviderDiscovery=true`, the trimmer cannot
+eliminate the reflection code in `LoadAzureExtensionProvider()`, so you will see
+additional IL2075/IL2026 warnings from that method. This confirms the feature
+switch is working — setting it to `false` (the test app's default, configured in
+the csproj) removes those warnings. Note that the *library's* default is `true`
+(reflection enabled); the test app overrides this to validate AOT trimming.
+
+## Expected output
+
+```text
+AOT Compatibility Test
+======================
+
+Feature switch checks:
+ EnableReflectionBasedAuthenticationProviderDiscovery: False
+
+SqlAuthenticationProvider API checks:
+ ApplicationClientId:
+ SetProvider(Default): True
+ SetProvider(ManagedIdentity): True
+ SetProvider(WorkloadIdentity): True
+ GetProvider(Default): ActiveDirectoryAuthenticationProvider
+
+SqlConnection construction:
+ Created successfully (State=Closed)
+
+All AOT compatibility checks passed.
+```
+
+## Trimmer warnings
+
+Some trimmer warnings may appear during publish. These fall into categories:
+
+| Source | Description | Status |
+| ------ | ----------- | ------ |
+| `AuthenticationBootstrapper` (config section) | `Type.GetType` in configuration-based provider loading | Pre-existing; future work to guard |
+| `SqlDiagnosticListener` | `DiagnosticSource.Write` usage | Pre-existing; unrelated to auth |
+| `System.Configuration` | Reflection in `ConfigurationManager` | External dependency |
+
+The auth provider **feature switch** correctly eliminates the `LoadAzureExtensionProvider()`
+reflection path. The remaining warnings are tracked separately and do not affect
+the validity of the AOT auth provider registration pattern.
+
+## Feature switch
+
+The project includes a `RuntimeHostConfigurationOption` in the `.csproj`:
+
+```xml
+
+```
+
+This tells the trimmer to substitute
+`LocalAppContextSwitches.EnableReflectionBasedAuthenticationProviderDiscovery`
+with `false` at compile time, enabling the trimmer to eliminate the entire
+`LoadAzureExtensionProvider()` method and its reflection dependencies.
+
+### How trimming works per TFM
+
+| TFM | Mechanism | How it works |
+| --- | --------- | ------------ |
+| **net9.0+** | `[FeatureSwitchDefinition]` attribute | The attribute on the property tells the trimmer directly that this property is a feature switch. When a `RuntimeHostConfigurationOption` sets it to `false`, the trimmer substitutes the property return value and eliminates guarded code. |
+| **net8.0** | `ILLink.Substitutions.xml` | The `[FeatureSwitchDefinition]` attribute does not exist on net8.0. Instead, the `ILLink.Substitutions.xml` file (embedded in the SqlClient assembly) declares the substitution. The trimmer reads this file and performs the same constant substitution, enabling dead-code elimination of the reflection path. |
+
+Both mechanisms produce the same result: the trimmer sees the property as
+returning a compile-time constant `false` and removes the unreachable
+`LoadAzureExtensionProvider()` call and its transitive reflection dependencies.
From 36be9be9ca4bcf025c63e262ea1b6fbf4629ac1a Mon Sep 17 00:00:00 2001
From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com>
Date: Mon, 22 Jun 2026 11:38:36 -0300
Subject: [PATCH 3/5] Refactor auth bootstrapper to auto-properties; simplify
registry; add tests
- AuthenticationBootstrapper: replace backing fields with internal
auto-properties (Registry, ApplicationClientId, UseWamBroker).
- AuthenticationProviderRegistry: remove PermanentProviderException
sentinel; document SetProvider null behavior.
- UnitTests: assert Registry/ApplicationClientId/UseWamBroker; add
UseWamBroker config tests (app.config sets useWamBroker=true).
---
.../src/AuthenticationProviderRegistry.cs | 100 +++++++-----------
.../AuthenticationBootstrapperGlobalTests.cs | 2 +
.../SqlClient/AuthenticationBootstrapper.cs | 68 ++++++------
.../AuthenticationBootstrapperTests.cs | 50 +++++++--
.../tests/UnitTests/app.config | 2 +-
5 files changed, 120 insertions(+), 102 deletions(-)
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
index 49780cc763..542fd3e38e 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs
@@ -62,21 +62,6 @@ internal sealed class AuthenticationProviderRegistry
///
private readonly ConcurrentDictionary _providers = new();
- ///
- /// Control-flow sentinel used to abort 's update factory when it
- /// would replace a permanent provider. Never escapes .
- ///
- private sealed class PermanentProviderException : Exception
- {
- }
-
- ///
- /// Sentinel thrown from 's update factory to abort the
- /// AddOrUpdate call when it would replace a permanent provider. It never escapes
- /// .
- ///
- private static readonly PermanentProviderException s_permanentProviderException = new();
-
#endregion
#region Construction
@@ -114,6 +99,9 @@ internal AuthenticationProviderRegistry()
///
/// The provider does not support the given authentication method.
///
+ ///
+ /// is .
+ ///
internal bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider)
{
if (!provider.IsSupported(authenticationMethod))
@@ -125,59 +113,53 @@ internal bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthe
authenticationMethod.ToString()));
}
- try
- {
- _providers.AddOrUpdate(
- authenticationMethod,
- // addValueFactory: no provider is registered for this method yet.
- (SqlAuthenticationMethod key) =>
+ ProviderEntry result = _providers.AddOrUpdate(
+ authenticationMethod,
+ // addValueFactory: no provider is registered for this method yet.
+ (SqlAuthenticationMethod key) =>
+ {
+ InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
+
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | Added auth provider {0} for authentication {1}.",
+ GetProviderType(provider),
+ key);
+
+ return new ProviderEntry(provider, IsPermanent: false);
+ },
+ // updateValueFactory: a provider is already registered for this method.
+ (SqlAuthenticationMethod key, ProviderEntry existing) =>
+ {
+ // Permanent providers cannot be replaced. Return the existing entry unchanged so
+ // AddOrUpdate keeps it; SetProvider detects this from the returned entry below.
+ if (existing.IsPermanent)
{
- InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
-
SqlClientEventSource.Log.TryTraceEvent(
- "AuthenticationProviderRegistry.SetProvider | Added auth provider {0} for authentication {1}.",
+ "AuthenticationProviderRegistry.SetProvider | Failed to add provider {0} because a " +
+ "permanent provider with type {1} already existed for authentication {2}.",
GetProviderType(provider),
+ GetProviderType(existing.Provider),
key);
- return new ProviderEntry(provider, IsPermanent: false);
- },
- // updateValueFactory: a provider is already registered for this method.
- (SqlAuthenticationMethod key, ProviderEntry existing) =>
- {
- // Permanent providers cannot be replaced.
- if (existing.IsPermanent)
- {
- SqlClientEventSource.Log.TryTraceEvent(
- "AuthenticationProviderRegistry.SetProvider | Failed to add provider {0} because a " +
- "permanent provider with type {1} already existed for authentication {2}.",
- GetProviderType(provider),
- GetProviderType(existing.Provider),
- key);
+ return existing;
+ }
- // A permanent provider was specified for this authentication method, so we
- // won't override it. Abort the update; SetProvider catches this below.
- throw s_permanentProviderException;
- }
+ InvokeProviderCallback(existing.Provider, existing.Provider.BeforeUnload, key, nameof(SqlAuthenticationProvider.BeforeUnload));
+ InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
- InvokeProviderCallback(existing.Provider, existing.Provider.BeforeUnload, key, nameof(SqlAuthenticationProvider.BeforeUnload));
- InvokeProviderCallback(provider, provider.BeforeLoad, key, nameof(SqlAuthenticationProvider.BeforeLoad));
+ SqlClientEventSource.Log.TryTraceEvent(
+ "AuthenticationProviderRegistry.SetProvider | Added auth provider {0}, overriding " +
+ "existing provider {1} for authentication {2}.",
+ GetProviderType(provider),
+ GetProviderType(existing.Provider),
+ key);
- SqlClientEventSource.Log.TryTraceEvent(
- "AuthenticationProviderRegistry.SetProvider | Added auth provider {0}, overriding " +
- "existing provider {1} for authentication {2}.",
- GetProviderType(provider),
- GetProviderType(existing.Provider),
- key);
-
- return new ProviderEntry(provider, IsPermanent: false);
- });
- }
- catch (PermanentProviderException)
- {
- return false;
- }
+ return new ProviderEntry(provider, IsPermanent: false);
+ });
- return true;
+ // The new provider is always stored non-permanent; if a permanent provider blocked the
+ // update, AddOrUpdate returned that (permanent) entry instead.
+ return !result.IsPermanent;
}
///
diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
index efd48b3046..e77fc711c7 100644
--- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
+++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs
@@ -74,12 +74,14 @@ public void Bootstrap_InstallsAzureProvider_ForAllActiveDirectoryMethods()
break;
}
default:
+ {
// There is either no provider installed, or it is not ours.
if (provider is not null)
{
Assert.IsNotType(provider);
}
break;
+ }
}
}
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
index f5fd0adf45..f8c2e139c1 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/AuthenticationBootstrapper.cs
@@ -45,23 +45,29 @@ internal sealed class AuthenticationBootstrapper
private static readonly Lazy s_instance =
new(static () => new AuthenticationBootstrapper(AuthenticationProviderRegistry.Instance));
- // The registry this bootstrapper seeds. Production uses the shared singleton registry;
- // tests can inject an isolated registry to avoid mutating global state.
- private readonly AuthenticationProviderRegistry _registry;
-
// Our logging instance.
private readonly SqlClientLogger _sqlAuthLogger = new();
- // Application client ID read from the app.config configuration section, or null if none
- // was configured.
- private string? _applicationClientId = null;
+ ///
+ /// Gets the registry this bootstrapper seeds. Production uses the shared singleton registry;
+ /// tests can inject an isolated registry to avoid mutating global state.
+ ///
+ internal AuthenticationProviderRegistry Registry { get; }
- // 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 bool? _useWamBroker = null;
+ ///
+ /// Gets the application client ID read from the app.config configuration section,
+ /// or if none was configured.
+ ///
+ internal string? ApplicationClientId { get; private set; }
+
+ ///
+ /// Gets the optional override for ActiveDirectoryAuthenticationProviderOptions.UseWamBroker
+ /// read from the app.config <SqlClientAuthenticationProviders useWamBroker="..."/>
+ /// attribute. 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.
+ ///
+ internal bool? UseWamBroker { get; private set; }
///
/// Creates a bootstrapper that seeds the supplied registry, running config-driven and
@@ -69,7 +75,7 @@ internal sealed class AuthenticationBootstrapper
///
internal AuthenticationBootstrapper(AuthenticationProviderRegistry registry)
{
- _registry = registry;
+ Registry = registry;
// Config-driven auth providers, initializers, and application client ID all use
// reflection (Type.GetType / Activator.CreateInstance) and are incompatible with AOT.
@@ -100,12 +106,6 @@ internal static void Bootstrap()
_ = s_instance.Value;
}
- ///
- /// Gets the application client ID read from the app.config configuration section,
- /// or if none was configured.
- ///
- internal string? ApplicationClientId => _applicationClientId;
-
///
/// Reads the app.config configuration section and registers config-driven initializers and
/// authentication providers. Uses reflection (Type.GetType / Activator.CreateInstance) and
@@ -151,7 +151,7 @@ private void LoadConfiguration()
if (!string.IsNullOrEmpty(configSection.ApplicationClientId))
{
- _applicationClientId = configSection.ApplicationClientId;
+ ApplicationClientId = configSection.ApplicationClientId;
_sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, "Received user-defined Application Client Id");
}
else
@@ -163,7 +163,7 @@ private void LoadConfiguration()
{
if (bool.TryParse(configSection.UseWamBroker, out bool useWamBroker))
{
- _useWamBroker = useWamBroker;
+ UseWamBroker = useWamBroker;
_sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, $"Received user-defined UseWamBroker={useWamBroker}.");
}
else
@@ -233,7 +233,7 @@ private void LoadConfiguration()
// Register as a permanent (application-specified) provider so it cannot be
// overridden by the Azure extension default or by a later SetProvider call.
- _registry.SetPermanentProvider(authentication, provider);
+ Registry.SetPermanentProvider(authentication, provider);
_sqlAuthLogger.LogInfo(nameof(AuthenticationBootstrapper), methodName, string.Format("Added user-defined auth provider: {0} for authentication {1}.", providerSettings?.Type, authentication));
}
}
@@ -371,8 +371,8 @@ private void LoadAzureExtensionProvider()
SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider(
type,
optionsType,
- _applicationClientId,
- _useWamBroker);
+ ApplicationClientId,
+ UseWamBroker);
if (instance is null)
{
@@ -390,17 +390,17 @@ private void LoadAzureExtensionProvider()
// 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.
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, instance);
#pragma warning disable 0618 // Type or member is obsolete
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, instance);
#pragma warning restore 0618 // Type or member is obsolete
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, instance);
- _registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, instance);
+ Registry.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, instance);
SqlClientEventSource.Log.TryTraceEvent(
nameof(AuthenticationBootstrapper) +
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
index 5d961a475f..d4119e51be 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs
@@ -201,15 +201,20 @@ public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_Uses
// ApplicationClientId tests ------------------------------------------------------------
///
- /// Verifies that ApplicationClientId is accessible and returns null when
- /// no app.config section is present (non-Framework targets).
+ /// Verifies that ApplicationClientId is accessible and returns null when no app.config
+ /// section is present (non-Framework targets), and that the bootstrapper exposes the
+ /// registry it was constructed with.
///
[ConditionalFact(nameof(IsNotNetFramework))]
public void ApplicationClientId_IsNull_WhenNoConfig()
{
- // On non-Framework targets there is no app.config, so the property
- // should be null.
- AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ // On non-Framework targets there is no app.config, so ApplicationClientId should be null.
+ AuthenticationProviderRegistry registry = new();
+ AuthenticationBootstrapper bootstrapper = new(registry);
+
+ // The bootstrapper exposes the registry it was given.
+ Assert.Same(registry, bootstrapper.Registry);
+
Assert.Null(bootstrapper.ApplicationClientId);
}
@@ -218,18 +223,47 @@ public void ApplicationClientId_IsNull_WhenNoConfig()
// applicationClientId. The following tests verify this on .NET Framework.
///
- /// Verifies that ApplicationClientId is read from the app.config section.
+ /// Verifies that ApplicationClientId is read from the app.config section and that the
+ /// bootstrapper exposes the registry it was constructed with.
///
[ConditionalFact(nameof(IsNetFramework))]
public void ApplicationClientId_ReadFromAppConfig()
{
- // The app.config sets applicationClientId="f3e3a0a0-1234-5678-9abc-def012345678"
- AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ // The app.config sets applicationClientId="f3e3a0a0-1234-5678-9abc-def012345678".
+ AuthenticationProviderRegistry registry = new();
+ AuthenticationBootstrapper bootstrapper = new(registry);
+
+ Assert.Same(registry, bootstrapper.Registry);
Assert.Equal(
"f3e3a0a0-1234-5678-9abc-def012345678",
bootstrapper.ApplicationClientId);
}
+ // UseWamBroker tests -------------------------------------------------------------------
+
+ ///
+ /// Verifies that UseWamBroker is accessible and returns null when no app.config
+ /// section is present (non-Framework targets).
+ ///
+ [ConditionalFact(nameof(IsNotNetFramework))]
+ public void UseWamBroker_IsNull_WhenNoConfig()
+ {
+ // On non-Framework targets there is no app.config, so the property should be null.
+ AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ Assert.Null(bootstrapper.UseWamBroker);
+ }
+
+ ///
+ /// Verifies that UseWamBroker is read from the app.config section.
+ ///
+ [ConditionalFact(nameof(IsNetFramework))]
+ public void UseWamBroker_ReadFromAppConfig()
+ {
+ // The app.config sets useWamBroker="true".
+ AuthenticationBootstrapper bootstrapper = new(new AuthenticationProviderRegistry());
+ Assert.True(bootstrapper.UseWamBroker);
+ }
+
///
/// Verifies that the dummy provider from app.config is registered for
/// ActiveDirectoryInteractive and that no other methods have providers.
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config b/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
index 72f7f0d22e..d238ea0106 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config
@@ -3,7 +3,7 @@
-
+
From 5d18805a3d7a25c03c1f638d7ccb664c8f555694 Mon Sep 17 00:00:00 2001
From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com>
Date: Fri, 21 Aug 2026 12:38:08 -0300
Subject: [PATCH 4/5] Tidying up the AotCompatibility tool.
---
tools/AotCompatibility/Program.cs | 113 +++++++++++++++++-------------
tools/AotCompatibility/README.md | 64 +++++++++++++++--
2 files changed, 126 insertions(+), 51 deletions(-)
diff --git a/tools/AotCompatibility/Program.cs b/tools/AotCompatibility/Program.cs
index 2b0f07d7e7..00decb863e 100644
--- a/tools/AotCompatibility/Program.cs
+++ b/tools/AotCompatibility/Program.cs
@@ -81,51 +81,85 @@
Console.WriteLine();
+// Force the federated-authentication code path to be statically reachable so the
+// trimmer's treatment of reflection-based provider discovery is actually exercised.
+//
+// In a real AOT app this path is reached when an Active Directory connection
+// authenticates:
+//
+// SqlConnection.Open()
+// -> SqlInternalConnectionTds.GetFedAuthToken()
+// -> AuthenticationBootstrapper.Bootstrap()
+// -> AuthenticationBootstrapper ctor
+// -> LoadAzureExtensionProvider() (the reflection-based discovery)
+//
+// We model that here with an Open() attempt using an Active Directory
+// authentication mode. The attempt is EXPECTED to fail at runtime (there is no
+// server listening), but trimming is a static analysis: the mere presence of this
+// call roots the fed-auth call graph, so the trimmer must decide whether to keep
+// or eliminate LoadAzureExtensionProvider() based on the feature switch.
+//
+// Without this call the entire bootstrapper path is unreachable and would be
+// trimmed regardless of the switch, making the trimming verification meaningless.
+Console.WriteLine("Active Directory connection open (roots the fed-auth path):");
+try
+{
+ using var adConnection = new SqlConnection(
+ "Server=localhost;Database=test;Encrypt=false;" +
+ "Authentication=Active Directory Default;Connect Timeout=1;");
+ adConnection.Open();
+ Console.WriteLine(" Opened unexpectedly (no server was expected to be present).");
+}
+catch (Exception ex)
+{
+ // Expected: there is no server to connect to. The point is static
+ // reachability for the trimmer, not a successful connection.
+ Console.WriteLine($" Open failed as expected: {ex.GetType().Name}");
+}
+
+Console.WriteLine();
+
// Check the ILC map file for trimming verification.
// The map file is generated alongside the native binary during publish.
// At runtime we can look for it relative to the executable.
Console.WriteLine("Trimming verification (ILC map file):");
+
+// The ILC map file is generated only by a Native AOT publish, at
+// /obj////native/.map.xml
+// while the native binary lives at
+// /bin////publish/
+//
+// We derive the map path deterministically from the running binary's location so
+// we only ever read the map for THIS exact configuration. Under the JIT
+// (dotnet run) the process path is the shared 'dotnet' host rather than our
+// native binary, so no map is found and the verification is skipped - this avoids
+// reading a stale map left over from a previous publish (possibly built with a
+// different feature-switch value).
var exePath = Environment.ProcessPath;
if (exePath is not null)
{
var exeDir = Path.GetDirectoryName(exePath)!;
var exeName = Path.GetFileNameWithoutExtension(exePath);
- // Map file is in obj////native/.map.xml
- // But at runtime we only have the publish dir. Check if it was copied or
- // look in the well-known obj path relative to the project directory.
- // For simplicity, search upward from exe for the map file.
- var mapFile = Path.Combine(exeDir, $"{exeName}.map.xml");
+ // Candidate 1: alongside the binary, in case it is ever copied there.
+ var beside = Path.Combine(exeDir, $"{exeName}.map.xml");
- // If not next to the binary, try the obj path (when running from the project dir)
- if (!File.Exists(mapFile))
+ // Candidate 2: the well-known obj/native path derived from the publish layout.
+ // exeDir is expected to be /bin////publish.
+ string? derived = null;
+ if (Path.GetDirectoryName(exeDir) is { } ridDir)
{
- // Try to find it via the well-known native output path pattern
- var projectDir = FindProjectDir(exeDir);
- if (projectDir is not null)
- {
- try
- {
- var candidates = new DirectoryInfo(projectDir)
- .EnumerateFiles($"{exeName}.map.xml", new EnumerationOptions
- {
- RecurseSubdirectories = true,
- IgnoreInaccessible = true
- });
- var first = candidates.FirstOrDefault();
- if (first is not null)
- {
- mapFile = first.FullName;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine($" Warning: Could not search for map file: {ex.Message}");
- }
- }
+ var sep = Path.DirectorySeparatorChar;
+ derived = Path.Combine(ridDir, "native", $"{exeName}.map.xml")
+ .Replace($"{sep}bin{sep}", $"{sep}obj{sep}");
}
- if (File.Exists(mapFile))
+ string? mapFile =
+ File.Exists(beside) ? beside :
+ derived is not null && File.Exists(derived) ? derived :
+ null;
+
+ if (mapFile is not null)
{
// Stream the file line-by-line to avoid loading a potentially huge ILC map
// file entirely into memory.
@@ -170,25 +204,10 @@
}
else
{
- Console.WriteLine($" Map file not found (expected after 'dotnet publish').");
- Console.WriteLine(" Skipping trimming verification.");
+ Console.WriteLine(" Skipped (no map file for this build; it is generated only by a Native AOT 'dotnet publish').");
}
}
Console.WriteLine();
Console.WriteLine("All AOT compatibility checks passed.");
return 0;
-
-static string? FindProjectDir(string startDir)
-{
- var dir = startDir;
- while (dir is not null)
- {
- if (Directory.GetFiles(dir, "*.csproj").Length > 0)
- {
- return dir;
- }
- dir = Path.GetDirectoryName(dir);
- }
- return null;
-}
diff --git a/tools/AotCompatibility/README.md b/tools/AotCompatibility/README.md
index 103f6672d9..540ff8ff4a 100644
--- a/tools/AotCompatibility/README.md
+++ b/tools/AotCompatibility/README.md
@@ -14,6 +14,32 @@ disabled via the feature switch.
and `GetProvider()` work correctly at runtime in an AOT context.
4. **SqlConnection construction** — Basic `SqlConnection` object creation works
without reflection.
+5. **Fed-auth path is rooted** — The app attempts to open an Active Directory
+ authenticated connection (expected to fail at runtime, since no server is
+ present). This makes the reflection-based discovery path *statically
+ reachable* for the trimmer, so the trimming verification below is meaningful.
+
+> ### Why the Active Directory open attempt matters
+>
+> The reflection-based discovery in `LoadAzureExtensionProvider()` is only
+> reachable through `AuthenticationBootstrapper.Bootstrap()`, which the driver
+> calls lazily the first time a federated/Active Directory connection
+> authenticates:
+>
+> ```text
+> SqlConnection.Open()
+> -> SqlInternalConnectionTds.GetFedAuthToken()
+> -> AuthenticationBootstrapper.Bootstrap()
+> -> AuthenticationBootstrapper ctor
+> -> LoadAzureExtensionProvider() // reflection-based discovery
+> ```
+>
+> If the app never roots this call graph (e.g. it only *constructs* a
+> `SqlConnection`), the entire bootstrapper path is unreachable and the trimmer
+> removes `LoadAzureExtensionProvider()` **regardless of the feature switch** —
+> which would make the switch verification a false positive. The app therefore
+> issues an Active Directory `Open()` attempt purely to root the path; trimming
+> is a static analysis, so the runtime connection failure is irrelevant.
## Usage
@@ -46,10 +72,20 @@ dotnet publish -c Release -f net9.0 -r linux-x64 -p:EnableReflectionBasedAuthPro
When `EnableReflectionBasedAuthProviderDiscovery=true`, the trimmer cannot
eliminate the reflection code in `LoadAzureExtensionProvider()`, so you will see
-additional IL2075/IL2026 warnings from that method. This confirms the feature
-switch is working — setting it to `false` (the test app's default, configured in
-the csproj) removes those warnings. Note that the *library's* default is `true`
-(reflection enabled); the test app overrides this to validate AOT trimming.
+two additional warnings rooted at the `AuthenticationBootstrapper` constructor
+(which calls that method):
+
+- **IL2026** (trim analysis) — `LoadAzureExtensionProvider()` is annotated with
+ `[RequiresUnreferencedCode]` because Azure extension discovery uses
+ `Assembly.Load` and `Activator.CreateInstance`.
+- **IL3050** (AOT analysis) — the same method is annotated with
+ `[RequiresDynamicCode]` because it uses `Activator.CreateInstance`.
+
+This confirms the feature switch is working — setting it to `false` (the test
+app's default, configured in the csproj) substitutes the guard with a constant
+and removes both warnings along with the method itself. Note that the
+*library's* default is `true` (reflection enabled); the test app overrides this
+to validate AOT trimming.
## Expected output
@@ -70,9 +106,29 @@ SqlAuthenticationProvider API checks:
SqlConnection construction:
Created successfully (State=Closed)
+Active Directory connection open (roots the fed-auth path):
+ Open failed as expected: SqlException
+
+Trimming verification (ILC map file):
+ Map file: /.../native/AotCompatibility.map.xml
+ Contains LoadAzureExtensionProvider: False
+ PASS: Reflection code was successfully trimmed.
+
All AOT compatibility checks passed.
```
+When published with `EnableReflectionBasedAuthProviderDiscovery=true`, the
+trimming verification instead reports:
+
+```text
+ Contains LoadAzureExtensionProvider: True
+ PASS: Reflection code is present (as expected with discovery enabled).
+```
+
+> The map-file verification only produces a meaningful result after a Native AOT
+> `dotnet publish` (which generates `AotCompatibility.map.xml`). In JIT
+> (`dotnet run`) mode the map file is absent and that step is skipped.
+
## Trimmer warnings
Some trimmer warnings may appear during publish. These fall into categories:
From 78b81750b828f256d718358c55338e0df3818f94 Mon Sep 17 00:00:00 2001
From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com>
Date: Fri, 21 Aug 2026 12:58:36 -0300
Subject: [PATCH 5/5] Assert the reflection-discovery switch directly in the
defaults test
Integration fix for replaying the AOT commits onto current main. Main's
LocalAppContextSwitchesHelper resolves switch defaults by reflecting over public
static properties, but EnableReflectionBasedAuthenticationProviderDiscovery is
internal, so the helper cannot see it. Assert it directly on
LocalAppContextSwitches, as the original AOT test did, and keep the helper's
field-based accessor unchanged.
---
.../Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
index 4942aa9d21..d1e6ff677b 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
@@ -70,7 +70,9 @@ public void TestDefaultAppContextSwitchValues()
Assert.False(switchesHelper.IgnoreServerProvidedFailoverPartner);
Assert.False(switchesHelper.UseLegacyFailoverAlternationOnLoginSqlErrors);
Assert.False(switchesHelper.EnableMultiSubnetFailoverByDefault);
- Assert.True(switchesHelper.EnableReflectionBasedAuthenticationProviderDiscovery);
+ // Asserted directly: this switch is internal, and the helper's property
+ // reflection only resolves public switches.
+ Assert.True(LocalAppContextSwitches.EnableReflectionBasedAuthenticationProviderDiscovery);
#if NET
Assert.False(switchesHelper.GlobalizationInvariantMode);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))