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 0957180637..6418c8034b 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/Abstractions.csproj @@ -35,9 +35,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..542fd3e38e --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/AuthenticationProviderRegistry.cs @@ -0,0 +1,238 @@ +// 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(); + + #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. + /// + /// + /// is . + /// + internal bool SetProvider(SqlAuthenticationMethod authenticationMethod, SqlAuthenticationProvider provider) + { + if (!provider.IsSupported(authenticationMethod)) + { + throw new NotSupportedException( + string.Format( + AbstractionsStrings.SQL_UnsupportedAuthenticationByProvider, + provider.GetType().Name, + authenticationMethod.ToString())); + } + + 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) + { + 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; + } + + 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); + }); + + // The new provider is always stored non-permanent; if a permanent provider blocked the + // update, AddOrUpdate returned that (permanent) entry instead. + return !result.IsPermanent; + } + + /// + /// 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 693206261e..0000000000 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ /dev/null @@ -1,243 +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 SqlClient assembly is present, load its - // SqlAuthenticationProviderManager class and get/set methods. - try - { - // Try to load the SqlClient assembly. - - #if STRONG_NAME_SIGNING - - // The expected public key token of the SqlClient assembly, used to avoid invoking - // APIs from imposter assemblies. This is the same token used by all assemblies in - // this repository when strong-name signed. - byte[] expectedPublicKeyToken = - [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ]; - - // When strong-name signing is enabled, build a fully-qualified AssemblyName that - // includes the expected public key token. - Log($"Attempting to load SqlClient assembly={assemblyName} with " + - "expected public key token=" + - BitConverter.ToString(expectedPublicKeyToken).Replace("-", "")); - - var qualifiedName = new AssemblyName(assemblyName); - qualifiedName.SetPublicKeyToken(expectedPublicKeyToken); - - // The .NET Framework runtime enforces the token during binding, causing Load() to - // throw if it doesn't match. The .NET (Core) runtime ignores the token, so we - // verify it ourselves below. - var assembly = Assembly.Load(qualifiedName); - - // Defense-in-depth: verify the public key token after loading. This is necessary - // on .NET Core where the runtime does not enforce the token. It is harmless on .NET - // Framework. - if (assembly is not null) - { - byte[]? actualToken = assembly.GetName().GetPublicKeyToken(); - - if (actualToken is null || - !actualToken.AsSpan().SequenceEqual(expectedPublicKeyToken)) - { - Log($"SqlClient assembly={assembly.GetName()} has an " + - "unexpected public key token; " + - "Get/SetProvider() will not function"); - return; - } - } - - #else - - // Strong-name signing is disabled, so we cannot verify the public key token. - Log($"Loading SqlClient assembly={assemblyName} without strong-name identity " + - "verification; ensure this assembly is from a trusted source"); - - var assembly = Assembly.Load(assemblyName); - - #endif - - if (assembly is null) - { - Log($"SqlClient assembly={assemblyName} not found; " + - "Get/SetProvider() will not function"); - return; - } - - // Look for the manager class. - const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; - Type? manager = assembly.GetType(className); - - if (manager is null) - { - Log($"SqlClient auth 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($"SqlClient GetProvider() method not found; " + - "GetProvider() will not function"); - } - - _setProvider = manager.GetMethod( - "SetProvider", - BindingFlags.NonPublic | BindingFlags.Static); - - if (_setProvider is null) - { - Log($"SqlClient 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($"SqlClient 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..e77fc711c7 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthenticationBootstrapperGlobalTests.cs @@ -0,0 +1,115 @@ +// 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 e52cbdc8d3..6db9ddf601 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 06eaf9e914..22fa46d7c6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj +++ b/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj @@ -230,12 +230,14 @@ - - - - + + + + + + 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 63% 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 4c77fbe8df..f8c2e139c1 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)); + + // Our logging instance. + private readonly SqlClientLogger _sqlAuthLogger = new(); + + /// + /// 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; } + + /// + /// 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 + /// 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() + { + _ = s_instance.Value; + } + + /// + /// 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() { - SqlAuthenticationProviderConfigurationSection? configurationSection = null; + 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; + } + + 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."); } - Instance = new SqlAuthenticationProviderManager(configurationSection); + 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,14 +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) + + nameof(AuthenticationBootstrapper) + $": Attempting to load Azure extension assembly={azureAssemblyName} with " + "expected public key token=" + - BitConverter.ToString(s_azurePublicKeyToken).Replace("-", "")); + 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 @@ -92,10 +299,10 @@ 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) + + nameof(AuthenticationBootstrapper) + $": Azure extension assembly={assembly.GetName()} has an " + "unexpected public key token; " + "no default Active Directory provider installed"); @@ -107,7 +314,7 @@ static SqlAuthenticationProviderManager() #else SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Attempting to load Azure extension assembly={azureAssemblyName} without " + "strong name verification; ensure this assembly is from a trusted source"); @@ -118,14 +325,14 @@ static SqlAuthenticationProviderManager() if (assembly is null) { SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Azure extension assembly={azureAssemblyName} not found; " + "no default Active Directory provider installed"); return; } SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Azure extension assembly={assembly.GetName()} found; " + "attempting to set as default provider for all Active " + "Directory authentication methods"); @@ -137,7 +344,7 @@ static SqlAuthenticationProviderManager() if (type is null) { SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Azure extension does not contain class={className}; " + "no default Active Directory provider installed"); @@ -164,13 +371,13 @@ static SqlAuthenticationProviderManager() SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider( type, optionsType, - Instance._applicationClientId, - Instance._useWamBroker); + ApplicationClientId, + UseWamBroker); if (instance is null) { SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Failed to instantiate Azure extension class={className}; " + "no default Active Directory provider installed"); @@ -183,20 +390,20 @@ 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) + + nameof(AuthenticationBootstrapper) + $": Azure extension class={className} installed as " + "provider for all Active Directory authentication methods"); } @@ -220,7 +427,7 @@ TypeInitializationException or TypeLoadException) { SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + + nameof(AuthenticationBootstrapper) + $": Azure extension assembly={azureAssemblyName} not found or " + "not usable; no default provider installed; " + $"{ex.GetType().Name}: {ex.Message}"); @@ -228,136 +435,6 @@ TypeInitializationException or // 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. @@ -432,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); @@ -505,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 d87333c05b..b7c6990a72 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 @@ -2724,7 +2724,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 df762ed7ae..f5d6a6c68e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -132,7 +132,7 @@ internal static class LocalAppContextSwitches private const string UseOverallConnectTimeoutForPoolWaitString = "Switch.Microsoft.Data.SqlClient.UseOverallConnectTimeoutForPoolWait"; - #if NET && _WINDOWS + #if NET /// /// The name of the app context switch that controls whether to use the /// managed SNI implementation instead of the native SNI implementation on @@ -148,6 +148,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 @@ -246,11 +254,11 @@ private enum SwitchValue : byte /// private static SwitchValue s_useOverallConnectTimeoutForPoolWait = SwitchValue.None; - #if NET && _WINDOWS + #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 /// @@ -258,6 +266,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 @@ -590,51 +603,47 @@ public static bool UseCompatibilityAsyncBehaviour defaultValue: false, ref s_useOverallConnectTimeoutForPoolWait); - #if NET && _WINDOWS + #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.Windows.xml and ILLink.Substitutions.Unix.xml - /// resource files for details. - /// - /// The default value of this switch is false. + /// 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. + /// + 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. /// - public static bool UseManagedNetworking + private static bool UseManagedNetworkingOnWindows { get { - if (s_useManagedNetworking != SwitchValue.None) + if (s_useManagedNetworkingOnWindows != SwitchValue.None) { - return s_useManagedNetworking == SwitchValue.True; - } - - if (!OperatingSystem.IsWindows()) - { - s_useManagedNetworking = SwitchValue.True; - return 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; } } - #elif NET - /// - /// .NET Core on Unix does not support native SNI, so this will always be - /// true. - /// - public static bool UseManagedNetworking => true; #else /// /// .NET Framework does not support the managed SNI, so this will always be @@ -655,6 +664,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 430d6645a8..9d0b39556b 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs @@ -57,8 +57,9 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable private readonly bool? _useOverallConnectTimeoutForPoolWaitOriginal; #if NET && _WINDOWS private readonly bool? _useManagedNetworkingOriginal; - #endif + #endif private readonly bool? _useMinimumLoginTimeoutOriginal; + private readonly bool? _enableReflectionBasedAuthenticationProviderDiscoveryOriginal; #endregion @@ -120,10 +121,12 @@ public LocalAppContextSwitchesHelper() GetSwitchValue("s_useOverallConnectTimeoutForPoolWait"); #if NET && _WINDOWS _useManagedNetworkingOriginal = - GetSwitchValue("s_useManagedNetworking"); + GetSwitchValue("s_useManagedNetworkingOnWindows"); #endif _useMinimumLoginTimeoutOriginal = GetSwitchValue("s_useMinimumLoginTimeout"); + _enableReflectionBasedAuthenticationProviderDiscoveryOriginal = + GetSwitchValue("s_enableReflectionBasedAuthenticationProviderDiscovery"); } catch { @@ -162,7 +165,7 @@ public void Dispose() "s_useLegacyFailoverAlternationOnLoginSqlErrors", _useLegacyFailoverAlternationOnLoginSqlErrorsOriginal); SetSwitchValue( - "s_legacyRowVersionNullBehavior", + "s_legacyRowVersionNullBehavior", _legacyRowVersionNullBehaviorOriginal); SetSwitchValue( "s_legacyVarTimeZeroScaleBehaviour", @@ -190,12 +193,15 @@ public void Dispose() _useOverallConnectTimeoutForPoolWaitOriginal); #if NET && _WINDOWS SetSwitchValue( - "s_useManagedNetworking", + "s_useManagedNetworkingOnWindows", _useManagedNetworkingOriginal); #endif SetSwitchValue( "s_useMinimumLoginTimeout", _useMinimumLoginTimeoutOriginal); + SetSwitchValue( + "s_enableReflectionBasedAuthenticationProviderDiscovery", + _enableReflectionBasedAuthenticationProviderDiscoveryOriginal); } finally { @@ -344,12 +350,12 @@ public bool? UseOverallConnectTimeoutForPoolWait #if NET && _WINDOWS /// - /// Get or set the UseManagedNetworking switch value. + /// Get or set the UseManagedNetworkingOnWindows switch value. /// public bool? UseManagedNetworking { - get => GetSwitchValue("s_useManagedNetworking"); - set => SetSwitchValue("s_useManagedNetworking", value); + get => GetSwitchValue("s_useManagedNetworkingOnWindows"); + set => SetSwitchValue("s_useManagedNetworkingOnWindows", value); } #endif @@ -362,6 +368,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 @@ -377,7 +392,7 @@ public bool? UseMinimumLoginTimeout throw new InvalidOperationException( "Could not get assembly for Microsoft.Data.SqlClient"); } - + var type = assembly.GetType("Microsoft.Data.SqlClient.LocalAppContextSwitches"); if (type is null) { @@ -424,7 +439,7 @@ private static void SetSwitchValue(string fieldName, bool? value) throw new InvalidOperationException( "Could not get assembly for Microsoft.Data.SqlClient"); } - + var type = assembly.GetType("Microsoft.Data.SqlClient.LocalAppContextSwitches"); if (type is null) { 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 9098091ad8..ca993570c2 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 @@ -32,11 +32,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 e09e49c967..dd8a1561f7 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 @@ -30,6 +30,9 @@ True Resources.resx + + Always + PreserveNewest xunit.runner.json 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 new file mode 100644 index 0000000000..d4119e51be --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthenticationBootstrapperTests.cs @@ -0,0 +1,350 @@ +// 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.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// 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 +{ + // 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() + { + // 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 ---------------------------------------------- + // + // Each Stub* container mimics one shape the real Azure extension might expose: + // * StubModern - both a (string) ctor and an (Options) ctor. + // * StubLegacy - only the (string) ctor; no Options type at all. + // * StubMinimal - only a parameterless ctor. + // + // The helper takes a Type directly, so these stubs do not need any particular full name. + + public class StubProviderBase : SqlAuthenticationProvider + { + public string? CapturedApplicationClientId; + public bool? CapturedUseWamBroker; + public bool ParameterlessCtorUsed; + public bool StringCtorUsed; + public bool OptionsCtorUsed; + + public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) + => Task.FromResult(new SqlAuthenticationToken("stub", DateTimeOffset.UtcNow.AddMinutes(5))); + + public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; + } + + public static class StubModern + { + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + public string? ApplicationClientId { get; set; } + public bool UseWamBroker { get; set; } + } + + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + + public ActiveDirectoryAuthenticationProvider(string applicationClientId) + { + StringCtorUsed = true; + CapturedApplicationClientId = applicationClientId; + } + + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) + { + OptionsCtorUsed = true; + CapturedApplicationClientId = options.ApplicationClientId; + CapturedUseWamBroker = options.UseWamBroker; + } + } + } + + public static class StubLegacy + { + // No Options type defined -- mimics older Azure extension versions. + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + + public ActiveDirectoryAuthenticationProvider(string applicationClientId) + { + StringCtorUsed = true; + CapturedApplicationClientId = applicationClientId; + } + } + } + + public static class StubMinimal + { + // Parameterless only -- mimics a hypothetical extension with no 1-arg ctors at all. + public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase + { + public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } + } + } + + [Fact] + public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterlessCtor() + { + var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: null, + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.ParameterlessCtorUsed); + Assert.False(stub.StringCtorUsed); + Assert.False(stub.OptionsCtorUsed); + Assert.Null(stub.CapturedApplicationClientId); + Assert.Null(stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOptionsCtor() + { + var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: "app-123", + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.OptionsCtorUsed); + Assert.False(stub.StringCtorUsed); + Assert.Equal("app-123", stub.CapturedApplicationClientId); + Assert.Equal(false, stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBackToStringCtor() + { + var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: "legacy-456", + useWamBroker: null); + + var stub = Assert.IsType(instance); + Assert.True(stub.StringCtorUsed); + Assert.False(stub.OptionsCtorUsed); + Assert.False(stub.ParameterlessCtorUsed); + Assert.Equal("legacy-456", stub.CapturedApplicationClientId); + Assert.Null(stub.CapturedUseWamBroker); + } + + [Fact] + public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_ReturnsNull() + { + var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubMinimal.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: "no-ctor", + useWamBroker: null); + + Assert.Null(instance); + } + + [Fact] + public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws() + { + InvalidOperationException ex = Assert.Throws(() => + AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), + optionsType: null, + applicationClientId: null, + useWamBroker: true)); + + Assert.Contains("ActiveDirectoryAuthenticationProviderOptions", ex.Message); + Assert.Contains("Microsoft.Data.SqlClient.Extensions.Azure", ex.Message); + } + + [Fact] + public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_UsesOptionsCtor() + { + var instance = AuthenticationBootstrapper.CreateAzureAuthenticationProvider( + typeof(StubModern.ActiveDirectoryAuthenticationProvider), + typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), + applicationClientId: "app-789", + useWamBroker: true); + + var stub = Assert.IsType(instance); + Assert.True(stub.OptionsCtorUsed); + Assert.Equal("app-789", stub.CapturedApplicationClientId); + Assert.Equal(true, stub.CapturedUseWamBroker); + } + + // ApplicationClientId tests ------------------------------------------------------------ + + /// + /// 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 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); + } + + // 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 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". + 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. + /// + [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 c5c6f7ec73..a78b516068 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 @@ -39,6 +39,7 @@ public void TestDefaultAppContextSwitchValues() switchesHelper.UseCompatibilityProcessSni = null; switchesHelper.UseConnectionPoolV2 = null; switchesHelper.UseMinimumLoginTimeout = null; + switchesHelper.EnableReflectionBasedAuthenticationProviderDiscovery = null; #if NET switchesHelper.GlobalizationInvariantMode = null; #endif @@ -62,6 +63,7 @@ public void TestDefaultAppContextSwitchValues() Assert.False(LocalAppContextSwitches.IgnoreServerProvidedFailoverPartner); Assert.False(LocalAppContextSwitches.UseLegacyFailoverAlternationOnLoginSqlErrors); Assert.False(LocalAppContextSwitches.EnableMultiSubnetFailoverByDefault); + Assert.True(LocalAppContextSwitches.EnableReflectionBasedAuthenticationProviderDiscovery); #if NET Assert.False(LocalAppContextSwitches.GlobalizationInvariantMode); #endif diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs deleted file mode 100644 index 08089abe98..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs +++ /dev/null @@ -1,272 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.Data.SqlClient.UnitTests; - -public class SqlAuthenticationProviderManagerTests -{ - private class Provider : SqlAuthenticationProvider - { - public override Task AcquireTokenAsync( - SqlAuthenticationParameters parameters) - { - return Task.FromResult( - new SqlAuthenticationToken( - "SampleAccessToken", DateTimeOffset.UtcNow.AddMinutes(5))); - } - - public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) - { - return authenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow; - } - } - - // Verify that we can get and set providers via both the Abstractions - // package and Manager class interchangeably. - // - // This tests the dynamic assembly loading code in the Abstractions - // package. - [Fact] - public void Abstractions_And_Manager_GetSetProvider_Equivalent() - { - // Set via Manager, get via both. - Provider provider1 = new(); - - Assert.True( - SqlAuthenticationProviderManager.SetProvider( - // GOTCHA: On .NET Framework, the dummy provider is already - // registered as the default provider for Interactive, so we - // use DeviceCodeFlow instead. - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - provider1)); - - Assert.Same( - provider1, - SqlAuthenticationProviderManager.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - Assert.Same( - provider1, - SqlAuthenticationProvider.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - // Set via Abstractions, get via both. - Provider provider2 = new(); - - Assert.True( - SqlAuthenticationProvider.SetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - provider2)); - - Assert.Same( - provider2, - SqlAuthenticationProviderManager.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - - Assert.Same( - provider2, - SqlAuthenticationProvider.GetProvider( - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow)); - } - - // Regression: the manager's static initializer reflectively constructs the Azure extension's - // ActiveDirectoryAuthenticationProvider. That class has overlapping 1-arg constructors - // ((string) and (ProviderOptions)), so calling Activator.CreateInstance(type, [null]) used - // to throw AmbiguousMatchException -- which surfaced as TypeInitializationException from - // GetProvider and broke every AD-authenticated connection. Calling GetProvider for an AD - // method must succeed (returning either the registered provider or null) and must not throw. - [Fact] - public void GetProvider_ForActiveDirectoryMethod_DoesNotThrow() - { - foreach (SqlAuthenticationMethod method in new[] - { - SqlAuthenticationMethod.ActiveDirectoryIntegrated, - #pragma warning disable CS0618 // ActiveDirectoryPassword is obsolete. - SqlAuthenticationMethod.ActiveDirectoryPassword, - #pragma warning restore CS0618 - SqlAuthenticationMethod.ActiveDirectoryInteractive, - SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, - SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, - SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, - SqlAuthenticationMethod.ActiveDirectoryMSI, - SqlAuthenticationMethod.ActiveDirectoryDefault, - SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, - }) - { - // No assertion on the value -- the provider may or may not be installed depending on - // whether the Azure extension is on disk. We only assert no throw (which is what a - // TypeInitializationException from the static initializer would do). - _ = SqlAuthenticationProviderManager.GetProvider(method); - } - } - - // CreateAzureAuthenticationProvider tests ---------------------------------------------- - // - // Each Stub* container mimics one shape the real Azure extension might expose: - // * StubModern - both a (string) ctor and an (Options) ctor. - // * StubLegacy - only the (string) ctor; no Options type at all. - // * StubMinimal - only a parameterless ctor. - // - // The helper takes a Type directly, so these stubs do not need any particular full name. - - public class StubProviderBase : SqlAuthenticationProvider - { - public string? CapturedApplicationClientId; - public bool? CapturedUseWamBroker; - public bool ParameterlessCtorUsed; - public bool StringCtorUsed; - public bool OptionsCtorUsed; - - public override Task AcquireTokenAsync(SqlAuthenticationParameters parameters) - => Task.FromResult(new SqlAuthenticationToken("stub", DateTimeOffset.UtcNow.AddMinutes(5))); - - public override bool IsSupported(SqlAuthenticationMethod authenticationMethod) => true; - } - - public static class StubModern - { - public sealed class ActiveDirectoryAuthenticationProviderOptions - { - public string? ApplicationClientId { get; set; } - public bool UseWamBroker { get; set; } - } - - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - - public ActiveDirectoryAuthenticationProvider(string applicationClientId) - { - StringCtorUsed = true; - CapturedApplicationClientId = applicationClientId; - } - - public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) - { - OptionsCtorUsed = true; - CapturedApplicationClientId = options.ApplicationClientId; - CapturedUseWamBroker = options.UseWamBroker; - } - } - } - - public static class StubLegacy - { - // No Options type defined -- mimics older Azure extension versions. - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - - public ActiveDirectoryAuthenticationProvider(string applicationClientId) - { - StringCtorUsed = true; - CapturedApplicationClientId = applicationClientId; - } - } - } - - public static class StubMinimal - { - // Parameterless only -- mimics a hypothetical extension with no 1-arg ctors at all. - public sealed class ActiveDirectoryAuthenticationProvider : StubProviderBase - { - public ActiveDirectoryAuthenticationProvider() { ParameterlessCtorUsed = true; } - } - } - - [Fact] - public void CreateAzureAuthenticationProvider_NeitherConfigured_UsesParameterlessCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: null, - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.ParameterlessCtorUsed); - Assert.False(stub.StringCtorUsed); - Assert.False(stub.OptionsCtorUsed); - Assert.Null(stub.CapturedApplicationClientId); - Assert.Null(stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsAvailable_UsesOptionsCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: "app-123", - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.OptionsCtorUsed); - Assert.False(stub.StringCtorUsed); - Assert.Equal("app-123", stub.CapturedApplicationClientId); - Assert.Equal(false, stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_OptionsMissing_FallsBackToStringCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: "legacy-456", - useWamBroker: null); - - var stub = Assert.IsType(instance); - Assert.True(stub.StringCtorUsed); - Assert.False(stub.OptionsCtorUsed); - Assert.False(stub.ParameterlessCtorUsed); - Assert.Equal("legacy-456", stub.CapturedApplicationClientId); - Assert.Null(stub.CapturedUseWamBroker); - } - - [Fact] - public void CreateAzureAuthenticationProvider_AppIdOnly_NoCompatibleCtor_ReturnsNull() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubMinimal.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: "no-ctor", - useWamBroker: null); - - Assert.Null(instance); - } - - [Fact] - public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsMissing_Throws() - { - InvalidOperationException ex = Assert.Throws(() => - SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubLegacy.ActiveDirectoryAuthenticationProvider), - optionsType: null, - applicationClientId: null, - useWamBroker: true)); - - Assert.Contains("ActiveDirectoryAuthenticationProviderOptions", ex.Message); - Assert.Contains("Microsoft.Data.SqlClient.Extensions.Azure", ex.Message); - } - - [Fact] - public void CreateAzureAuthenticationProvider_UseWamBroker_OptionsAvailable_UsesOptionsCtor() - { - var instance = SqlAuthenticationProviderManager.CreateAzureAuthenticationProvider( - typeof(StubModern.ActiveDirectoryAuthenticationProvider), - typeof(StubModern.ActiveDirectoryAuthenticationProviderOptions), - applicationClientId: "app-789", - useWamBroker: true); - - var stub = Assert.IsType(instance); - Assert.True(stub.OptionsCtorUsed); - Assert.Equal("app-789", stub.CapturedApplicationClientId); - Assert.Equal(true, stub.CapturedUseWamBroker); - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config b/src/Microsoft.Data.SqlClient/tests/UnitTests/app.config new file mode 100644 index 0000000000..d238ea0106 --- /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.