Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@
</summary>
<remarks>
Mandatory to be set only on Android. See https://aka.ms/msal-net-android-activity for further documentation and details.
<para>
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 <c>Authentication=Active Directory Default</c> with a configured non-interactive credential for windowless hosts.
To use interactive authentication without WAM, supply a custom
<see cref="P:Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions.ApplicationClientId"/> and set
<see cref="P:Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProviderOptions.UseWamBroker"/> to <see langword="false"/>.
WAM cannot be disabled for SqlClient's first-party application ID.
</para>
</remarks>
</SetParentActivityOrWindowFunc>
<SetIWin32WindowFunc>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
Defaults to <see langword="false"/> when you provide a custom <see cref="ApplicationClientId"/>.
Otherwise, WAM broker mode is used automatically on Windows.
</summary>
<remarks>
Setting this property to <see langword="false"/> 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 <see cref="M:Microsoft.Data.SqlClient.ActiveDirectoryAuthenticationProvider.SetParentActivityOrWindowFunc(System.Func{System.Object})"/>.
Windowless hosts should use a non-interactive credential with <c>Authentication=Active Directory Default</c>,
or use their own application registration with WAM disabled if interactive sign-in is required.
</remarks>
</UseWamBroker>
</members>
</docs>
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
private static readonly ConcurrentDictionary<PublicClientAppKey, IPublicClientApplication> s_pcaMap = new();
// Internal for test access.
internal static readonly ConcurrentDictionary<PublicClientAppKey, IPublicClientApplication> s_pcaMap = new();
private static readonly ConcurrentDictionary<TokenCredentialKey, TokenCredentialData> s_tokenCredentialMap = new();
private static SemaphoreSlim s_pcaMapModifierSemaphore = new(1, 1);
private static SemaphoreSlim s_tokenCredentialMapModifierSemaphore = new(1, 1);
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// </remarks>
[Collection("SqlAuthenticationProvider")]
[Collection(SqlAuthenticationProviderCollection.Name)]
public class AuthorityParsingTests
{
private const string Tenant = "72f988bf-86f1-41af-91ab-2d7cd011db47";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="global::Microsoft.Data.SqlClient.SqlAuthenticationProvider"/> registry.
/// </summary>
[CollectionDefinition("SqlAuthenticationProvider")]
[CollectionDefinition(Name)]
public class SqlAuthenticationProviderCollection
{
/// <summary>
/// The collection name used by tests that mutate global authentication provider state.
/// </summary>
public const string Name = "SqlAuthenticationProvider";
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Verifies missing-window diagnostics without opening UI or contacting Entra ID.
/// </summary>
[Collection(SqlAuthenticationProviderCollection.Name)]
public class WamBrokerErrorTests
{
/// <summary>
/// Both first-party and custom applications using WAM receive actionable guidance on Windows.
/// Other platforms retain the original MSAL diagnostic.
/// </summary>
[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);
}
}

/// <summary>
/// Non-broker flows, other authentication methods, and unrelated MSAL failures are not
/// relabeled as missing WAM windows.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="provider">The provider configuration under test.</param>
/// <param name="method">The authentication method under test.</param>
/// <param name="cause">The MSAL failure to propagate through token acquisition.</param>
/// <returns>The exception surfaced by the provider.</returns>
private static async Task<MsalClientException> 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<MsalClientException>(() => provider.AcquireTokenAsync(parameters));
}
finally
{
cache.TryRemove(key, out _);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading