diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml
index 15c946e9f9..04061a5439 100644
--- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml
+++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProvider.xml
@@ -139,6 +139,15 @@
Mandatory to be set only on Android. See https://aka.ms/msal-net-android-activity for further documentation and details.
+
+ A worker process can lack a console window even when it runs on an interactive desktop, for example in the Azure Functions local host.
+ If no valid parent window is available and WAM requires interactive sign-in, authentication fails with guidance on supported alternatives.
+ Use Authentication=Active Directory Default with a configured non-interactive credential for windowless hosts.
+ To use interactive authentication without WAM, supply a custom
+ and set
+ to .
+ WAM cannot be disabled for SqlClient's first-party application ID.
+
diff --git a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml
index 737faa0e09..1e4ca4942a 100644
--- a/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml
+++ b/doc/snippets/Microsoft.Data.SqlClient/ActiveDirectoryAuthenticationProviderOptions.xml
@@ -29,6 +29,14 @@
Defaults to when you provide a custom .
Otherwise, WAM broker mode is used automatically on Windows.
+
+ Setting this property to does not disable WAM for SqlClient's first-party application ID,
+ including when that ID is supplied explicitly. Disabling WAM requires a custom application registration.
+ WAM interactive authentication requires a valid parent window handle. Applications with a window can supply
+ its handle using .
+ Windowless hosts should use a non-interactive credential with Authentication=Active Directory Default,
+ or use their own application registration with WAM disabled if interactive sign-in is required.
+
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs
index 02a4e2b68c..4aefd53531 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ActiveDirectoryAuthenticationProvider.cs
@@ -27,7 +27,8 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic
/// The purpose of this cache is to allow re-use of Access Tokens fetched for a user interactively or with any other mode
/// to avoid interactive authentication request every-time, within application scope making use of MSAL's userTokenCache.
///
- private static readonly ConcurrentDictionary s_pcaMap = new();
+ // Internal for test access.
+ internal static readonly ConcurrentDictionary s_pcaMap = new();
private static readonly ConcurrentDictionary s_tokenCredentialMap = new();
private static SemaphoreSlim s_pcaMapModifierSemaphore = new(1, 1);
private static SemaphoreSlim s_tokenCredentialMapModifierSemaphore = new(1, 1);
@@ -38,6 +39,24 @@ public sealed partial class ActiveDirectoryAuthenticationProvider : SqlAuthentic
// expects the suffix to match the client id of the registered application.
private const string s_wamBrokerRedirectUriPrefix = "ms-appx-web://microsoft.aad.brokerplugin/";
+ // MSAL error code raised by the Windows broker when no parent window handle is available.
+ // MSAL does not expose a public constant for this value.
+ internal const string WindowHandleRequiredErrorCode = "window_handle_required";
+
+ internal static MsalClientException CreateWindowHandleRequiredException(MsalClientException ex)
+ {
+ // TODO: Localize this message: https://github.com/dotnet/SqlClient/issues/4904
+ return new MsalClientException(
+ ex.ErrorCode,
+ "Active Directory Interactive authentication with Windows Web Account Manager (WAM) requires a valid parent window handle. " +
+ "Supply an application-owned window handle using ActiveDirectoryAuthenticationProvider.SetParentActivityOrWindowFunc. " +
+ "For windowless hosts, such as Azure Functions local workers, use Authentication=Active Directory Default with a configured non-interactive credential. " +
+ "To use interactive authentication without WAM, provide your own ApplicationClientId and set UseWamBroker=false. " +
+ "WAM cannot be disabled for SqlClient's first-party application ID. " +
+ ex.Message,
+ ex);
+ }
+
#if NETFRAMEWORK
// Non-broker redirect URI used on .NET Framework when WAM is not in use (legacy embedded
// WebView path).
@@ -367,23 +386,37 @@ previousPw is byte[] previousPwBytes &&
{
try
{
- result = await TryAcquireTokenSilent(app, parameters, scopes, cts).ConfigureAwait(false);
- SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (silent) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
- }
- catch (MsalUiRequiredException)
- {
- // An 'MsalUiRequiredException' is thrown in the case where an interaction is required with the end user of the application,
- // for instance, if no refresh token was in the cache, or the user needs to consent, or re-sign-in (for instance if the password expired),
- // or the user needs to perform two factor authentication.
- result = await AcquireTokenInteractiveDeviceFlowAsync(app, scopes, parameters.ConnectionId, parameters.UserId, parameters.AuthenticationMethod, cts, _customWebUI, _deviceCodeFlowCallback).ConfigureAwait(false);
- SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (interactive) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
- }
+ try
+ {
+ result = await TryAcquireTokenSilent(app, parameters, scopes, cts).ConfigureAwait(false);
+ SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (silent) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
+ }
+ catch (MsalUiRequiredException)
+ {
+ // An 'MsalUiRequiredException' is thrown in the case where an interaction is required with the end user of the application,
+ // for instance, if no refresh token was in the cache, or the user needs to consent, or re-sign-in (for instance if the password expired),
+ // or the user needs to perform two factor authentication.
+ result = await AcquireTokenInteractiveDeviceFlowAsync(app, scopes, parameters.ConnectionId, parameters.UserId, parameters.AuthenticationMethod, cts, _customWebUI, _deviceCodeFlowCallback).ConfigureAwait(false);
+ SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (interactive) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
+ }
- if (result == null)
+ if (result == null)
+ {
+ // If no existing 'account' is found, we request user to sign in interactively.
+ result = await AcquireTokenInteractiveDeviceFlowAsync(app, scopes, parameters.ConnectionId, parameters.UserId, parameters.AuthenticationMethod, cts, _customWebUI, _deviceCodeFlowCallback).ConfigureAwait(false);
+ SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (interactive) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
+ }
+ }
+ // MSAL raises window_handle_required only on Windows, where WAM needs a parent window.
+ // If WAM support is added for other platforms, revisit this filter and message.
+ catch (MsalClientException ex) when (
+ parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryInteractive &&
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
+ _useWamBroker &&
+ ex.ErrorCode == WindowHandleRequiredErrorCode)
{
- // If no existing 'account' is found, we request user to sign in interactively.
- result = await AcquireTokenInteractiveDeviceFlowAsync(app, scopes, parameters.ConnectionId, parameters.UserId, parameters.AuthenticationMethod, cts, _customWebUI, _deviceCodeFlowCallback).ConfigureAwait(false);
- SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token (interactive) for {0} auth mode. Expiry Time: {1}", parameters.AuthenticationMethod, result?.ExpiresOn);
+ SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | WAM interactive authentication requires a valid parent window handle.");
+ throw CreateWindowHandleRequiredException(ex);
}
}
else
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthorityParsingTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthorityParsingTests.cs
index afdce9d83a..6e14527b0c 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthorityParsingTests.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AuthorityParsingTests.cs
@@ -16,7 +16,7 @@ namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient
/// The cases below only cover authority shapes that Entra ID actually documents:
/// https://learn.microsoft.com/entra/identity-platform/authentication-national-cloud
///
- [Collection("SqlAuthenticationProvider")]
+ [Collection(SqlAuthenticationProviderCollection.Name)]
public class AuthorityParsingTests
{
private const string Tenant = "72f988bf-86f1-41af-91ab-2d7cd011db47";
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs
index fe51e2587e..b39c3fe79d 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlAuthenticationProviderCollection.cs
@@ -10,8 +10,12 @@ namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient
/// Defines a test collection that serializes execution of test classes
/// which mutate the global registry.
///
- [CollectionDefinition("SqlAuthenticationProvider")]
+ [CollectionDefinition(Name)]
public class SqlAuthenticationProviderCollection
{
+ ///
+ /// The collection name used by tests that mutate global authentication provider state.
+ ///
+ public const string Name = "SqlAuthenticationProvider";
}
}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerErrorTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerErrorTests.cs
new file mode 100644
index 0000000000..00bf84b893
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerErrorTests.cs
@@ -0,0 +1,135 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient
+{
+ ///
+ /// Verifies missing-window diagnostics without opening UI or contacting Entra ID.
+ ///
+ [Collection(SqlAuthenticationProviderCollection.Name)]
+ public class WamBrokerErrorTests
+ {
+ ///
+ /// Both first-party and custom applications using WAM receive actionable guidance on Windows.
+ /// Other platforms retain the original MSAL diagnostic.
+ ///
+ [Theory]
+ [InlineData(null)]
+ [InlineData("11111111-2222-3333-4444-555555555555")]
+ public async Task AcquireTokenAsync_MissingWamWindow_ExplainsSupportedAlternatives(string applicationClientId)
+ {
+ var provider = new ActiveDirectoryAuthenticationProvider(
+ new ActiveDirectoryAuthenticationProviderOptions
+ {
+ ApplicationClientId = applicationClientId,
+ UseWamBroker = true,
+ });
+ provider.SetParentActivityOrWindowFunc(
+ () => throw new InvalidOperationException("Window discovery must not precede MSAL token acquisition."));
+ var cause = new MsalClientException("window_handle_required", "Original MSAL diagnostic.");
+
+ MsalClientException exception = await AcquireWithMsalExceptionAsync(
+ provider, SqlAuthenticationMethod.ActiveDirectoryInteractive, cause);
+
+ Assert.Equal(cause.ErrorCode, exception.ErrorCode);
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.NotSame(cause, exception);
+ Assert.Same(cause, exception.InnerException);
+ Assert.Contains(cause.Message, exception.Message);
+ Assert.Contains("parent window handle", exception.Message);
+ Assert.Contains("SetParentActivityOrWindowFunc", exception.Message);
+ Assert.Contains("Active Directory Default", exception.Message);
+ Assert.Contains("ApplicationClientId", exception.Message);
+ Assert.Contains("UseWamBroker", exception.Message);
+ Assert.Contains("first-party", exception.Message);
+ }
+ else
+ {
+ Assert.Same(cause, exception);
+ }
+ }
+
+ ///
+ /// Non-broker flows, other authentication methods, and unrelated MSAL failures are not
+ /// relabeled as missing WAM windows.
+ ///
+ [Theory]
+ [InlineData(SqlAuthenticationMethod.ActiveDirectoryInteractive, false, "window_handle_required")]
+ [InlineData(SqlAuthenticationMethod.ActiveDirectoryDeviceCodeFlow, true, "window_handle_required")]
+ [InlineData(SqlAuthenticationMethod.ActiveDirectoryIntegrated, true, "window_handle_required")]
+ [InlineData(SqlAuthenticationMethod.ActiveDirectoryInteractive, true, "authentication_canceled")]
+ public async Task AcquireTokenAsync_OtherMsalFailures_PreservesDiagnostic(
+ SqlAuthenticationMethod method, bool useWamBroker, string errorCode)
+ {
+ var provider = new ActiveDirectoryAuthenticationProvider(
+ new ActiveDirectoryAuthenticationProviderOptions
+ {
+ ApplicationClientId = "11111111-2222-3333-4444-555555555555",
+ UseWamBroker = useWamBroker,
+ });
+ var cause = new MsalClientException(errorCode, "Original MSAL diagnostic.");
+
+ MsalClientException exception = await AcquireWithMsalExceptionAsync(provider, method, cause);
+
+ Assert.Same(cause, exception);
+ }
+
+ ///
+ /// Installs an isolated MSAL client whose cache callback throws the supplied exception,
+ /// exercising the provider's real exception handling without a network request.
+ /// The cache entry is removed even if an assertion fails.
+ ///
+ /// The provider configuration under test.
+ /// The authentication method under test.
+ /// The MSAL failure to propagate through token acquisition.
+ /// The exception surfaced by the provider.
+ private static async Task AcquireWithMsalExceptionAsync(
+ ActiveDirectoryAuthenticationProvider provider, SqlAuthenticationMethod method, MsalClientException cause)
+ {
+ string authority = $"https://login.microsoftonline.com/{Guid.NewGuid()}";
+ string redirectUri = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? provider.UseWamBroker
+ ? $"ms-appx-web://microsoft.aad.brokerplugin/{provider.ApplicationClientId}"
+#if NETFRAMEWORK
+ : "https://login.microsoftonline.com/common/oauth2/nativeclient"
+#else
+ : "http://localhost"
+#endif
+ : "http://localhost";
+
+ var key = new ActiveDirectoryAuthenticationProvider.PublicClientAppKey(
+ authority, redirectUri, provider.ApplicationClientId
+#if NETFRAMEWORK
+ , null
+#endif
+ );
+ IPublicClientApplication app = PublicClientApplicationBuilder.Create(provider.ApplicationClientId)
+ .WithAuthority(authority)
+ .WithRedirectUri(redirectUri)
+ .Build();
+ app.UserTokenCache.SetBeforeAccess(_ => throw cause);
+
+ var cache = ActiveDirectoryAuthenticationProvider.s_pcaMap;
+ Assert.True(cache.TryAdd(key, app));
+ try
+ {
+ SqlAuthenticationParameters parameters = new SqlAuthenticationParameters.Builder(
+ method, "https://database.windows.net/", authority, "test-server", "test-database");
+ return await Assert.ThrowsAsync(() => provider.AcquireTokenAsync(parameters));
+ }
+ finally
+ {
+ cache.TryRemove(key, out _);
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs
index d9e1d5e3f8..9fc020e908 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/WamBrokerTests.cs
@@ -9,7 +9,7 @@
namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient
{
- [Collection("SqlAuthenticationProvider")]
+ [Collection(SqlAuthenticationProviderCollection.Name)]
public class WamBrokerTests
{
// The SqlClient first-party application client id that is hard-coded in the provider.