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)); + } +}