From 3db600913211dc9416d1275596e44b2d453d1ab4 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 13 May 2026 15:20:42 -0700 Subject: [PATCH 01/41] Enable WAM Broker support for Entra ID Auth modes --- Directory.Packages.props | 1 + specs/002-wam-broker/spec.md | 131 ++++++++++++++++++ ...DirectoryAuthenticationProvider.Windows.cs | 59 ++++++++ .../ActiveDirectoryAuthenticationProvider.cs | 47 ++++++- .../Azure/src/Azure.csproj | 1 + .../Azure/src/Interop/Interop.GetAncestor.cs | 29 ++++ .../src/Interop/Interop.GetConsoleWindow.cs | 19 +++ .../Azure/test/WamBrokerTests.cs | 31 +++++ 8 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 specs/002-wam-broker/spec.md create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 62918f7d65..752bdbe0f8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,6 +83,7 @@ + diff --git a/specs/002-wam-broker/spec.md b/specs/002-wam-broker/spec.md new file mode 100644 index 0000000000..60ee74b882 --- /dev/null +++ b/specs/002-wam-broker/spec.md @@ -0,0 +1,131 @@ +# Feature Specification: WAM Broker Support for Entra ID Authentication + +**Feature Branch**: `dev/automation/wam-broker-support` +**Created**: 2026-05-20 +**Status**: Draft +**References**: + +- PR [#2884](https://github.com/dotnet/SqlClient/pull/2884) (original POC, closed) +- PR [#3874](https://github.com/dotnet/SqlClient/pull/3874) (updated POC, closed) +- ICM 781210079 (Authentication failure on persistent AVD with Conditional Access) + +## Problem Statement + +Microsoft.Data.SqlClient's `ActiveDirectoryIntegrated` and other Public Client Application (PCA) authentication flows do not pass device information when acquiring tokens. This causes failures on persistent Azure Virtual Desktop (AVD) devices when Conditional Access Policies require device compliance or MFA based on device state. + +### Root Cause + +MSAL's `AcquireTokenByIntegratedWindowsAuth` does not pass device claims to the identity provider. The Windows Web Account Manager (WAM) broker passes device information (PRT, device compliance state) to Entra ID, satisfying Conditional Access policies. + +### MSAL PCA Compliance + +Microsoft identity platform requires first-party applications using Public Client Applications to use WAM broker on Windows for compliance. This ensures: + +- Device-based Conditional Access policies work correctly +- Primary Refresh Token (PRT) is leveraged for SSO +- Device compliance state is included in token requests + +## Design + +### Target Location + +The `ActiveDirectoryAuthenticationProvider` is in `src/Microsoft.Data.SqlClient.Extensions/Azure/src/`. This package targets `net462;netstandard2.0`. + +### Platform Support Matrix + +| Platform | WAM Broker | Fallback | +| ---------- | ----------- | ---------- | +| Windows (.NET Framework 4.6.2+) | ✅ Supported | IWA (legacy) | +| Windows (.NET 8.0+ via netstandard2.0) | ✅ Supported | System browser | +| Linux/macOS (.NET via netstandard2.0) | ❌ Not available | System browser / IWA | + +### Authentication Modes Covered + +| Mode | WAM Broker Behavior | +| ------ | ------------------- | +| `ActiveDirectoryInteractive` | Uses WAM for interactive token acquisition on Windows | +| `ActiveDirectoryIntegrated` | Uses WAM broker to pass device claims (solves CAP issues) | +| `ActiveDirectoryDeviceCodeFlow` | Uses WAM for device code flow on Windows | +| `ActiveDirectoryPassword` | Uses WAM for username/password flow on Windows | +| `ActiveDirectoryDefault` | No change (uses Azure.Identity DefaultAzureCredential) | +| `ActiveDirectoryManagedIdentity` | No change (server-side, no WAM needed) | +| `ActiveDirectoryServicePrincipal` | No change (confidential client, no WAM needed) | +| `ActiveDirectoryWorkloadIdentity` | No change (workload identity, no WAM needed) | + +### Architecture Changes + +1. **Make class `partial`**: Split `ActiveDirectoryAuthenticationProvider` into platform-specific files +2. **Add WAM broker**: Configure `BrokerOptions` on `PublicClientApplicationBuilder` on Windows +3. **Parent window handle**: Provide window handle for WAM dialog (required by WAM on Windows) +4. **Cross-platform `SetParentActivityOrWindow`**: Replace `#if NETFRAMEWORK`-only `SetIWin32WindowFunc` with cross-platform `Func` API + +### New Public APIs + +```csharp +public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider +{ + // Cross-platform API to set the parent window/activity for WAM dialog + // On Windows: accepts IntPtr (window handle) or IWin32Window via Func + // On Unix: no-op (WAM not available) + public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc); +} +``` + +### Dependencies + +- **New**: `Microsoft.Identity.Client.Broker` (same version as `Microsoft.Identity.Client`: 4.83.0) +- Conditional on Windows platform at runtime (the package includes platform-specific native binaries) + +### File Changes + +| File | Change | +| ------ | -------- | +| `Directory.Packages.props` | Add `Microsoft.Identity.Client.Broker` version | +| `Azure.csproj` | Add package reference | +| `ActiveDirectoryAuthenticationProvider.cs` | Make partial, add broker logic | +| `ActiveDirectoryAuthenticationProvider.Windows.cs` (NEW) | Windows-specific: parent window detection | +| `Interop/Interop.GetConsoleWindow.cs` (NEW) | P/Invoke for kernel32 GetConsoleWindow | +| `Interop/Interop.GetAncestor.cs` (NEW) | P/Invoke for user32 GetAncestor | + +### Conditional Compilation Strategy + +Since the Extensions/Azure project targets `net462;netstandard2.0`, we cannot use `#if _WINDOWS` (that's for the main SqlClient project). Instead: + +- Use **runtime OS detection** (`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)`) for broker activation +- The `Microsoft.Identity.Client.Broker` package is always referenced but only invoked on Windows +- Platform-specific partial class files use `#if NETFRAMEWORK` for .NET Framework-only code paths + +### Implementation Flow + +```flowchart +AcquireTokenAsync +├── Non-PCA methods (Default, MSI, ServicePrincipal, Workload) → unchanged +└── PCA methods (Interactive, Integrated, Password, DeviceCodeFlow) + ├── Build PublicClientApplication with BrokerOptions (Windows only) + ├── Set ParentActivityOrWindow for WAM dialog + ├── Try silent token acquisition + └── If silent fails: + ├── Windows + Broker: WAM handles interactive/integrated flow + └── Non-Windows: Fallback to existing behavior (system browser, IWA) +``` + +## Testing + +### Unit Tests + +- Verify `SetParentActivityOrWindow` stores the function correctly +- Verify `SetParentActivityOrWindow` throws `ArgumentNullException` for null argument +- Verify `IsSupported` returns true for all expected auth methods + +### Manual/Integration Tests (require SQL Server) + +- `ActiveDirectoryInteractive` with WAM on Windows +- `ActiveDirectoryIntegrated` with WAM on Windows (validates device claims pass) +- Verify Unix/macOS falls back to non-broker behavior +- Verify CAP-protected Azure SQL MI access works from AVD + +## Rollout + +- WAM broker is **always enabled** on Windows when using PCA flows +- No opt-in connection string keyword needed (aligns with MSAL PCA compliance requirements) +- Existing `SetIWin32WindowFunc` remains as a backward-compatible API on .NET Framework, delegating to `SetParentActivityOrWindow` diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs new file mode 100644 index 0000000000..f9c3e03ab0 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Microsoft.Data.SqlClient; + +public sealed partial class ActiveDirectoryAuthenticationProvider +{ + /// + /// Gets the parent window handle to be used for interactive authentication prompts + /// via the Windows Account Manager (WAM) broker. + /// + /// + /// The parent window handle as an , or if + /// not running on Windows or no window handle is available. + /// + private IntPtr GetParentWindow() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return IntPtr.Zero; + } + + // If the user has provided a custom parent activity/window function, use it. + if (_parentActivityOrWindowFunc is not null) + { + object parentWindow = _parentActivityOrWindowFunc(); + if (parentWindow is IntPtr hwnd) + { + return hwnd; + } + } + + // Fall back to finding the console window, then getting its root owner. + IntPtr consoleHandle = Interop.Kernel32.GetConsoleWindow(); + if (consoleHandle != IntPtr.Zero) + { + IntPtr rootOwner = Interop.User32.GetRootOwner(consoleHandle); + if (rootOwner != IntPtr.Zero) + { + return rootOwner; + } + return consoleHandle; + } + + return IntPtr.Zero; + } + + /// + /// Gets the parent activity or window object for the broker authentication flow. + /// On Windows, returns the window handle. On other platforms, returns . + /// + private object GetBrokerParentWindow() + { + return GetParentWindow(); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index dfc6199457..db98b9839d 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -4,19 +4,21 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using Azure.Core; using Azure.Identity; using Microsoft.Extensions.Caching.Memory; using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Broker; using Microsoft.Identity.Client.Extensibility; using Microsoft.Data.SqlClient.Internal; namespace Microsoft.Data.SqlClient; /// -public sealed class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider +public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider { /// /// This is a static cache instance meant to hold instances of "PublicClientApplication" mapping to information available in PublicClientAppKey. @@ -118,6 +120,24 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) public void SetIWin32WindowFunc(Func iWin32WindowFunc) => _iWin32WindowFunc = iWin32WindowFunc; #endif + private Func? _parentActivityOrWindowFunc = null; + + /// + /// Sets a function to return the parent activity or window handle to be used for + /// WAM (Web Account Manager) broker authentication prompts. + /// + /// + /// A function that returns an window handle on Windows. + /// + /// + /// On Windows, this handle is used to parent the WAM broker dialog. + /// If not set, the provider will attempt to automatically detect the console window handle. + /// + public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc) + { + _parentActivityOrWindowFunc = parentActivityOrWindowFunc ?? throw new ArgumentNullException(nameof(parentActivityOrWindowFunc)); + } + /// public override async Task AcquireTokenAsync(SqlAuthenticationParameters parameters) { @@ -724,9 +744,32 @@ private IPublicClientApplication CreateClientAppInstance(PublicClientAppKey publ // tenant. .WithAuthority(publicClientAppKey.Authority); + // Enable WAM broker on Windows for all supported authentication modes. + // The broker provides enhanced security by enabling device-based Conditional Access + // policies through the Windows Account Manager (WAM). + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + builder.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)); + + // Set the parent window handle for broker UI. + // On .NET Framework, prefer the IWin32WindowFunc if provided by the caller. + #if NETFRAMEWORK + if (publicClientAppKey.IWin32WindowFunc is not null) + { + builder.WithParentActivityOrWindow(publicClientAppKey.IWin32WindowFunc); + } + else + { + builder.WithParentActivityOrWindow(GetBrokerParentWindow); + } + #else + builder.WithParentActivityOrWindow(GetBrokerParentWindow); + #endif + } #if NETFRAMEWORK - if (publicClientAppKey.IWin32WindowFunc is not null) + else if (publicClientAppKey.IWin32WindowFunc is not null) { + // Not on Windows (shouldn't happen for NETFRAMEWORK, but be defensive). builder.WithParentActivityOrWindow(publicClientAppKey.IWin32WindowFunc); } #endif diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj index 423c55387f..ad4af04ca0 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj @@ -91,6 +91,7 @@ + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs new file mode 100644 index 0000000000..68f77e2e0b --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs @@ -0,0 +1,29 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.Data.SqlClient; + +internal static partial class Interop +{ + internal static partial class User32 + { + private const uint GA_ROOTOWNER = 3; + + /// + /// Retrieves the handle to the ancestor of the specified window. + /// + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + /// + /// Gets the root owner window of the specified window handle. + /// + internal static IntPtr GetRootOwner(IntPtr hwnd) + { + return GetAncestor(hwnd, GA_ROOTOWNER); + } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs new file mode 100644 index 0000000000..66e34d16fc --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs @@ -0,0 +1,19 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.Data.SqlClient; + +internal static partial class Interop +{ + internal static partial class Kernel32 + { + /// + /// Retrieves the window handle used by the console associated with the calling process. + /// + [DllImport("kernel32.dll")] + internal static extern IntPtr GetConsoleWindow(); + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs new file mode 100644 index 0000000000..89b86addae --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -0,0 +1,31 @@ +// 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; + +public class WamBrokerTests +{ + [Fact] + public void SetParentActivityOrWindow_NullArgument_ThrowsArgumentNullException() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + Assert.Throws("parentActivityOrWindowFunc", + () => provider.SetParentActivityOrWindow(null!)); + } + + [Fact] + public void SetParentActivityOrWindow_ValidFunc_DoesNotThrow() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + provider.SetParentActivityOrWindow(() => IntPtr.Zero); + } + + [Fact] + public void SetParentActivityOrWindow_CanBeCalledMultipleTimes() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + provider.SetParentActivityOrWindow(() => IntPtr.Zero); + provider.SetParentActivityOrWindow(() => new IntPtr(12345)); + } +} From 7a0a460c1c3a88fe83df1671fbdea773dc64f855 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:13:26 -0700 Subject: [PATCH 02/41] Add test connection project for validating WAM broker behavior on UI thread --- .../AzureSqlConnector.csproj | 25 + .../AzureSqlConnector/Directory.Build.props | 10 + .../Directory.Packages.props | 27 + .../AzureSqlConnector/MainForm.Designer.cs | 370 +++++++++++++ doc/apps/AzureSqlConnector/MainForm.cs | 498 ++++++++++++++++++ doc/apps/AzureSqlConnector/NuGet.config | 23 + doc/apps/AzureSqlConnector/Program.cs | 22 + doc/apps/AzureSqlConnector/README.md | 109 ++++ global.json | 2 +- src/Microsoft.Data.SqlClient.slnx | 1 + .../Connection/SqlConnectionInternal.cs | 13 +- 11 files changed, 1092 insertions(+), 8 deletions(-) create mode 100644 doc/apps/AzureSqlConnector/AzureSqlConnector.csproj create mode 100644 doc/apps/AzureSqlConnector/Directory.Build.props create mode 100644 doc/apps/AzureSqlConnector/Directory.Packages.props create mode 100644 doc/apps/AzureSqlConnector/MainForm.Designer.cs create mode 100644 doc/apps/AzureSqlConnector/MainForm.cs create mode 100644 doc/apps/AzureSqlConnector/NuGet.config create mode 100644 doc/apps/AzureSqlConnector/Program.cs create mode 100644 doc/apps/AzureSqlConnector/README.md diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj new file mode 100644 index 0000000000..78e4375cfc --- /dev/null +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -0,0 +1,25 @@ + + + + WinExe + net481 + Microsoft.Data.SqlClient.Samples.AzureSqlConnector + AzureSqlConnector + true + latest + disable + AnyCPU + true + + + + + + + + + + + diff --git a/doc/apps/AzureSqlConnector/Directory.Build.props b/doc/apps/AzureSqlConnector/Directory.Build.props new file mode 100644 index 0000000000..425707ecad --- /dev/null +++ b/doc/apps/AzureSqlConnector/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + + true + + + diff --git a/doc/apps/AzureSqlConnector/Directory.Packages.props b/doc/apps/AzureSqlConnector/Directory.Packages.props new file mode 100644 index 0000000000..a3c9905926 --- /dev/null +++ b/doc/apps/AzureSqlConnector/Directory.Packages.props @@ -0,0 +1,27 @@ + + + + + + + + 7.1.0-preview1.26124.5 + + + 1.0.0 + 1.0.0 + + + + + + + + + + diff --git a/doc/apps/AzureSqlConnector/MainForm.Designer.cs b/doc/apps/AzureSqlConnector/MainForm.Designer.cs new file mode 100644 index 0000000000..5e513e553c --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.Designer.cs @@ -0,0 +1,370 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs new file mode 100644 index 0000000000..e322800cf6 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -0,0 +1,498 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Main UI form that collects Azure SQL connection parameters from the user, builds a + /// connection string via , and optionally tests + /// connectivity using . + /// + public partial class MainForm : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + /// + /// Initializes a new instance of the class. + /// + public MainForm() + { + InitializeComponent(); + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + UpdateCredentialFieldsAvailability(); + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + /// + /// Populates the authentication combo box with every supported + /// value. + /// + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + /// + /// Populates the Encrypt combo box with the three supported encryption modes. + /// + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + /// + /// Registers a single instance for + /// every Entra ID authentication method and gives it this form as the parent window + /// owner. This is what enables the interactive (browser) sign-in popup to actually appear + /// on top of the WinForms host on .NET Framework — without parenting MSAL can fail to + /// display its UI. + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + provider.SetIWin32WindowFunc(() => this); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = builder.ConnectionString; + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = builder.ConnectionString; + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Testing connection..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + + try + { + // NOTE: We intentionally call OpenAsync on the UI thread (instead of wrapping in + // Task.Run) so that the SynchronizationContext is preserved. The + // ActiveDirectoryInteractive flow needs a running UI message pump on the calling + // thread for MSAL.NET to display its embedded sign-in browser parented to this + // form (see RegisterActiveDirectoryProvider). OpenAsync still keeps the UI + // responsive because the I/O wait does not block the message loop. + string serverVersion; + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await connection.OpenAsync().ConfigureAwait(true); + serverVersion = connection.ServerVersion; + } + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = builder.ConnectionString; + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Querying logged-in identity..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " ..."); + + try + { + // NOTE: Same UI-thread reasoning as btnTest_Click — keep the message pump alive + // for any ActiveDirectoryInteractive sign-in that may be required. + using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) + { + await connection.OpenAsync().ConfigureAwait(true); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = + "SELECT " + + " SUSER_SNAME() AS LoggedInUser, " + + " ORIGINAL_LOGIN() AS OriginalLogin, " + + " USER_NAME() AS DatabaseUser, " + + " SUSER_ID() AS LoginSid, " + + " DB_NAME() AS DatabaseName, " + + " @@SERVERNAME AS ServerName, " + + " HOST_NAME() AS ClientHost, " + + " APP_NAME() AS AppName, " + + " SESSION_USER AS SessionUser, " + + " CURRENT_USER AS CurrentUser, " + + " @@SPID AS SessionId, " + + " @@VERSION AS ServerVersion;"; + + using (SqlDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(true)) + { + if (await reader.ReadAsync().ConfigureAwait(true)) + { + AppendStatus("Identity:"); + for (int i = 0; i < reader.FieldCount; i++) + { + string name = reader.GetName(i); + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + else + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + } + } + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(txtConnectionString.Text); + SetStatus("Connection string copied to clipboard.", isError: false); } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + /// + /// Builds a from the current form values. + /// + /// The populated builder. + /// When required fields are missing. + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + // Credentials are only required for password-based authentication methods. + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + // User ID is optional for these methods (e.g. for ManagedIdentity it can hold the + // client id of a user-assigned identity; for ServicePrincipal it holds the app id). + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + // ServicePrincipal needs the client secret in the Password field. + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + /// + /// Returns true when the supplied authentication method requires both User ID and Password. + /// + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + /// + /// Returns a copy of the connection string with the password redacted. + /// + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + /// + /// Updates the enabled state of the User ID / Password fields based on the selected + /// authentication method. + /// + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + // User ID is meaningful for most methods (some make it optional). Disable for + // Integrated/Default/ManagedIdentity-style flows where the OS / environment supplies it. + bool userEnabled = + method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + /// + /// Updates the bottom status bar label. + /// + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + /// + /// Appends a line to the result/status log. + /// + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + /// + /// Toggles the form's busy state during the asynchronous test-connection call. + /// + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + /// + /// String constants used to populate the Encrypt combo box. + /// + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/NuGet.config b/doc/apps/AzureSqlConnector/NuGet.config new file mode 100644 index 0000000000..0a30d80eeb --- /dev/null +++ b/doc/apps/AzureSqlConnector/NuGet.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/doc/apps/AzureSqlConnector/Program.cs b/doc/apps/AzureSqlConnector/Program.cs new file mode 100644 index 0000000000..7b751f56fe --- /dev/null +++ b/doc/apps/AzureSqlConnector/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Application entry point for the Azure SQL Connector WinForms test app. + /// + internal static class Program + { + /// + /// The main entry point for the application. + /// + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new MainForm()); + } + } +} diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md new file mode 100644 index 0000000000..323407da9b --- /dev/null +++ b/doc/apps/AzureSqlConnector/README.md @@ -0,0 +1,109 @@ +# Azure SQL Connector (WinForms) + +A small **.NET Framework 4.8.1** Windows Forms test application that lets a user fill in Azure SQL +Database connection parameters in a UI, builds the corresponding ADO.NET connection string via +`SqlConnectionStringBuilder`, and tests connectivity using +[`Microsoft.Data.SqlClient`](https://www.nuget.org/packages/Microsoft.Data.SqlClient). + +It is intended as a quick, repeatable scratch tool for manually validating connection-string +combinations (server / database / authentication mode / encryption / etc.) against an Azure SQL DB +or SQL Server instance. + +## Form Inputs + +| Field | Maps to connection string keyword | +| -------------------------- | ----------------------------------------------- | +| Server name | `Data Source` | +| Database name | `Initial Catalog` *(only added when non-empty)* | +| Authentication | `Authentication` *(SqlAuthenticationMethod)* | +| User ID | `User ID` | +| Password | `Password` | +| Encrypt | `Encrypt` *(Mandatory / Optional / Strict)* | +| Trust server certificate | `TrustServerCertificate` | +| Connect timeout (s) | `Connect Timeout` | + +The **Authentication** dropdown is populated from every member of +`Microsoft.Data.SqlClient.SqlAuthenticationMethod`. The User ID and Password fields are enabled / +disabled automatically based on the selected method: + +- **SqlPassword** / **ActiveDirectoryPassword** — both User ID and Password are required. +- **ActiveDirectoryServicePrincipal** — User ID = App (Client) ID, Password = client secret. +- **ActiveDirectoryManagedIdentity / MSI / Default / Interactive / DeviceCodeFlow / WorkloadIdentity** + — User ID is optional (e.g. user-assigned MI client id), Password is disabled. +- **ActiveDirectoryIntegrated** — credentials come from the OS, both fields disabled. + +## Buttons + +| Button | Action | +| ----------------------- | ---------------------------------------------------------------------- | +| Build Connection String | Builds the connection string from the form values and displays it. | +| Test Connection | Builds the connection string and calls `SqlConnection.Open()`. | +| Copy to Clipboard | Copies the currently-built connection string to the clipboard. | +| Clear All | Resets every input field to its default state. | +| Who Am I? | Connects and runs an identity query (`SUSER_SNAME()`, `ORIGINAL_LOGIN()`, `USER_NAME()`, `DB_NAME()`, `@@SPID`, etc.) and prints the results. | + +The result pane shows the built connection string with the password masked, the test connection +outcome (including SQL error number when applicable), and the server version on success. + +## Prerequisites + +- .NET Framework **4.8.1** Developer Pack (Visual Studio 2026 Enterprise installs this by default). +- Network connectivity to your Azure SQL Database (server firewall must allow your client IP). +- For Entra ID authentication modes, valid credentials available through Azure CLI / environment + variables / managed identity, depending on the chosen method. + +## Build & Run + +From the project folder: + +```pwsh +dotnet build .\AzureSqlConnector.csproj +dotnet run --project .\AzureSqlConnector.csproj +``` + +Or load `src\Microsoft.Data.SqlClient.slnx` in Visual Studio, set **AzureSqlConnector** as the +startup project, and press **F5**. + +## Example + +1. **Server name:** `myserver.database.windows.net` +2. **Database name:** `MyDb` +3. **Authentication:** `SqlPassword` +4. **User ID:** `sqladmin` +5. **Password:** *your password* +6. **Encrypt:** `Mandatory` +7. **Trust server certificate:** unchecked +8. Click **Test Connection** — the result pane should display + `Connected successfully! Server version: 12.00.xxxx`. + +## Entra ID (Azure AD) Authentication Notes + +For any `ActiveDirectory*` authentication method (especially **ActiveDirectoryInteractive**) +the app does two things at startup that are required for the interactive browser sign-in window +to appear: + +1. References [`Microsoft.Data.SqlClient.Extensions.Azure`](https://www.nuget.org/packages/Microsoft.Data.SqlClient.Extensions.Azure/) + which contains the `ActiveDirectoryAuthenticationProvider`. Starting with + `Microsoft.Data.SqlClient` 7.0, the AD providers were moved out of the core driver into this + extension package, so without it SqlClient throws: + `Cannot find an authentication provider for 'ActiveDirectoryInteractive'`. + +2. In the `MainForm` constructor it calls `provider.SetIWin32WindowFunc(() => this)` and + registers the provider for every `SqlAuthenticationMethod.ActiveDirectory*` value. This + gives MSAL.NET the parent `IWin32Window` it needs to display its embedded sign-in browser + on top of the form. + +The **Test Connection** button intentionally calls `SqlConnection.OpenAsync()` on the **UI +thread** (no `Task.Run` wrapper) so the Windows Forms message pump stays alive on the calling +thread while MSAL is waiting for the user to sign in — without this the popup can fail to render +or remain unresponsive. + +## Notes + +- Like the sibling [`AzureAuthentication`](../AzureAuthentication/README.md) sample, this project + opts out of inherited `Directory.Build.props` / `Directory.Packages.props` and uses its own + `NuGet.config` pointing at the governed SqlClient ADO feed (plus a local `packages/` folder for + developer overrides). +- This is a sample / diagnostic tool, **not** a product. It does not persist credentials. +dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj +dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj diff --git a/global.json b/global.json index 48cbbf1a80..8f72af87e2 100644 --- a/global.json +++ b/global.json @@ -7,7 +7,7 @@ // .NET 10 SDK versions in the 10.0.2xx series require MSBuild 18.x, so we specify the most // recent 10.0.1xx series release which is compatible with MSBuild 17.x. // - "version": "10.0.107", + "version": "10.0.108", // We cannot allow any roll forward due to the above MSBuild compatibility issues. "rollForward": "disable", diff --git a/src/Microsoft.Data.SqlClient.slnx b/src/Microsoft.Data.SqlClient.slnx index d65c3edc90..3732671b81 100644 --- a/src/Microsoft.Data.SqlClient.slnx +++ b/src/Microsoft.Data.SqlClient.slnx @@ -12,6 +12,7 @@ + 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 e62541137c..3d0fcb69a2 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 @@ -2204,10 +2204,7 @@ private void AttemptOneLogin( /// if a cached token exists from a previous auth attempt (see GetFedAuthToken). /// // @TODO: Rename to meet naming conventions - private bool AttemptRetryADAuthWithTimeoutError( - SqlException sqlex, - SqlConnectionOptions connectionOptions, // @TODO: this is not used - TimeoutTimer timeout) + private bool AttemptRetryADAuthWithTimeoutError(SqlException sqlex, TimeoutTimer timeout) { if (!_activeDirectoryAuthTimeoutRetryHelper.CanRetryWithSqlException(sqlex)) { @@ -2216,8 +2213,10 @@ private bool AttemptRetryADAuthWithTimeoutError( // Reset client-side timeout. timeout.Reset(); - // When server timeout, the auth context key was already created. Clean it up here. + // Clear fed-auth state captured by the failed attempt so OnFedAuthInfo's cache-reuse branch starts from null on the retry. _dbConnectionPoolAuthenticationContextKey = null; + _fedAuthToken = null; + _newDbConnectionPoolAuthenticationContext = null; // When server timeouts, connection is doomed. Reset here to allow reconnection. UnDoomThisConnection(); @@ -3316,7 +3315,7 @@ private void LoginNoFailover( } catch (SqlException sqlex) { - if (AttemptRetryADAuthWithTimeoutError(sqlex, connectionOptions, timeout)) + if (AttemptRetryADAuthWithTimeoutError(sqlex, timeout)) { continue; } @@ -3630,7 +3629,7 @@ private void LoginWithFailover( } catch (SqlException sqlex) { - if (AttemptRetryADAuthWithTimeoutError(sqlex, connectionOptions, timeout)) + if (AttemptRetryADAuthWithTimeoutError(sqlex, timeout)) { continue; } From bc5178a3f35d9920b63803e792f96d2a3d5318a0 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:32:09 -0700 Subject: [PATCH 03/41] Update redirectURI + minor changes --- .../ActiveDirectoryAuthenticationProvider.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index db98b9839d..0cec993a0c 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -31,12 +31,13 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private static readonly SemaphoreSlim s_tokenCredentialMapModifierSemaphore = new(1, 1); private static readonly MemoryCache s_accountPwCache = new MemoryCache(new MemoryCacheOptions()); private const int s_accountPwCacheTtlInHours = 2; + private readonly string _applicationClientId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; private const string s_nativeClientRedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; + private const string _wamBrokerRedirectUriPrefix = $"ms-appx-web://microsoft.aad.brokerplugin/"; private const string s_defaultScopeSuffix = "/.default"; private readonly string _type = typeof(ActiveDirectoryAuthenticationProvider).Name; private Func _deviceCodeFlowCallback; private ICustomWebUi? _customWebUI = null; - private readonly string _applicationClientId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; // The MSAL error code that indicates the action should be retried. // @@ -124,7 +125,7 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) /// /// Sets a function to return the parent activity or window handle to be used for - /// WAM (Web Account Manager) broker authentication prompts. + /// WAM (Windows Account Manager) broker authentication prompts. /// /// /// A function that returns an window handle on Windows. @@ -242,14 +243,14 @@ public override async Task AcquireTokenAsync(SqlAuthenti * * https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-app-registration#redirect-uris */ - string redirectUri = s_nativeClientRedirectUri; + string redirectUri = _wamBrokerRedirectUriPrefix + _applicationClientId; - #if NETSTANDARD - if (parameters.AuthenticationMethod != SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow) - { - redirectUri = "http://localhost"; - } - #endif + // #if NETSTANDARD + // if (parameters.AuthenticationMethod != SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow) + // { + // redirectUri = "http://localhost"; + // } + // #endif PublicClientAppKey pcaKey = #if NETFRAMEWORK @@ -525,7 +526,7 @@ private static async Task AcquireTokenInteractiveDeviceFlo // separate process. MSAL does not have control over this // browser, but once the user finishes authentication, the web // page is redirected in such a way that MSAL can intercept the - // Uri. MSAL cannot detect if the user navigates away or simply + // Uri. MSAL cannot detect if the user navigates away or simply // closes the browser. Apps using this technique are encouraged // to define a timeout (via CancellationToken). We recommend a // timeout of at least a few minutes, to take into account cases From bca2a62c6a319781257111be3a3057a503405d5e Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:36:48 -0700 Subject: [PATCH 04/41] Comments/cleanup --- .../ActiveDirectoryAuthenticationProvider.cs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 0cec993a0c..a6e945e656 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -32,7 +32,6 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private static readonly MemoryCache s_accountPwCache = new MemoryCache(new MemoryCacheOptions()); private const int s_accountPwCacheTtlInHours = 2; private readonly string _applicationClientId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; - private const string s_nativeClientRedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; private const string _wamBrokerRedirectUriPrefix = $"ms-appx-web://microsoft.aad.brokerplugin/"; private const string s_defaultScopeSuffix = "/.default"; private readonly string _type = typeof(ActiveDirectoryAuthenticationProvider).Name; @@ -237,21 +236,18 @@ public override async Task AcquireTokenAsync(SqlAuthenti } /* - * Today, MSAL.NET uses another redirect URI by default in desktop applications that run on Windows - * (urn:ietf:wg:oauth:2.0:oob). In the future, we'll want to change this default, so we recommend - * that you use https://login.microsoftonline.com/common/oauth2/nativeclient. + * For the remaining Active Directory authentication methods, we use MSAL.NET to acquire tokens. + * To do that, we need to construct a PublicClientApplication instance. * - * https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-app-registration#redirect-uris - */ + * With WAM broker support in MSAL enabled, on Windows we use a fixed redirect URI in the format + * "ms-appx-web://microsoft.aad.brokerplugin/{clientId}" where {clientId} is the application (client) ID of the calling application. + * This is required for MSAL to correctly route the authentication request to the WAM broker and for WAM to route the response back to MSAL. + * + * This means that an application using ActiveDirectoryAuthenticationProvider must have a redirect URI in the above format + * registered in Entra ID in order to use WAM brokered authentication on Windows. + */ string redirectUri = _wamBrokerRedirectUriPrefix + _applicationClientId; - // #if NETSTANDARD - // if (parameters.AuthenticationMethod != SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow) - // { - // redirectUri = "http://localhost"; - // } - // #endif - PublicClientAppKey pcaKey = #if NETFRAMEWORK new(parameters.Authority, redirectUri, _applicationClientId, _iWin32WindowFunc); From 58083b62cf8fbf14623caa33a17338a874f66bfb Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:38:57 -0700 Subject: [PATCH 05/41] Update config file --- doc/apps/AzureSqlConnector/NuGet.config | 7 ------- 1 file changed, 7 deletions(-) diff --git a/doc/apps/AzureSqlConnector/NuGet.config b/doc/apps/AzureSqlConnector/NuGet.config index 0a30d80eeb..7561e191a2 100644 --- a/doc/apps/AzureSqlConnector/NuGet.config +++ b/doc/apps/AzureSqlConnector/NuGet.config @@ -12,12 +12,5 @@ https://sqlclientdrivers.visualstudio.com/public/_artifacts/feed/sqlclient --> - - - From fd54502fb764a85aae0a8a5f9b072e924a5b83dc Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:45:34 -0700 Subject: [PATCH 06/41] Update Redirect URI for Unix --- .../Azure/src/ActiveDirectoryAuthenticationProvider.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index a6e945e656..c1b8b2af68 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -32,7 +32,8 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private static readonly MemoryCache s_accountPwCache = new MemoryCache(new MemoryCacheOptions()); private const int s_accountPwCacheTtlInHours = 2; private readonly string _applicationClientId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; - private const string _wamBrokerRedirectUriPrefix = $"ms-appx-web://microsoft.aad.brokerplugin/"; + private const string _wamBrokerRedirectUriPrefix = "ms-appx-web://microsoft.aad.brokerplugin/"; + private const string _systemBrowserRedirectUri = "http://localhost"; private const string s_defaultScopeSuffix = "/.default"; private readonly string _type = typeof(ActiveDirectoryAuthenticationProvider).Name; private Func _deviceCodeFlowCallback; @@ -246,7 +247,9 @@ public override async Task AcquireTokenAsync(SqlAuthenti * This means that an application using ActiveDirectoryAuthenticationProvider must have a redirect URI in the above format * registered in Entra ID in order to use WAM brokered authentication on Windows. */ - string redirectUri = _wamBrokerRedirectUriPrefix + _applicationClientId; + string redirectUri = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? _wamBrokerRedirectUriPrefix + _applicationClientId + : _systemBrowserRedirectUri; PublicClientAppKey pcaKey = #if NETFRAMEWORK From b657f3287081f5a59d8ff1af0810bfc3091211ce Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:46:59 -0700 Subject: [PATCH 07/41] Remove duplication --- doc/apps/AzureSqlConnector/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md index 323407da9b..63a607472b 100644 --- a/doc/apps/AzureSqlConnector/README.md +++ b/doc/apps/AzureSqlConnector/README.md @@ -106,4 +106,3 @@ or remain unresponsive. developer overrides). - This is a sample / diagnostic tool, **not** a product. It does not persist credentials. dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj -dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj From 01529eb7c2fce9b2d072c2e253f48ece965b8498 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 20:56:19 -0700 Subject: [PATCH 08/41] Fix unix build --- .../AzureSqlConnector.csproj | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj index 78e4375cfc..57c15d133a 100644 --- a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -1,8 +1,21 @@ - + + + net481 + netstandard2.0 + false + + + + WinExe - net481 Microsoft.Data.SqlClient.Samples.AzureSqlConnector AzureSqlConnector true @@ -12,12 +25,29 @@ true - - - - + + + false + true + false + + + + From 152b44e845654cc233b1f698baab85990283fce4 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:02:12 -0700 Subject: [PATCH 09/41] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/apps/AzureSqlConnector/MainForm.cs | 4 +++- specs/002-wam-broker/spec.md | 5 ++--- .../ActiveDirectoryAuthenticationProvider.Windows.cs | 11 +++++++++++ .../src/ActiveDirectoryAuthenticationProvider.cs | 8 ++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index e322800cf6..0a0c942f38 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using System.Windows.Forms; +using Microsoft.Data.SqlClient; namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector { @@ -254,7 +255,8 @@ private void btnCopy_Click(object sender, EventArgs e) try { Clipboard.SetText(txtConnectionString.Text); - SetStatus("Connection string copied to clipboard.", isError: false); } + SetStatus("Connection string copied to clipboard.", isError: false); + } catch (Exception ex) { SetStatus("Failed to copy to clipboard.", isError: true); diff --git a/specs/002-wam-broker/spec.md b/specs/002-wam-broker/spec.md index 60ee74b882..494e6ac701 100644 --- a/specs/002-wam-broker/spec.md +++ b/specs/002-wam-broker/spec.md @@ -57,15 +57,14 @@ The `ActiveDirectoryAuthenticationProvider` is in `src/Microsoft.Data.SqlClient. 1. **Make class `partial`**: Split `ActiveDirectoryAuthenticationProvider` into platform-specific files 2. **Add WAM broker**: Configure `BrokerOptions` on `PublicClientApplicationBuilder` on Windows 3. **Parent window handle**: Provide window handle for WAM dialog (required by WAM on Windows) -4. **Cross-platform `SetParentActivityOrWindow`**: Replace `#if NETFRAMEWORK`-only `SetIWin32WindowFunc` with cross-platform `Func` API - +4. **Cross-platform `SetParentActivityOrWindow`**: Add a cross-platform `Func` API for parenting broker UI (in addition to the existing .NET Framework-only `SetIWin32WindowFunc`) ### New Public APIs ```csharp public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider { // Cross-platform API to set the parent window/activity for WAM dialog - // On Windows: accepts IntPtr (window handle) or IWin32Window via Func + // On Windows: accepts an IntPtr window handle (and on .NET Framework also accepts IWin32Window) // On Unix: no-op (WAM not available) public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc); } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs index f9c3e03ab0..b87c16bd08 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs @@ -31,6 +31,17 @@ private IntPtr GetParentWindow() { return hwnd; } +#if NETFRAMEWORK + if (parentWindow is System.Windows.Forms.IWin32Window win32Window) + { + return win32Window.Handle; + } +#endif + if (parentWindow is not null) + { + throw new InvalidOperationException($"{nameof(SetParentActivityOrWindow)} expects the callback to return an IntPtr window handle" + + " (or an IWin32Window on .NET Framework)." ); + } } // Fall back to finding the console window, then getting its root owner. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index c1b8b2af68..890c4bf105 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -240,12 +240,12 @@ public override async Task AcquireTokenAsync(SqlAuthenti * For the remaining Active Directory authentication methods, we use MSAL.NET to acquire tokens. * To do that, we need to construct a PublicClientApplication instance. * - * With WAM broker support in MSAL enabled, on Windows we use a fixed redirect URI in the format - * "ms-appx-web://microsoft.aad.brokerplugin/{clientId}" where {clientId} is the application (client) ID of the calling application. + * With WAM broker support in MSAL enabled, on Windows we use a fixed redirect URI in the format + * "ms-appx-web://microsoft.aad.brokerplugin/{clientId}" where {clientId} is the client ID configured for this provider + * (by default SqlClient's first-party app id, but it can be overridden via the constructor). * This is required for MSAL to correctly route the authentication request to the WAM broker and for WAM to route the response back to MSAL. * - * This means that an application using ActiveDirectoryAuthenticationProvider must have a redirect URI in the above format - * registered in Entra ID in order to use WAM brokered authentication on Windows. + * This means the Entra ID app registration for that client ID must include the above redirect URI to use WAM brokered authentication on Windows. */ string redirectUri = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? _wamBrokerRedirectUriPrefix + _applicationClientId From fff68be21fd1b55e8c4f831598450b17824ef7c8 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:03:29 -0700 Subject: [PATCH 10/41] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/apps/AzureSqlConnector/README.md | 2 +- specs/002-wam-broker/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md index 63a607472b..894a51a9d8 100644 --- a/doc/apps/AzureSqlConnector/README.md +++ b/doc/apps/AzureSqlConnector/README.md @@ -105,4 +105,4 @@ or remain unresponsive. `NuGet.config` pointing at the governed SqlClient ADO feed (plus a local `packages/` folder for developer overrides). - This is a sample / diagnostic tool, **not** a product. It does not persist credentials. -dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj +- From the repo root: `dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj` diff --git a/specs/002-wam-broker/spec.md b/specs/002-wam-broker/spec.md index 494e6ac701..9b65d0a9da 100644 --- a/specs/002-wam-broker/spec.md +++ b/specs/002-wam-broker/spec.md @@ -127,4 +127,4 @@ AcquireTokenAsync - WAM broker is **always enabled** on Windows when using PCA flows - No opt-in connection string keyword needed (aligns with MSAL PCA compliance requirements) -- Existing `SetIWin32WindowFunc` remains as a backward-compatible API on .NET Framework, delegating to `SetParentActivityOrWindow` +- Existing `SetIWin32WindowFunc` remains as a backward-compatible .NET Framework API; the new cross-platform API is `SetParentActivityOrWindow`. From 17564caee702dbd9d8de4871e1db6df81edafb77 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:17:43 -0700 Subject: [PATCH 11/41] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/apps/AzureSqlConnector/MainForm.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index 0a0c942f38..57ccc9c9f3 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -97,10 +97,9 @@ private void btnBuild_Click(object sender, EventArgs e) try { SqlConnectionStringBuilder builder = BuildConnectionString(); - txtConnectionString.Text = builder.ConnectionString; + txtConnectionString.Text = MaskPassword(builder); SetStatus("Connection string built successfully.", isError: false); AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); - } catch (Exception ex) { txtConnectionString.Text = string.Empty; @@ -115,8 +114,7 @@ private async void btnTest_Click(object sender, EventArgs e) try { builder = BuildConnectionString(); - txtConnectionString.Text = builder.ConnectionString; - } + txtConnectionString.Text = MaskPassword(builder); catch (Exception ex) { SetStatus("Failed to build connection string.", isError: true); @@ -166,10 +164,8 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) { SqlConnectionStringBuilder builder; try - { builder = BuildConnectionString(); - txtConnectionString.Text = builder.ConnectionString; - } + txtConnectionString.Text = MaskPassword(builder); catch (Exception ex) { SetStatus("Failed to build connection string.", isError: true); @@ -254,7 +250,7 @@ private void btnCopy_Click(object sender, EventArgs e) try { - Clipboard.SetText(txtConnectionString.Text); + Clipboard.SetText(BuildConnectionString().ConnectionString); SetStatus("Connection string copied to clipboard.", isError: false); } catch (Exception ex) From 0d105e0fe1817dca55ffcfc2cad8b30c63307782 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Fri, 5 Jun 2026 21:19:21 -0700 Subject: [PATCH 12/41] Update comment --- doc/apps/AzureSqlConnector/MainForm.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index 57ccc9c9f3..7a52eb0872 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -421,7 +421,7 @@ private void UpdateCredentialFieldsAvailability() SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; // User ID is meaningful for most methods (some make it optional). Disable for - // Integrated/Default/ManagedIdentity-style flows where the OS / environment supplies it. + // Integrated flow where the OS / environment supplies it. bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; From 97fce03ccc32f8dce83bdf337a77967c2f5f169f Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Mon, 8 Jun 2026 22:58:58 -0700 Subject: [PATCH 13/41] useWamBroker option and tests --- doc/apps/AzureSqlConnector/MainForm.cs | 4 + .../ActiveDirectoryAuthenticationProvider.xml | 10 +- .../ActiveDirectoryAuthenticationProvider.xml | 8 +- .../ActiveDirectoryAuthenticationProvider.cs | 36 +++-- .../Azure/test/WamBrokerTests.cs | 144 ++++++++++++++++++ 5 files changed, 187 insertions(+), 15 deletions(-) diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index 7a52eb0872..d780a60359 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -100,6 +100,7 @@ private void btnBuild_Click(object sender, EventArgs e) txtConnectionString.Text = MaskPassword(builder); SetStatus("Connection string built successfully.", isError: false); AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } catch (Exception ex) { txtConnectionString.Text = string.Empty; @@ -115,6 +116,7 @@ private async void btnTest_Click(object sender, EventArgs e) { builder = BuildConnectionString(); txtConnectionString.Text = MaskPassword(builder); + } catch (Exception ex) { SetStatus("Failed to build connection string.", isError: true); @@ -164,8 +166,10 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) { SqlConnectionStringBuilder builder; try + { builder = BuildConnectionString(); txtConnectionString.Text = MaskPassword(builder); + } catch (Exception ex) { SetStatus("Failed to build connection string.", isError: true); diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 36f10a6aab..6685cfbf14 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,4 @@ - + @@ -14,6 +14,9 @@ Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + (Optional) Indicates whether to use the Windows Account Manager (WAM) broker for authentication. + Initializes the class with the provided application client id. @@ -30,7 +33,7 @@ public static void Main() { // Supported for all authentication modes supported by ActiveDirectoryAuthenticationProvider - ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider("<application_client_id>"); + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider("<application_client_id>", useWamBroker: true); if (provider.IsSupported(SqlAuthenticationMethod.ActiveDirectoryInteractive)) { SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); @@ -54,6 +57,9 @@ (Optional) Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + (Optional)Indicates whether to use the Windows Account Manager (WAM) broker for authentication. + Initializes the class with the provided device code flow callback method and application client id. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 84b6343497..5b3f913071 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -1,4 +1,4 @@ - @@ -14,9 +19,6 @@ Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - (Optional) Indicates whether to use the Windows Account Manager (WAM) broker for authentication. - Initializes the class with the provided application client id. @@ -33,7 +35,7 @@ public static void Main() { // Supported for all authentication modes supported by ActiveDirectoryAuthenticationProvider - ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider("<application_client_id>", useWamBroker: true); + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider("<application_client_id>"); if (provider.IsSupported(SqlAuthenticationMethod.ActiveDirectoryInteractive)) { SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); @@ -50,6 +52,17 @@ + + + Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + + When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. + + + Initializes the class with the provided application client id and Windows Account Manager (WAM) broker setting. + + The callback method to be used with 'Active Directory Device Code Flow' authentication. @@ -57,13 +70,24 @@ (Optional) Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - (Optional)Indicates whether to use the Windows Account Manager (WAM) broker for authentication. - Initializes the class with the provided device code flow callback method and application client id. + + + The callback method to be used with 'Active Directory Device Code Flow' authentication. + + + Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + + When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. + + + Initializes the class with the provided device code flow callback method, application client id, and Windows Account Manager (WAM) broker setting. + + The Active Directory authentication parameters passed to authentication providers. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 5b3f913071..1406856576 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -19,9 +19,6 @@ See the LICENSE file in the project root for more information. Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - (Optional) When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows. This setting is ignored when the driver's built-in application client id is used (WAM broker is always enabled in that case). Defaults to . - Initializes the class with the provided application client id. @@ -55,6 +52,17 @@ See the LICENSE file in the project root for more information. + + + Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + + When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. + + + Initializes the class with the provided application client id and Windows Account Manager (WAM) broker setting. + + The callback method to be used with 'Active Directory Device Code Flow' authentication. @@ -62,13 +70,24 @@ See the LICENSE file in the project root for more information. (Optional) Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - (Optional) When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows. This setting is ignored when the driver's built-in application client id is used (WAM broker is always enabled in that case). Defaults to . - Initializes the class with the provided device code flow callback method and application client id. + + + The callback method to be used with 'Active Directory Device Code Flow' authentication. + + + Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. + + + When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. + + + Initializes the class with the provided device code flow callback method, application client id, and Windows Account Manager (WAM) broker setting. + + The Active Directory authentication parameters passed to authentication providers. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 059e2068c0..42be4c2330 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -56,13 +56,25 @@ public ActiveDirectoryAuthenticationProvider() } /// - public ActiveDirectoryAuthenticationProvider(string applicationClientId, bool useWamBroker = false) + public ActiveDirectoryAuthenticationProvider(string applicationClientId) + : this(DefaultDeviceFlowCallback, applicationClientId) + { + } + + /// + public ActiveDirectoryAuthenticationProvider(string applicationClientId, bool useWamBroker) : this(DefaultDeviceFlowCallback, applicationClientId, useWamBroker) { } /// - public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId = null, bool useWamBroker = false) + public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId = null) + : this(deviceCodeFlowCallbackMethod, applicationClientId, useWamBroker: false) + { + } + + /// + public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId, bool useWamBroker) { _deviceCodeFlowCallback = deviceCodeFlowCallbackMethod; if (applicationClientId is not null) From 82a2d4efb815619b233a433b7b9c3ae5098754e9 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 9 Jun 2026 17:14:41 -0700 Subject: [PATCH 15/41] handle .net standard --- .../Azure/src/ActiveDirectoryAuthenticationProvider.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 42be4c2330..d8f43b2041 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -269,7 +269,11 @@ public override async Task AcquireTokenAsync(SqlAuthenti */ string redirectUri = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? (_useWamBroker ? s_wamBrokerRedirectUriPrefix + _applicationClientId + #if NETFRAMEWORK : s_windowsNativeRedirectUri) + #else + : s_systemBrowserRedirectUri) + #endif : s_systemBrowserRedirectUri; PublicClientAppKey pcaKey = From f14c637cee2675767cb0fc57629e82fdd05d065d Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 9 Jun 2026 17:24:10 -0700 Subject: [PATCH 16/41] Remove specs --- specs/002-wam-broker/spec.md | 130 ----------------------------------- 1 file changed, 130 deletions(-) delete mode 100644 specs/002-wam-broker/spec.md diff --git a/specs/002-wam-broker/spec.md b/specs/002-wam-broker/spec.md deleted file mode 100644 index 9b65d0a9da..0000000000 --- a/specs/002-wam-broker/spec.md +++ /dev/null @@ -1,130 +0,0 @@ -# Feature Specification: WAM Broker Support for Entra ID Authentication - -**Feature Branch**: `dev/automation/wam-broker-support` -**Created**: 2026-05-20 -**Status**: Draft -**References**: - -- PR [#2884](https://github.com/dotnet/SqlClient/pull/2884) (original POC, closed) -- PR [#3874](https://github.com/dotnet/SqlClient/pull/3874) (updated POC, closed) -- ICM 781210079 (Authentication failure on persistent AVD with Conditional Access) - -## Problem Statement - -Microsoft.Data.SqlClient's `ActiveDirectoryIntegrated` and other Public Client Application (PCA) authentication flows do not pass device information when acquiring tokens. This causes failures on persistent Azure Virtual Desktop (AVD) devices when Conditional Access Policies require device compliance or MFA based on device state. - -### Root Cause - -MSAL's `AcquireTokenByIntegratedWindowsAuth` does not pass device claims to the identity provider. The Windows Web Account Manager (WAM) broker passes device information (PRT, device compliance state) to Entra ID, satisfying Conditional Access policies. - -### MSAL PCA Compliance - -Microsoft identity platform requires first-party applications using Public Client Applications to use WAM broker on Windows for compliance. This ensures: - -- Device-based Conditional Access policies work correctly -- Primary Refresh Token (PRT) is leveraged for SSO -- Device compliance state is included in token requests - -## Design - -### Target Location - -The `ActiveDirectoryAuthenticationProvider` is in `src/Microsoft.Data.SqlClient.Extensions/Azure/src/`. This package targets `net462;netstandard2.0`. - -### Platform Support Matrix - -| Platform | WAM Broker | Fallback | -| ---------- | ----------- | ---------- | -| Windows (.NET Framework 4.6.2+) | ✅ Supported | IWA (legacy) | -| Windows (.NET 8.0+ via netstandard2.0) | ✅ Supported | System browser | -| Linux/macOS (.NET via netstandard2.0) | ❌ Not available | System browser / IWA | - -### Authentication Modes Covered - -| Mode | WAM Broker Behavior | -| ------ | ------------------- | -| `ActiveDirectoryInteractive` | Uses WAM for interactive token acquisition on Windows | -| `ActiveDirectoryIntegrated` | Uses WAM broker to pass device claims (solves CAP issues) | -| `ActiveDirectoryDeviceCodeFlow` | Uses WAM for device code flow on Windows | -| `ActiveDirectoryPassword` | Uses WAM for username/password flow on Windows | -| `ActiveDirectoryDefault` | No change (uses Azure.Identity DefaultAzureCredential) | -| `ActiveDirectoryManagedIdentity` | No change (server-side, no WAM needed) | -| `ActiveDirectoryServicePrincipal` | No change (confidential client, no WAM needed) | -| `ActiveDirectoryWorkloadIdentity` | No change (workload identity, no WAM needed) | - -### Architecture Changes - -1. **Make class `partial`**: Split `ActiveDirectoryAuthenticationProvider` into platform-specific files -2. **Add WAM broker**: Configure `BrokerOptions` on `PublicClientApplicationBuilder` on Windows -3. **Parent window handle**: Provide window handle for WAM dialog (required by WAM on Windows) -4. **Cross-platform `SetParentActivityOrWindow`**: Add a cross-platform `Func` API for parenting broker UI (in addition to the existing .NET Framework-only `SetIWin32WindowFunc`) -### New Public APIs - -```csharp -public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider -{ - // Cross-platform API to set the parent window/activity for WAM dialog - // On Windows: accepts an IntPtr window handle (and on .NET Framework also accepts IWin32Window) - // On Unix: no-op (WAM not available) - public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc); -} -``` - -### Dependencies - -- **New**: `Microsoft.Identity.Client.Broker` (same version as `Microsoft.Identity.Client`: 4.83.0) -- Conditional on Windows platform at runtime (the package includes platform-specific native binaries) - -### File Changes - -| File | Change | -| ------ | -------- | -| `Directory.Packages.props` | Add `Microsoft.Identity.Client.Broker` version | -| `Azure.csproj` | Add package reference | -| `ActiveDirectoryAuthenticationProvider.cs` | Make partial, add broker logic | -| `ActiveDirectoryAuthenticationProvider.Windows.cs` (NEW) | Windows-specific: parent window detection | -| `Interop/Interop.GetConsoleWindow.cs` (NEW) | P/Invoke for kernel32 GetConsoleWindow | -| `Interop/Interop.GetAncestor.cs` (NEW) | P/Invoke for user32 GetAncestor | - -### Conditional Compilation Strategy - -Since the Extensions/Azure project targets `net462;netstandard2.0`, we cannot use `#if _WINDOWS` (that's for the main SqlClient project). Instead: - -- Use **runtime OS detection** (`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)`) for broker activation -- The `Microsoft.Identity.Client.Broker` package is always referenced but only invoked on Windows -- Platform-specific partial class files use `#if NETFRAMEWORK` for .NET Framework-only code paths - -### Implementation Flow - -```flowchart -AcquireTokenAsync -├── Non-PCA methods (Default, MSI, ServicePrincipal, Workload) → unchanged -└── PCA methods (Interactive, Integrated, Password, DeviceCodeFlow) - ├── Build PublicClientApplication with BrokerOptions (Windows only) - ├── Set ParentActivityOrWindow for WAM dialog - ├── Try silent token acquisition - └── If silent fails: - ├── Windows + Broker: WAM handles interactive/integrated flow - └── Non-Windows: Fallback to existing behavior (system browser, IWA) -``` - -## Testing - -### Unit Tests - -- Verify `SetParentActivityOrWindow` stores the function correctly -- Verify `SetParentActivityOrWindow` throws `ArgumentNullException` for null argument -- Verify `IsSupported` returns true for all expected auth methods - -### Manual/Integration Tests (require SQL Server) - -- `ActiveDirectoryInteractive` with WAM on Windows -- `ActiveDirectoryIntegrated` with WAM on Windows (validates device claims pass) -- Verify Unix/macOS falls back to non-broker behavior -- Verify CAP-protected Azure SQL MI access works from AVD - -## Rollout - -- WAM broker is **always enabled** on Windows when using PCA flows -- No opt-in connection string keyword needed (aligns with MSAL PCA compliance requirements) -- Existing `SetIWin32WindowFunc` remains as a backward-compatible .NET Framework API; the new cross-platform API is `SetParentActivityOrWindow`. From 81312dc77b7b3707b67eb78d048626f470a9c4b0 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 9 Jun 2026 17:35:45 -0700 Subject: [PATCH 17/41] Add collection, address docs --- .../doc/ActiveDirectoryAuthenticationProvider.xml | 12 +++++++----- .../src/ActiveDirectoryAuthenticationProvider.cs | 12 +----------- .../Azure/test/AADAuthenticationTests.cs | 1 + .../Azure/test/DefaultAuthProviderTests.cs | 1 + .../test/SqlAuthenticationProviderCollection.cs | 14 ++++++++++++++ .../Azure/test/WamBrokerTests.cs | 1 + 6 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 1406856576..f4d3304abc 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -149,17 +149,19 @@ See the LICENSE file in the project root for more information. - + - The parent as an object, in order to be used from shared .NET Standard assemblies. + A function that returns an window handle on Windows + (or an on .NET Framework). - Sets a reference to the ViewController (if using Xamarin.iOS), Activity (if using Xamarin.Android) IWin32Window or IntPtr (if using .NET Framework). Used for invoking the browser for Active Directory Interactive authentication. + Sets a function to return the parent window handle to be used for WAM (Windows Account Manager) broker authentication prompts. - Mandatory to be set only on Android. See https://aka.ms/msal-net-android-activity for further documentation and details. + On Windows, this handle is used to parent the WAM broker dialog. If not set, the provider will attempt to automatically detect the console window handle. + On non-Windows platforms this is a no-op as the WAM broker is not available. - + A function to return the current window. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index d8f43b2041..d766c39797 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -143,17 +143,7 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) private Func? _parentActivityOrWindowFunc = null; - /// - /// Sets a function to return the parent activity or window handle to be used for - /// WAM (Windows Account Manager) broker authentication prompts. - /// - /// - /// A function that returns an window handle on Windows. - /// - /// - /// On Windows, this handle is used to parent the WAM broker dialog. - /// If not set, the provider will attempt to automatically detect the console window handle. - /// + /// public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc) { _parentActivityOrWindowFunc = parentActivityOrWindowFunc ?? throw new ArgumentNullException(nameof(parentActivityOrWindowFunc)); diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs index 5c7572ec84..e90dcce506 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADAuthenticationTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; // These tests were moved from MDS FunctionalTests AADAuthenticationTests.cs. +[Collection("SqlAuthenticationProvider")] public class AADAuthenticationTests { [Fact] diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs index 84d32651d5..fa426141c9 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/DefaultAuthProviderTests.cs @@ -4,6 +4,7 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; +[Collection("SqlAuthenticationProvider")] public class DefaultAuthProviderTests { // Verify that our auth provider has been installed for all AAD/Entra diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs new file mode 100644 index 0000000000..2bf23f7873 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/SqlAuthenticationProviderCollection.cs @@ -0,0 +1,14 @@ +// 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; + +/// +/// Defines a test collection that serializes execution of test classes +/// which mutate the global registry. +/// +[CollectionDefinition("SqlAuthenticationProvider")] +public class SqlAuthenticationProviderCollection +{ +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index c46b578c62..9dc8548f4a 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -6,6 +6,7 @@ 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. From 5683852221379f6e32c5cfafa937626df652ac7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:34:03 +0000 Subject: [PATCH 18/41] fix: allow AzureSqlConnector fallback build on non-Windows Co-authored-by: cheenamalhotra <13396919+cheenamalhotra@users.noreply.github.com> --- doc/apps/AzureSqlConnector/AzureSqlConnector.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj index 57c15d133a..d445fd5aee 100644 --- a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -33,7 +33,6 @@ --> false - true false From 3f91b03efade605db6bda73fa390fec7705d2971 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 16 Jun 2026 23:00:42 -0700 Subject: [PATCH 19/41] Address feedback --- .../AzureSqlConnector.csproj | 45 +- .../AzureSqlConnector/Directory.Build.props | 10 - .../Directory.Packages.props | 27 - doc/apps/AzureSqlConnector/IdentityQuery.cs | 25 + doc/apps/AzureSqlConnector/MainForm.cs | 109 +--- .../MainFormWorker.Designer.cs | 370 +++++++++++++ doc/apps/AzureSqlConnector/MainFormWorker.cs | 518 ++++++++++++++++++ .../AzureSqlConnector/ModeSelectorForm.cs | 116 ++++ doc/apps/AzureSqlConnector/Program.cs | 21 +- doc/apps/AzureSqlConnector/README.md | 87 +-- .../ActiveDirectoryAuthenticationProvider.xml | 32 +- ...DirectoryAuthenticationProvider.Windows.cs | 65 ++- .../ActiveDirectoryAuthenticationProvider.cs | 139 ++++- .../Azure/src/Interop/Interop.GetAncestor.cs | 17 +- .../src/Interop/Interop.GetConsoleWindow.cs | 10 +- .../Azure/test/WamBrokerTests.cs | 189 +++++-- .../SqlAuthenticationProviderManager.cs | 32 +- 17 files changed, 1529 insertions(+), 283 deletions(-) delete mode 100644 doc/apps/AzureSqlConnector/Directory.Build.props delete mode 100644 doc/apps/AzureSqlConnector/Directory.Packages.props create mode 100644 doc/apps/AzureSqlConnector/IdentityQuery.cs create mode 100644 doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs create mode 100644 doc/apps/AzureSqlConnector/MainFormWorker.cs create mode 100644 doc/apps/AzureSqlConnector/ModeSelectorForm.cs diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj index d445fd5aee..5afd31529a 100644 --- a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -1,20 +1,17 @@ - net481 - netstandard2.0 - false - - - - + net481;net10.0-windows + net10.0-windows WinExe Microsoft.Data.SqlClient.Samples.AzureSqlConnector AzureSqlConnector @@ -23,30 +20,10 @@ disable AnyCPU true + false - - - false - false - - - - + diff --git a/doc/apps/AzureSqlConnector/Directory.Build.props b/doc/apps/AzureSqlConnector/Directory.Build.props deleted file mode 100644 index 425707ecad..0000000000 --- a/doc/apps/AzureSqlConnector/Directory.Build.props +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - true - - - diff --git a/doc/apps/AzureSqlConnector/Directory.Packages.props b/doc/apps/AzureSqlConnector/Directory.Packages.props deleted file mode 100644 index a3c9905926..0000000000 --- a/doc/apps/AzureSqlConnector/Directory.Packages.props +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - 7.1.0-preview1.26124.5 - - - 1.0.0 - 1.0.0 - - - - - - - - - - diff --git a/doc/apps/AzureSqlConnector/IdentityQuery.cs b/doc/apps/AzureSqlConnector/IdentityQuery.cs new file mode 100644 index 0000000000..6c6cff84a1 --- /dev/null +++ b/doc/apps/AzureSqlConnector/IdentityQuery.cs @@ -0,0 +1,25 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Shared SQL text used by both (UI-thread variant) and + /// (worker-thread variant). Keeping the literal in one place + /// avoids drift when one variant gains a new column. + /// + internal static class IdentityQuery + { + public const string CommandText = + "SELECT " + + " SUSER_SNAME() AS LoggedInUser, " + + " ORIGINAL_LOGIN() AS OriginalLogin, " + + " USER_NAME() AS DatabaseUser, " + + " SUSER_ID() AS LoginSid, " + + " DB_NAME() AS DatabaseName, " + + " @@SERVERNAME AS ServerName, " + + " HOST_NAME() AS ClientHost, " + + " APP_NAME() AS AppName, " + + " SESSION_USER AS SessionUser, " + + " CURRENT_USER AS CurrentUser, " + + " @@SPID AS SessionId, " + + " @@VERSION AS ServerVersion;"; + } +} diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index d780a60359..9c192273a2 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -6,21 +6,21 @@ namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector { /// - /// Main UI form that collects Azure SQL connection parameters from the user, builds a - /// connection string via , and optionally tests - /// connectivity using . + /// "UI-thread" variant of the connector form. Opens the SQL connection via + /// on the UI thread; the WinForms + /// keeps the message pump alive while + /// the async I/O completes, so the form remains responsive and MSAL.NET's embedded sign-in + /// browser (for ActiveDirectoryInteractive) parents itself correctly. /// public partial class MainForm : Form { // ────────────────────────────────────────────────────────────────── #region Construction - /// - /// Initializes a new instance of the class. - /// public MainForm() { InitializeComponent(); + this.Text = "Azure SQL Connector — UI thread (OpenAsync)"; PopulateAuthenticationMethods(); PopulateEncryptOptions(); UpdateCredentialFieldsAvailability(); @@ -32,10 +32,6 @@ public MainForm() // ────────────────────────────────────────────────────────────────── #region UI Initialization - /// - /// Populates the authentication combo box with every supported - /// value. - /// private void PopulateAuthenticationMethods() { foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) @@ -46,9 +42,6 @@ private void PopulateAuthenticationMethods() cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; } - /// - /// Populates the Encrypt combo box with the three supported encryption modes. - /// private void PopulateEncryptOptions() { cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); @@ -58,17 +51,22 @@ private void PopulateEncryptOptions() } /// - /// Registers a single instance for - /// every Entra ID authentication method and gives it this form as the parent window - /// owner. This is what enables the interactive (browser) sign-in popup to actually appear - /// on top of the WinForms host on .NET Framework — without parenting MSAL can fail to - /// display its UI. + /// Registers a single for every + /// Entra ID authentication method and parents its UI to this form. With the UI-thread + /// approach, the callbacks run on the UI thread, + /// so it is safe for them to return this directly. /// private void RegisterActiveDirectoryProvider() { ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. provider.SetIWin32WindowFunc(() => this); - +#else + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => this.Handle); +#endif SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); @@ -130,16 +128,16 @@ private async void btnTest_Click(object sender, EventArgs e) try { - // NOTE: We intentionally call OpenAsync on the UI thread (instead of wrapping in - // Task.Run) so that the SynchronizationContext is preserved. The - // ActiveDirectoryInteractive flow needs a running UI message pump on the calling - // thread for MSAL.NET to display its embedded sign-in browser parented to this - // form (see RegisterActiveDirectoryProvider). OpenAsync still keeps the UI - // responsive because the I/O wait does not block the message loop. + // Call OpenAsync on the UI thread so the WinForms SynchronizationContext is + // preserved. The ActiveDirectoryInteractive flow needs a running UI message pump + // on the calling thread for MSAL.NET to display its embedded sign-in browser. + // OpenAsync still keeps the UI responsive because the I/O wait does not block the + // message loop. string serverVersion; using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) { await connection.OpenAsync().ConfigureAwait(true); + // connection.Open(); serverVersion = connection.ServerVersion; } @@ -149,12 +147,13 @@ private async void btnTest_Click(object sender, EventArgs e) catch (SqlException ex) { SetStatus("Connection failed (SqlException).", isError: true); - AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message + "\r\n" + ex.StackTrace); } catch (Exception ex) { SetStatus("Connection failed.", isError: true); - AppendStatus(ex.GetType().Name + ": " + ex.Message); + AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); + AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); } finally { @@ -183,28 +182,15 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) try { - // NOTE: Same UI-thread reasoning as btnTest_Click — keep the message pump alive - // for any ActiveDirectoryInteractive sign-in that may be required. + // Same UI-thread reasoning as btnTest_Click — keep the message pump alive for any + // ActiveDirectoryInteractive sign-in that may be required. using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) { await connection.OpenAsync().ConfigureAwait(true); using (SqlCommand command = connection.CreateCommand()) { - command.CommandText = - "SELECT " + - " SUSER_SNAME() AS LoggedInUser, " + - " ORIGINAL_LOGIN() AS OriginalLogin, " + - " USER_NAME() AS DatabaseUser, " + - " SUSER_ID() AS LoginSid, " + - " DB_NAME() AS DatabaseName, " + - " @@SERVERNAME AS ServerName, " + - " HOST_NAME() AS ClientHost, " + - " APP_NAME() AS AppName, " + - " SESSION_USER AS SessionUser, " + - " CURRENT_USER AS CurrentUser, " + - " @@SPID AS SessionId, " + - " @@VERSION AS ServerVersion;"; + command.CommandText = IdentityQuery.CommandText; using (SqlDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(true)) { @@ -284,11 +270,6 @@ private void btnClear_Click(object sender, EventArgs e) // ────────────────────────────────────────────────────────────────── #region Connection String Construction - /// - /// Builds a from the current form values. - /// - /// The populated builder. - /// When required fields are missing. private SqlConnectionStringBuilder BuildConnectionString() { string server = (txtServer.Text ?? string.Empty).Trim(); @@ -316,7 +297,6 @@ private SqlConnectionStringBuilder BuildConnectionString() builder.Authentication = authMethod; } - // Credentials are only required for password-based authentication methods. if (RequiresUserAndPassword(authMethod)) { string userId = (txtUserId.Text ?? string.Empty).Trim(); @@ -337,15 +317,12 @@ private SqlConnectionStringBuilder BuildConnectionString() || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) { - // User ID is optional for these methods (e.g. for ManagedIdentity it can hold the - // client id of a user-assigned identity; for ServicePrincipal it holds the app id). string userId = (txtUserId.Text ?? string.Empty).Trim(); if (!string.IsNullOrEmpty(userId)) { builder.UserID = userId; } - // ServicePrincipal needs the client secret in the Password field. if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal && !string.IsNullOrEmpty(txtPassword.Text)) { @@ -372,9 +349,6 @@ private SqlConnectionStringBuilder BuildConnectionString() return builder; } - /// - /// Returns true when the supplied authentication method requires both User ID and Password. - /// private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) { switch (method) @@ -389,9 +363,6 @@ private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) } } - /// - /// Returns a copy of the connection string with the password redacted. - /// private static string MaskPassword(SqlConnectionStringBuilder builder) { if (string.IsNullOrEmpty(builder.Password)) @@ -411,10 +382,6 @@ private static string MaskPassword(SqlConnectionStringBuilder builder) // ────────────────────────────────────────────────────────────────── #region UI Helpers - /// - /// Updates the enabled state of the User ID / Password fields based on the selected - /// authentication method. - /// private void UpdateCredentialFieldsAvailability() { if (cmbAuthentication.SelectedItem == null) @@ -424,11 +391,7 @@ private void UpdateCredentialFieldsAvailability() SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; - // User ID is meaningful for most methods (some make it optional). Disable for - // Integrated flow where the OS / environment supplies it. - bool userEnabled = - method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; - + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; bool passwordEnabled = RequiresUserAndPassword(method) || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; @@ -441,18 +404,12 @@ private void UpdateCredentialFieldsAvailability() } } - /// - /// Updates the bottom status bar label. - /// private void SetStatus(string text, bool isError) { statusLabel.Text = text; statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; } - /// - /// Appends a line to the result/status log. - /// private void AppendStatus(string line) { if (txtStatus.TextLength > 0) @@ -462,9 +419,6 @@ private void AppendStatus(string line) txtStatus.AppendText(line ?? string.Empty); } - /// - /// Toggles the form's busy state during the asynchronous test-connection call. - /// private void SetBusy(bool busy, string statusText) { btnBuild.Enabled = !busy; @@ -485,9 +439,6 @@ private void SetBusy(bool busy, string statusText) // ────────────────────────────────────────────────────────────────── #region Nested Types - /// - /// String constants used to populate the Encrypt combo box. - /// private static class EncryptDisplay { public const string Mandatory = "Mandatory"; diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs new file mode 100644 index 0000000000..76a7184649 --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs @@ -0,0 +1,370 @@ +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + partial class MainFormWorker + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.lblServer = new System.Windows.Forms.Label(); + this.txtServer = new System.Windows.Forms.TextBox(); + this.lblDatabase = new System.Windows.Forms.Label(); + this.txtDatabase = new System.Windows.Forms.TextBox(); + this.lblAuthentication = new System.Windows.Forms.Label(); + this.cmbAuthentication = new System.Windows.Forms.ComboBox(); + this.lblUserId = new System.Windows.Forms.Label(); + this.txtUserId = new System.Windows.Forms.TextBox(); + this.lblPassword = new System.Windows.Forms.Label(); + this.txtPassword = new System.Windows.Forms.TextBox(); + this.lblEncrypt = new System.Windows.Forms.Label(); + this.cmbEncrypt = new System.Windows.Forms.ComboBox(); + this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); + this.lblTimeout = new System.Windows.Forms.Label(); + this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.lblConnectionString = new System.Windows.Forms.Label(); + this.txtConnectionString = new System.Windows.Forms.TextBox(); + this.btnBuild = new System.Windows.Forms.Button(); + this.btnTest = new System.Windows.Forms.Button(); + this.btnCopy = new System.Windows.Forms.Button(); + this.btnClear = new System.Windows.Forms.Button(); + this.btnWhoAmI = new System.Windows.Forms.Button(); + this.lblStatus = new System.Windows.Forms.Label(); + this.txtStatus = new System.Windows.Forms.TextBox(); + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).BeginInit(); + this.statusStrip.SuspendLayout(); + this.SuspendLayout(); + // + // lblServer + // + this.lblServer.AutoSize = true; + this.lblServer.Location = new System.Drawing.Point(16, 18); + this.lblServer.Name = "lblServer"; + this.lblServer.Size = new System.Drawing.Size(75, 13); + this.lblServer.TabIndex = 0; + this.lblServer.Text = "&Server name:"; + // + // txtServer + // + this.txtServer.Location = new System.Drawing.Point(150, 15); + this.txtServer.Name = "txtServer"; + this.txtServer.Size = new System.Drawing.Size(400, 20); + this.txtServer.TabIndex = 1; + // + // lblDatabase + // + this.lblDatabase.AutoSize = true; + this.lblDatabase.Location = new System.Drawing.Point(16, 48); + this.lblDatabase.Name = "lblDatabase"; + this.lblDatabase.Size = new System.Drawing.Size(86, 13); + this.lblDatabase.TabIndex = 2; + this.lblDatabase.Text = "&Database name:"; + // + // txtDatabase + // + this.txtDatabase.Location = new System.Drawing.Point(150, 45); + this.txtDatabase.Name = "txtDatabase"; + this.txtDatabase.Size = new System.Drawing.Size(400, 20); + this.txtDatabase.TabIndex = 3; + // + // lblAuthentication + // + this.lblAuthentication.AutoSize = true; + this.lblAuthentication.Location = new System.Drawing.Point(16, 78); + this.lblAuthentication.Name = "lblAuthentication"; + this.lblAuthentication.Size = new System.Drawing.Size(80, 13); + this.lblAuthentication.TabIndex = 4; + this.lblAuthentication.Text = "&Authentication:"; + // + // cmbAuthentication + // + this.cmbAuthentication.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbAuthentication.FormattingEnabled = true; + this.cmbAuthentication.Location = new System.Drawing.Point(150, 75); + this.cmbAuthentication.Name = "cmbAuthentication"; + this.cmbAuthentication.Size = new System.Drawing.Size(400, 21); + this.cmbAuthentication.TabIndex = 5; + this.cmbAuthentication.SelectedIndexChanged += new System.EventHandler(this.cmbAuthentication_SelectedIndexChanged); + // + // lblUserId + // + this.lblUserId.AutoSize = true; + this.lblUserId.Location = new System.Drawing.Point(16, 108); + this.lblUserId.Name = "lblUserId"; + this.lblUserId.Size = new System.Drawing.Size(45, 13); + this.lblUserId.TabIndex = 6; + this.lblUserId.Text = "&User ID:"; + // + // txtUserId + // + this.txtUserId.Location = new System.Drawing.Point(150, 105); + this.txtUserId.Name = "txtUserId"; + this.txtUserId.Size = new System.Drawing.Size(400, 20); + this.txtUserId.TabIndex = 7; + // + // lblPassword + // + this.lblPassword.AutoSize = true; + this.lblPassword.Location = new System.Drawing.Point(16, 138); + this.lblPassword.Name = "lblPassword"; + this.lblPassword.Size = new System.Drawing.Size(56, 13); + this.lblPassword.TabIndex = 8; + this.lblPassword.Text = "&Password:"; + // + // txtPassword + // + this.txtPassword.Location = new System.Drawing.Point(150, 135); + this.txtPassword.Name = "txtPassword"; + this.txtPassword.Size = new System.Drawing.Size(400, 20); + this.txtPassword.TabIndex = 9; + this.txtPassword.UseSystemPasswordChar = true; + // + // lblEncrypt + // + this.lblEncrypt.AutoSize = true; + this.lblEncrypt.Location = new System.Drawing.Point(16, 168); + this.lblEncrypt.Name = "lblEncrypt"; + this.lblEncrypt.Size = new System.Drawing.Size(46, 13); + this.lblEncrypt.TabIndex = 10; + this.lblEncrypt.Text = "&Encrypt:"; + // + // cmbEncrypt + // + this.cmbEncrypt.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbEncrypt.FormattingEnabled = true; + this.cmbEncrypt.Location = new System.Drawing.Point(150, 165); + this.cmbEncrypt.Name = "cmbEncrypt"; + this.cmbEncrypt.Size = new System.Drawing.Size(200, 21); + this.cmbEncrypt.TabIndex = 11; + // + // chkTrustServerCertificate + // + this.chkTrustServerCertificate.AutoSize = true; + this.chkTrustServerCertificate.Location = new System.Drawing.Point(370, 167); + this.chkTrustServerCertificate.Name = "chkTrustServerCertificate"; + this.chkTrustServerCertificate.Size = new System.Drawing.Size(149, 17); + this.chkTrustServerCertificate.TabIndex = 12; + this.chkTrustServerCertificate.Text = "&Trust server certificate"; + this.chkTrustServerCertificate.UseVisualStyleBackColor = true; + // + // lblTimeout + // + this.lblTimeout.AutoSize = true; + this.lblTimeout.Location = new System.Drawing.Point(16, 198); + this.lblTimeout.Name = "lblTimeout"; + this.lblTimeout.Size = new System.Drawing.Size(101, 13); + this.lblTimeout.TabIndex = 13; + this.lblTimeout.Text = "Connect timeout (s):"; + // + // numTimeout + // + this.numTimeout.Location = new System.Drawing.Point(150, 196); + this.numTimeout.Maximum = new decimal(new int[] { 600, 0, 0, 0 }); + this.numTimeout.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTimeout.Name = "numTimeout"; + this.numTimeout.Size = new System.Drawing.Size(80, 20); + this.numTimeout.TabIndex = 14; + this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); + // + // lblConnectionString + // + this.lblConnectionString.AutoSize = true; + this.lblConnectionString.Location = new System.Drawing.Point(16, 230); + this.lblConnectionString.Name = "lblConnectionString"; + this.lblConnectionString.Size = new System.Drawing.Size(94, 13); + this.lblConnectionString.TabIndex = 15; + this.lblConnectionString.Text = "Connection string:"; + // + // txtConnectionString + // + this.txtConnectionString.Location = new System.Drawing.Point(16, 246); + this.txtConnectionString.Multiline = true; + this.txtConnectionString.Name = "txtConnectionString"; + this.txtConnectionString.ReadOnly = true; + this.txtConnectionString.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.txtConnectionString.Size = new System.Drawing.Size(534, 60); + this.txtConnectionString.TabIndex = 16; + this.txtConnectionString.BackColor = System.Drawing.SystemColors.Info; + // + // btnBuild + // + this.btnBuild.Location = new System.Drawing.Point(16, 316); + this.btnBuild.Name = "btnBuild"; + this.btnBuild.Size = new System.Drawing.Size(140, 26); + this.btnBuild.TabIndex = 17; + this.btnBuild.Text = "&Build Connection String"; + this.btnBuild.UseVisualStyleBackColor = true; + this.btnBuild.Click += new System.EventHandler(this.btnBuild_Click); + // + // btnTest + // + this.btnTest.Location = new System.Drawing.Point(166, 316); + this.btnTest.Name = "btnTest"; + this.btnTest.Size = new System.Drawing.Size(120, 26); + this.btnTest.TabIndex = 18; + this.btnTest.Text = "Te&st Connection"; + this.btnTest.UseVisualStyleBackColor = true; + this.btnTest.Click += new System.EventHandler(this.btnTest_Click); + // + // btnCopy + // + this.btnCopy.Location = new System.Drawing.Point(296, 316); + this.btnCopy.Name = "btnCopy"; + this.btnCopy.Size = new System.Drawing.Size(120, 26); + this.btnCopy.TabIndex = 19; + this.btnCopy.Text = "Cop&y to Clipboard"; + this.btnCopy.UseVisualStyleBackColor = true; + this.btnCopy.Click += new System.EventHandler(this.btnCopy_Click); + // + // btnClear + // + this.btnClear.Location = new System.Drawing.Point(426, 316); + this.btnClear.Name = "btnClear"; + this.btnClear.Size = new System.Drawing.Size(124, 26); + this.btnClear.TabIndex = 20; + this.btnClear.Text = "Cl&ear All"; + this.btnClear.UseVisualStyleBackColor = true; + this.btnClear.Click += new System.EventHandler(this.btnClear_Click); + // + // btnWhoAmI + // + this.btnWhoAmI.Location = new System.Drawing.Point(16, 348); + this.btnWhoAmI.Name = "btnWhoAmI"; + this.btnWhoAmI.Size = new System.Drawing.Size(534, 26); + this.btnWhoAmI.TabIndex = 21; + this.btnWhoAmI.Text = "&Who Am I? (run identity query on the database)"; + this.btnWhoAmI.UseVisualStyleBackColor = true; + this.btnWhoAmI.Click += new System.EventHandler(this.btnWhoAmI_Click); + // + // lblStatus + // + this.lblStatus.AutoSize = true; + this.lblStatus.Location = new System.Drawing.Point(16, 386); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Size = new System.Drawing.Size(40, 13); + this.lblStatus.TabIndex = 22; + this.lblStatus.Text = "Result:"; + // + // txtStatus + // + this.txtStatus.Location = new System.Drawing.Point(16, 402); + this.txtStatus.Multiline = true; + this.txtStatus.Name = "txtStatus"; + this.txtStatus.ReadOnly = true; + this.txtStatus.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.txtStatus.Size = new System.Drawing.Size(534, 160); + this.txtStatus.TabIndex = 23; + this.txtStatus.WordWrap = false; + this.txtStatus.Font = new System.Drawing.Font("Consolas", 9F); + // + // statusStrip + // + this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.statusLabel}); + this.statusStrip.Location = new System.Drawing.Point(0, 578); + this.statusStrip.Name = "statusStrip"; + this.statusStrip.Size = new System.Drawing.Size(566, 22); + this.statusStrip.TabIndex = 24; + // + // statusLabel + // + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(39, 17); + this.statusLabel.Text = "Ready"; + // + // MainForm + // + this.AcceptButton = this.btnTest; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(566, 600); + this.Controls.Add(this.statusStrip); + this.Controls.Add(this.txtStatus); + this.Controls.Add(this.lblStatus); + this.Controls.Add(this.btnWhoAmI); + this.Controls.Add(this.btnClear); + this.Controls.Add(this.btnCopy); + this.Controls.Add(this.btnTest); + this.Controls.Add(this.btnBuild); + this.Controls.Add(this.txtConnectionString); + this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.numTimeout); + this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkTrustServerCertificate); + this.Controls.Add(this.cmbEncrypt); + this.Controls.Add(this.lblEncrypt); + this.Controls.Add(this.txtPassword); + this.Controls.Add(this.lblPassword); + this.Controls.Add(this.txtUserId); + this.Controls.Add(this.lblUserId); + this.Controls.Add(this.cmbAuthentication); + this.Controls.Add(this.lblAuthentication); + this.Controls.Add(this.txtDatabase); + this.Controls.Add(this.lblDatabase); + this.Controls.Add(this.txtServer); + this.Controls.Add(this.lblServer); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + this.Name = "MainFormWorker"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Azure SQL Connector — Worker thread (Task.Run + Open)"; + ((System.ComponentModel.ISupportInitialize)(this.numTimeout)).EndInit(); + this.statusStrip.ResumeLayout(false); + this.statusStrip.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.Label lblServer; + private System.Windows.Forms.TextBox txtServer; + private System.Windows.Forms.Label lblDatabase; + private System.Windows.Forms.TextBox txtDatabase; + private System.Windows.Forms.Label lblAuthentication; + private System.Windows.Forms.ComboBox cmbAuthentication; + private System.Windows.Forms.Label lblUserId; + private System.Windows.Forms.TextBox txtUserId; + private System.Windows.Forms.Label lblPassword; + private System.Windows.Forms.TextBox txtPassword; + private System.Windows.Forms.Label lblEncrypt; + private System.Windows.Forms.ComboBox cmbEncrypt; + private System.Windows.Forms.CheckBox chkTrustServerCertificate; + private System.Windows.Forms.Label lblTimeout; + private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.Label lblConnectionString; + private System.Windows.Forms.TextBox txtConnectionString; + private System.Windows.Forms.Button btnBuild; + private System.Windows.Forms.Button btnTest; + private System.Windows.Forms.Button btnCopy; + private System.Windows.Forms.Button btnClear; + private System.Windows.Forms.Button btnWhoAmI; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.TextBox txtStatus; + private System.Windows.Forms.StatusStrip statusStrip; + private System.Windows.Forms.ToolStripStatusLabel statusLabel; + } +} diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.cs b/doc/apps/AzureSqlConnector/MainFormWorker.cs new file mode 100644 index 0000000000..b1af89284c --- /dev/null +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Data.SqlClient; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// "Worker thread" variant of the connector form. Opens the SQL connection synchronously + /// inside a call so the UI thread never blocks. + /// + /// + /// + /// The form's HWND is captured on the UI thread in the constructor and stashed in + /// . Both Entra ID parent-window callbacks return that captured + /// handle, so they are safe to invoke from the worker thread (touching Form.Handle + /// from a non-UI thread is illegal). + /// + /// + /// Compare with , which keeps Open on the UI thread and relies on + /// for responsiveness. + /// + /// + public partial class MainFormWorker : Form + { + // ────────────────────────────────────────────────────────────────── + #region Construction + + public MainFormWorker() + { + InitializeComponent(); + PopulateAuthenticationMethods(); + PopulateEncryptOptions(); + UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely capture its HWND for MSAL to use later from a worker thread. Touching + // Form.Handle from a non-UI thread is illegal, so we read it here once and stash it. + _ownerHwnd = this.Handle; + + RegisterActiveDirectoryProvider(); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Initialization + + private void PopulateAuthenticationMethods() + { + foreach (SqlAuthenticationMethod method in Enum.GetValues(typeof(SqlAuthenticationMethod))) + { + cmbAuthentication.Items.Add(method); + } + + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + } + + private void PopulateEncryptOptions() + { + cmbEncrypt.Items.Add(EncryptDisplay.Mandatory); + cmbEncrypt.Items.Add(EncryptDisplay.Optional); + cmbEncrypt.Items.Add(EncryptDisplay.Strict); + cmbEncrypt.SelectedIndex = 0; + } + + /// + /// Registers a single for every + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle; they are invoked by MSAL on + /// the worker thread that called . + /// + private void RegisterActiveDirectoryProvider() + { + ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + +#if NETFRAMEWORK + // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM + // broker consults on Windows. + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryMSI, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryDefault, provider); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity, provider); + #pragma warning disable CS0618 // Type or member is obsolete + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryPassword, provider); + #pragma warning restore CS0618 // Type or member is obsolete + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Event Handlers + + private void cmbAuthentication_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateCredentialFieldsAvailability(); + } + + private void btnBuild_Click(object sender, EventArgs e) + { + try + { + SqlConnectionStringBuilder builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + SetStatus("Connection string built successfully.", isError: false); + AppendStatus("Connection string built:\r\n" + MaskPassword(builder)); + } + catch (Exception ex) + { + txtConnectionString.Text = string.Empty; + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private async void btnTest_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Testing connection..."); + AppendStatus(string.Empty); + AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + + try + { + // Run Open() on a thread-pool worker so the UI thread never blocks. The await + // continuation hops back onto the UI thread automatically (the awaiter captures + // the current SynchronizationContext), so it is safe to touch the form's controls + // after the await. + // + // The Entra ID interactive / WAM flows still find a parent window because we + // captured the form's HWND on the UI thread in the constructor and the callbacks + // registered in RegisterActiveDirectoryProvider return that captured handle (no + // UI-thread-only Form.Handle access from the worker thread). + string connectionString = builder.ConnectionString; + string serverVersion = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + return connection.ServerVersion; + } + }).ConfigureAwait(true); + + SetStatus("Connected successfully.", isError: false); + AppendStatus("Connected successfully! Server version: " + serverVersion); + } + catch (SqlException ex) + { + SetStatus("Connection failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Connection failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private async void btnWhoAmI_Click(object sender, EventArgs e) + { + SqlConnectionStringBuilder builder; + try + { + builder = BuildConnectionString(); + txtConnectionString.Text = MaskPassword(builder); + } + catch (Exception ex) + { + SetStatus("Failed to build connection string.", isError: true); + AppendStatus("ERROR: " + ex.Message); + return; + } + + SetBusy(true, "Querying logged-in identity..."); + AppendStatus(string.Empty); + AppendStatus("Running identity query against " + builder.DataSource + " ..."); + + try + { + // Run the whole open + query + read on a worker thread so the UI never blocks. + // We materialize the single result row into a List<(name, value)> on the worker + // and then format it on the UI thread once the await returns. + string connectionString = builder.ConnectionString; + List<(string Name, object Value)> row = await Task.Run(() => + { + using (SqlConnection connection = new SqlConnection(connectionString)) + { + connection.Open(); + + using (SqlCommand command = connection.CreateCommand()) + { + command.CommandText = IdentityQuery.CommandText; + + using (SqlDataReader reader = command.ExecuteReader()) + { + if (!reader.Read()) + { + return null; + } + + var fields = new List<(string, object)>(reader.FieldCount); + for (int i = 0; i < reader.FieldCount; i++) + { + object value = reader.IsDBNull(i) ? "(null)" : reader.GetValue(i); + fields.Add((reader.GetName(i), value)); + } + return fields; + } + } + } + }).ConfigureAwait(true); + + if (row is null) + { + SetStatus("Identity query returned no rows.", isError: true); + AppendStatus("(no rows returned)"); + } + else + { + AppendStatus("Identity:"); + foreach (var (name, value) in row) + { + AppendStatus(" " + name.PadRight(16) + ": " + value); + } + SetStatus("Identity query succeeded.", isError: false); + } + } + catch (SqlException ex) + { + SetStatus("Identity query failed (SqlException).", isError: true); + AppendStatus("SqlException [" + ex.Number + "]: " + ex.Message); + } + catch (Exception ex) + { + SetStatus("Identity query failed.", isError: true); + AppendStatus(ex.GetType().Name + ": " + ex.Message); + } + finally + { + SetBusy(false, null); + } + } + + private void btnCopy_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(txtConnectionString.Text)) + { + SetStatus("Nothing to copy. Build the connection string first.", isError: true); + return; + } + + try + { + Clipboard.SetText(BuildConnectionString().ConnectionString); + SetStatus("Connection string copied to clipboard.", isError: false); + } + catch (Exception ex) + { + SetStatus("Failed to copy to clipboard.", isError: true); + AppendStatus("ERROR: " + ex.Message); + } + } + + private void btnClear_Click(object sender, EventArgs e) + { + txtServer.Clear(); + txtDatabase.Clear(); + txtUserId.Clear(); + txtPassword.Clear(); + txtConnectionString.Clear(); + txtStatus.Clear(); + cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; + cmbEncrypt.SelectedIndex = 0; + chkTrustServerCertificate.Checked = false; + numTimeout.Value = 30; + SetStatus("Ready", isError: false); + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Connection String Construction + + private SqlConnectionStringBuilder BuildConnectionString() + { + string server = (txtServer.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(server)) + { + throw new InvalidOperationException("Server name is required."); + } + + SqlAuthenticationMethod authMethod = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + DataSource = server, + ConnectTimeout = (int)numTimeout.Value, + }; + + string database = (txtDatabase.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(database)) + { + builder.InitialCatalog = database; + } + + if (authMethod != SqlAuthenticationMethod.NotSpecified) + { + builder.Authentication = authMethod; + } + + if (RequiresUserAndPassword(authMethod)) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException( + "User ID is required for " + authMethod + " authentication."); + } + + builder.UserID = userId; + builder.Password = txtPassword.Text ?? string.Empty; + } + else if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + || authMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity + || authMethod == SqlAuthenticationMethod.ActiveDirectoryMSI + || authMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow + || authMethod == SqlAuthenticationMethod.ActiveDirectoryDefault + || authMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) + { + string userId = (txtUserId.Text ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(userId)) + { + builder.UserID = userId; + } + + if (authMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal + && !string.IsNullOrEmpty(txtPassword.Text)) + { + builder.Password = txtPassword.Text; + } + } + + string encryptValue = cmbEncrypt.SelectedItem as string ?? EncryptDisplay.Mandatory; + switch (encryptValue) + { + case EncryptDisplay.Mandatory: + builder.Encrypt = SqlConnectionEncryptOption.Mandatory; + break; + case EncryptDisplay.Optional: + builder.Encrypt = SqlConnectionEncryptOption.Optional; + break; + case EncryptDisplay.Strict: + builder.Encrypt = SqlConnectionEncryptOption.Strict; + break; + } + + builder.TrustServerCertificate = chkTrustServerCertificate.Checked; + + return builder; + } + + private static bool RequiresUserAndPassword(SqlAuthenticationMethod method) + { + switch (method) + { + case SqlAuthenticationMethod.SqlPassword: +#pragma warning disable CS0618 // Type or member is obsolete + case SqlAuthenticationMethod.ActiveDirectoryPassword: +#pragma warning restore CS0618 + return true; + default: + return false; + } + } + + private static string MaskPassword(SqlConnectionStringBuilder builder) + { + if (string.IsNullOrEmpty(builder.Password)) + { + return builder.ConnectionString; + } + + SqlConnectionStringBuilder copy = new SqlConnectionStringBuilder(builder.ConnectionString) + { + Password = "********", + }; + return copy.ConnectionString; + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region UI Helpers + + private void UpdateCredentialFieldsAvailability() + { + if (cmbAuthentication.SelectedItem == null) + { + return; + } + + SqlAuthenticationMethod method = (SqlAuthenticationMethod)cmbAuthentication.SelectedItem; + + bool userEnabled = method != SqlAuthenticationMethod.ActiveDirectoryIntegrated; + bool passwordEnabled = RequiresUserAndPassword(method) + || method == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + + txtUserId.Enabled = userEnabled; + txtPassword.Enabled = passwordEnabled; + + if (!passwordEnabled) + { + txtPassword.Clear(); + } + } + + private void SetStatus(string text, bool isError) + { + statusLabel.Text = text; + statusLabel.ForeColor = isError ? System.Drawing.Color.Firebrick : System.Drawing.Color.Black; + } + + private void AppendStatus(string line) + { + if (txtStatus.TextLength > 0) + { + txtStatus.AppendText(Environment.NewLine); + } + txtStatus.AppendText(line ?? string.Empty); + } + + private void SetBusy(bool busy, string statusText) + { + btnBuild.Enabled = !busy; + btnTest.Enabled = !busy; + btnCopy.Enabled = !busy; + btnClear.Enabled = !busy; + btnWhoAmI.Enabled = !busy; + Cursor = busy ? Cursors.WaitCursor : Cursors.Default; + + if (statusText != null) + { + SetStatus(statusText, isError: false); + } + } + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Nested Types + + private static class EncryptDisplay + { + public const string Mandatory = "Mandatory"; + public const string Optional = "Optional"; + public const string Strict = "Strict"; + } + + /// + /// Tiny wrapper around a raw HWND captured on the UI thread. + /// Used so that MSAL.NET's IWin32WindowFunc callback can safely return a window + /// owner from a worker thread without ever touching off-UI. + /// Only needed on .NET Framework where the legacy SetIWin32WindowFunc API is used. + /// +#if NETFRAMEWORK + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ────────────────────────────────────────────────────────────────── + #region Private Fields + + /// + /// The form's Win32 window handle, captured on the UI thread in the constructor. + /// Read from worker threads by the Entra ID provider callbacks to parent MSAL's + /// sign-in / WAM broker UI without illegally touching . + /// + private readonly IntPtr _ownerHwnd; + + #endregion + } +} diff --git a/doc/apps/AzureSqlConnector/ModeSelectorForm.cs b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs new file mode 100644 index 0000000000..92366692f1 --- /dev/null +++ b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs @@ -0,0 +1,116 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector +{ + /// + /// Choice exposed by . + /// + internal enum ConnectionMode + { + /// + /// Use , which calls SqlConnection.OpenAsync() on the UI + /// thread. Relies on the WinForms SynchronizationContext to keep the message pump alive. + /// + UiThreadOpenAsync, + + /// + /// Use , which calls SqlConnection.Open() inside + /// Task.Run on a thread-pool worker. The captured form HWND is passed to MSAL. + /// + WorkerThreadOpen, + } + + /// + /// Tiny modal dialog shown at startup that lets the user pick which connector form + /// (UI-thread async or worker-thread sync) to launch. + /// + internal sealed class ModeSelectorForm : Form + { + private readonly RadioButton _rdoUiThread; + private readonly RadioButton _rdoWorker; + + internal ConnectionMode SelectedMode => + _rdoWorker.Checked ? ConnectionMode.WorkerThreadOpen : ConnectionMode.UiThreadOpenAsync; + + internal ModeSelectorForm() + { + Text = "Azure SQL Connector — Choose Mode"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterScreen; + MaximizeBox = false; + MinimizeBox = false; + ClientSize = new Size(460, 200); + + Label lblHeader = new Label + { + AutoSize = false, + Text = "Select how SqlConnection.Open should be invoked:", + Location = new Point(16, 14), + Size = new Size(420, 20), + Font = new Font(Font, FontStyle.Bold), + }; + + _rdoUiThread = new RadioButton + { + Text = "&UI thread — SqlConnection.OpenAsync()", + Location = new Point(20, 42), + Size = new Size(420, 20), + Checked = true, + }; + + Label lblUiHint = new Label + { + AutoSize = false, + Text = " Async open on the UI thread; SynchronizationContext keeps the form responsive.", + Location = new Point(20, 62), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + _rdoWorker = new RadioButton + { + Text = "&Worker thread — Task.Run(() => connection.Open())", + Location = new Point(20, 90), + Size = new Size(420, 20), + }; + + Label lblWorkerHint = new Label + { + AutoSize = false, + Text = " Sync open on a thread-pool worker; HWND is captured up-front for MSAL.", + Location = new Point(20, 110), + Size = new Size(420, 18), + ForeColor = SystemColors.GrayText, + }; + + Button btnOk = new Button + { + Text = "&Launch", + DialogResult = DialogResult.OK, + Location = new Point(268, 152), + Size = new Size(82, 28), + }; + + Button btnCancel = new Button + { + Text = "Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(358, 152), + Size = new Size(82, 28), + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + + Controls.AddRange(new Control[] + { + lblHeader, + _rdoUiThread, lblUiHint, + _rdoWorker, lblWorkerHint, + btnOk, btnCancel, + }); + } + } +} diff --git a/doc/apps/AzureSqlConnector/Program.cs b/doc/apps/AzureSqlConnector/Program.cs index 7b751f56fe..c4bfad836c 100644 --- a/doc/apps/AzureSqlConnector/Program.cs +++ b/doc/apps/AzureSqlConnector/Program.cs @@ -9,14 +9,31 @@ namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector internal static class Program { /// - /// The main entry point for the application. + /// The main entry point for the application. Shows a small chooser dialog at startup so + /// the user can pick between the UI-thread and the worker-thread + /// variant of the connector. /// [STAThread] private static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new MainForm()); + + ConnectionMode mode; + using (ModeSelectorForm selector = new ModeSelectorForm()) + { + if (selector.ShowDialog() != DialogResult.OK) + { + return; + } + mode = selector.SelectedMode; + } + + Form main = mode == ConnectionMode.WorkerThreadOpen + ? (Form)new MainFormWorker() + : new MainForm(); + + Application.Run(main); } } } diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md index 894a51a9d8..1dabc1f30b 100644 --- a/doc/apps/AzureSqlConnector/README.md +++ b/doc/apps/AzureSqlConnector/README.md @@ -1,15 +1,38 @@ # Azure SQL Connector (WinForms) -A small **.NET Framework 4.8.1** Windows Forms test application that lets a user fill in Azure SQL -Database connection parameters in a UI, builds the corresponding ADO.NET connection string via -`SqlConnectionStringBuilder`, and tests connectivity using -[`Microsoft.Data.SqlClient`](https://www.nuget.org/packages/Microsoft.Data.SqlClient). +A small Windows Forms test application that lets a user fill in Azure SQL Database connection +parameters in a UI, builds the corresponding ADO.NET connection string via +`SqlConnectionStringBuilder`, and tests connectivity using `Microsoft.Data.SqlClient`. It is intended as a quick, repeatable scratch tool for manually validating connection-string combinations (server / database / authentication mode / encryption / etc.) against an Azure SQL DB -or SQL Server instance. +or SQL Server instance, **and as a manual repro** for the WAM-broker behavior added in this +branch's `ActiveDirectoryAuthenticationProvider`. -## Form Inputs +The sample multi-targets: + +| TFM | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| `net481` | Exercises the legacy `SetIWin32WindowFunc` API used by .NET Framework callers with WinForms. | +| `net10.0-windows` | Exercises the modern `SetParentActivityOrWindowFunc` API used on .NET 8+. | + +`net10.0-windows` restores and builds cleanly on Linux/macOS hosts even though the resulting +binary only runs on Windows, so the project no longer needs a separate no-op cross-platform +fallback. + +## Mode selector + +When the app launches it shows a small `ModeSelectorForm` that picks between two top-level forms: + +| Mode | Form | What it exercises | +| ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **UI thread (`OpenAsync`)** | `MainForm` | Calls `SqlConnection.OpenAsync()` on the UI thread so the Windows Forms message pump stays alive during MSAL sign-in. | +| **Worker thread (`Open`, sync)** | `MainFormWorker` | Calls `SqlConnection.Open()` on a background worker thread; the parent window handle is captured up-front on the UI thread. | + +Both forms demonstrate the supported patterns for parenting the WAM broker (or the legacy +embedded WebView on .NET Framework). + +## Form inputs | Field | Maps to connection string keyword | | -------------------------- | ----------------------------------------------- | @@ -37,7 +60,7 @@ disabled automatically based on the selected method: | Button | Action | | ----------------------- | ---------------------------------------------------------------------- | | Build Connection String | Builds the connection string from the form values and displays it. | -| Test Connection | Builds the connection string and calls `SqlConnection.Open()`. | +| Test Connection | Builds the connection string and opens the connection. | | Copy to Clipboard | Copies the currently-built connection string to the clipboard. | | Clear All | Resets every input field to its default state. | | Who Am I? | Connects and runs an identity query (`SUSER_SNAME()`, `ORIGINAL_LOGIN()`, `USER_NAME()`, `DB_NAME()`, `@@SPID`, etc.) and prints the results. | @@ -47,18 +70,20 @@ outcome (including SQL error number when applicable), and the server version on ## Prerequisites -- .NET Framework **4.8.1** Developer Pack (Visual Studio 2026 Enterprise installs this by default). +- Visual Studio 2026 (or any IDE / SDK with .NET Framework **4.8.1** Developer Pack installed) for + the `net481` target. The `net10.0-windows` target only needs the .NET 10 SDK. - Network connectivity to your Azure SQL Database (server firewall must allow your client IP). - For Entra ID authentication modes, valid credentials available through Azure CLI / environment - variables / managed identity, depending on the chosen method. + variables / managed identity / the WAM broker, depending on the chosen method. -## Build & Run +## Build & run From the project folder: ```pwsh dotnet build .\AzureSqlConnector.csproj -dotnet run --project .\AzureSqlConnector.csproj +dotnet run --project .\AzureSqlConnector.csproj -f net10.0-windows # modern WAM API +dotnet run --project .\AzureSqlConnector.csproj -f net481 # legacy IWin32Window API ``` Or load `src\Microsoft.Data.SqlClient.slnx` in Visual Studio, set **AzureSqlConnector** as the @@ -76,33 +101,31 @@ startup project, and press **F5**. 8. Click **Test Connection** — the result pane should display `Connected successfully! Server version: 12.00.xxxx`. -## Entra ID (Azure AD) Authentication Notes +## Entra ID parent-window plumbing + +For any `ActiveDirectory*` authentication method (especially **ActiveDirectoryInteractive**) the +app installs an `ActiveDirectoryAuthenticationProvider` and tells it which window should host the +sign-in UI: + +- On **`net481`** the form calls `provider.SetIWin32WindowFunc(() => this)`. This is the legacy + API used by .NET Framework callers with the embedded WebView. +- On **`net10.0-windows`** the form calls + `provider.SetParentActivityOrWindowFunc(() => this.Handle)`. This is the modern API that also + integrates with the WAM broker on Windows. -For any `ActiveDirectory*` authentication method (especially **ActiveDirectoryInteractive**) -the app does two things at startup that are required for the interactive browser sign-in window -to appear: +The provider is registered for every `SqlAuthenticationMethod.ActiveDirectory*` value at startup. -1. References [`Microsoft.Data.SqlClient.Extensions.Azure`](https://www.nuget.org/packages/Microsoft.Data.SqlClient.Extensions.Azure/) - which contains the `ActiveDirectoryAuthenticationProvider`. Starting with - `Microsoft.Data.SqlClient` 7.0, the AD providers were moved out of the core driver into this - extension package, so without it SqlClient throws: - `Cannot find an authentication provider for 'ActiveDirectoryInteractive'`. +### Threading patterns -2. In the `MainForm` constructor it calls `provider.SetIWin32WindowFunc(() => this)` and - registers the provider for every `SqlAuthenticationMethod.ActiveDirectory*` value. This - gives MSAL.NET the parent `IWin32Window` it needs to display its embedded sign-in browser - on top of the form. +| Form | Open mode | Parent window callback | +| ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `MainForm` | `OpenAsync` on UI thread | Callback runs on the UI thread when MSAL invokes it, so `this`/`this.Handle` is naturally safe to access. | +| `MainFormWorker` | `Open` (sync) on worker | The form captures `this.Handle` into a field on the UI thread before kicking off the worker; the callback closes over that captured value so it never needs to marshal back. | -The **Test Connection** button intentionally calls `SqlConnection.OpenAsync()` on the **UI -thread** (no `Task.Run` wrapper) so the Windows Forms message pump stays alive on the calling -thread while MSAL is waiting for the user to sign in — without this the popup can fail to render -or remain unresponsive. +Without one of these patterns the WAM broker (or the embedded WebView on .NET Framework) can fail +to render or stay unresponsive while it waits for the user. ## Notes -- Like the sibling [`AzureAuthentication`](../AzureAuthentication/README.md) sample, this project - opts out of inherited `Directory.Build.props` / `Directory.Packages.props` and uses its own - `NuGet.config` pointing at the governed SqlClient ADO feed (plus a local `packages/` folder for - developer overrides). - This is a sample / diagnostic tool, **not** a product. It does not persist credentials. - From the repo root: `dotnet run --project .\doc\apps\AzureSqlConnector\AzureSqlConnector.csproj` diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index f4d3304abc..572f1300e5 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -88,6 +88,25 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method, application client id, and Windows Account Manager (WAM) broker setting. + + + A instance whose properties initialize the provider. Must not be . + + + Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ProviderOptions without forcing breaking changes on callers. + + + Thrown when is . + + + + + Property bag used by the constructor. + + + Prefer this options-bag constructor over the positional-argument overloads in new code. Additional options can be added here in future versions without introducing new constructor overloads. + + The Active Directory authentication parameters passed to authentication providers. @@ -149,10 +168,12 @@ See the LICENSE file in the project root for more information. - + A function that returns an window handle on Windows (or an on .NET Framework). + Pass to clear a previously installed callback and revert to + the provider's automatic console-window fallback. Sets a function to return the parent window handle to be used for WAM (Windows Account Manager) broker authentication prompts. @@ -160,15 +181,20 @@ See the LICENSE file in the project root for more information. On Windows, this handle is used to parent the WAM broker dialog. If not set, the provider will attempt to automatically detect the console window handle. On non-Windows platforms this is a no-op as the WAM broker is not available. + + Exceptions thrown by when it is invoked by MSAL are not caught by the provider; they propagate up to the caller of the originating AcquireToken request so that bugs in the callback surface where they are most diagnosable. - + A function to return the current window. - Sets a reference to the current that triggers the browser to be shown. Used to center the browser pop-up onto this window." + Sets a reference to the current that triggers the browser to be shown. Used to center the browser pop-up onto this window. + + This API exists for .NET Framework callers using the embedded WebView. New code on any framework should prefer , which works on both .NET Framework and modern .NET and integrates with the WAM broker on Windows. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs index b87c16bd08..105b8a1ecb 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs @@ -6,16 +6,55 @@ namespace Microsoft.Data.SqlClient; +/// +/// Windows-only portion of . Kept in a +/// separate file so the Windows-specific P/Invoke helpers (console-window discovery, root-owner +/// lookup) live next to the broker plumbing they support. The other half of the partial class +/// lives in ActiveDirectoryAuthenticationProvider.cs and is fully cross-platform. +/// +/// +/// Responsibilities of this part: +/// +/// +/// Resolve the parent window handle handed to MSAL's WAM broker (and to the embedded WebView +/// on .NET Framework) via WithParentActivityOrWindow. +/// +/// +/// Honor the caller-supplied SetParentActivityOrWindowFunc callback when present, and +/// fall back to the process console window (resolved via Win32 interop) otherwise. +/// +/// +/// public sealed partial class ActiveDirectoryAuthenticationProvider { /// - /// Gets the parent window handle to be used for interactive authentication prompts - /// via the Windows Account Manager (WAM) broker. + /// Resolves the parent window handle used to parent MSAL UI (WAM broker dialog on Windows, + /// or the embedded WebView on .NET Framework). The boxed return type matches the signature + /// MSAL's WithParentActivityOrWindow overload expects. /// /// - /// The parent window handle as an , or if - /// not running on Windows or no window handle is available. + /// The parent window handle, or when we are not on Windows or + /// when neither a caller-supplied callback nor a discoverable console window is available. /// + /// + /// + /// Exception behavior: + /// + /// + /// Exceptions thrown by the caller-supplied callback installed via + /// SetParentActivityOrWindowFunc are intentionally not caught here; they propagate + /// up through MSAL into the caller of AcquireTokenAsync so that bugs in the + /// callback surface where they are most diagnosable. + /// + /// + /// The Win32 P/Invokes used to discover the console window (GetConsoleWindow, + /// GetAncestor) are documented by Windows not to throw — they return + /// NULL/ on failure, which this method treats as + /// "no parent window available". + /// + /// + /// + /// private IntPtr GetParentWindow() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -23,7 +62,8 @@ private IntPtr GetParentWindow() return IntPtr.Zero; } - // If the user has provided a custom parent activity/window function, use it. + // If the user has provided a custom parent activity/window function, use it. Exceptions + // from the user-supplied callback escape to MSAL by design — see method remarks above. if (_parentActivityOrWindowFunc is not null) { object parentWindow = _parentActivityOrWindowFunc(); @@ -39,12 +79,14 @@ private IntPtr GetParentWindow() #endif if (parentWindow is not null) { - throw new InvalidOperationException($"{nameof(SetParentActivityOrWindow)} expects the callback to return an IntPtr window handle" + + throw new InvalidOperationException($"{nameof(SetParentActivityOrWindowFunc)} expects the callback to return an IntPtr window handle" + " (or an IWin32Window on .NET Framework)." ); } } - // Fall back to finding the console window, then getting its root owner. + // Fall back to finding the console window, then getting its root owner. The Win32 calls + // are documented to return NULL on failure rather than throwing, so we treat any + // IntPtr.Zero return as "no console window available". IntPtr consoleHandle = Interop.Kernel32.GetConsoleWindow(); if (consoleHandle != IntPtr.Zero) { @@ -58,13 +100,4 @@ private IntPtr GetParentWindow() return IntPtr.Zero; } - - /// - /// Gets the parent activity or window object for the broker authentication flow. - /// On Windows, returns the window handle. On other platforms, returns . - /// - private object GetBrokerParentWindow() - { - return GetParentWindow(); - } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index d766c39797..0f8a65ae26 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -31,15 +31,37 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private static readonly SemaphoreSlim s_tokenCredentialMapModifierSemaphore = new(1, 1); private static readonly MemoryCache s_accountPwCache = new MemoryCache(new MemoryCacheOptions()); private const int s_accountPwCacheTtlInHours = 2; - private const string s_sqlclientapplicationid = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; - private readonly string _applicationClientId = s_sqlclientapplicationid; - // Maintain a flag to disable WAM broker mode for backwards compatibility - // in applications using ActiveDirectoryAuthenticationProvider with custom Application Client ID. - private readonly bool _useWamBroker = false; + + // SqlClient's first-party Entra ID application id. When the provider is constructed without + // a caller-supplied application id (or with this id explicitly), WAM broker mode is forced on + // because the corresponding app registration is configured for the WAM broker redirect URI. + private const string s_sqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; + + // MSAL redirect URI used when WAM brokered authentication is in effect on Windows. MSAL + // expects the suffix to match the client id of the registered application. private const string s_wamBrokerRedirectUriPrefix = "ms-appx-web://microsoft.aad.brokerplugin/"; + + // Non-broker redirect URI used on .NET Framework when WAM is not in use (legacy embedded + // WebView path). private const string s_windowsNativeRedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; + + // Loopback redirect URI used by MSAL's system browser flow on non-Windows platforms and on + // .NET (non-framework) Windows without WAM. private const string s_systemBrowserRedirectUri = "http://localhost"; + + // Suffix MSAL requires on Entra ID resource scopes (e.g. "https://database.windows.net/.default"). private const string s_defaultScopeSuffix = "/.default"; + + // The Entra ID application client id used by this provider instance. Defaults to the SqlClient + // first-party app id; a caller can override it via the Options-pattern constructor or the + // single-string overload. + private readonly string _applicationClientId = s_sqlClientApplicationId; + + // True when this provider should enable the Windows Account Manager (WAM) broker for + // interactive Entra ID flows on Windows. Always true for the SqlClient first-party app id; + // for caller-supplied app ids, opt-in via the Options-pattern constructor. + private readonly bool _useWamBroker = false; + private readonly string _type = typeof(ActiveDirectoryAuthenticationProvider).Name; private Func _deviceCodeFlowCallback; private ICustomWebUi? _customWebUI = null; @@ -50,42 +72,89 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private const int MsalRetryStatusCode = 429; /// + /// + /// New code should prefer the + /// overload to avoid adding more constructor overloads as new options are introduced. + /// public ActiveDirectoryAuthenticationProvider() - : this(DefaultDeviceFlowCallback) + : this(new ProviderOptions()) { } /// + /// + /// New code should prefer the + /// overload to avoid adding more constructor overloads as new options are introduced. + /// public ActiveDirectoryAuthenticationProvider(string applicationClientId) - : this(DefaultDeviceFlowCallback, applicationClientId) + : this(new ProviderOptions { ApplicationClientId = applicationClientId }) { } /// + /// + /// New code should prefer the + /// overload to avoid adding more constructor overloads as new options are introduced. + /// public ActiveDirectoryAuthenticationProvider(string applicationClientId, bool useWamBroker) - : this(DefaultDeviceFlowCallback, applicationClientId, useWamBroker) + : this(new ProviderOptions { ApplicationClientId = applicationClientId, UseWamBroker = useWamBroker }) { } /// + /// + /// New code should prefer the + /// overload to avoid adding more constructor overloads as new options are introduced. + /// public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId = null) - : this(deviceCodeFlowCallbackMethod, applicationClientId, useWamBroker: false) + : this(new ProviderOptions + { + DeviceCodeFlowCallback = deviceCodeFlowCallbackMethod, + ApplicationClientId = applicationClientId, + }) { } /// + /// + /// New code should prefer the + /// overload to avoid adding more constructor overloads as new options are introduced. + /// public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId, bool useWamBroker) + : this(new ProviderOptions + { + DeviceCodeFlowCallback = deviceCodeFlowCallbackMethod, + ApplicationClientId = applicationClientId, + UseWamBroker = useWamBroker, + }) { - _deviceCodeFlowCallback = deviceCodeFlowCallbackMethod; - if (applicationClientId is not null) + } + + /// + public ActiveDirectoryAuthenticationProvider(ProviderOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + _deviceCodeFlowCallback = options.DeviceCodeFlowCallback ?? DefaultDeviceFlowCallback; + if (options.ApplicationClientId is not null) { - _applicationClientId = applicationClientId; + _applicationClientId = options.ApplicationClientId; } - // WAM broker mode is always enabled for the SQL Client application. - // If a custom application client ID is provided, WAM broker mode will be enabled only when customer specifies it. - _useWamBroker = _applicationClientId == s_sqlclientapplicationid || useWamBroker; + // WAM broker mode is always enabled for the SqlClient first-party application id (its + // app registration is configured for the WAM broker redirect URI). For a caller-supplied + // application id, WAM is opt-in via ProviderOptions.UseWamBroker. + _useWamBroker = _applicationClientId == s_sqlClientApplicationId || options.UseWamBroker; } + /// + /// Indicates whether this provider instance has the Windows Account Manager (WAM) broker + /// enabled for interactive Entra ID flows on Windows. Exposed as internal for tests. + /// + internal bool UseWamBroker => _useWamBroker; + /// public static void ClearUserTokenCache() { @@ -143,10 +212,12 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) private Func? _parentActivityOrWindowFunc = null; - /// - public void SetParentActivityOrWindow(Func parentActivityOrWindowFunc) + /// + public void SetParentActivityOrWindowFunc(Func? parentActivityOrWindowFunc) { - _parentActivityOrWindowFunc = parentActivityOrWindowFunc ?? throw new ArgumentNullException(nameof(parentActivityOrWindowFunc)); + // Passing null clears a previously-installed callback (and reverts the provider to its + // automatic console-window fallback on Windows). + _parentActivityOrWindowFunc = parentActivityOrWindowFunc; } /// @@ -778,10 +849,10 @@ private IPublicClientApplication CreateClientAppInstance(PublicClientAppKey publ } else { - builder.WithParentActivityOrWindow(GetBrokerParentWindow); + builder.WithParentActivityOrWindow(() => (object)GetParentWindow()); } #else - builder.WithParentActivityOrWindow(GetBrokerParentWindow); + builder.WithParentActivityOrWindow(() => (object)GetParentWindow()); #endif } #if NETFRAMEWORK @@ -867,6 +938,34 @@ private static TokenCredentialData CreateTokenCredentialInstance(TokenCredential throw new ArgumentException(nameof(ActiveDirectoryAuthenticationProvider)); } + /// + public sealed class ProviderOptions + { + /// + /// Optional device-code-flow callback invoked for + /// SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow. When , + /// the provider's default callback (which writes the device-code instructions to the + /// console) is used. + /// + public Func? DeviceCodeFlowCallback { get; set; } + + /// + /// Optional Entra ID application (client) id. When , the SqlClient + /// first-party application id is used and WAM broker mode is forced on (regardless of + /// ). + /// + public string? ApplicationClientId { get; set; } + + /// + /// When , enables the Windows Account Manager (WAM) broker for + /// interactive Entra ID flows on Windows when a caller-supplied + /// is used. Ignored (treated as ) + /// when is because the SqlClient + /// first-party app id always uses the broker. + /// + public bool UseWamBroker { get; set; } + } + internal class PublicClientAppKey { public string Authority { get; } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs index 68f77e2e0b..af255844fc 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetAncestor.cs @@ -6,20 +6,33 @@ namespace Microsoft.Data.SqlClient; +/// +/// Win32 P/Invoke wrappers used by for +/// console-window discovery. Follows the .NET runtime's Interop convention: one Win32 +/// import per file, grouped into a nested Interop.<module> static class that mirrors +/// the Win32 DLL it targets. Only the internal helper is exposed; the raw +/// DllImport stays private. +/// internal static partial class Interop { internal static partial class User32 { + /// + /// GA_ROOTOWNER flag value for GetAncestor — "Retrieves the owned root + /// window by walking the chain of parent and owner windows returned by GetParent." + /// private const uint GA_ROOTOWNER = 3; /// - /// Retrieves the handle to the ancestor of the specified window. + /// Raw user32!GetAncestor P/Invoke. Documented by Windows to return + /// rather than throw when the input handle is invalid. /// [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); /// - /// Gets the root owner window of the specified window handle. + /// Walks the parent/owner chain of and returns the root owner + /// window, or when none can be found. /// internal static IntPtr GetRootOwner(IntPtr hwnd) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs index 66e34d16fc..4638e1458d 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Interop/Interop.GetConsoleWindow.cs @@ -6,12 +6,20 @@ namespace Microsoft.Data.SqlClient; +/// +/// Win32 P/Invoke wrappers used by for +/// console-window discovery. Follows the .NET runtime's Interop convention: one Win32 +/// import per file, grouped into a nested Interop.<module> static class that mirrors +/// the Win32 DLL it targets. +/// internal static partial class Interop { internal static partial class Kernel32 { /// - /// Retrieves the window handle used by the console associated with the calling process. + /// Raw kernel32!GetConsoleWindow P/Invoke. Documented by Windows to return + /// when the calling process is not attached to a console + /// (and to never throw). /// [DllImport("kernel32.dll")] internal static extern IntPtr GetConsoleWindow(); diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 9dc8548f4a..07eda2acc2 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -2,8 +2,6 @@ // 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; [Collection("SqlAuthenticationProvider")] @@ -12,97 +10,152 @@ public class WamBrokerTests // The SqlClient first-party application client id that is hard-coded in the provider. private const string SqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; + /// + /// Defensive guard: every test that uses as a stand-in for a + /// caller-supplied application id assumes the GUID will never collide with the SqlClient + /// first-party id. This test makes that assumption explicit. + /// + [Fact] + public void Ctor_CustomAppId_GuidIsNotSqlClientAppId() + { + for (int i = 0; i < 16; i++) + { + Assert.NotEqual(SqlClientApplicationId, Guid.NewGuid().ToString()); + } + } + + /// + /// A callback is treated as "clear any previously installed callback" + /// and must not throw. This is a deliberate API contract change from the original + /// behavior so callers can opt out without recreating + /// the provider. + /// + [Fact] + public void SetParentActivityOrWindowFunc_Null_ClearsCallback() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + provider.SetParentActivityOrWindowFunc(null); + } + + /// A non-null callback installs cleanly and does not throw. [Fact] - public void SetParentActivityOrWindow_NullArgument_ThrowsArgumentNullException() + public void SetParentActivityOrWindowFunc_ValidFunc_DoesNotThrow() { var provider = new ActiveDirectoryAuthenticationProvider(); - Assert.Throws("parentActivityOrWindowFunc", - () => provider.SetParentActivityOrWindow(null!)); + provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); } + /// Repeated calls must be supported (no internal locking guard rejects a second set). [Fact] - public void SetParentActivityOrWindow_ValidFunc_DoesNotThrow() + public void SetParentActivityOrWindowFunc_CanBeCalledMultipleTimes() { var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindow(() => IntPtr.Zero); + provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + provider.SetParentActivityOrWindowFunc(() => new IntPtr(12345)); } + /// + /// Last-write-wins: the most recently installed callback must be the one the provider holds. + /// We can't observe the field directly without reflection (the field is intentionally + /// private), but we can observe it transitively through whether a sentinel side-effect + /// fires when MSAL would invoke it. Since invoking MSAL requires a live token request, + /// we instead assert behavioral overwrite by installing two callbacks that record into + /// distinct flags and then ensuring only the second one is captured by the provider's + /// public surface: re-installing a no-throw callback after a throwing one must not + /// re-surface the throwing one. + /// [Fact] - public void SetParentActivityOrWindow_CanBeCalledMultipleTimes() + public void SetParentActivityOrWindowFunc_LastSetWins() { var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindow(() => IntPtr.Zero); - provider.SetParentActivityOrWindow(() => new IntPtr(12345)); + provider.SetParentActivityOrWindowFunc(() => throw new InvalidOperationException("first")); + provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + // Re-installing a null clears it. + provider.SetParentActivityOrWindowFunc(null); + provider.SetParentActivityOrWindowFunc(() => new IntPtr(7)); } + /// + /// The parameterless constructor uses the SqlClient first-party application id, which always + /// enables WAM broker mode regardless of any opt-in flag. + /// [Fact] public void Ctor_Default_EnablesWamBroker() { - // The parameterless ctor uses the SqlClient first-party app id, so WAM is always on. var provider = new ActiveDirectoryAuthenticationProvider(); - Assert.True(GetUseWamBrokerField(provider), + Assert.True(provider.UseWamBroker, "Default ctor must enable WAM broker (uses SqlClient first-party application id)."); } + /// A caller-supplied application id without explicit opt-in must NOT enable WAM broker. [Fact] public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() { - // A custom application id without explicitly opting in must NOT enable WAM broker. string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider(customAppId); - Assert.False(GetUseWamBrokerField(provider), + Assert.False(provider.UseWamBroker, "Custom application id without useWamBroker=true must keep WAM broker disabled."); } + /// A caller-supplied application id with explicit opt-in must enable WAM broker. [Fact] public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() { - // A custom application id with useWamBroker=true must enable WAM broker. string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: true); - Assert.True(GetUseWamBrokerField(provider), + Assert.True(provider.UseWamBroker, "Custom application id with useWamBroker=true must enable WAM broker."); } + /// A caller-supplied application id with explicit opt-out keeps WAM broker disabled. [Fact] public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() { string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: false); - Assert.False(GetUseWamBrokerField(provider)); + Assert.False(provider.UseWamBroker); } + /// + /// Even when the SqlClient first-party application id is passed explicitly with + /// useWamBroker:false, WAM broker mode must remain enabled because the first-party + /// app id is hard-wired to the WAM broker redirect URI. This guards the OR-condition in + /// the provider's constructor. + /// [Fact] public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker() { - // Even if a caller passes the SqlClient first-party application id explicitly with - // useWamBroker=false, WAM broker mode must still be enabled because the SqlClient - // first-party app id is hard-wired to use WAM. This guards the OR-condition in: - // _useWamBroker = _applicationClientId == s_sqlclientapplicationid || useWamBroker; var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId, useWamBroker: false); - Assert.True(GetUseWamBrokerField(provider), + Assert.True(provider.UseWamBroker, "SqlClient first-party application id must always enable WAM broker, regardless of the useWamBroker argument."); } + /// + /// The three-arg constructor (deviceCodeCallback, applicationClientId, useWamBroker) must + /// honor the broker flag the same way the two-arg constructor does. + /// [Fact] public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() { - // The three-arg ctor (deviceCodeCallback, applicationClientId, useWamBroker) is the most - // flexible overload and must honor useWamBroker just like the two-arg ctor. string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask, applicationClientId: customAppId, useWamBroker: true); - Assert.True(GetUseWamBrokerField(provider)); + Assert.True(provider.UseWamBroker); } + /// + /// The two-arg device-code constructor (deviceCodeCallback, applicationClientId) must default + /// useWamBroker to for caller-supplied application ids. + /// [Fact] public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFalse() { @@ -111,32 +164,80 @@ public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFa deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask, applicationClientId: customAppId); - Assert.False(GetUseWamBrokerField(provider)); + Assert.False(provider.UseWamBroker); } + /// + /// When the device-code callback constructor is invoked without an application id, the + /// provider falls back to the SqlClient first-party id and must enable WAM broker. + /// [Fact] public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() { - // When applicationClientId is omitted, the provider uses the SqlClient first-party app id - // and therefore WAM broker must be enabled, regardless of the useWamBroker default. var provider = new ActiveDirectoryAuthenticationProvider( deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask); - Assert.True(GetUseWamBrokerField(provider)); + Assert.True(provider.UseWamBroker); + } + + /// + /// The -based constructor + /// is the recommended overload for new code. It must honor + /// the same way the positional-argument overloads do. + /// + [Fact] + public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() + { + string customAppId = Guid.NewGuid().ToString(); + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = customAppId, + UseWamBroker = true, + }); + + Assert.True(provider.UseWamBroker); + } + + /// + /// The Options-based constructor with no application id falls back to the SqlClient + /// first-party id and must always enable WAM broker, regardless of UseWamBroker. + /// + [Fact] + public void Ctor_Options_NoAppId_AlwaysEnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions { UseWamBroker = false }); + + Assert.True(provider.UseWamBroker); } + /// + /// The Options-based constructor must reject a options instance with + /// so misuse fails fast at construction. + /// + [Fact] + public void Ctor_Options_Null_ThrowsArgumentNullException() + { + Assert.Throws( + () => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProvider.ProviderOptions)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() { - // A provider created with useWamBroker=true and registered via SqlAuthenticationProvider - // must retain its WAM broker setting after registration (i.e. SetProvider must not wrap - // or replace the instance). string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: true); - // Save the original provider so we can restore it after the assertion, otherwise we - // leak state into other tests that depend on the default provider being installed - // (e.g. DefaultAuthProviderTests.AuthProviderInstalled). SqlAuthenticationProvider? original = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive); try @@ -147,7 +248,7 @@ public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() as ActiveDirectoryAuthenticationProvider; Assert.NotNull(retrieved); Assert.Same(provider, retrieved); - Assert.True(GetUseWamBrokerField(retrieved)); + Assert.True(retrieved!.UseWamBroker); } finally { @@ -157,20 +258,4 @@ public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() } } } - - /// - /// Reads the private _useWamBroker field from the provider via reflection. - /// The field is intentionally private because it is an internal implementation detail - /// of the WAM broker plumbing, so tests must reach in to verify the constructor logic. - /// - private static bool GetUseWamBrokerField(ActiveDirectoryAuthenticationProvider provider) - { - FieldInfo? field = typeof(ActiveDirectoryAuthenticationProvider) - .GetField("_useWamBroker", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(field); - object? value = field!.GetValue(provider); - Assert.NotNull(value); - return (bool)value!; - } } - diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 653063b68e..bdc4695b81 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -144,11 +144,31 @@ static SqlAuthenticationProviderManager() return; } - // Try to instantiate it. - var instance = Activator.CreateInstance( - type, - [Instance._applicationClientId]) - as SqlAuthenticationProvider; + // Try to instantiate it. When no application client id is configured we use + // the parameterless constructor (which defaults to the SqlClient first-party app + // id and enables WAM brokering on Windows). Otherwise, explicitly resolve the + // (string) constructor to avoid AmbiguousMatchException between the + // (string applicationClientId) and (ProviderOptions options) overloads when the + // single argument is null. + SqlAuthenticationProvider? instance; + if (Instance._applicationClientId is null) + { + instance = Activator.CreateInstance(type) as SqlAuthenticationProvider; + } + else + { + var ctor = type.GetConstructor(new[] { typeof(string) }); + if (ctor is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderManager) + + $": Azure extension class={className} is missing the (string) " + + "constructor; no default Active Directory provider installed"); + return; + } + + instance = ctor.Invoke(new object[] { Instance._applicationClientId }) as SqlAuthenticationProvider; + } if (instance is null) { @@ -189,6 +209,7 @@ static SqlAuthenticationProviderManager() // attempt to use Active Directory authentication. catch (Exception ex) when (ex is + AmbiguousMatchException or ArgumentException or BadImageFormatException or FileLoadException or @@ -198,6 +219,7 @@ MethodAccessException or MissingMethodException or NotSupportedException or TargetInvocationException or + TypeInitializationException or TypeLoadException) { SqlClientEventSource.Log.TryTraceEvent( From d23b7601860a94f7dc55257cd1c54a9c277dc354 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 16 Jun 2026 23:33:07 -0700 Subject: [PATCH 20/41] Address comments/more changes --- .../AzureSqlConnector.csproj | 3 + .../AzureSqlConnector/MainForm.Designer.cs | 24 ++++ doc/apps/AzureSqlConnector/MainForm.cs | 119 ++++++++++++++---- .../AzureSqlConnector/ModeSelectorForm.cs | 6 +- doc/apps/AzureSqlConnector/NuGet.config | 16 --- .../SqlAuthenticationProviderManagerTests.cs | 31 +++++ 6 files changed, 159 insertions(+), 40 deletions(-) delete mode 100644 doc/apps/AzureSqlConnector/NuGet.config diff --git a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj index 5afd31529a..0ea12bd7d2 100644 --- a/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj +++ b/doc/apps/AzureSqlConnector/AzureSqlConnector.csproj @@ -12,6 +12,9 @@ net481;net10.0-windows net10.0-windows + + true WinExe Microsoft.Data.SqlClient.Samples.AzureSqlConnector AzureSqlConnector diff --git a/doc/apps/AzureSqlConnector/MainForm.Designer.cs b/doc/apps/AzureSqlConnector/MainForm.Designer.cs index 5e513e553c..4dd6c5a047 100644 --- a/doc/apps/AzureSqlConnector/MainForm.Designer.cs +++ b/doc/apps/AzureSqlConnector/MainForm.Designer.cs @@ -43,6 +43,8 @@ private void InitializeComponent() this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); this.lblTimeout = new System.Windows.Forms.Label(); this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.lblOpenMode = new System.Windows.Forms.Label(); + this.cmbOpenMode = new System.Windows.Forms.ComboBox(); this.lblConnectionString = new System.Windows.Forms.Label(); this.txtConnectionString = new System.Windows.Forms.TextBox(); this.btnBuild = new System.Windows.Forms.Button(); @@ -189,6 +191,24 @@ private void InitializeComponent() this.numTimeout.TabIndex = 14; this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); // + // lblOpenMode + // + this.lblOpenMode.AutoSize = true; + this.lblOpenMode.Location = new System.Drawing.Point(260, 198); + this.lblOpenMode.Name = "lblOpenMode"; + this.lblOpenMode.Size = new System.Drawing.Size(67, 13); + this.lblOpenMode.TabIndex = 25; + this.lblOpenMode.Text = "&Open mode:"; + // + // cmbOpenMode + // + this.cmbOpenMode.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cmbOpenMode.FormattingEnabled = true; + this.cmbOpenMode.Location = new System.Drawing.Point(350, 195); + this.cmbOpenMode.Name = "cmbOpenMode"; + this.cmbOpenMode.Size = new System.Drawing.Size(200, 21); + this.cmbOpenMode.TabIndex = 26; + // // lblConnectionString // this.lblConnectionString.AutoSize = true; @@ -311,6 +331,8 @@ private void InitializeComponent() this.Controls.Add(this.btnBuild); this.Controls.Add(this.txtConnectionString); this.Controls.Add(this.lblConnectionString); + this.Controls.Add(this.cmbOpenMode); + this.Controls.Add(this.lblOpenMode); this.Controls.Add(this.numTimeout); this.Controls.Add(this.lblTimeout); this.Controls.Add(this.chkTrustServerCertificate); @@ -355,6 +377,8 @@ private void InitializeComponent() private System.Windows.Forms.CheckBox chkTrustServerCertificate; private System.Windows.Forms.Label lblTimeout; private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.Label lblOpenMode; + private System.Windows.Forms.ComboBox cmbOpenMode; private System.Windows.Forms.Label lblConnectionString; private System.Windows.Forms.TextBox txtConnectionString; private System.Windows.Forms.Button btnBuild; diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index 9c192273a2..fa5408d55c 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -20,10 +20,19 @@ public partial class MainForm : Form public MainForm() { InitializeComponent(); - this.Text = "Azure SQL Connector — UI thread (OpenAsync)"; + this.Text = "Azure SQL Connector — UI thread"; PopulateAuthenticationMethods(); PopulateEncryptOptions(); + PopulateOpenModes(); UpdateCredentialFieldsAvailability(); + + // Force the underlying Win32 window to be created NOW (on the UI thread) so we can + // safely hand its HWND to MSAL later. Even in async mode, MSAL.NET may invoke the + // parent-window callback from a worker thread (e.g. when the driver blocks on a + // synchronous Open()), and touching Form.Handle from a non-UI thread throws + // InvalidOperationException ("Cross-thread operation not valid"). + _ownerHwnd = this.Handle; + RegisterActiveDirectoryProvider(); } @@ -50,23 +59,34 @@ private void PopulateEncryptOptions() cmbEncrypt.SelectedIndex = 0; } + private void PopulateOpenModes() + { + cmbOpenMode.Items.Add(OpenModeDisplay.Async); + cmbOpenMode.Items.Add(OpenModeDisplay.Sync); + cmbOpenMode.SelectedIndex = 0; + } + /// /// Registers a single for every - /// Entra ID authentication method and parents its UI to this form. With the UI-thread - /// approach, the callbacks run on the UI thread, - /// so it is safe for them to return this directly. + /// Entra ID authentication method and gives it the form's captured HWND as the parent + /// window owner. Both callbacks intentionally use the HWND captured in the constructor + /// () rather than this.Handle, because MSAL.NET can invoke + /// them from a worker thread (e.g. when the driver blocks on a synchronous Open() + /// or when its internal continuations resume off-UI). /// private void RegisterActiveDirectoryProvider() { ActiveDirectoryAuthenticationProvider provider = new ActiveDirectoryAuthenticationProvider(); + IntPtr ownerHwnd = _ownerHwnd; + #if NETFRAMEWORK // .NET Framework: parent the embedded WebView via the legacy IWin32Window API. - provider.SetIWin32WindowFunc(() => this); -#else + provider.SetIWin32WindowFunc(() => new Win32WindowHandle(ownerHwnd)); +#endif + // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM // broker consults on Windows. - provider.SetParentActivityOrWindowFunc(() => this.Handle); -#endif + provider.SetParentActivityOrWindowFunc(() => ownerHwnd); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); @@ -122,22 +142,18 @@ private async void btnTest_Click(object sender, EventArgs e) return; } - SetBusy(true, "Testing connection..."); + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync ? "Testing connection (OpenAsync)..." : "Testing connection (Open)..."); AppendStatus(string.Empty); - AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + AppendStatus("Testing connectivity to " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); try { - // Call OpenAsync on the UI thread so the WinForms SynchronizationContext is - // preserved. The ActiveDirectoryInteractive flow needs a running UI message pump - // on the calling thread for MSAL.NET to display its embedded sign-in browser. - // OpenAsync still keeps the UI responsive because the I/O wait does not block the - // message loop. string serverVersion; using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) { - await connection.OpenAsync().ConfigureAwait(true); - // connection.Open(); + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); serverVersion = connection.ServerVersion; } @@ -153,7 +169,6 @@ private async void btnTest_Click(object sender, EventArgs e) { SetStatus("Connection failed.", isError: true); AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); - AppendStatus(ex.GetType().Name + ": " + ex.Message + "\r\n" + ex.StackTrace); } finally { @@ -176,9 +191,13 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) return; } - SetBusy(true, "Querying logged-in identity..."); + bool useAsync = IsAsyncOpenSelected(); + SetBusy(true, useAsync + ? "Querying logged-in identity (OpenAsync)..." + : "Querying logged-in identity (Open)..."); AppendStatus(string.Empty); - AppendStatus("Running identity query against " + builder.DataSource + " ..."); + AppendStatus("Running identity query against " + builder.DataSource + " (" + + (useAsync ? "OpenAsync" : "sync Open") + ") ..."); try { @@ -186,7 +205,7 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) // ActiveDirectoryInteractive sign-in that may be required. using (SqlConnection connection = new SqlConnection(builder.ConnectionString)) { - await connection.OpenAsync().ConfigureAwait(true); + await OpenConnectionAsync(connection, useAsync).ConfigureAwait(true); using (SqlCommand command = connection.CreateCommand()) { @@ -260,6 +279,7 @@ private void btnClear_Click(object sender, EventArgs e) txtStatus.Clear(); cmbAuthentication.SelectedItem = SqlAuthenticationMethod.SqlPassword; cmbEncrypt.SelectedIndex = 0; + cmbOpenMode.SelectedIndex = 0; chkTrustServerCertificate.Checked = false; numTimeout.Value = 30; SetStatus("Ready", isError: false); @@ -377,6 +397,34 @@ private static string MaskPassword(SqlConnectionStringBuilder builder) return copy.ConnectionString; } + /// + /// Returns when the user picked Async (OpenAsync) in the + /// open-mode selector. Defaults to async if the selector has not been initialized yet. + /// + private bool IsAsyncOpenSelected() + { + return cmbOpenMode.SelectedItem as string != OpenModeDisplay.Sync; + } + + /// + /// Opens on the calling thread using either + /// or the synchronous + /// based on . The method itself is always async-returning so + /// callers can await uniformly; for the sync case it runs Open() inline on + /// the UI thread (which is supported with WAM broker because the broker dialog is hosted + /// by a separate process and does not need this thread's message pump). + /// + private static Task OpenConnectionAsync(SqlConnection connection, bool useAsync) + { + if (useAsync) + { + return connection.OpenAsync(); + } + + connection.Open(); + return Task.CompletedTask; + } + #endregion // ────────────────────────────────────────────────────────────────── @@ -426,6 +474,7 @@ private void SetBusy(bool busy, string statusText) btnCopy.Enabled = !busy; btnClear.Enabled = !busy; btnWhoAmI.Enabled = !busy; + cmbOpenMode.Enabled = !busy; Cursor = busy ? Cursors.WaitCursor : Cursors.Default; if (statusText != null) @@ -446,6 +495,34 @@ private static class EncryptDisplay public const string Strict = "Strict"; } + private static class OpenModeDisplay + { + public const string Async = "Async (OpenAsync)"; + public const string Sync = "Sync (Open)"; + } + +#if NETFRAMEWORK + // Tiny IWin32Window wrapper around a raw HWND captured on the UI thread so MSAL.NET's + // legacy IWin32WindowFunc callback can safely return a window owner from a worker thread + // without ever touching Control.Handle off-UI. + private sealed class Win32WindowHandle : IWin32Window + { + private readonly IntPtr _hwnd; + public Win32WindowHandle(IntPtr hwnd) => _hwnd = hwnd; + public IntPtr Handle => _hwnd; + } +#endif + + #endregion + + // ─────────────────────────────────────────────────────────────── + #region Private Fields + + // The form's Win32 window handle, captured on the UI thread in the constructor. + // Read from worker threads by the Entra ID provider callbacks to parent MSAL's sign-in + // / WAM broker UI without illegally touching Control.Handle. + private readonly IntPtr _ownerHwnd; + #endregion } } diff --git a/doc/apps/AzureSqlConnector/ModeSelectorForm.cs b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs index 92366692f1..651412de32 100644 --- a/doc/apps/AzureSqlConnector/ModeSelectorForm.cs +++ b/doc/apps/AzureSqlConnector/ModeSelectorForm.cs @@ -54,7 +54,7 @@ internal ModeSelectorForm() _rdoUiThread = new RadioButton { - Text = "&UI thread — SqlConnection.OpenAsync()", + Text = "&UI thread", Location = new Point(20, 42), Size = new Size(420, 20), Checked = true, @@ -63,7 +63,7 @@ internal ModeSelectorForm() Label lblUiHint = new Label { AutoSize = false, - Text = " Async open on the UI thread; SynchronizationContext keeps the form responsive.", + Text = " Async/Sync open on the UI thread; SynchronizationContext keeps the form responsive.", Location = new Point(20, 62), Size = new Size(420, 18), ForeColor = SystemColors.GrayText, @@ -71,7 +71,7 @@ internal ModeSelectorForm() _rdoWorker = new RadioButton { - Text = "&Worker thread — Task.Run(() => connection.Open())", + Text = "&Worker thread", Location = new Point(20, 90), Size = new Size(420, 20), }; diff --git a/doc/apps/AzureSqlConnector/NuGet.config b/doc/apps/AzureSqlConnector/NuGet.config deleted file mode 100644 index 7561e191a2..0000000000 --- a/doc/apps/AzureSqlConnector/NuGet.config +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - 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 index 778d69991b..c9851cf345 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs @@ -73,4 +73,35 @@ public void Abstractions_And_Manager_GetSetProvider_Equivalent() 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); + } + } } From 449d289d9e0620f446be9c9ee76b65a91dd08efe Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 16 Jun 2026 23:42:00 -0700 Subject: [PATCH 21/41] Verify Sibling Assembly --- .../src/SqlAuthenticationProvider.Internal.cs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs index e0adf34e49..293a0136ed 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs @@ -54,12 +54,26 @@ static Internal() return; } - // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/39845): - // Verify the assembly is signed by us? + // Defense-in-depth: only reflect into MDS if it carries the same strong-name + // public key as this Extensions assembly. This is not a substitute for + // Authenticode verification (which would require WinVerifyTrust and is + // Windows-only) — it only catches an MDS built by a different publisher + // dropped on the load path. On .NET Framework Assembly.Load already enforces + // strong-name matching; on .NET (Core+) it does not, which is why we check + // explicitly here. Throws on mismatch so that consumers see a hard failure + // (surfaced as TypeInitializationException on first GetProvider/SetProvider + // call) instead of silently falling back to a no-op provider table. + if (!IsSiblingAssembly(assembly)) + { + throw new InvalidOperationException( + $"MDS assembly={assemblyName} is loaded but is not signed with the " + + "same strong-name key as Microsoft.Data.SqlClient.Extensions.Abstractions. " + + "Refusing to reflect into a foreign-signed MDS for security reasons."); + } // Look for the manager class. const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; - var manager = assembly.GetType(className); + Type? manager = assembly.GetType(className); if (manager is null) { @@ -103,6 +117,42 @@ or FileLoadException // Any other exceptions are fatal. } + /// + /// Returns when it is safe to reflect into . + /// Policy: if this Extensions assembly is strong-name signed, the loaded MDS must carry + /// the same public-key token; if Extensions itself is unsigned (e.g. local developer + /// builds), no token comparison is possible, so we permit it. + /// + private static bool IsSiblingAssembly(Assembly assembly) + { + byte[]? expected = typeof(SqlAuthenticationProvider) + .Assembly.GetName().GetPublicKeyToken(); + + // Extensions itself isn't strong-name signed (local dev build) — no token to + // compare against, so we can't make a meaningful authenticity claim either way. + if (expected is null || expected.Length == 0) + { + return true; + } + + byte[]? actual = assembly.GetName().GetPublicKeyToken(); + + if (actual is null || actual.Length != expected.Length) + { + return false; + } + + for (int i = 0; i < expected.Length; i++) + { + if (expected[i] != actual[i]) + { + return false; + } + } + + return true; + } + /// /// Call the reflected GetProvider method. /// From 90760e3c03322db495fdd1c811ac1a1d9e366905 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 16 Jun 2026 23:48:43 -0700 Subject: [PATCH 22/41] Deprecation note for future --- .../Azure/src/ActiveDirectoryAuthenticationProvider.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 0f8a65ae26..91ce3a75c7 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -206,6 +206,13 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication) #if NETFRAMEWORK private Func? _iWin32WindowFunc = null; + // @TODO: deprecate SetIWin32WindowFunc. It is redundant with SetParentActivityOrWindowFunc: + // callers can return an IWin32Window from the Func callback and GetParentWindow() + // already unwraps it to an HWND on .NET Framework. Keeping both APIs also splits the + // PublicClientAppKey cache (IWin32WindowFunc is part of its equality), so the same logical + // identity ends up with two IPublicClientApplication instances depending on which setter + // the caller used. Mark [Obsolete] in a future release once we have a migration window. + /// public void SetIWin32WindowFunc(Func iWin32WindowFunc) => _iWin32WindowFunc = iWin32WindowFunc; #endif From 69b378783959a3c061f83cf4af0c2bd59f09be0c Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 16 Jun 2026 23:55:24 -0700 Subject: [PATCH 23/41] Support clearing user token cache to enable retesting token acquisition flow --- .../MainFormWorker.Designer.cs | 13 +++++++++++++ doc/apps/AzureSqlConnector/MainFormWorker.cs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs index 76a7184649..c872ee38ab 100644 --- a/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs +++ b/doc/apps/AzureSqlConnector/MainFormWorker.Designer.cs @@ -43,6 +43,7 @@ private void InitializeComponent() this.chkTrustServerCertificate = new System.Windows.Forms.CheckBox(); this.lblTimeout = new System.Windows.Forms.Label(); this.numTimeout = new System.Windows.Forms.NumericUpDown(); + this.chkClearTokenCache = new System.Windows.Forms.CheckBox(); this.lblConnectionString = new System.Windows.Forms.Label(); this.txtConnectionString = new System.Windows.Forms.TextBox(); this.btnBuild = new System.Windows.Forms.Button(); @@ -189,6 +190,16 @@ private void InitializeComponent() this.numTimeout.TabIndex = 14; this.numTimeout.Value = new decimal(new int[] { 30, 0, 0, 0 }); // + // chkClearTokenCache + // + this.chkClearTokenCache.AutoSize = true; + this.chkClearTokenCache.Location = new System.Drawing.Point(260, 198); + this.chkClearTokenCache.Name = "chkClearTokenCache"; + this.chkClearTokenCache.Size = new System.Drawing.Size(290, 17); + this.chkClearTokenCache.TabIndex = 15; + this.chkClearTokenCache.Text = "Clear MSAL token &cache before connect (force prompt)"; + this.chkClearTokenCache.UseVisualStyleBackColor = true; + // // lblConnectionString // this.lblConnectionString.AutoSize = true; @@ -313,6 +324,7 @@ private void InitializeComponent() this.Controls.Add(this.lblConnectionString); this.Controls.Add(this.numTimeout); this.Controls.Add(this.lblTimeout); + this.Controls.Add(this.chkClearTokenCache); this.Controls.Add(this.chkTrustServerCertificate); this.Controls.Add(this.cmbEncrypt); this.Controls.Add(this.lblEncrypt); @@ -355,6 +367,7 @@ private void InitializeComponent() private System.Windows.Forms.CheckBox chkTrustServerCertificate; private System.Windows.Forms.Label lblTimeout; private System.Windows.Forms.NumericUpDown numTimeout; + private System.Windows.Forms.CheckBox chkClearTokenCache; private System.Windows.Forms.Label lblConnectionString; private System.Windows.Forms.TextBox txtConnectionString; private System.Windows.Forms.Button btnBuild; diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.cs b/doc/apps/AzureSqlConnector/MainFormWorker.cs index b1af89284c..a12b019204 100644 --- a/doc/apps/AzureSqlConnector/MainFormWorker.cs +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -145,6 +145,8 @@ private async void btnTest_Click(object sender, EventArgs e) AppendStatus(string.Empty); AppendStatus("Testing connectivity to " + builder.DataSource + " ..."); + MaybeClearTokenCache(); + try { // Run Open() on a thread-pool worker so the UI thread never blocks. The await @@ -204,6 +206,8 @@ private async void btnWhoAmI_Click(object sender, EventArgs e) AppendStatus(string.Empty); AppendStatus("Running identity query against " + builder.DataSource + " ..."); + MaybeClearTokenCache(); + try { // Run the whole open + query + read on a worker thread so the UI never blocks. @@ -474,6 +478,21 @@ private void SetBusy(bool busy, string statusText) } } + // Only drops the in-process PCA / TokenCredential maps; MSAL's persistent on-disk cache + // and WAM broker accounts are untouched. Sufficient to demo a worker-thread interactive + // prompt when the persistent cache has already been cleared (fresh run or no WAM account + // bound), and a useful reset between back-to-back connects within a single session. + private void MaybeClearTokenCache() + { + if (!chkClearTokenCache.Checked) + { + return; + } + + ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); + AppendStatus("Cleared in-process MSAL token cache."); + } + #endregion // ────────────────────────────────────────────────────────────────── From b44ece5424673b88a6c8d56115632a9f320d332e Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 00:28:03 -0700 Subject: [PATCH 24/41] More improvements, clear token cache properly + fix device code flow auth timeout --- .tmp-pr-body-updated.md | 112 ++++++++++++++++++ doc/apps/AzureSqlConnector/MainForm.cs | 62 ++++++++++ doc/apps/AzureSqlConnector/MainFormWorker.cs | 61 ++++++++++ .../ActiveDirectoryAuthenticationProvider.xml | 7 +- .../doc/SqlAuthenticationProvider.xml | 13 ++ .../src/SqlAuthenticationProvider.Internal.cs | 48 ++++++++ .../src/SqlAuthenticationProvider.cs | 6 + .../ActiveDirectoryAuthenticationProvider.xml | 7 +- .../ActiveDirectoryAuthenticationProvider.cs | 32 ++++- .../ConnectionPool/DbConnectionPoolGroup.cs | 14 +++ .../SqlAuthenticationProviderManager.cs | 15 +++ .../Data/SqlClient/SqlConnectionFactory.cs | 16 +++ 12 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 .tmp-pr-body-updated.md diff --git a/.tmp-pr-body-updated.md b/.tmp-pr-body-updated.md new file mode 100644 index 0000000000..a6b9bf7063 --- /dev/null +++ b/.tmp-pr-body-updated.md @@ -0,0 +1,112 @@ +## Enable WAM Broker support for Entra ID Auth modes + +### Summary + +Enables [Windows Account Manager (WAM)](https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam) broker support for interactive Entra ID authentication in `Microsoft.Data.SqlClient.Extensions.Azure`. WAM provides a more secure, integrated sign-in experience on Windows (single sign-on, Windows Hello, conditional access) for the following Entra ID auth modes: + +- Active Directory Integrated +- Active Directory Password (deprecated) +- Active Directory Interactive +- Active Directory Device Code Flow + +### Behavior + +- The WAM broker is **always enabled** when the driver's built-in application client id is used. +- When a **custom** application client id is supplied, the WAM broker is **opt-in** via the new `useWamBroker` parameter (defaults to disabled for backward compatibility). +- On non-Windows platforms, the broker is not used. The system-browser / device-code flows remain unchanged, and `SetParentActivityOrWindowFunc` is now forwarded to MSAL so Android/iOS/MAUI callers can supply a parent `Activity` / `UIViewController` when interactive auth is required. +- `ActiveDirectoryAuthenticationProvider.ClearUserTokenCache()` additionally evicts the driver's per-pool federated-authentication token cache, so subsequent `SqlConnection.Open` calls reacquire fed-auth tokens instead of reusing cached entries. Pooled physical connections are not torn down. + +### New Public APIs + +#### `Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider` (in `Microsoft.Data.SqlClient.Extensions.Azure`) + +Three new constructor **overloads** plus a nested options-bag type. Existing constructors are unchanged and remain fully source- and binary-compatible: + +```csharp +// New: application client id + WAM broker toggle +public ActiveDirectoryAuthenticationProvider( + string applicationClientId, + bool useWamBroker); + +// New: device code flow callback + application client id + WAM broker toggle +public ActiveDirectoryAuthenticationProvider( + Func deviceCodeFlowCallbackMethod, + string? applicationClientId, + bool useWamBroker); + +// New: options-bag overload, future-proofing additional knobs +public ActiveDirectoryAuthenticationProvider(ProviderOptions options); + +// New: nested options type +public sealed class ProviderOptions +{ + public Func? DeviceCodeFlowCallback { get; set; } + public string? ApplicationClientId { get; set; } + public bool UseWamBroker { get; set; } +} +``` + +New instance method (cross-platform, replaces the previously-Windows-only Win32-typed helper): + +```csharp +// Forwarded to MSAL via WithParentActivityOrWindow on every platform. +// On Windows the returned object is an IntPtr/IWin32Window; on Android +// it is an Activity; on iOS it is a UIViewController. +public void SetParentActivityOrWindowFunc(Func? parentActivityOrWindowFunc); +``` + +Existing constructors are unchanged: + +```csharp +public ActiveDirectoryAuthenticationProvider(); +public ActiveDirectoryAuthenticationProvider(string applicationClientId); +public ActiveDirectoryAuthenticationProvider( + Func deviceCodeFlowCallbackMethod, + string? applicationClientId = null); +``` + +#### `Microsoft.Data.SqlClient.SqlAuthenticationProvider` (in `Microsoft.Data.SqlClient.Extensions.Abstractions`) + +New public static API that clears the driver's in-memory cache of federated-authentication tokens (`DbConnectionPoolAuthenticationContext`) across every active connection pool. Pools and their pooled physical connections are not torn down; only the cached fed-auth tokens are evicted, so the next connection that needs a fed-auth token will reacquire it from its registered `SqlAuthenticationProvider`. Wired into `Microsoft.Data.SqlClient` via the existing Abstractions reflection bridge (`SqlAuthenticationProviderManager.ClearFederatedAuthenticationInformationCache`), and called from `ActiveDirectoryAuthenticationProvider.ClearUserTokenCache()` so the driver's cache stays in sync with the upstream MSAL/credential cache. + +```csharp +public static void ClearFederatedAuthenticationInformationCache(); +``` + +#### Usage + +```csharp +// Custom application client id, opting into the WAM broker on Windows +var provider = new ActiveDirectoryAuthenticationProvider("", useWamBroker: true); +SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); + +// Options-bag form +var provider2 = new ActiveDirectoryAuthenticationProvider(new ActiveDirectoryAuthenticationProvider.ProviderOptions +{ + ApplicationClientId = "", + UseWamBroker = true, +}); + +// Clear MSAL caches AND the driver's per-pool fed-auth token cache +ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); +``` + +### Changes + +- Added WAM broker wiring and Windows-specific parent-window handling (`ActiveDirectoryAuthenticationProvider.Windows.cs`, new `Interop.GetAncestor` / `Interop.GetConsoleWindow`). +- Updated redirect URI handling for WAM broker (Windows) and system browser (Unix). +- Added the three new constructor overloads and the `ProviderOptions` options-bag type. +- Forwarded `SetParentActivityOrWindowFunc` to MSAL on non-Windows targets (Android/iOS/MAUI) in addition to the existing Windows path. +- Added cross-assembly plumbing so `ClearUserTokenCache()` also clears the driver's per-pool fed-auth context cache: + - `DbConnectionPoolGroup.ClearAuthenticationContexts()` and `SqlConnectionFactory.ClearAllAuthenticationContexts()` in `Microsoft.Data.SqlClient`. + - `SqlAuthenticationProviderManager.ClearFederatedAuthenticationInformationCache()` reflection target. + - `SqlAuthenticationProvider.ClearFederatedAuthenticationInformationCache()` public API and reflection wrapper in `Microsoft.Data.SqlClient.Extensions.Abstractions`. +- Updated XML docs/snippets to clarify WAM broker behavior, defaults, and the new clear-cache side effect. +- Added `WamBrokerTests.cs` covering built-in vs. custom client id behavior with `useWamBroker` true/false, and the `ProviderOptions` constructor. +- Added a WinForms sample (`doc/apps/AzureSqlConnector`) that exercises sync `Open()` and `OpenAsync()` from the UI thread with the WAM broker. + +### Testing + +- [x] Unit tests added (`WamBrokerTests.cs`) +- [x] Public API changes documented (doc XML + snippets) +- [x] No breaking changes (new overloads and the new `ClearFederatedAuthenticationInformationCache` API preserve binary compatibility) diff --git a/doc/apps/AzureSqlConnector/MainForm.cs b/doc/apps/AzureSqlConnector/MainForm.cs index fa5408d55c..9cc6d0648c 100644 --- a/doc/apps/AzureSqlConnector/MainForm.cs +++ b/doc/apps/AzureSqlConnector/MainForm.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector { @@ -87,6 +89,12 @@ private void RegisterActiveDirectoryProvider() // Modern API: works on both .NET Framework and .NET 8+, and is the one MSAL's WAM // broker consults on Windows. provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); @@ -100,6 +108,60 @@ private void RegisterActiveDirectoryProvider() #pragma warning restore CS0618 // Type or member is obsolete } + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (works whenever the UI + /// thread is pumping — async OpenAsync), (2) the verification URL launched in + /// the default browser, and (3) a modal owned by the MSAL worker thread (works even + /// when the UI thread is blocked by a synchronous Open()). MSAL polling waits + /// for the returned Task to complete, so dismissing the dialog also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + #endregion // ────────────────────────────────────────────────────────────────── diff --git a/doc/apps/AzureSqlConnector/MainFormWorker.cs b/doc/apps/AzureSqlConnector/MainFormWorker.cs index a12b019204..8d344cf6c4 100644 --- a/doc/apps/AzureSqlConnector/MainFormWorker.cs +++ b/doc/apps/AzureSqlConnector/MainFormWorker.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Data.SqlClient; +using Microsoft.Identity.Client; namespace Microsoft.Data.SqlClient.Samples.AzureSqlConnector { @@ -86,6 +88,11 @@ private void RegisterActiveDirectoryProvider() // broker consults on Windows. provider.SetParentActivityOrWindowFunc(() => ownerHwnd); + // Without this, MSAL's default device-code callback writes the prompt to + // Console.WriteLine, which is invisible in a WinForms host — the connection + // appears to hang while MSAL polls for a code the user never sees. + provider.SetDeviceCodeFlowCallback(DeviceCodeFlowCallback); + SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, provider); @@ -99,6 +106,60 @@ private void RegisterActiveDirectoryProvider() #pragma warning restore CS0618 // Type or member is obsolete } + /// + /// Device Code Flow callback. MSAL invokes this on a worker thread before it begins + /// polling the token endpoint. We surface the user code three ways so the user always + /// sees it: (1) appended to the log textbox via BeginInvoke (the UI thread is free in + /// this variant because Open() runs on a Task.Run worker), (2) the verification URL + /// launched in the default browser, and (3) a modal owned by the MSAL worker thread. + /// MSAL polling waits for the returned Task to complete, so dismissing the dialog + /// also resumes polling. + /// + private Task DeviceCodeFlowCallback(DeviceCodeResult result) + { + string message = result.Message; + string url = result.VerificationUrl; + string code = result.UserCode; + + if (IsHandleCreated) + { + try + { + BeginInvoke((Action)(() => + { + AppendStatus(string.Empty); + AppendStatus("=== Device Code Flow ==="); + AppendStatus(message); + })); + } + catch (InvalidOperationException) + { + // Form is closing or handle was destroyed; fall through to the modal. + } + } + + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch + { + // Best-effort; the modal below still shows the URL and code. + } + + MessageBox.Show( + "Sign in to complete Device Code Flow:" + Environment.NewLine + Environment.NewLine + + " URL : " + url + Environment.NewLine + + " Code: " + code + Environment.NewLine + Environment.NewLine + + "A browser window has been opened. Enter the code above, complete sign-in," + + Environment.NewLine + "then click OK to resume the connection.", + "Device Code Flow", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + + return Task.CompletedTask; + } + #endregion // ────────────────────────────────────────────────────────────────── diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 1406856576..20c34610b5 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -104,7 +104,12 @@ See the LICENSE file in the project root for more information. Clears cached user tokens from the token provider. - This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + + The driver's per-pool federated-authentication token cache is also cleared, so subsequent calls will reacquire fed-auth tokens instead of reusing cached entries. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml index 7848aaec1a..7bc5c02e81 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml @@ -139,5 +139,18 @@ See the LICENSE file in the project root for more information. if the operation succeeded; otherwise, (for example, the existing provider disallows overriding). + + + Clears the driver's in-memory cache of federated-authentication tokens across every active connection pool. + + + + Pools and their pooled physical connections are not torn down. Only the cached fed-auth tokens are evicted, so the next connection that needs a fed-auth token will reacquire it from its registered . + + + This is invoked by extension token-cache-clear APIs (for example ActiveDirectoryAuthenticationProvider.ClearUserTokenCache) so the driver's cache stays in sync with the upstream MSAL/credential cache. The call is a no-op if Microsoft.Data.SqlClient is not loaded. + + + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs index 293a0136ed..129f9f1a0b 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs @@ -33,6 +33,11 @@ private static class Internal /// private static readonly MethodInfo? _setProvider = null; + /// + /// Our handle to the reflected ClearFederatedAuthenticationInformationCache() method. + /// + private static readonly MethodInfo? _clearFedAuthCache = null; + /// /// Static construction performs the reflection lookups. /// @@ -102,6 +107,16 @@ static Internal() Log($"MDS SetProvider() method not found; " + "SetProvider() will not function"); } + + _clearFedAuthCache = manager.GetMethod( + "ClearFederatedAuthenticationInformationCache", + BindingFlags.NonPublic | BindingFlags.Static); + + if (_clearFedAuthCache is null) + { + Log($"MDS ClearFederatedAuthenticationInformationCache() method not found; " + + "ClearFederatedAuthenticationInformationCache() will not function"); + } } // All of these exceptions mean we couldn't find the get/set // methods. @@ -240,6 +255,39 @@ or NotSupportedException } } + /// + /// Call the reflected ClearFederatedAuthenticationInformationCache method to + /// evict any fed-auth tokens the driver has cached across its connection pools. + /// + /// + /// True if the reflected call ran successfully, false if reflection wasn't + /// available or the invocation threw a recognized exception. + /// + internal static bool ClearFederatedAuthenticationInformationCache() + { + if (_clearFedAuthCache is null) + { + return false; + } + + try + { + _clearFedAuthCache.Invoke(null, null); + return true; + } + catch (Exception ex) + when (ex is InvalidOperationException + or MemberAccessException + or MethodAccessException + or NotSupportedException + or TargetInvocationException) + { + Log($"ClearFederatedAuthenticationInformationCache() 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..25bb9de006 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs @@ -42,4 +42,10 @@ public static bool SetProvider( { return Internal.SetProvider(authenticationMethod, provider); } + + /// + public static void ClearFederatedAuthenticationInformationCache() + { + Internal.ClearFederatedAuthenticationInformationCache(); + } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 572f1300e5..9e0908c154 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -123,7 +123,12 @@ See the LICENSE file in the project root for more information. Clears cached user tokens from the token provider. - This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + + The driver's per-pool federated-authentication token cache is also cleared, so subsequent SqlConnection.Open calls will reacquire fed-auth tokens instead of reusing cached entries. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 91ce3a75c7..26593c715f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -167,6 +167,11 @@ public static void ClearUserTokenCache() { s_tokenCredentialMap.Clear(); } + + // Also evict any fed-auth tokens the driver has cached per pool, so subsequent + // SqlConnection.Open calls reacquire tokens from MSAL/Azure.Identity instead of + // reusing entries that are now considered stale by the caller. + SqlAuthenticationProvider.ClearFederatedAuthenticationInformationCache(); } /// @@ -665,10 +670,19 @@ private static async Task AcquireTokenInteractiveDeviceFlo } else { + // Device Code Flow needs minutes for the user to navigate to the verification + // URL and enter the user code. The outer per-request CTS inherits the + // SqlConnection's Connect Timeout (default 15s) and would cancel MSAL long + // before the user can finish, surfacing as "The operation was canceled." + // Use a dedicated CTS with the typical AAD user-code lifetime as the cap, + // mirroring the pattern used for ActiveDirectoryInteractive above. + using CancellationTokenSource ctsDeviceFlow = new(); + ctsDeviceFlow.CancelAfter(900000); // 15 minutes + return await app.AcquireTokenWithDeviceCode(scopes, deviceCodeResult => deviceCodeFlowCallback(deviceCodeResult)) .WithCorrelationId(connectionId) - .ExecuteAsync(cancellationToken: cts.Token) + .ExecuteAsync(cancellationToken: ctsDeviceFlow.Token) .ConfigureAwait(false); } } @@ -862,13 +876,19 @@ private IPublicClientApplication CreateClientAppInstance(PublicClientAppKey publ builder.WithParentActivityOrWindow(() => (object)GetParentWindow()); #endif } - #if NETFRAMEWORK - else if (publicClientAppKey.IWin32WindowFunc is not null) + else { - // Not on Windows (shouldn't happen for NETFRAMEWORK, but be defensive). - builder.WithParentActivityOrWindow(publicClientAppKey.IWin32WindowFunc); + // Non-Windows (Android / iOS / MAUI on .NET). Forward the caller-supplied callback + // to MSAL so interactive flows can parent their UI to the host Activity / + // UIViewController. WAM broker remains Windows-only and is intentionally skipped. + // The callback is snapshotted at PCA build time; callers who update the callback + // after the PCA is cached must invoke ClearUserTokenCache() to pick up the change. + Func? parentActivityOrWindowFunc = _parentActivityOrWindowFunc; + if (parentActivityOrWindowFunc is not null) + { + builder.WithParentActivityOrWindow(parentActivityOrWindowFunc); + } } - #endif return builder.Build(); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs index 8f62787e30..affb759610 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs @@ -128,6 +128,20 @@ internal int Clear() return _poolCollection.Count; } + /// + /// Clears the cached federated-authentication contexts on every pool in this group + /// without disposing the pools or their pooled connections. Used to force the driver + /// to re-acquire fed-auth tokens (e.g. when the caller has cleared the upstream MSAL + /// token cache). + /// + internal void ClearAuthenticationContexts() + { + foreach (IDbConnectionPool pool in _poolCollection.Values) + { + pool?.AuthenticationContexts.Clear(); + } + } + internal IDbConnectionPool GetConnectionPool(SqlConnectionFactory connectionFactory) { // When this method returns null it indicates that the connection diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index bdc4695b81..17d49c0fa3 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -380,6 +380,21 @@ internal static bool SetProvider(SqlAuthenticationMethod authenticationMethod, S return true; } + /// + /// Clears the driver's in-memory cache of federated-authentication tokens + /// (DbConnectionPoolAuthenticationContext) across every active pool. + /// Pools and pooled physical connections are not torn down; only the cached + /// fed-auth contexts are evicted, so the next connection that needs a fed-auth + /// token will reacquire it from its . + /// Reflected into by Microsoft.Data.SqlClient.Extensions.Abstractions so + /// extension token-cache-clear APIs (e.g. ActiveDirectoryAuthenticationProvider.ClearUserTokenCache) + /// can keep the driver's cache in sync with the upstream MSAL/credential cache. + /// + internal static void ClearFederatedAuthenticationInformationCache() + { + SqlConnectionFactory.Instance.ClearAllAuthenticationContexts(); + } + /// /// Fetches provided configuration section from app.config file. /// Does not support reading from appsettings.json yet. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index 4cdee8bdc2..537b4a4f13 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -87,6 +87,22 @@ internal void ClearAllPools() group?.Clear(); } } + + /// + /// Clears every pool's cached federated-authentication contexts without disposing + /// the pools or their pooled connections. Pooled connections remain reusable; only + /// the cached fed-auth tokens are evicted, so the next connection that needs a + /// fed-auth token will reacquire it. Invoked when an extension's token-cache-clear + /// API is called so the driver's in-memory cache stays in sync. + /// + internal void ClearAllAuthenticationContexts() + { + using SqlClientEventScope scope = SqlClientEventScope.Create(nameof(SqlConnectionFactory)); + foreach (DbConnectionPoolGroup group in _connectionPoolGroups.Values) + { + group?.ClearAuthenticationContexts(); + } + } internal void ClearPool(DbConnection connection) { From b82106d04a538cf19a54061a979efbd0250a85a4 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 00:30:41 -0700 Subject: [PATCH 25/41] Remove temp file --- .tmp-pr-body-updated.md | 112 ---------------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 .tmp-pr-body-updated.md diff --git a/.tmp-pr-body-updated.md b/.tmp-pr-body-updated.md deleted file mode 100644 index a6b9bf7063..0000000000 --- a/.tmp-pr-body-updated.md +++ /dev/null @@ -1,112 +0,0 @@ -## Enable WAM Broker support for Entra ID Auth modes - -### Summary - -Enables [Windows Account Manager (WAM)](https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam) broker support for interactive Entra ID authentication in `Microsoft.Data.SqlClient.Extensions.Azure`. WAM provides a more secure, integrated sign-in experience on Windows (single sign-on, Windows Hello, conditional access) for the following Entra ID auth modes: - -- Active Directory Integrated -- Active Directory Password (deprecated) -- Active Directory Interactive -- Active Directory Device Code Flow - -### Behavior - -- The WAM broker is **always enabled** when the driver's built-in application client id is used. -- When a **custom** application client id is supplied, the WAM broker is **opt-in** via the new `useWamBroker` parameter (defaults to disabled for backward compatibility). -- On non-Windows platforms, the broker is not used. The system-browser / device-code flows remain unchanged, and `SetParentActivityOrWindowFunc` is now forwarded to MSAL so Android/iOS/MAUI callers can supply a parent `Activity` / `UIViewController` when interactive auth is required. -- `ActiveDirectoryAuthenticationProvider.ClearUserTokenCache()` additionally evicts the driver's per-pool federated-authentication token cache, so subsequent `SqlConnection.Open` calls reacquire fed-auth tokens instead of reusing cached entries. Pooled physical connections are not torn down. - -### New Public APIs - -#### `Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider` (in `Microsoft.Data.SqlClient.Extensions.Azure`) - -Three new constructor **overloads** plus a nested options-bag type. Existing constructors are unchanged and remain fully source- and binary-compatible: - -```csharp -// New: application client id + WAM broker toggle -public ActiveDirectoryAuthenticationProvider( - string applicationClientId, - bool useWamBroker); - -// New: device code flow callback + application client id + WAM broker toggle -public ActiveDirectoryAuthenticationProvider( - Func deviceCodeFlowCallbackMethod, - string? applicationClientId, - bool useWamBroker); - -// New: options-bag overload, future-proofing additional knobs -public ActiveDirectoryAuthenticationProvider(ProviderOptions options); - -// New: nested options type -public sealed class ProviderOptions -{ - public Func? DeviceCodeFlowCallback { get; set; } - public string? ApplicationClientId { get; set; } - public bool UseWamBroker { get; set; } -} -``` - -New instance method (cross-platform, replaces the previously-Windows-only Win32-typed helper): - -```csharp -// Forwarded to MSAL via WithParentActivityOrWindow on every platform. -// On Windows the returned object is an IntPtr/IWin32Window; on Android -// it is an Activity; on iOS it is a UIViewController. -public void SetParentActivityOrWindowFunc(Func? parentActivityOrWindowFunc); -``` - -Existing constructors are unchanged: - -```csharp -public ActiveDirectoryAuthenticationProvider(); -public ActiveDirectoryAuthenticationProvider(string applicationClientId); -public ActiveDirectoryAuthenticationProvider( - Func deviceCodeFlowCallbackMethod, - string? applicationClientId = null); -``` - -#### `Microsoft.Data.SqlClient.SqlAuthenticationProvider` (in `Microsoft.Data.SqlClient.Extensions.Abstractions`) - -New public static API that clears the driver's in-memory cache of federated-authentication tokens (`DbConnectionPoolAuthenticationContext`) across every active connection pool. Pools and their pooled physical connections are not torn down; only the cached fed-auth tokens are evicted, so the next connection that needs a fed-auth token will reacquire it from its registered `SqlAuthenticationProvider`. Wired into `Microsoft.Data.SqlClient` via the existing Abstractions reflection bridge (`SqlAuthenticationProviderManager.ClearFederatedAuthenticationInformationCache`), and called from `ActiveDirectoryAuthenticationProvider.ClearUserTokenCache()` so the driver's cache stays in sync with the upstream MSAL/credential cache. - -```csharp -public static void ClearFederatedAuthenticationInformationCache(); -``` - -#### Usage - -```csharp -// Custom application client id, opting into the WAM broker on Windows -var provider = new ActiveDirectoryAuthenticationProvider("", useWamBroker: true); -SqlAuthenticationProvider.SetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive, provider); - -// Options-bag form -var provider2 = new ActiveDirectoryAuthenticationProvider(new ActiveDirectoryAuthenticationProvider.ProviderOptions -{ - ApplicationClientId = "", - UseWamBroker = true, -}); - -// Clear MSAL caches AND the driver's per-pool fed-auth token cache -ActiveDirectoryAuthenticationProvider.ClearUserTokenCache(); -``` - -### Changes - -- Added WAM broker wiring and Windows-specific parent-window handling (`ActiveDirectoryAuthenticationProvider.Windows.cs`, new `Interop.GetAncestor` / `Interop.GetConsoleWindow`). -- Updated redirect URI handling for WAM broker (Windows) and system browser (Unix). -- Added the three new constructor overloads and the `ProviderOptions` options-bag type. -- Forwarded `SetParentActivityOrWindowFunc` to MSAL on non-Windows targets (Android/iOS/MAUI) in addition to the existing Windows path. -- Added cross-assembly plumbing so `ClearUserTokenCache()` also clears the driver's per-pool fed-auth context cache: - - `DbConnectionPoolGroup.ClearAuthenticationContexts()` and `SqlConnectionFactory.ClearAllAuthenticationContexts()` in `Microsoft.Data.SqlClient`. - - `SqlAuthenticationProviderManager.ClearFederatedAuthenticationInformationCache()` reflection target. - - `SqlAuthenticationProvider.ClearFederatedAuthenticationInformationCache()` public API and reflection wrapper in `Microsoft.Data.SqlClient.Extensions.Abstractions`. -- Updated XML docs/snippets to clarify WAM broker behavior, defaults, and the new clear-cache side effect. -- Added `WamBrokerTests.cs` covering built-in vs. custom client id behavior with `useWamBroker` true/false, and the `ProviderOptions` constructor. -- Added a WinForms sample (`doc/apps/AzureSqlConnector`) that exercises sync `Open()` and `OpenAsync()` from the UI thread with the WAM broker. - -### Testing - -- [x] Unit tests added (`WamBrokerTests.cs`) -- [x] Public API changes documented (doc XML + snippets) -- [x] No breaking changes (new overloads and the new `ClearFederatedAuthenticationInformationCache` API preserve binary compatibility) From 3064e77048bbf3d8740070b4e075d1504aabd343 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:50:45 -0700 Subject: [PATCH 26/41] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../doc/ActiveDirectoryAuthenticationProvider.xml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 9e0908c154..3b1f7276d4 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -175,17 +175,16 @@ See the LICENSE file in the project root for more information. - A function that returns an window handle on Windows - (or an on .NET Framework). - Pass to clear a previously installed callback and revert to - the provider's automatic console-window fallback. - + A function that returns the parent window/activity object to be used by MSAL for interactive authentication UI. + On Windows, return an window handle (or an on .NET Framework). + On Android, return an Activity; on iOS, return a UIViewController (or the platform equivalent). + Pass to clear a previously installed callback and revert to the provider's default behavior. Sets a function to return the parent window handle to be used for WAM (Windows Account Manager) broker authentication prompts. On Windows, this handle is used to parent the WAM broker dialog. If not set, the provider will attempt to automatically detect the console window handle. - On non-Windows platforms this is a no-op as the WAM broker is not available. + On non-Windows platforms, this callback is forwarded to MSAL so interactive flows can parent their UI to the host Activity / UIViewController (or platform equivalent). Exceptions thrown by when it is invoked by MSAL are not caught by the provider; they propagate up to the caller of the originating AcquireToken request so that bugs in the callback surface where they are most diagnosable. From d436a7730a85b6f15d20bf500602f8ebce30ca56 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 12:41:31 -0700 Subject: [PATCH 27/41] Remove unwanted new ctors, fix errors --- .../ActiveDirectoryAuthenticationProvider.xml | 2 +- .../ActiveDirectoryAuthenticationProvider.xml | 25 ---------- .../ActiveDirectoryAuthenticationProvider.cs | 25 ---------- .../Azure/test/WamBrokerTests.cs | 48 ++++++++++++++----- 4 files changed, 37 insertions(+), 63 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 20c34610b5..0e026e54dd 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -52,7 +52,7 @@ See the LICENSE file in the project root for more information. - + Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 9e0908c154..aeb025a723 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -52,17 +52,6 @@ See the LICENSE file in the project root for more information. - - - Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - - When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. - - - Initializes the class with the provided application client id and Windows Account Manager (WAM) broker setting. - - The callback method to be used with 'Active Directory Device Code Flow' authentication. @@ -74,20 +63,6 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method and application client id. - - - The callback method to be used with 'Active Directory Device Code Flow' authentication. - - - Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - - When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. - - - Initializes the class with the provided device code flow callback method, application client id, and Windows Account Manager (WAM) broker setting. - - A instance whose properties initialize the provider. Must not be . diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 26593c715f..df774eb0ad 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -91,16 +91,6 @@ public ActiveDirectoryAuthenticationProvider(string applicationClientId) { } - /// - /// - /// New code should prefer the - /// overload to avoid adding more constructor overloads as new options are introduced. - /// - public ActiveDirectoryAuthenticationProvider(string applicationClientId, bool useWamBroker) - : this(new ProviderOptions { ApplicationClientId = applicationClientId, UseWamBroker = useWamBroker }) - { - } - /// /// /// New code should prefer the @@ -115,21 +105,6 @@ public ActiveDirectoryAuthenticationProvider(Func device { } - /// - /// - /// New code should prefer the - /// overload to avoid adding more constructor overloads as new options are introduced. - /// - public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId, bool useWamBroker) - : this(new ProviderOptions - { - DeviceCodeFlowCallback = deviceCodeFlowCallbackMethod, - ApplicationClientId = applicationClientId, - UseWamBroker = useWamBroker, - }) - { - } - /// public ActiveDirectoryAuthenticationProvider(ProviderOptions options) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 07eda2acc2..6efacaf302 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -105,10 +105,15 @@ public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() { string customAppId = Guid.NewGuid().ToString(); - var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: true); + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = customAppId, + UseWamBroker = true, + }); Assert.True(provider.UseWamBroker, - "Custom application id with useWamBroker=true must enable WAM broker."); + "Custom application id with UseWamBroker=true must enable WAM broker."); } /// A caller-supplied application id with explicit opt-out keeps WAM broker disabled. @@ -116,38 +121,52 @@ public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() { string customAppId = Guid.NewGuid().ToString(); - var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: false); + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = customAppId, + UseWamBroker = false, + }); Assert.False(provider.UseWamBroker); } /// /// Even when the SqlClient first-party application id is passed explicitly with - /// useWamBroker:false, WAM broker mode must remain enabled because the first-party + /// UseWamBroker=false, WAM broker mode must remain enabled because the first-party /// app id is hard-wired to the WAM broker redirect URI. This guards the OR-condition in /// the provider's constructor. /// [Fact] public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker() { - var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId, useWamBroker: false); + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = SqlClientApplicationId, + UseWamBroker = false, + }); Assert.True(provider.UseWamBroker, - "SqlClient first-party application id must always enable WAM broker, regardless of the useWamBroker argument."); + "SqlClient first-party application id must always enable WAM broker, regardless of the UseWamBroker option."); } /// - /// The three-arg constructor (deviceCodeCallback, applicationClientId, useWamBroker) must - /// honor the broker flag the same way the two-arg constructor does. + /// Passing a device-code callback together with a custom application id and + /// UseWamBroker=true via + /// must enable WAM broker mode. /// [Fact] public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() { string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( - deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask, - applicationClientId: customAppId, - useWamBroker: true); + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + DeviceCodeFlowCallback = static _ => Task.CompletedTask, + ApplicationClientId = customAppId, + UseWamBroker = true, + }); Assert.True(provider.UseWamBroker); } @@ -236,7 +255,12 @@ public void Ctor_Options_Null_ThrowsArgumentNullException() public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() { string customAppId = Guid.NewGuid().ToString(); - var provider = new ActiveDirectoryAuthenticationProvider(customAppId, useWamBroker: true); + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = customAppId, + UseWamBroker = true, + }); SqlAuthenticationProvider? original = SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryInteractive); From 8c8914122f01e8cdf14f39b8b3a0892173604b58 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 13:26:05 -0700 Subject: [PATCH 28/41] Address PR review: fix , expand WamBroker tests, README note - Close missing on SetParentActivityOrWindowFunc xml doc (Copilot reviewer #20) - Replace Guid.NewGuid() with TestCustomAppId const for deterministic tests (#21, #16, #28) - Add Ctor_AppClientId_SqlClientId_EnablesWamBroker covering single-string ctor with 1st-party id (#13) - Add Ctor_Options_NullAppId_AlwaysEnablesWamBroker theory (#33) - Use reflection to assert _parentActivityOrWindowFunc field state in Set tests (#29, #30) - README: note SetParentActivityOrWindowFunc works on net481 (#23) --- doc/apps/AzureSqlConnector/README.md | 6 + .../ActiveDirectoryAuthenticationProvider.xml | 1 + .../Azure/test/WamBrokerTests.cs | 134 ++++++++++++------ 3 files changed, 98 insertions(+), 43 deletions(-) diff --git a/doc/apps/AzureSqlConnector/README.md b/doc/apps/AzureSqlConnector/README.md index 1dabc1f30b..62f100e966 100644 --- a/doc/apps/AzureSqlConnector/README.md +++ b/doc/apps/AzureSqlConnector/README.md @@ -20,6 +20,12 @@ The sample multi-targets: binary only runs on Windows, so the project no longer needs a separate no-op cross-platform fallback. +> **Note:** `SetParentActivityOrWindowFunc` is also available on `net481` and is the +> recommended API for new code on any framework. The sample wires `net481` up to +> `SetIWin32WindowFunc` only to keep coverage of that legacy code path; replacing the +> `SetIWin32WindowFunc(() => this)` call with `SetParentActivityOrWindowFunc(() => this.Handle)` +> on `net481` works the same way. + ## Mode selector When the app launches it shows a small `ModeSelectorForm` that picks between two top-level forms: diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 048cced20b..3804a751d4 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -154,6 +154,7 @@ See the LICENSE file in the project root for more information. On Windows, return an window handle (or an on .NET Framework). On Android, return an Activity; on iOS, return a UIViewController (or the platform equivalent). Pass to clear a previously installed callback and revert to the provider's default behavior. + Sets a function to return the parent window handle to be used for WAM (Windows Account Manager) broker authentication prompts. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 6efacaf302..5f61baeff7 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -2,6 +2,8 @@ // 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; [Collection("SqlAuthenticationProvider")] @@ -10,40 +12,60 @@ public class WamBrokerTests // The SqlClient first-party application client id that is hard-coded in the provider. private const string SqlClientApplicationId = "2fd908ad-0664-4344-b9be-cd3e8b574c38"; + // A fixed, deterministic stand-in for a caller-supplied application id. Hard-coded (instead + // of Guid.NewGuid()) so test outcomes don't depend on RNG and so a single point asserts + // that this value differs from the SqlClient first-party id. + private const string TestCustomAppId = "11111111-2222-3333-4444-555555555555"; + /// - /// Defensive guard: every test that uses as a stand-in for a - /// caller-supplied application id assumes the GUID will never collide with the SqlClient - /// first-party id. This test makes that assumption explicit. + /// Defensive guard: is reused across the entire suite as a + /// stand-in for a caller-supplied application id. This single assertion makes the + /// "not the SqlClient first-party id" precondition explicit instead of relying on RNG. /// [Fact] - public void Ctor_CustomAppId_GuidIsNotSqlClientAppId() + public void TestCustomAppId_IsNotSqlClientAppId() { - for (int i = 0; i < 16; i++) - { - Assert.NotEqual(SqlClientApplicationId, Guid.NewGuid().ToString()); - } + Assert.NotEqual(SqlClientApplicationId, TestCustomAppId); + } + + // Reads the private _parentActivityOrWindowFunc field. Used to assert downstream effects + // of SetParentActivityOrWindowFunc without triggering a live MSAL flow. + private static Func? GetParentActivityOrWindowFunc(ActiveDirectoryAuthenticationProvider provider) + { + FieldInfo? field = typeof(ActiveDirectoryAuthenticationProvider).GetField( + "_parentActivityOrWindowFunc", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return (Func?)field!.GetValue(provider); } /// /// A callback is treated as "clear any previously installed callback" /// and must not throw. This is a deliberate API contract change from the original /// behavior so callers can opt out without recreating - /// the provider. + /// the provider. Asserts the underlying field is reset to so the + /// provider's downstream consumer (MSAL parameters builder) sees the cleared state. /// [Fact] public void SetParentActivityOrWindowFunc_Null_ClearsCallback() { var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + Func first = () => IntPtr.Zero; + provider.SetParentActivityOrWindowFunc(first); + Assert.Same(first, GetParentActivityOrWindowFunc(provider)); + provider.SetParentActivityOrWindowFunc(null); + Assert.Null(GetParentActivityOrWindowFunc(provider)); } - /// A non-null callback installs cleanly and does not throw. + /// A non-null callback installs cleanly, does not throw, and is the value the provider holds. [Fact] public void SetParentActivityOrWindowFunc_ValidFunc_DoesNotThrow() { var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + Func func = () => IntPtr.Zero; + provider.SetParentActivityOrWindowFunc(func); + Assert.Same(func, GetParentActivityOrWindowFunc(provider)); } /// Repeated calls must be supported (no internal locking guard rejects a second set). @@ -52,28 +74,40 @@ public void SetParentActivityOrWindowFunc_CanBeCalledMultipleTimes() { var provider = new ActiveDirectoryAuthenticationProvider(); provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); - provider.SetParentActivityOrWindowFunc(() => new IntPtr(12345)); + Func second = () => new IntPtr(12345); + provider.SetParentActivityOrWindowFunc(second); + Assert.Same(second, GetParentActivityOrWindowFunc(provider)); } /// - /// Last-write-wins: the most recently installed callback must be the one the provider holds. - /// We can't observe the field directly without reflection (the field is intentionally - /// private), but we can observe it transitively through whether a sentinel side-effect - /// fires when MSAL would invoke it. Since invoking MSAL requires a live token request, - /// we instead assert behavioral overwrite by installing two callbacks that record into - /// distinct flags and then ensuring only the second one is captured by the provider's - /// public surface: re-installing a no-throw callback after a throwing one must not - /// re-surface the throwing one. + /// Last-write-wins: the most recently installed callback is the one the provider exposes + /// downstream. Verified by reading the private backing field after a sequence of sets + /// (including a null clear in the middle). /// [Fact] public void SetParentActivityOrWindowFunc_LastSetWins() { var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindowFunc(() => throw new InvalidOperationException("first")); - provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); + + Func firstThrowing = () => throw new InvalidOperationException("first"); + Func secondZero = () => IntPtr.Zero; + Func finalSeven = () => new IntPtr(7); + + provider.SetParentActivityOrWindowFunc(firstThrowing); + Assert.Same(firstThrowing, GetParentActivityOrWindowFunc(provider)); + + provider.SetParentActivityOrWindowFunc(secondZero); + Assert.Same(secondZero, GetParentActivityOrWindowFunc(provider)); + // Re-installing a null clears it. provider.SetParentActivityOrWindowFunc(null); - provider.SetParentActivityOrWindowFunc(() => new IntPtr(7)); + Assert.Null(GetParentActivityOrWindowFunc(provider)); + + provider.SetParentActivityOrWindowFunc(finalSeven); + Assert.Same(finalSeven, GetParentActivityOrWindowFunc(provider)); + + // The earlier throwing callback must not resurface. + Assert.NotSame(firstThrowing, GetParentActivityOrWindowFunc(provider)); } /// @@ -93,22 +127,35 @@ public void Ctor_Default_EnablesWamBroker() [Fact] public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() { - string customAppId = Guid.NewGuid().ToString(); - var provider = new ActiveDirectoryAuthenticationProvider(customAppId); + var provider = new ActiveDirectoryAuthenticationProvider(TestCustomAppId); Assert.False(provider.UseWamBroker, "Custom application id without useWamBroker=true must keep WAM broker disabled."); } + /// + /// Passing the SqlClient first-party application id to the single-string constructor must + /// enable WAM broker. The first-party app id is hard-wired to the WAM broker redirect URI, + /// so callers that opt into it explicitly should get the same behavior as the parameterless + /// constructor. + /// + [Fact] + public void Ctor_AppClientId_SqlClientId_EnablesWamBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + + Assert.True(provider.UseWamBroker, + "Single-string ctor with the SqlClient first-party id must enable WAM broker."); + } + /// A caller-supplied application id with explicit opt-in must enable WAM broker. [Fact] public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( new ActiveDirectoryAuthenticationProvider.ProviderOptions { - ApplicationClientId = customAppId, + ApplicationClientId = TestCustomAppId, UseWamBroker = true, }); @@ -120,11 +167,10 @@ public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() [Fact] public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( new ActiveDirectoryAuthenticationProvider.ProviderOptions { - ApplicationClientId = customAppId, + ApplicationClientId = TestCustomAppId, UseWamBroker = false, }); @@ -159,12 +205,11 @@ public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker( [Fact] public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( new ActiveDirectoryAuthenticationProvider.ProviderOptions { DeviceCodeFlowCallback = static _ => Task.CompletedTask, - ApplicationClientId = customAppId, + ApplicationClientId = TestCustomAppId, UseWamBroker = true, }); @@ -178,10 +223,9 @@ public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() [Fact] public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFalse() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask, - applicationClientId: customAppId); + applicationClientId: TestCustomAppId); Assert.False(provider.UseWamBroker); } @@ -207,11 +251,10 @@ public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() [Fact] public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( new ActiveDirectoryAuthenticationProvider.ProviderOptions { - ApplicationClientId = customAppId, + ApplicationClientId = TestCustomAppId, UseWamBroker = true, }); @@ -219,14 +262,20 @@ public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() } /// - /// The Options-based constructor with no application id falls back to the SqlClient - /// first-party id and must always enable WAM broker, regardless of UseWamBroker. + /// Options with ApplicationClientId = null falls back to the SqlClient first-party + /// id, which always enables WAM broker, regardless of UseWamBroker. /// - [Fact] - public void Ctor_Options_NoAppId_AlwaysEnablesWamBroker() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions { UseWamBroker = false }); + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = null, + UseWamBroker = useWamBroker, + }); Assert.True(provider.UseWamBroker); } @@ -254,11 +303,10 @@ public void Ctor_Options_Null_ThrowsArgumentNullException() [Fact] public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() { - string customAppId = Guid.NewGuid().ToString(); var provider = new ActiveDirectoryAuthenticationProvider( new ActiveDirectoryAuthenticationProvider.ProviderOptions { - ApplicationClientId = customAppId, + ApplicationClientId = TestCustomAppId, UseWamBroker = true, }); From ff4f373f67420d8ed41f32cc31667958e1360f5b Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 14:02:23 -0700 Subject: [PATCH 29/41] More improvements + tests --- .../ActiveDirectoryAuthenticationProvider.xml | 33 +++---- ...DirectoryAuthenticationProvider.Windows.cs | 9 +- .../ActiveDirectoryAuthenticationProvider.cs | 7 ++ .../Azure/src/Azure.csproj | 16 +++ .../Azure/test/Azure.Test.csproj | 8 ++ .../Azure/test/WamBrokerTests.cs | 36 ++++++- .../SqlAuthenticationProviderManager.cs | 99 ++++++++++++++++--- 7 files changed, 168 insertions(+), 40 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 0e026e54dd..e6f2681e56 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -52,17 +52,6 @@ See the LICENSE file in the project root for more information. - - - Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - - When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. - - - Initializes the class with the provided application client id and Windows Account Manager (WAM) broker setting. - - The callback method to be used with 'Active Directory Device Code Flow' authentication. @@ -74,20 +63,20 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method and application client id. - - - The callback method to be used with 'Active Directory Device Code Flow' authentication. - - - Client Application Id to be used for acquiring an access token for federated authentication. The driver uses its own application client id by default. - - - When , enables the Windows Account Manager (WAM) broker for interactive Entra ID authentication on Windows when a custom application client id is used. When the driver's built-in application client id is used, the WAM broker is always enabled regardless of this value. + + + A instance whose properties initialize the provider. Must not be . - Initializes the class with the provided device code flow callback method, application client id, and Windows Account Manager (WAM) broker setting. + Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ProviderOptions without forcing breaking changes on callers. - + + When is left unset (or set to the driver's built-in application client id), the Windows Account Manager (WAM) broker is always enabled regardless of . For caller-supplied application client ids, WAM is opt-in via UseWamBroker. + + + Thrown when is . + + The Active Directory authentication parameters passed to authentication providers. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs index 105b8a1ecb..29ca3e9acb 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.Windows.cs @@ -79,8 +79,13 @@ private IntPtr GetParentWindow() #endif if (parentWindow is not null) { - throw new InvalidOperationException($"{nameof(SetParentActivityOrWindowFunc)} expects the callback to return an IntPtr window handle" + - " (or an IWin32Window on .NET Framework)." ); + throw new InvalidOperationException( + $"{nameof(SetParentActivityOrWindowFunc)} expects the callback to return an " + + "IntPtr window handle" + +#if NETFRAMEWORK + " (or an IWin32Window on .NET Framework)" + +#endif + $"; got {parentWindow.GetType().FullName}."); } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index df774eb0ad..db47100711 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -130,6 +130,13 @@ public ActiveDirectoryAuthenticationProvider(ProviderOptions options) /// internal bool UseWamBroker => _useWamBroker; + /// + /// The Entra ID application client id used by this provider instance. Exposed as internal for tests. + /// The client id is used in the redirect URI when WAM broker mode is enabled, so it must match the client id configured + /// in the app registration for the Entra ID application to successfully broker with WAM on Windows. + /// + internal string ApplicationClientId => _applicationClientId; + /// public static void ClearUserTokenCache() { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj index b9a3b82c27..3a4e6a7184 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj @@ -34,10 +34,26 @@ + + + + + + <_Parameter1>$(AssemblyName).Test, PublicKey=00240000048000001401000006020000002400005253413100080000010001003D19684676DA365F331D00CE7BD4B8EF03E74102F39A5681B40622703D68F0298ECACECC723D3FFC1EA9365AF4958578550EA1EBEEC084B0B3757F3762449F5365E872802A4B548056760764FAD062BFEE81ED26183109AD46810E7E6E965419D0A10473680144D20C1BFE1027A5F586CA987523C06F5C126C44EA7D4F51EB023867A9F294315F95775ACEFD2D678186919458DFCCB4DE2E9F53AEFC766C7CBCEC474ED21C1616E5A9414D366D91D121C39F5FE6641295ADC058EF3FB10593BCDE2E82D9F217C2634909EEF496CD53AE78ABBEA572B871D72EBFC5378205950ABA97C7CCC2B9635D96933D5F9C9624D71FF53EE2094CF3A6BD38534D66E414B7 + + + $(RepoRoot)artifacts/ diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj index 1c020c2ae4..28db991b29 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj @@ -25,6 +25,14 @@ enable + + + + true + $(TestSigningKeyPath) + + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 5f61baeff7..36a1341b80 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -119,6 +119,7 @@ public void Ctor_Default_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider(); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker, "Default ctor must enable WAM broker (uses SqlClient first-party application id)."); } @@ -129,10 +130,33 @@ public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() { var provider = new ActiveDirectoryAuthenticationProvider(TestCustomAppId); + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); Assert.False(provider.UseWamBroker, "Custom application id without useWamBroker=true must keep WAM broker disabled."); } + /// + /// 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 + /// Options ctor and only forwarding the properties that were explicitly configured. + /// + [Fact] + public void Ctor_Options_AppClientIdOnly_DefaultsUseWamBrokerToFalse() + { + var provider = new ActiveDirectoryAuthenticationProvider( + new ActiveDirectoryAuthenticationProvider.ProviderOptions + { + ApplicationClientId = TestCustomAppId, + // UseWamBroker intentionally left at its default (false). + }); + + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); + Assert.False(provider.UseWamBroker, + "Options ctor with ApplicationClientId set and UseWamBroker omitted must keep WAM broker disabled."); + } + /// /// Passing the SqlClient first-party application id to the single-string constructor must /// enable WAM broker. The first-party app id is hard-wired to the WAM broker redirect URI, @@ -144,6 +168,7 @@ public void Ctor_AppClientId_SqlClientId_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker, "Single-string ctor with the SqlClient first-party id must enable WAM broker."); } @@ -159,6 +184,7 @@ public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() UseWamBroker = true, }); + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker, "Custom application id with UseWamBroker=true must enable WAM broker."); } @@ -174,6 +200,7 @@ public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() UseWamBroker = false, }); + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); Assert.False(provider.UseWamBroker); } @@ -193,6 +220,7 @@ public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker( UseWamBroker = false, }); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker, "SqlClient first-party application id must always enable WAM broker, regardless of the UseWamBroker option."); } @@ -213,6 +241,7 @@ public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() UseWamBroker = true, }); + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker); } @@ -228,6 +257,7 @@ public void Ctor_WithDeviceCodeCallback_AppClientIdOnly_DefaultsUseWamBrokerToFa applicationClientId: TestCustomAppId); Assert.False(provider.UseWamBroker); + Assert.NotEqual(SqlClientApplicationId, provider.ApplicationClientId); } /// @@ -241,6 +271,7 @@ public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() deviceCodeFlowCallbackMethod: static _ => Task.CompletedTask); Assert.True(provider.UseWamBroker); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); } /// @@ -258,6 +289,7 @@ public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() UseWamBroker = true, }); + Assert.Equal(TestCustomAppId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker); } @@ -277,6 +309,7 @@ public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) UseWamBroker = useWamBroker, }); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker); } @@ -320,7 +353,8 @@ public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() as ActiveDirectoryAuthenticationProvider; Assert.NotNull(retrieved); Assert.Same(provider, retrieved); - Assert.True(retrieved!.UseWamBroker); + Assert.Equal(TestCustomAppId, retrieved!.ApplicationClientId); + Assert.True(retrieved.UseWamBroker); } finally { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 17d49c0fa3..1a8e71f43d 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -32,7 +32,7 @@ internal sealed class SqlAuthenticationProviderManager // The public key token of our Azure extension assembly, used to avoid loading imposter // assemblies. - private static readonly byte[] azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ]; + private static readonly byte[] s_azurePublicKeyToken = [ 0x23, 0xec, 0x7f, 0xc2, 0xd6, 0xea, 0xa4, 0xa5 ]; static SqlAuthenticationProviderManager() { @@ -70,10 +70,10 @@ static SqlAuthenticationProviderManager() nameof(SqlAuthenticationProviderManager) + $": Attempting to load Azure extension assembly={azureAssemblyName} with " + "expected public key token=" + - BitConverter.ToString(azurePublicKeyToken).Replace("-", "")); + BitConverter.ToString(s_azurePublicKeyToken).Replace("-", "")); var qualifiedName = new AssemblyName(azureAssemblyName); - qualifiedName.SetPublicKeyToken(azurePublicKeyToken); + qualifiedName.SetPublicKeyToken(s_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,7 +92,7 @@ static SqlAuthenticationProviderManager() { byte[]? actualToken = assembly.GetName().GetPublicKeyToken(); - if (actualToken is null || !actualToken.AsSpan().SequenceEqual(azurePublicKeyToken)) + if (actualToken is null || !actualToken.AsSpan().SequenceEqual(s_azurePublicKeyToken)) { SqlClientEventSource.Log.TryTraceEvent( nameof(SqlAuthenticationProviderManager) + @@ -144,30 +144,66 @@ static SqlAuthenticationProviderManager() return; } - // Try to instantiate it. When no application client id is configured we use - // the parameterless constructor (which defaults to the SqlClient first-party app - // id and enables WAM brokering on Windows). Otherwise, explicitly resolve the - // (string) constructor to avoid AmbiguousMatchException between the - // (string applicationClientId) and (ProviderOptions options) overloads when the - // single argument is null. + // Try to instantiate it. When neither an application client id nor a + // useWamBroker flag has been configured we use the parameterless constructor + // (which defaults to the SqlClient first-party app id and enables WAM brokering + // on Windows). Otherwise we resolve the (ProviderOptions) constructor and pass + // through only the properties the app actually configured: either may be + // supplied without the other (e.g. applicationClientId set with no + // useWamBroker, or useWamBroker set with no applicationClientId), and any + // omitted property keeps the ProviderOptions default. SqlAuthenticationProvider? instance; - if (Instance._applicationClientId is null) + if (Instance._applicationClientId is null && Instance._useWamBroker is null) { instance = Activator.CreateInstance(type) as SqlAuthenticationProvider; } else { - var ctor = type.GetConstructor(new[] { typeof(string) }); + const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider+ProviderOptions"; + var optionsType = assembly.GetType(optionsTypeName); + if (optionsType is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderManager) + + $": Azure extension class={className} is missing the nested " + + $"options type={optionsTypeName}; no default Active Directory " + + "provider installed"); + return; + } + + var ctor = type.GetConstructor([optionsType]); if (ctor is null) { SqlClientEventSource.Log.TryTraceEvent( nameof(SqlAuthenticationProviderManager) + - $": Azure extension class={className} is missing the (string) " + - "constructor; no default Active Directory provider installed"); + $": Azure extension class={className} is missing the " + + "(ProviderOptions) constructor; no default Active Directory " + + "provider installed"); return; } - instance = ctor.Invoke(new object[] { Instance._applicationClientId }) as SqlAuthenticationProvider; + var options = Activator.CreateInstance(optionsType); + if (options is null) + { + SqlClientEventSource.Log.TryTraceEvent( + nameof(SqlAuthenticationProviderManager) + + $": Failed to instantiate options type={optionsTypeName}; " + + "no default Active Directory provider installed"); + return; + } + + if (Instance._applicationClientId is not null) + { + optionsType.GetProperty("ApplicationClientId") + ?.SetValue(options, Instance._applicationClientId); + } + if (Instance._useWamBroker is bool useWamBroker) + { + optionsType.GetProperty("UseWamBroker") + ?.SetValue(options, useWamBroker); + } + + instance = ctor.Invoke([options]) as SqlAuthenticationProvider; } if (instance is null) @@ -238,6 +274,13 @@ TypeInitializationException or private readonly SqlClientLogger _sqlAuthLogger = new SqlClientLogger(); private readonly string? _applicationClientId = null; + // Optional override for ActiveDirectoryAuthenticationProvider.ProviderOptions.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. /// @@ -261,6 +304,23 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS _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)) { @@ -485,6 +545,15 @@ internal class SqlAuthenticationProviderConfigurationSection : ConfigurationSect /// [ConfigurationProperty("applicationClientId", IsRequired = false)] public string ApplicationClientId => this["applicationClientId"] as string ?? string.Empty; + + /// + /// Forwarded to ActiveDirectoryAuthenticationProvider.ProviderOptions.UseWamBroker + /// when the Azure extension's default provider is auto-installed. Stored as a string so + /// that an unset attribute can be distinguished from useWamBroker="false"; the + /// runtime parses it with . + /// + [ConfigurationProperty("useWamBroker", IsRequired = false)] + public string UseWamBroker => this["useWamBroker"] as string ?? string.Empty; } /// From da1929d3eaab5acd4c04f6696a1f7b6b3d28596d Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 14:47:36 -0700 Subject: [PATCH 30/41] Last Updates --- Directory.Packages.props | 2 +- .../ActiveDirectoryAuthenticationProvider.xml | 3 + .../ActiveDirectoryAuthenticationProvider.xml | 3 + .../ActiveDirectoryAuthenticationProvider.cs | 12 ++-- .../Azure/test/WamBrokerTests.cs | 65 ++----------------- 5 files changed, 22 insertions(+), 63 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2a6aa8363d..25118209a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -102,7 +102,7 @@ - + diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index e6f2681e56..72f371a393 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -72,6 +72,9 @@ See the LICENSE file in the project root for more information. When is left unset (or set to the driver's built-in application client id), the Windows Account Manager (WAM) broker is always enabled regardless of . For caller-supplied application client ids, WAM is opt-in via UseWamBroker. + + The WAM broker is a Windows-only feature. UseWamBroker has no effect on non-Windows platforms; interactive Entra ID flows there always fall back to the system browser. + Thrown when is . diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 3804a751d4..9535feecaf 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -80,6 +80,9 @@ See the LICENSE file in the project root for more information. Prefer this options-bag constructor over the positional-argument overloads in new code. Additional options can be added here in future versions without introducing new constructor overloads. + + is a Windows-only setting: it has no effect on non-Windows platforms, where interactive Entra ID flows always use the system browser. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index db47100711..03f8a62229 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -961,17 +961,21 @@ public sealed class ProviderOptions /// /// Optional Entra ID application (client) id. When , the SqlClient /// first-party application id is used and WAM broker mode is forced on (regardless of - /// ). + /// ) when running on Windows. /// public string? ApplicationClientId { get; set; } /// /// When , enables the Windows Account Manager (WAM) broker for - /// interactive Entra ID flows on Windows when a caller-supplied - /// is used. Ignored (treated as ) - /// when is because the SqlClient + /// interactive Entra ID flows when a caller-supplied + /// is used. Ignored (treated as ) when + /// is because the SqlClient /// first-party app id always uses the broker. /// + /// + /// The WAM broker is a Windows-only feature. On non-Windows platforms this property has + /// no effect and interactive Entra ID flows always fall back to the system browser. + /// public bool UseWamBroker { get; set; } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 36a1341b80..9d204d7ffd 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -17,17 +17,6 @@ public class WamBrokerTests // that this value differs from the SqlClient first-party id. private const string TestCustomAppId = "11111111-2222-3333-4444-555555555555"; - /// - /// Defensive guard: is reused across the entire suite as a - /// stand-in for a caller-supplied application id. This single assertion makes the - /// "not the SqlClient first-party id" precondition explicit instead of relying on RNG. - /// - [Fact] - public void TestCustomAppId_IsNotSqlClientAppId() - { - Assert.NotEqual(SqlClientApplicationId, TestCustomAppId); - } - // Reads the private _parentActivityOrWindowFunc field. Used to assert downstream effects // of SetParentActivityOrWindowFunc without triggering a live MSAL flow. private static Func? GetParentActivityOrWindowFunc(ActiveDirectoryAuthenticationProvider provider) @@ -58,56 +47,17 @@ public void SetParentActivityOrWindowFunc_Null_ClearsCallback() Assert.Null(GetParentActivityOrWindowFunc(provider)); } - /// A non-null callback installs cleanly, does not throw, and is the value the provider holds. - [Fact] - public void SetParentActivityOrWindowFunc_ValidFunc_DoesNotThrow() - { - var provider = new ActiveDirectoryAuthenticationProvider(); - Func func = () => IntPtr.Zero; - provider.SetParentActivityOrWindowFunc(func); - Assert.Same(func, GetParentActivityOrWindowFunc(provider)); - } - - /// Repeated calls must be supported (no internal locking guard rejects a second set). - [Fact] - public void SetParentActivityOrWindowFunc_CanBeCalledMultipleTimes() - { - var provider = new ActiveDirectoryAuthenticationProvider(); - provider.SetParentActivityOrWindowFunc(() => IntPtr.Zero); - Func second = () => new IntPtr(12345); - provider.SetParentActivityOrWindowFunc(second); - Assert.Same(second, GetParentActivityOrWindowFunc(provider)); - } - /// - /// Last-write-wins: the most recently installed callback is the one the provider exposes - /// downstream. Verified by reading the private backing field after a sequence of sets - /// (including a null clear in the middle). + /// The constructor uses the SqlClient first-party application id, which always + /// enables WAM broker mode regardless of any opt-in flag. /// [Fact] - public void SetParentActivityOrWindowFunc_LastSetWins() + public void Ctor_ApplicationClientId_EnablesWamBroker() { - var provider = new ActiveDirectoryAuthenticationProvider(); - - Func firstThrowing = () => throw new InvalidOperationException("first"); - Func secondZero = () => IntPtr.Zero; - Func finalSeven = () => new IntPtr(7); - - provider.SetParentActivityOrWindowFunc(firstThrowing); - Assert.Same(firstThrowing, GetParentActivityOrWindowFunc(provider)); - - provider.SetParentActivityOrWindowFunc(secondZero); - Assert.Same(secondZero, GetParentActivityOrWindowFunc(provider)); - - // Re-installing a null clears it. - provider.SetParentActivityOrWindowFunc(null); - Assert.Null(GetParentActivityOrWindowFunc(provider)); - - provider.SetParentActivityOrWindowFunc(finalSeven); - Assert.Same(finalSeven, GetParentActivityOrWindowFunc(provider)); - - // The earlier throwing callback must not resurface. - Assert.NotSame(firstThrowing, GetParentActivityOrWindowFunc(provider)); + var provider = new ActiveDirectoryAuthenticationProvider(SqlClientApplicationId); + Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); + Assert.True(provider.UseWamBroker, + "Constructor with SqlClient first-party application id must enable WAM broker."); } /// @@ -118,7 +68,6 @@ public void SetParentActivityOrWindowFunc_LastSetWins() public void Ctor_Default_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider(); - Assert.Equal(SqlClientApplicationId, provider.ApplicationClientId); Assert.True(provider.UseWamBroker, "Default ctor must enable WAM broker (uses SqlClient first-party application id)."); From 681b64fa5a8e5e26f10860c38c43c598a0b6dfab Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Wed, 17 Jun 2026 15:14:44 -0700 Subject: [PATCH 31/41] Update MSAL as well --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 25118209a1..8f18c8f992 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -70,7 +70,7 @@ - + From e628e2b5e0e37178fad092763553fe6d7356d80a Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:15:37 -0700 Subject: [PATCH 32/41] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Azure/src/ActiveDirectoryAuthenticationProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 03f8a62229..5d97a2857e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -659,7 +659,7 @@ private static async Task AcquireTokenInteractiveDeviceFlo // Use a dedicated CTS with the typical AAD user-code lifetime as the cap, // mirroring the pattern used for ActiveDirectoryInteractive above. using CancellationTokenSource ctsDeviceFlow = new(); - ctsDeviceFlow.CancelAfter(900000); // 15 minutes + ctsDeviceFlow.CancelAfter(TimeSpan.FromMinutes(15)); // 15 minutes return await app.AcquireTokenWithDeviceCode(scopes, deviceCodeResult => deviceCodeFlowCallback(deviceCodeResult)) From 7eac4993d3381fe482d8bf21e0b6572d9a580959 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 10:11:39 -0700 Subject: [PATCH 33/41] Removed unwanted changes --- .../doc/SqlAuthenticationProvider.xml | 13 --- .../src/SqlAuthenticationProvider.Internal.cs | 102 +----------------- .../ActiveDirectoryAuthenticationProvider.xml | 13 --- .../Azure/src/Azure.csproj | 16 --- .../Azure/test/Azure.Test.csproj | 8 -- .../ConnectionPool/DbConnectionPoolGroup.cs | 14 --- .../Data/SqlClient/SqlConnectionFactory.cs | 16 --- 7 files changed, 2 insertions(+), 180 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml index 7bc5c02e81..7848aaec1a 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/doc/SqlAuthenticationProvider.xml @@ -139,18 +139,5 @@ See the LICENSE file in the project root for more information. if the operation succeeded; otherwise, (for example, the existing provider disallows overriding). - - - Clears the driver's in-memory cache of federated-authentication tokens across every active connection pool. - - - - Pools and their pooled physical connections are not torn down. Only the cached fed-auth tokens are evicted, so the next connection that needs a fed-auth token will reacquire it from its registered . - - - This is invoked by extension token-cache-clear APIs (for example ActiveDirectoryAuthenticationProvider.ClearUserTokenCache) so the driver's cache stays in sync with the upstream MSAL/credential cache. The call is a no-op if Microsoft.Data.SqlClient is not loaded. - - - diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs index 129f9f1a0b..5007384465 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.Internal.cs @@ -33,11 +33,6 @@ private static class Internal /// private static readonly MethodInfo? _setProvider = null; - /// - /// Our handle to the reflected ClearFederatedAuthenticationInformationCache() method. - /// - private static readonly MethodInfo? _clearFedAuthCache = null; - /// /// Static construction performs the reflection lookups. /// @@ -59,22 +54,8 @@ static Internal() return; } - // Defense-in-depth: only reflect into MDS if it carries the same strong-name - // public key as this Extensions assembly. This is not a substitute for - // Authenticode verification (which would require WinVerifyTrust and is - // Windows-only) — it only catches an MDS built by a different publisher - // dropped on the load path. On .NET Framework Assembly.Load already enforces - // strong-name matching; on .NET (Core+) it does not, which is why we check - // explicitly here. Throws on mismatch so that consumers see a hard failure - // (surfaced as TypeInitializationException on first GetProvider/SetProvider - // call) instead of silently falling back to a no-op provider table. - if (!IsSiblingAssembly(assembly)) - { - throw new InvalidOperationException( - $"MDS assembly={assemblyName} is loaded but is not signed with the " + - "same strong-name key as Microsoft.Data.SqlClient.Extensions.Abstractions. " + - "Refusing to reflect into a foreign-signed MDS for security reasons."); - } + // TODO(https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/39845): + // Verify the assembly is signed by us? // Look for the manager class. const string className = "Microsoft.Data.SqlClient.SqlAuthenticationProviderManager"; @@ -107,16 +88,6 @@ static Internal() Log($"MDS SetProvider() method not found; " + "SetProvider() will not function"); } - - _clearFedAuthCache = manager.GetMethod( - "ClearFederatedAuthenticationInformationCache", - BindingFlags.NonPublic | BindingFlags.Static); - - if (_clearFedAuthCache is null) - { - Log($"MDS ClearFederatedAuthenticationInformationCache() method not found; " + - "ClearFederatedAuthenticationInformationCache() will not function"); - } } // All of these exceptions mean we couldn't find the get/set // methods. @@ -132,42 +103,6 @@ or FileLoadException // Any other exceptions are fatal. } - /// - /// Returns when it is safe to reflect into . - /// Policy: if this Extensions assembly is strong-name signed, the loaded MDS must carry - /// the same public-key token; if Extensions itself is unsigned (e.g. local developer - /// builds), no token comparison is possible, so we permit it. - /// - private static bool IsSiblingAssembly(Assembly assembly) - { - byte[]? expected = typeof(SqlAuthenticationProvider) - .Assembly.GetName().GetPublicKeyToken(); - - // Extensions itself isn't strong-name signed (local dev build) — no token to - // compare against, so we can't make a meaningful authenticity claim either way. - if (expected is null || expected.Length == 0) - { - return true; - } - - byte[]? actual = assembly.GetName().GetPublicKeyToken(); - - if (actual is null || actual.Length != expected.Length) - { - return false; - } - - for (int i = 0; i < expected.Length; i++) - { - if (expected[i] != actual[i]) - { - return false; - } - } - - return true; - } - /// /// Call the reflected GetProvider method. /// @@ -255,39 +190,6 @@ or NotSupportedException } } - /// - /// Call the reflected ClearFederatedAuthenticationInformationCache method to - /// evict any fed-auth tokens the driver has cached across its connection pools. - /// - /// - /// True if the reflected call ran successfully, false if reflection wasn't - /// available or the invocation threw a recognized exception. - /// - internal static bool ClearFederatedAuthenticationInformationCache() - { - if (_clearFedAuthCache is null) - { - return false; - } - - try - { - _clearFedAuthCache.Invoke(null, null); - return true; - } - catch (Exception ex) - when (ex is InvalidOperationException - or MemberAccessException - or MethodAccessException - or NotSupportedException - or TargetInvocationException) - { - Log($"ClearFederatedAuthenticationInformationCache() 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/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 9535feecaf..ef6b62d7b3 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -96,19 +96,6 @@ See the LICENSE file in the project root for more information. Represents an asynchronous operation that returns the authentication token. - - - Clears cached user tokens from the token provider. - - - - This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. - - - The driver's per-pool federated-authentication token cache is also cleared, so subsequent SqlConnection.Open calls will reacquire fed-auth tokens instead of reusing cached entries. - - - The callback method to be used with 'Active Directory Device Code Flow' authentication. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj index 3a4e6a7184..b9a3b82c27 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/Azure.csproj @@ -34,26 +34,10 @@ - - - - - - <_Parameter1>$(AssemblyName).Test, PublicKey=00240000048000001401000006020000002400005253413100080000010001003D19684676DA365F331D00CE7BD4B8EF03E74102F39A5681B40622703D68F0298ECACECC723D3FFC1EA9365AF4958578550EA1EBEEC084B0B3757F3762449F5365E872802A4B548056760764FAD062BFEE81ED26183109AD46810E7E6E965419D0A10473680144D20C1BFE1027A5F586CA987523C06F5C126C44EA7D4F51EB023867A9F294315F95775ACEFD2D678186919458DFCCB4DE2E9F53AEFC766C7CBCEC474ED21C1616E5A9414D366D91D121C39F5FE6641295ADC058EF3FB10593BCDE2E82D9F217C2634909EEF496CD53AE78ABBEA572B871D72EBFC5378205950ABA97C7CCC2B9635D96933D5F9C9624D71FF53EE2094CF3A6BD38534D66E414B7 - - - $(RepoRoot)artifacts/ diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj index 28db991b29..1c020c2ae4 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Azure.Test.csproj @@ -25,14 +25,6 @@ enable - - - - true - $(TestSigningKeyPath) - - diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs index affb759610..8f62787e30 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs @@ -128,20 +128,6 @@ internal int Clear() return _poolCollection.Count; } - /// - /// Clears the cached federated-authentication contexts on every pool in this group - /// without disposing the pools or their pooled connections. Used to force the driver - /// to re-acquire fed-auth tokens (e.g. when the caller has cleared the upstream MSAL - /// token cache). - /// - internal void ClearAuthenticationContexts() - { - foreach (IDbConnectionPool pool in _poolCollection.Values) - { - pool?.AuthenticationContexts.Clear(); - } - } - internal IDbConnectionPool GetConnectionPool(SqlConnectionFactory connectionFactory) { // When this method returns null it indicates that the connection diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index 537b4a4f13..4cdee8bdc2 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -87,22 +87,6 @@ internal void ClearAllPools() group?.Clear(); } } - - /// - /// Clears every pool's cached federated-authentication contexts without disposing - /// the pools or their pooled connections. Pooled connections remain reusable; only - /// the cached fed-auth tokens are evicted, so the next connection that needs a - /// fed-auth token will reacquire it. Invoked when an extension's token-cache-clear - /// API is called so the driver's in-memory cache stays in sync. - /// - internal void ClearAllAuthenticationContexts() - { - using SqlClientEventScope scope = SqlClientEventScope.Create(nameof(SqlConnectionFactory)); - foreach (DbConnectionPoolGroup group in _connectionPoolGroups.Values) - { - group?.ClearAuthenticationContexts(); - } - } internal void ClearPool(DbConnection connection) { From 744af8d4f992db1426b74686c616a82d92a89053 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 10:13:25 -0700 Subject: [PATCH 34/41] Missing doc --- .../doc/ActiveDirectoryAuthenticationProvider.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index ef6b62d7b3..9535feecaf 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -96,6 +96,19 @@ See the LICENSE file in the project root for more information. Represents an asynchronous operation that returns the authentication token. + + + Clears cached user tokens from the token provider. + + + + This will cause interactive authentication prompts to appear again if tokens were previously being obtained from the cache. + + + The driver's per-pool federated-authentication token cache is also cleared, so subsequent SqlConnection.Open calls will reacquire fed-auth tokens instead of reusing cached entries. + + + The callback method to be used with 'Active Directory Device Code Flow' authentication. From c003f4a6dd8ff060ccc18b795f7774a91b5ce720 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 10:28:44 -0700 Subject: [PATCH 35/41] Final changes --- .../src/SqlAuthenticationProvider.cs | 6 ------ .../ActiveDirectoryAuthenticationProvider.xml | 12 +++++++++++ .../ActiveDirectoryAuthenticationProvider.cs | 21 ++----------------- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs index 25bb9de006..86c045efc0 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Abstractions/src/SqlAuthenticationProvider.cs @@ -42,10 +42,4 @@ public static bool SetProvider( { return Internal.SetProvider(authenticationMethod, provider); } - - /// - public static void ClearFederatedAuthenticationInformationCache() - { - Internal.ClearFederatedAuthenticationInformationCache(); - } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 9535feecaf..2bdd1f3a2e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -14,6 +14,10 @@ See the LICENSE file in the project root for more information. Initializes the class. + + New code should prefer the + overload to avoid adding more constructor overloads as new options are introduced. + @@ -51,6 +55,10 @@ See the LICENSE file in the project root for more information. } + + New code should prefer the + overload to avoid adding more constructor overloads as new options are introduced. + @@ -62,6 +70,10 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method and application client id. + + New code should prefer the + overload to avoid adding more constructor overloads as new options are introduced. + diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 5d97a2857e..1676eb7e9f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -72,30 +72,18 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic private const int MsalRetryStatusCode = 429; /// - /// - /// New code should prefer the - /// overload to avoid adding more constructor overloads as new options are introduced. - /// public ActiveDirectoryAuthenticationProvider() : this(new ProviderOptions()) { } /// - /// - /// New code should prefer the - /// overload to avoid adding more constructor overloads as new options are introduced. - /// public ActiveDirectoryAuthenticationProvider(string applicationClientId) : this(new ProviderOptions { ApplicationClientId = applicationClientId }) { } /// - /// - /// New code should prefer the - /// overload to avoid adding more constructor overloads as new options are introduced. - /// public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId = null) : this(new ProviderOptions { @@ -149,11 +137,6 @@ public static void ClearUserTokenCache() { s_tokenCredentialMap.Clear(); } - - // Also evict any fed-auth tokens the driver has cached per pool, so subsequent - // SqlConnection.Open calls reacquire tokens from MSAL/Azure.Identity instead of - // reusing entries that are now considered stale by the caller. - SqlAuthenticationProvider.ClearFederatedAuthenticationInformationCache(); } /// @@ -351,7 +334,7 @@ public override async Task AcquireTokenAsync(SqlAuthenti // as obsolete in MSAL.NET but it is still a supported way // to acquire tokens for Active Directory Integrated // authentication. - var builder = + AcquireTokenByIntegratedWindowsAuthParameterBuilder builder = #pragma warning disable CS0618 // Type or member is obsolete app.AcquireTokenByIntegratedWindowsAuth(scopes) #pragma warning restore CS0618 // Type or member is obsolete @@ -659,7 +642,7 @@ private static async Task AcquireTokenInteractiveDeviceFlo // Use a dedicated CTS with the typical AAD user-code lifetime as the cap, // mirroring the pattern used for ActiveDirectoryInteractive above. using CancellationTokenSource ctsDeviceFlow = new(); - ctsDeviceFlow.CancelAfter(TimeSpan.FromMinutes(15)); // 15 minutes + ctsDeviceFlow.CancelAfter(TimeSpan.FromMinutes(3)); // 3 minutes return await app.AcquireTokenWithDeviceCode(scopes, deviceCodeResult => deviceCodeFlowCallback(deviceCodeResult)) From 8a72de17c10755de1bc3f4c72e0aa60df1b1afeb Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 10:56:15 -0700 Subject: [PATCH 36/41] More updates, rename options type --- .../ActiveDirectoryAuthenticationProvider.xml | 17 ------- .../ActiveDirectoryAuthenticationProvider.xml | 21 +++------ ...DirectoryAuthenticationProviderOptions.xml | 35 +++++++++++++++ .../ActiveDirectoryAuthenticationProvider.cs | 44 +++---------------- ...eDirectoryAuthenticationProviderOptions.cs | 21 +++++++++ .../Azure/test/WamBrokerTests.cs | 20 ++++----- .../SqlAuthenticationProviderManager.cs | 30 +++---------- 7 files changed, 84 insertions(+), 104 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProviderOptions.cs diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml index 72f371a393..4dd9f61d5a 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml @@ -63,23 +63,6 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method and application client id. - - - A instance whose properties initialize the provider. Must not be . - - - Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ProviderOptions without forcing breaking changes on callers. - - - When is left unset (or set to the driver's built-in application client id), the Windows Account Manager (WAM) broker is always enabled regardless of . For caller-supplied application client ids, WAM is opt-in via UseWamBroker. - - The WAM broker is a Windows-only feature. UseWamBroker has no effect on non-Windows platforms; interactive Entra ID flows there always fall back to the system browser. - - - - Thrown when is . - - The Active Directory authentication parameters passed to authentication providers. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml index 2bdd1f3a2e..2b58a8676f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProvider.xml @@ -15,7 +15,7 @@ See the LICENSE file in the project root for more information. Initializes the class. - New code should prefer the + New code should prefer the overload to avoid adding more constructor overloads as new options are introduced. @@ -56,7 +56,7 @@ See the LICENSE file in the project root for more information. - New code should prefer the + New code should prefer the overload to avoid adding more constructor overloads as new options are introduced. @@ -71,32 +71,21 @@ See the LICENSE file in the project root for more information. Initializes the class with the provided device code flow callback method and application client id. - New code should prefer the + New code should prefer the overload to avoid adding more constructor overloads as new options are introduced. - A instance whose properties initialize the provider. Must not be . + A instance whose properties initialize the provider. Must not be . - Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ProviderOptions without forcing breaking changes on callers. + Initializes the class from a bag. This is the recommended constructor for new code because new options can be added to ActiveDirectoryAuthenticationProviderOptions without forcing breaking changes on callers. Thrown when is . - - - Property bag used by the constructor. - - - Prefer this options-bag constructor over the positional-argument overloads in new code. Additional options can be added here in future versions without introducing new constructor overloads. - - is a Windows-only setting: it has no effect on non-Windows platforms, where interactive Entra ID flows always use the system browser. - - - The Active Directory authentication parameters passed to authentication providers. diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml new file mode 100644 index 0000000000..e3794cd4dc --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml @@ -0,0 +1,35 @@ + + + + + Property bag used by the constructor. + + + Prefer this options-bag constructor over the positional-argument overloads in new code. Additional options can be added here in future versions without introducing new constructor overloads. + + is a Windows-only setting: it has no effect on non-Windows platforms, where interactive Entra ID flows always use the system browser. + + + + + + Optional device-code-flow callback invoked for SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow. + When , the provider's default callback (which writes the device-code instructions to the console) + is used. + + + + + Optional Entra ID application (client) id. When , the SqlClient first-party application id is used and WAM broker mode is forced on (regardless of ) when running on Windows. + + + + + Windows-only setting to control whether the provider should use the WAM broker for interactive authentication. + When , interactive flows will use WAM; when , they won't. + The default value is , but if is not null + and the provider is running on Windows, WAM broker mode will be forced on regardless of this setting. + + + + \ No newline at end of file diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 1676eb7e9f..7a39bb808f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -73,19 +73,19 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic /// public ActiveDirectoryAuthenticationProvider() - : this(new ProviderOptions()) + : this(new ActiveDirectoryAuthenticationProviderOptions()) { } /// public ActiveDirectoryAuthenticationProvider(string applicationClientId) - : this(new ProviderOptions { ApplicationClientId = applicationClientId }) + : this(new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = applicationClientId }) { } /// public ActiveDirectoryAuthenticationProvider(Func deviceCodeFlowCallbackMethod, string? applicationClientId = null) - : this(new ProviderOptions + : this(new ActiveDirectoryAuthenticationProviderOptions { DeviceCodeFlowCallback = deviceCodeFlowCallbackMethod, ApplicationClientId = applicationClientId, @@ -94,7 +94,7 @@ public ActiveDirectoryAuthenticationProvider(Func device } /// - public ActiveDirectoryAuthenticationProvider(ProviderOptions options) + public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProviderOptions options) { if (options is null) { @@ -930,38 +930,6 @@ private static TokenCredentialData CreateTokenCredentialInstance(TokenCredential throw new ArgumentException(nameof(ActiveDirectoryAuthenticationProvider)); } - /// - public sealed class ProviderOptions - { - /// - /// Optional device-code-flow callback invoked for - /// SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow. When , - /// the provider's default callback (which writes the device-code instructions to the - /// console) is used. - /// - public Func? DeviceCodeFlowCallback { get; set; } - - /// - /// Optional Entra ID application (client) id. When , the SqlClient - /// first-party application id is used and WAM broker mode is forced on (regardless of - /// ) when running on Windows. - /// - public string? ApplicationClientId { get; set; } - - /// - /// When , enables the Windows Account Manager (WAM) broker for - /// interactive Entra ID flows when a caller-supplied - /// is used. Ignored (treated as ) when - /// is because the SqlClient - /// first-party app id always uses the broker. - /// - /// - /// The WAM broker is a Windows-only feature. On non-Windows platforms this property has - /// no effect and interactive Entra ID flows always fall back to the system browser. - /// - public bool UseWamBroker { get; set; } - } - internal class PublicClientAppKey { public string Authority { get; } @@ -992,13 +960,13 @@ public override bool Equals(object? obj) { if (obj != null && obj is PublicClientAppKey pcaKey) { - return (string.CompareOrdinal(Authority, pcaKey.Authority) == 0 + return string.CompareOrdinal(Authority, pcaKey.Authority) == 0 && string.CompareOrdinal(RedirectUri, pcaKey.RedirectUri) == 0 && string.CompareOrdinal(ApplicationClientId, pcaKey.ApplicationClientId) == 0 #if NETFRAMEWORK && IWin32WindowFunc == pcaKey.IWin32WindowFunc #endif - ); + ; } return false; } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProviderOptions.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProviderOptions.cs new file mode 100644 index 0000000000..1573eb7f55 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProviderOptions.cs @@ -0,0 +1,21 @@ +// 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 Microsoft.Identity.Client; + +namespace Microsoft.Data.SqlClient +{ + /// + public sealed class ActiveDirectoryAuthenticationProviderOptions + { + /// + public Func? DeviceCodeFlowCallback { get; set; } + + /// + public string? ApplicationClientId { get; set; } + + /// + public bool UseWamBroker { get; set; } + } +} diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs index 9d204d7ffd..9b2aa5dcf7 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/WamBrokerTests.cs @@ -85,7 +85,7 @@ public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() } /// - /// Mirrors the previous test for the + /// 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 @@ -95,7 +95,7 @@ public void Ctor_AppClientId_DefaultsUseWamBrokerToFalse() public void Ctor_Options_AppClientIdOnly_DefaultsUseWamBrokerToFalse() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = TestCustomAppId, // UseWamBroker intentionally left at its default (false). @@ -127,7 +127,7 @@ public void Ctor_AppClientId_SqlClientId_EnablesWamBroker() public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = TestCustomAppId, UseWamBroker = true, @@ -143,7 +143,7 @@ public void Ctor_AppClientId_UseWamBrokerTrue_EnablesWamBroker() public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = TestCustomAppId, UseWamBroker = false, @@ -163,7 +163,7 @@ public void Ctor_AppClientId_UseWamBrokerFalse_DisablesWamBroker() public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = SqlClientApplicationId, UseWamBroker = false, @@ -183,7 +183,7 @@ public void Ctor_SqlClientAppIdExplicit_UseWamBrokerFalse_StillEnablesWamBroker( public void Ctor_WithDeviceCodeCallback_UseWamBrokerTrue_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { DeviceCodeFlowCallback = static _ => Task.CompletedTask, ApplicationClientId = TestCustomAppId, @@ -232,7 +232,7 @@ public void Ctor_WithDeviceCodeCallback_NoAppClientId_EnablesWamBroker() public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = TestCustomAppId, UseWamBroker = true, @@ -252,7 +252,7 @@ public void Ctor_Options_CustomAppId_UseWamBrokerTrue_EnablesWamBroker() public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = null, UseWamBroker = useWamBroker, @@ -270,7 +270,7 @@ public void Ctor_Options_NullAppId_AlwaysEnablesWamBroker(bool useWamBroker) public void Ctor_Options_Null_ThrowsArgumentNullException() { Assert.Throws( - () => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProvider.ProviderOptions)null!)); + () => new ActiveDirectoryAuthenticationProvider((ActiveDirectoryAuthenticationProviderOptions)null!)); } /// @@ -286,7 +286,7 @@ public void Ctor_Options_Null_ThrowsArgumentNullException() public void Ctor_RegisteredAsProvider_PreservesUseWamBrokerSetting() { var provider = new ActiveDirectoryAuthenticationProvider( - new ActiveDirectoryAuthenticationProvider.ProviderOptions + new ActiveDirectoryAuthenticationProviderOptions { ApplicationClientId = TestCustomAppId, UseWamBroker = true, diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 1a8e71f43d..6e33c624dc 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -147,11 +147,11 @@ static SqlAuthenticationProviderManager() // Try to instantiate it. When neither an application client id nor a // useWamBroker flag has been configured we use the parameterless constructor // (which defaults to the SqlClient first-party app id and enables WAM brokering - // on Windows). Otherwise we resolve the (ProviderOptions) constructor and pass - // through only the properties the app actually configured: either may be + // on Windows). Otherwise we resolve the (ActiveDirectoryAuthenticationProviderOptions) + // constructor and pass through only the properties the app actually configured: either may be // supplied without the other (e.g. applicationClientId set with no // useWamBroker, or useWamBroker set with no applicationClientId), and any - // omitted property keeps the ProviderOptions default. + // omitted property keeps the ActiveDirectoryAuthenticationProviderOptions default. SqlAuthenticationProvider? instance; if (Instance._applicationClientId is null && Instance._useWamBroker is null) { @@ -159,7 +159,7 @@ static SqlAuthenticationProviderManager() } else { - const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider+ProviderOptions"; + const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; var optionsType = assembly.GetType(optionsTypeName); if (optionsType is null) { @@ -274,7 +274,7 @@ TypeInitializationException or private readonly SqlClientLogger _sqlAuthLogger = new SqlClientLogger(); private readonly string? _applicationClientId = null; - // Optional override for ActiveDirectoryAuthenticationProvider.ProviderOptions.UseWamBroker + // 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 @@ -394,8 +394,7 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS /// Authentication provider or null if not found. internal static SqlAuthenticationProvider? GetProvider(SqlAuthenticationMethod authenticationMethod) { - SqlAuthenticationProvider? value; - return Instance._providers.TryGetValue(authenticationMethod, out value) ? value : null; + return Instance._providers.TryGetValue(authenticationMethod, out SqlAuthenticationProvider? value) ? value : null; } /// @@ -440,21 +439,6 @@ internal static bool SetProvider(SqlAuthenticationMethod authenticationMethod, S return true; } - /// - /// Clears the driver's in-memory cache of federated-authentication tokens - /// (DbConnectionPoolAuthenticationContext) across every active pool. - /// Pools and pooled physical connections are not torn down; only the cached - /// fed-auth contexts are evicted, so the next connection that needs a fed-auth - /// token will reacquire it from its . - /// Reflected into by Microsoft.Data.SqlClient.Extensions.Abstractions so - /// extension token-cache-clear APIs (e.g. ActiveDirectoryAuthenticationProvider.ClearUserTokenCache) - /// can keep the driver's cache in sync with the upstream MSAL/credential cache. - /// - internal static void ClearFederatedAuthenticationInformationCache() - { - SqlConnectionFactory.Instance.ClearAllAuthenticationContexts(); - } - /// /// Fetches provided configuration section from app.config file. /// Does not support reading from appsettings.json yet. @@ -547,7 +531,7 @@ internal class SqlAuthenticationProviderConfigurationSection : ConfigurationSect public string ApplicationClientId => this["applicationClientId"] as string ?? string.Empty; /// - /// Forwarded to ActiveDirectoryAuthenticationProvider.ProviderOptions.UseWamBroker + /// Forwarded to ActiveDirectoryAuthenticationProviderOptions.UseWamBroker /// when the Azure extension's default provider is auto-installed. Stored as a string so /// that an unset attribute can be distinguished from useWamBroker="false"; the /// runtime parses it with . From ed46a315f901477deb99d81c075a6dcb20828c25 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 12:39:39 -0700 Subject: [PATCH 37/41] Update provider instance initialization flow, other minor changes --- ...DirectoryAuthenticationProviderOptions.xml | 7 +- .../SqlAuthenticationProviderManager.cs | 159 ++++++++++------- .../src/Microsoft/Data/SqlClient/SqlUtil.cs | 5 + .../src/Resources/Strings.Designer.cs | 9 + .../src/Resources/Strings.resx | 3 + .../SqlAuthenticationProviderManagerTests.cs | 165 ++++++++++++++++++ 6 files changed, 282 insertions(+), 66 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml index e3794cd4dc..c290947adb 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/doc/ActiveDirectoryAuthenticationProviderOptions.xml @@ -25,10 +25,9 @@ - Windows-only setting to control whether the provider should use the WAM broker for interactive authentication. - When , interactive flows will use WAM; when , they won't. - The default value is , but if is not null - and the provider is running on Windows, WAM broker mode will be forced on regardless of this setting. + Windows-only setting to control whether the provider uses the WAM broker for interactive authentication. + Defaults to when you provide a custom . + Otherwise, WAM broker mode is used automatically on Windows. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs index 6e33c624dc..4c77fbe8df 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlAuthenticationProviderManager.cs @@ -132,7 +132,7 @@ static SqlAuthenticationProviderManager() // Look for the authentication provider class. const string className = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider"; - var type = assembly.GetType(className); + Type? type = assembly.GetType(className); if (type is null) { @@ -144,67 +144,28 @@ static SqlAuthenticationProviderManager() return; } - // Try to instantiate it. When neither an application client id nor a - // useWamBroker flag has been configured we use the parameterless constructor - // (which defaults to the SqlClient first-party app id and enables WAM brokering - // on Windows). Otherwise we resolve the (ActiveDirectoryAuthenticationProviderOptions) - // constructor and pass through only the properties the app actually configured: either may be - // supplied without the other (e.g. applicationClientId set with no - // useWamBroker, or useWamBroker set with no applicationClientId), and any - // omitted property keeps the ActiveDirectoryAuthenticationProviderOptions default. - SqlAuthenticationProvider? instance; - if (Instance._applicationClientId is null && Instance._useWamBroker is null) - { - instance = Activator.CreateInstance(type) as SqlAuthenticationProvider; - } - else - { - const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; - var optionsType = assembly.GetType(optionsTypeName); - if (optionsType is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - $": Azure extension class={className} is missing the nested " + - $"options type={optionsTypeName}; no default Active Directory " + - "provider installed"); - return; - } - - var ctor = type.GetConstructor([optionsType]); - if (ctor is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - $": Azure extension class={className} is missing the " + - "(ProviderOptions) constructor; no default Active Directory " + - "provider installed"); - return; - } - - var options = Activator.CreateInstance(optionsType); - if (options is null) - { - SqlClientEventSource.Log.TryTraceEvent( - nameof(SqlAuthenticationProviderManager) + - $": Failed to instantiate options type={optionsTypeName}; " + - "no default Active Directory provider installed"); - return; - } - - if (Instance._applicationClientId is not null) - { - optionsType.GetProperty("ApplicationClientId") - ?.SetValue(options, Instance._applicationClientId); - } - if (Instance._useWamBroker is bool useWamBroker) - { - optionsType.GetProperty("UseWamBroker") - ?.SetValue(options, useWamBroker); - } - - instance = ctor.Invoke([options]) as SqlAuthenticationProvider; - } + // Try to instantiate it. Behavior depends on what the app + // configured in : + // * Neither applicationClientId nor useWamBroker -> use the + // parameterless constructor (defaults to the SqlClient + // first-party app id and enables WAM brokering on Windows). + // * applicationClientId only -> prefer the + // (ActiveDirectoryAuthenticationProviderOptions) constructor + // when the Azure extension exposes it; otherwise fall back + // to the legacy (string applicationClientId) constructor so + // older Azure extension versions keep working. + // * useWamBroker (with or without applicationClientId) -> + // requires the (Options) constructor because there is no + // positional analog. If the Azure extension is too old to + // expose Options, throw to surface the misconfiguration. + const string optionsTypeName = "Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions"; + Type? optionsType = assembly.GetType(optionsTypeName); + + SqlAuthenticationProvider? instance = CreateAzureAuthenticationProvider( + type, + optionsType, + Instance._applicationClientId, + Instance._useWamBroker); if (instance is null) { @@ -397,6 +358,80 @@ private SqlAuthenticationProviderManager(SqlAuthenticationProviderConfigurationS return Instance._providers.TryGetValue(authenticationMethod, out SqlAuthenticationProvider? value) ? value : null; } + // Reflectively constructs the Azure extension's ActiveDirectoryAuthenticationProvider, + // selecting the constructor that matches what the app configured. Extracted from the + // static initializer so it can be unit-tested with stub provider/options shapes. + // + // Returns null when no compatible constructor is available (e.g. a custom assembly + // that lacks both the (Options) and (string) ctors). + // + // Throws InvalidOperationException when useWamBroker is configured but the Azure + // extension is too old to expose ActiveDirectoryAuthenticationProviderOptions; that + // signals user-actionable misconfiguration and intentionally escapes the static ctor's + // catch-when filter so it surfaces as a TypeInitializationException. + internal static SqlAuthenticationProvider? CreateAzureAuthenticationProvider( + Type providerType, + Type? optionsType, + string? applicationClientId, + bool? useWamBroker) + { + if (applicationClientId is null && useWamBroker is null) + { + return Activator.CreateInstance(providerType) as SqlAuthenticationProvider; + } + + ConstructorInfo? optionsCtor = optionsType is null + ? null + : providerType.GetConstructor([optionsType]); + + if (useWamBroker is bool useWam) + { + if (optionsType is null || optionsCtor is null) + { + throw SQL.UseWamBrokerRequiresAzureExtensionUpgrade(); + } + + var options = Activator.CreateInstance(optionsType); + if (options is null) + { + return null; + } + + if (applicationClientId is not null) + { + optionsType.GetProperty("ApplicationClientId") + ?.SetValue(options, applicationClientId); + } + optionsType.GetProperty("UseWamBroker") + ?.SetValue(options, useWam); + + return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; + } + + // applicationClientId-only: prefer Options when the extension exposes it, + // otherwise fall back to the legacy (string) ctor for backward compatibility + // with older Azure extension versions. + if (optionsType is not null && optionsCtor is not null) + { + var options = Activator.CreateInstance(optionsType); + if (options is null) + { + return null; + } + optionsType.GetProperty("ApplicationClientId") + ?.SetValue(options, applicationClientId); + return optionsCtor.Invoke([options]) as SqlAuthenticationProvider; + } + + ConstructorInfo? legacyCtor = providerType.GetConstructor([typeof(string)]); + if (legacyCtor is not null) + { + return legacyCtor.Invoke([applicationClientId]) as SqlAuthenticationProvider; + } + + return null; + } + /// /// Set an authentication provider by method. /// diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index 84667d99e7..049734993e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -511,6 +511,11 @@ internal static Exception UnsupportedAuthenticationByProvider(string authenticat return ADP.NotSupported(StringsHelper.GetString(Strings.SQL_UnsupportedAuthenticationByProvider, type, authentication)); } + internal static Exception UseWamBrokerRequiresAzureExtensionUpgrade() + { + return ADP.InvalidOperation(StringsHelper.GetString(Strings.SQL_UseWamBrokerRequiresAzureExtensionUpgrade)); + } + internal static Exception CannotFindAuthProvider(SqlAuthenticationMethod authentication) { string authName = authentication.ToString(); diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 041b9aa8aa..eed5c58a69 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -3048,6 +3048,15 @@ internal static string SQL_CannotFindActiveDirectoryAuthProvider { } } + /// + /// Looks up a localized string similar to Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to a version that includes this type.. + /// + internal static string SQL_UseWamBrokerRequiresAzureExtensionUpgrade { + get { + return ResourceManager.GetString("SQL_UseWamBrokerRequiresAzureExtensionUpgrade", resourceCulture); + } + } + /// /// Looks up a localized string similar to Cannot find an authentication provider for '{0}'.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index ca9dcf0a93..08fdb97d76 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -606,6 +606,9 @@ Cannot find an authentication provider for '{0}'. Install the 'Microsoft.Data.SqlClient.Extensions.Azure' NuGet package (https://www.nuget.org/packages/Microsoft.Data.SqlClient.Extensions.Azure) to use Active Directory (Entra ID) authentication methods. + + Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to atleast v1.1.0 that includes this type. + Parameter '{0}' cannot be null or empty. 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 index c9851cf345..08089abe98 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderManagerTests.cs @@ -104,4 +104,169 @@ public void GetProvider_ForActiveDirectoryMethod_DoesNotThrow() _ = 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); + } } From b802a2e8fa27d2bef6c62b4059e5f6fa5e01d912 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 15:38:26 -0700 Subject: [PATCH 38/41] Disable broker for AAD Password auth (not supported by MSAL unless user is signed in interactively) --- .../ActiveDirectoryAuthenticationProvider.cs | 21 +++++++++ .../Azure/test/AADConnectionTest.cs | 8 ++++ .../test/AadPasswordWithoutBrokerScope.cs | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 7a39bb808f..9d858de243 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -118,6 +119,26 @@ public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProvid /// internal bool UseWamBroker => _useWamBroker; + /// + /// Test-only factory: builds a provider bound to the SqlClient first-party application id + /// but with WAM broker explicitly disabled. The production ctor at + /// + /// forces WAM on for the first-party app id, so callers cannot otherwise express this + /// combination. WAM requires an active interactive Windows user session + /// (see https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam#integration-best-practices), + /// which non-interactive CI agents cannot satisfy. Production callers that want WAM off + /// must instead supply their own application id via + /// . + /// + internal static ActiveDirectoryAuthenticationProvider CreateForTestsWithoutBroker() + { + var provider = new ActiveDirectoryAuthenticationProvider(); + typeof(ActiveDirectoryAuthenticationProvider) + .GetField(nameof(_useWamBroker), BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(provider, false); + return provider; + } + /// /// The Entra ID application client id used by this provider instance. Exposed as internal for tests. /// The client id is used in the redirect URI when WAM broker mode is enabled, so it must match the client id configured diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs index 8c4006c5b4..db86489389 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs @@ -12,6 +12,10 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; // These tests were migrated from MDS ManualTests AADConnectionTest.cs. +// [Collection] serializes this class against other tests (e.g. WamBrokerTests) that mutate the +// global SqlAuthenticationProvider registry; individual password tests opt into a non-broker +// provider via `using var _ = new AadPasswordWithoutBrokerScope();` (see scope class for why). +[Collection("SqlAuthenticationProvider")] public class AADConnectionTest { [ConditionalFact( @@ -45,6 +49,8 @@ public static void AADPasswordWithWrongPassword() nameof(Config.HasPasswordConnectionString))] public static void TestADPasswordAuthentication() { + using var _ = new AadPasswordWithoutBrokerScope(); + // Connect to Azure DB with password and retrieve user name. using (SqlConnection conn = new SqlConnection(Config.PasswordConnectionString)) { @@ -128,6 +134,8 @@ public static void AADPasswordWithInvalidUser() nameof(Config.HasPasswordConnectionString))] public static void NoCredentialsActiveDirectoryPassword() { + using var _ = new AadPasswordWithoutBrokerScope(); + // test Passes with correct connection string. ConnectAndDisconnect(Config.PasswordConnectionString); diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs new file mode 100644 index 0000000000..03ab53264a --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs @@ -0,0 +1,46 @@ +// 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; + +/// +/// Per-test using scope that swaps the registered +/// for with a non-broker instance, +/// then restores the original provider on dispose. Use this only in tests that exercise a real +/// MSAL password flow on Windows CI; do NOT promote it to a class fixture, because other tests +/// in the same class may rely on the default (broker-enabled) provider. +/// +/// WAM requires an active interactive Windows user session, which non-interactive CI agents +/// cannot satisfy; routing the SQL driver's password flow through WAM in that environment +/// fails with unknown_broker_error (0x80070520 ERROR_NO_SUCH_LOGON_SESSION). +/// See https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam#integration-best-practices. +/// +/// +/// Consumers MUST live in a test class tagged with [Collection("SqlAuthenticationProvider")] +/// so this swap is serialized against other tests that mutate the global provider registry. +/// +internal sealed class AadPasswordWithoutBrokerScope : IDisposable +{ +#pragma warning disable CS0618 // Type or member is obsolete (ActiveDirectoryPassword) + private const SqlAuthenticationMethod Method = SqlAuthenticationMethod.ActiveDirectoryPassword; +#pragma warning restore CS0618 + + private readonly SqlAuthenticationProvider? _original; + + public AadPasswordWithoutBrokerScope() + { + _original = SqlAuthenticationProvider.GetProvider(Method); + SqlAuthenticationProvider.SetProvider( + Method, + ActiveDirectoryAuthenticationProvider.CreateForTestsWithoutBroker()); + } + + public void Dispose() + { + if (_original is not null) + { + SqlAuthenticationProvider.SetProvider(Method, _original); + } + } +} From a63ac86022e0cbd75a07ac324451e229970c1db5 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 17:28:20 -0700 Subject: [PATCH 39/41] Revert "Disable broker for AAD Password auth (not supported by MSAL unless user is signed in interactively)" This reverts commit b802a2e8fa27d2bef6c62b4059e5f6fa5e01d912. --- .../ActiveDirectoryAuthenticationProvider.cs | 21 --------- .../Azure/test/AADConnectionTest.cs | 8 ---- .../test/AadPasswordWithoutBrokerScope.cs | 46 ------------------- 3 files changed, 75 deletions(-) delete mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 9d858de243..7a39bb808f 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Diagnostics; -using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -119,26 +118,6 @@ public ActiveDirectoryAuthenticationProvider(ActiveDirectoryAuthenticationProvid /// internal bool UseWamBroker => _useWamBroker; - /// - /// Test-only factory: builds a provider bound to the SqlClient first-party application id - /// but with WAM broker explicitly disabled. The production ctor at - /// - /// forces WAM on for the first-party app id, so callers cannot otherwise express this - /// combination. WAM requires an active interactive Windows user session - /// (see https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam#integration-best-practices), - /// which non-interactive CI agents cannot satisfy. Production callers that want WAM off - /// must instead supply their own application id via - /// . - /// - internal static ActiveDirectoryAuthenticationProvider CreateForTestsWithoutBroker() - { - var provider = new ActiveDirectoryAuthenticationProvider(); - typeof(ActiveDirectoryAuthenticationProvider) - .GetField(nameof(_useWamBroker), BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue(provider, false); - return provider; - } - /// /// The Entra ID application client id used by this provider instance. Exposed as internal for tests. /// The client id is used in the redirect URI when WAM broker mode is enabled, so it must match the client id configured diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs index db86489389..8c4006c5b4 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs @@ -12,10 +12,6 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; // These tests were migrated from MDS ManualTests AADConnectionTest.cs. -// [Collection] serializes this class against other tests (e.g. WamBrokerTests) that mutate the -// global SqlAuthenticationProvider registry; individual password tests opt into a non-broker -// provider via `using var _ = new AadPasswordWithoutBrokerScope();` (see scope class for why). -[Collection("SqlAuthenticationProvider")] public class AADConnectionTest { [ConditionalFact( @@ -49,8 +45,6 @@ public static void AADPasswordWithWrongPassword() nameof(Config.HasPasswordConnectionString))] public static void TestADPasswordAuthentication() { - using var _ = new AadPasswordWithoutBrokerScope(); - // Connect to Azure DB with password and retrieve user name. using (SqlConnection conn = new SqlConnection(Config.PasswordConnectionString)) { @@ -134,8 +128,6 @@ public static void AADPasswordWithInvalidUser() nameof(Config.HasPasswordConnectionString))] public static void NoCredentialsActiveDirectoryPassword() { - using var _ = new AadPasswordWithoutBrokerScope(); - // test Passes with correct connection string. ConnectAndDisconnect(Config.PasswordConnectionString); diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs deleted file mode 100644 index 03ab53264a..0000000000 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AadPasswordWithoutBrokerScope.cs +++ /dev/null @@ -1,46 +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; - -/// -/// Per-test using scope that swaps the registered -/// for with a non-broker instance, -/// then restores the original provider on dispose. Use this only in tests that exercise a real -/// MSAL password flow on Windows CI; do NOT promote it to a class fixture, because other tests -/// in the same class may rely on the default (broker-enabled) provider. -/// -/// WAM requires an active interactive Windows user session, which non-interactive CI agents -/// cannot satisfy; routing the SQL driver's password flow through WAM in that environment -/// fails with unknown_broker_error (0x80070520 ERROR_NO_SUCH_LOGON_SESSION). -/// See https://learn.microsoft.com/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam#integration-best-practices. -/// -/// -/// Consumers MUST live in a test class tagged with [Collection("SqlAuthenticationProvider")] -/// so this swap is serialized against other tests that mutate the global provider registry. -/// -internal sealed class AadPasswordWithoutBrokerScope : IDisposable -{ -#pragma warning disable CS0618 // Type or member is obsolete (ActiveDirectoryPassword) - private const SqlAuthenticationMethod Method = SqlAuthenticationMethod.ActiveDirectoryPassword; -#pragma warning restore CS0618 - - private readonly SqlAuthenticationProvider? _original; - - public AadPasswordWithoutBrokerScope() - { - _original = SqlAuthenticationProvider.GetProvider(Method); - SqlAuthenticationProvider.SetProvider( - Method, - ActiveDirectoryAuthenticationProvider.CreateForTestsWithoutBroker()); - } - - public void Dispose() - { - if (_original is not null) - { - SqlAuthenticationProvider.SetProvider(Method, _original); - } - } -} From 5fdca682939ef63d39ee07c9cb6dc9b183982ad8 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Thu, 18 Jun 2026 17:32:47 -0700 Subject: [PATCH 40/41] Remove AAD Password auth tests --- .../Azure/test/AADConnectionTest.cs | 114 ------------------ .../Azure/test/Config.cs | 1 + 2 files changed, 1 insertion(+), 114 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs index 8c4006c5b4..5dd3ac336e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AADConnectionTest.cs @@ -26,120 +26,6 @@ public static void KustoDatabaseTest() Assert.Equal(System.Data.ConnectionState.Open, connection.State); } - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void AADPasswordWithWrongPassword() - { - string[] credKeys = { "Password", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, credKeys) + "Password=TestPassword;"; - - Assert.Throws(() => ConnectAndDisconnect(connStr)); - - // We cannot verify error message with certainty as driver may cache token from other tests for current user - // and error message may change accordingly. - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void TestADPasswordAuthentication() - { - // Connect to Azure DB with password and retrieve user name. - using (SqlConnection conn = new SqlConnection(Config.PasswordConnectionString)) - { - conn.Open(); - using (SqlCommand sqlCommand = new SqlCommand - ( - cmdText: $"SELECT SUSER_SNAME();", - connection: conn, - transaction: null - )) - { - string customerId = (string)sqlCommand.ExecuteScalar(); - string expected = RetrieveValueFromConnStr(Config.PasswordConnectionString, new string[] { "User ID", "UID" }); - Assert.Equal(expected, customerId); - } - } - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyPasswordInConnStrAADPassword() - { - // connection fails with expected error message. - string[] pwdKey = { "Password", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, pwdKey) + "Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string? user = FetchKeyInConnStr(Config.PasswordConnectionString, new string[] { "User Id", "UID" }); - string expectedMessage = string.Format("Failed to authenticate the user {0} in Active Directory (Authentication=ActiveDirectoryPassword).", user); - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.OnWindows), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyCredInConnStrAADPassword() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "Password", "UID", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + "User ID=; Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = "Failed to authenticate the user in Active Directory (Authentication=ActiveDirectoryPassword)."; - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.OnUnix), - nameof(Config.HasPasswordConnectionString))] - public static void EmptyCredInConnStrAADPasswordAnyUnix() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "Password", "UID", "PWD" }; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + "User ID=; Password=;"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = "MSAL cannot determine the username (UPN) of the currently logged in user.For Integrated Windows Authentication and Username/Password flows, please use .WithUsername() before calling ExecuteAsync()."; - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void AADPasswordWithInvalidUser() - { - // connection fails with expected error message. - string[] removeKeys = { "User ID", "UID" }; - string user = "testdotnet@domain.com"; - string connStr = RemoveKeysInConnStr(Config.PasswordConnectionString, removeKeys) + $"User ID={user}"; - SqlException e = Assert.Throws(() => ConnectAndDisconnect(connStr)); - - string expectedMessage = string.Format("Failed to authenticate the user {0} in Active Directory (Authentication=ActiveDirectoryPassword).", user); - Assert.Contains(expectedMessage, e.Message); - } - - [ConditionalFact( - typeof(Config), - nameof(Config.HasPasswordConnectionString))] - public static void NoCredentialsActiveDirectoryPassword() - { - // test Passes with correct connection string. - ConnectAndDisconnect(Config.PasswordConnectionString); - - // connection fails with expected error message. - string[] credKeys = { "User ID", "Password", "UID", "PWD" }; - string connStrWithNoCred = RemoveKeysInConnStr(Config.PasswordConnectionString, credKeys); - InvalidOperationException e = Assert.Throws(() => ConnectAndDisconnect(connStrWithNoCred)); - - string expectedMessage = "Either Credential or both 'User ID' and 'Password' (or 'UID' and 'PWD') connection string keywords must be specified, if 'Authentication=Active Directory Password'."; - Assert.Contains(expectedMessage, e.Message); - } - [ConditionalFact( typeof(Config), nameof(Config.HasPasswordConnectionString), diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs index cec4721b95..1f0587685e 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/Config.cs @@ -40,6 +40,7 @@ internal static class Config internal static bool DebugEmit { get; } = false; internal static bool IntegratedSecuritySupported { get; } = false; internal static bool ManagedIdentitySupported { get; } = false; + // @TODO Remove PasswordConnectionString from config; AAD Password auth is deprecated internal static string PasswordConnectionString { get; } = string.Empty; internal static string ServicePrincipalId { get; } = string.Empty; internal static string ServicePrincipalSecret { get; } = string.Empty; From 9e703ed0022d69794dc1fcd32fae8119666f0df5 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra <13396919+cheenamalhotra@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:35:01 -0700 Subject: [PATCH 41/41] Fix wording Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.Data.SqlClient/src/Resources/Strings.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 08fdb97d76..0a65febfde 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -607,7 +607,7 @@ Cannot find an authentication provider for '{0}'. Install the 'Microsoft.Data.SqlClient.Extensions.Azure' NuGet package (https://www.nuget.org/packages/Microsoft.Data.SqlClient.Extensions.Azure) to use Active Directory (Entra ID) authentication methods. - Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to atleast v1.1.0 that includes this type. + Setting 'useWamBroker' requires the 'Microsoft.Data.SqlClient.Extensions.Azure' package to expose 'Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions'. Upgrade the 'Microsoft.Data.SqlClient.Extensions.Azure' package to at least v1.1.0 that includes this type. Parameter '{0}' cannot be null or empty.