Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@

<ItemGroup>
<PackageVersion Include="Azure.Identity" Version="1.18.0" />
<PackageVersion Include="Microsoft.Identity.Client.Broker" Version="4.83.0" />
</ItemGroup>

<!-- ===================================================================== -->
Expand Down
131 changes: 131 additions & 0 deletions specs/002-wam-broker/spec.md
Original file line number Diff line number Diff line change
@@ -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 |
Comment on lines +48 to +49
| `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<object>` 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<object>
// On Unix: no-op (WAM not available)
public void SetParentActivityOrWindow(Func<object> 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`
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Gets the parent window handle to be used for interactive authentication prompts
/// via the Windows Account Manager (WAM) broker.
/// </summary>
/// <returns>
/// The parent window handle as an <see cref="IntPtr"/>, or <see cref="IntPtr.Zero"/> if
/// not running on Windows or no window handle is available.
/// </returns>
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;
}

/// <summary>
/// Gets the parent activity or window object for the broker authentication flow.
/// On Windows, returns the window handle. On other platforms, returns <see cref="IntPtr.Zero"/>.
/// </summary>
private object GetBrokerParentWindow()
{
return GetParentWindow();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <include file='../doc/ActiveDirectoryAuthenticationProvider.xml' path='docs/members[@name="ActiveDirectoryAuthenticationProvider"]/ActiveDirectoryAuthenticationProvider/*'/>
public sealed class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider
public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthenticationProvider
{
/// <summary>
/// This is a static cache instance meant to hold instances of "PublicClientApplication" mapping to information available in PublicClientAppKey.
Expand Down Expand Up @@ -118,6 +120,24 @@ public override void BeforeUnload(SqlAuthenticationMethod authentication)
public void SetIWin32WindowFunc(Func<System.Windows.Forms.IWin32Window> iWin32WindowFunc) => _iWin32WindowFunc = iWin32WindowFunc;
#endif

private Func<object>? _parentActivityOrWindowFunc = null;

/// <summary>
/// Sets a function to return the parent activity or window handle to be used for
/// WAM (Web Account Manager) broker authentication prompts.
/// </summary>
/// <param name="parentActivityOrWindowFunc">
/// A function that returns an <see cref="IntPtr"/> window handle on Windows.
/// </param>
/// <remarks>
/// 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.
/// </remarks>
public void SetParentActivityOrWindow(Func<object> parentActivityOrWindowFunc)
{
_parentActivityOrWindowFunc = parentActivityOrWindowFunc ?? throw new ArgumentNullException(nameof(parentActivityOrWindowFunc));
}

/// <include file='../doc/ActiveDirectoryAuthenticationProvider.xml' path='docs/members[@name="ActiveDirectoryAuthenticationProvider"]/AcquireTokenAsync/*'/>
public override async Task<SqlAuthenticationToken> AcquireTokenAsync(SqlAuthenticationParameters parameters)
{
Expand Down Expand Up @@ -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));
Comment on lines +750 to +752

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<!-- Explicitly depend on the same version of Microsoft.Identity.Client as SqlClient. -->
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Identity.Client.Broker" />
</ItemGroup>

<!-- CodeGen Targets ================================================= -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Retrieves the handle to the ancestor of the specified window.
/// </summary>
[DllImport("user32.dll")]
private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags);

/// <summary>
/// Gets the root owner window of the specified window handle.
/// </summary>
internal static IntPtr GetRootOwner(IntPtr hwnd)
{
return GetAncestor(hwnd, GA_ROOTOWNER);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Retrieves the window handle used by the console associated with the calling process.
/// </summary>
[DllImport("kernel32.dll")]
internal static extern IntPtr GetConsoleWindow();
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>("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));
}
}
Loading