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.