I use MSAL to authenticate users against Azure AD B2C in my .NET MAUI app targeting .NET 9.
Everything was working fine when my app targeted .NET 8 but since upgrading it to .NET 9, user login gets stuck on Android -- things work fine on iOS. It launches the browser but then doesn't go anywhere. The login page never comes up.
I see the following errors/warning in the console:
MSAL: EventLogLevel: Warning, Message: False MSAL 4.66.2.0 MSAL.Xamarin.Android .NET 9.0.0 32 [2025-01-08 07:15:15Z - a4d1f3c3-5806-4c30-976f-e8a4dd617008] Browser with custom tabs package not available. Launching with alternate browser. See https://aka.ms/msal-net-system-browsers for details.
The MSAL code is pretty much what Microsoft provided in this repo: https://github.com/Azure-Samples/ms-identity-dotnetcore-maui
The only change I made is that I added bool shouldPromptLogin
because I don't want my app to automatically open up a browser with login form. I want users to go to my LoginPage.xaml
and tap a button to start the login process. All the other code should be what Microsoft provided in the code sample.
BTW, I commented out the .WithUseEmbeddedWebView(true)
in MSALClientHelper.cs
but that didn't fix the issue.
I'd appreciate any suggestions on how to fix this issue. Thanks!
Here's the MSALClientHelper.cs
:
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensions.Msal;
using Microsoft.IdentityModel.Abstractions;
using System.Diagnostics;
namespace MyApp.Utils.MSALClient
{
public class MSALClientHelper
{
/// <summary>
/// As for the Tenant, you can use a name as obtained from the azure portal, e.g. kko365.onmicrosoft.com"
/// </summary>
public AzureADB2CConfig AzureADB2CConfig;
/// <summary>
/// Gets the authentication result (if available) from MSAL's various operations.
/// </summary>
/// <value>
/// The authentication result.
/// </value>
public AuthenticationResult AuthResult { get; private set; }
/// <summary>
/// Gets the MSAL public client application instance.
/// </summary>
/// <value>
/// The public client application.
/// </value>
public IPublicClientApplication PublicClientApplication { get; private set; }
/// <summary>
/// This will determine if the Interactive Authentication should be Embedded or System view
/// </summary>
public bool UseEmbedded { get; set; } = false;
/// <summary>
/// The PublicClientApplication builder used internally
/// </summary>
private PublicClientApplicationBuilder PublicClientApplicationBuilder;
// Token Caching setup - Mac
public static readonly string KeyChainServiceName = "MyCompany.MyApp";
public static readonly string KeyChainAccountName = "MSALCache";
// Token Caching setup - Linux
public static readonly string LinuxKeyRingSchema = "com.mycompany.myapp.msaltokencache";
public static readonly string LinuxKeyRingCollection = MsalCacheHelper.LinuxKeyRingDefaultCollection;
public static readonly string LinuxKeyRingLabel = "MSAL token cache for MyApp.";
public static readonly KeyValuePair<string, string> LinuxKeyRingAttr1 = new KeyValuePair<string, string>("Version", "1");
public static readonly KeyValuePair<string, string> LinuxKeyRingAttr2 = new KeyValuePair<string, string>("ProductGroup", "Contoso");
private static string PCANotInitializedExceptionMessage = "The PublicClientApplication needs to be initialized before calling this method. Use InitializePublicClientAppAsync() to initialize.";
/// <summary>
/// Initializes a new instance of the <see cref="MSALClientHelper"/> class.
/// </summary>
public MSALClientHelper(AzureADB2CConfig azureADB2CConfig)
{
AzureADB2CConfig = azureADB2CConfig;
this.InitializePublicClientApplicationBuilder();
}
/// <summary>
/// Initializes the MSAL's PublicClientApplication builder from config.
/// </summary>
/// <autogeneratedoc />
private void InitializePublicClientApplicationBuilder()
{
this.PublicClientApplicationBuilder = PublicClientApplicationBuilder.Create(AzureADB2CConfig.ClientId)
.WithExperimentalFeatures() // this is for upcoming logger
.WithB2CAuthority($"{AzureADB2CConfig.Instance}/tfp/{AzureADB2CConfig.Domain}/{AzureADB2CConfig.SignUpSignInPolicyid}")
.WithLogging(new IdentityLogger(EventLogLevel.Warning), enablePiiLogging: false) // This is the currently recommended way to log MSAL message. For more info refer to https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/logging. Set Identity Logging level to Warning which is a middle ground
.WithIosKeychainSecurityGroup("com.microsoft.adalcache");
}
/// <summary>
/// Initializes the public client application of MSAL.NET with the required information to correctly authenticate the user.
/// </summary>
/// <returns></returns>
public async Task<IAccount> InitializePublicClientAppAsync()
{
// Initialize the MSAL library by building a public client application
this.PublicClientApplication = this.PublicClientApplicationBuilder
.WithRedirectUri($"msal{PublicClientSingleton.Instance.MSALClientHelper.AzureADB2CConfig.ClientId}://auth")
#if ANDROID
.WithParentActivityOrWindow(() => Platform.CurrentActivity)
#endif
.Build();
await AttachTokenCache();
return await FetchSignedInUserFromCache().ConfigureAwait(false);
}
/// <summary>
/// Attaches the token cache to the Public Client app.
/// </summary>
/// <returns>IAccount list of already signed-in users (if available)</returns>
private async Task<IEnumerable<IAccount>> AttachTokenCache()
{
if (DeviceInfo.Current.Platform != DevicePlatform.WinUI)
{
return null;
}
// Cache configuration and hook-up to public application. Refer to https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache#configuring-the-token-cache
var storageProperties = new StorageCreationPropertiesBuilder(AzureADB2CConfig.CacheFileName, AzureADB2CConfig.CacheDir)
.Build();
var msalcachehelper = await MsalCacheHelper.CreateAsync(storageProperties);
msalcachehelper.RegisterCache(PublicClientApplication.UserTokenCache);
// If the cache file is being reused, we'd find some already-signed-in accounts
return await PublicClientApplication.GetAccountsAsync().ConfigureAwait(false);
}
/// <summary>
/// Signs in the user and obtains an Access token for a provided set of scopes
/// </summary>
/// <param name="scopes"></param>
/// <returns> Access Token</returns>
public async Task<string> SignInUserAndAcquireAccessToken(string[] scopes, bool shouldPromptLogin)
{
Exception<NullReferenceException>.ThrowOn(() => this.PublicClientApplication == null, PCANotInitializedExceptionMessage);
var existingUser = await FetchSignedInUserFromCache().ConfigureAwait(false);
try
{
// 1. Try to sign-in the previously signed-in account
if (existingUser != null)
{
this.AuthResult = await this.PublicClientApplication
.AcquireTokenSilent(scopes, existingUser)
.ExecuteAsync()
.ConfigureAwait(false);
}
else
{
if (shouldPromptLogin)
this.AuthResult = await SignInUserInteractivelyAsync(scopes);
else
return null;
}
}
catch (MsalUiRequiredException ex)
{
// A MsalUiRequiredException happened on AcquireTokenSilentAsync. This indicates you need to call AcquireTokenInteractive to acquire a token interactively
Debug.WriteLine($"MsalUiRequiredException: {ex.Message}");
if(shouldPromptLogin)
this.AuthResult = await this.PublicClientApplication
.AcquireTokenInteractive(scopes)
.ExecuteAsync()
.ConfigureAwait(false);
}
catch (MsalException msalEx)
{
Debug.WriteLine($"Error Acquiring Token interactively:{Environment.NewLine}{msalEx}");
}
return this.AuthResult != null && !string.IsNullOrWhiteSpace(this.AuthResult.AccessToken) ? this.AuthResult.AccessToken : null;
}
/// <summary>
/// Signs the in user and acquire access token for a provided set of scopes.
/// </summary>
/// <param name="scopes">The scopes.</param>
/// <param name="extraclaims">The extra claims, usually from CAE. We basically handle CAE by sending the user back to Azure AD for
/// additional processing and requesting a new access token for Graph</param>
/// <returns></returns>
public async Task<String> SignInUserAndAcquireAccessToken(string[] scopes, string extraclaims)
{
Exception<NullReferenceException>.ThrowOn(() => this.PublicClientApplication == null, PCANotInitializedExceptionMessage);
try
{
// Send the user to Azure AD for re-authentication as a silent acquisition wont resolve any CAE scenarios like an extra claims request
this.AuthResult = await this.PublicClientApplication.AcquireTokenInteractive(scopes)
.WithClaims(extraclaims)
.ExecuteAsync()
.ConfigureAwait(false);
}
catch (MsalException msalEx)
{
Debug.WriteLine($"Error Acquiring Token:{Environment.NewLine}{msalEx}");
}
return this.AuthResult.AccessToken;
}
/// <summary>
/// Shows a pattern to sign-in a user interactively in applications that are input constrained and would need to fall-back on device code flow.
/// </summary>
/// <param name="scopes">The scopes.</param>
/// <param name="existingAccount">The existing account.</param>
/// <returns></returns>
public async Task<AuthenticationResult> SignInUserInteractivelyAsync(string[] scopes, IAccount existingAccount = null)
{
Exception<NullReferenceException>.ThrowOn(() => this.PublicClientApplication == null, PCANotInitializedExceptionMessage);
if (this.PublicClientApplication == null)
throw new NullReferenceException();
if (this.PublicClientApplication.IsUserInteractive())
{
#if IOS
SystemWebViewOptions systemWebViewOptions = new SystemWebViewOptions();
#endif
return await this.PublicClientApplication.AcquireTokenInteractive(scopes)
.WithUseEmbeddedWebView(true)
.WithParentActivityOrWindow(PlatformConfig.Instance.ParentWindow)
.ExecuteAsync()
.ConfigureAwait(false);
#if IOS
// Hide the privacy prompt in iOS
systemWebViewOptions.iOSHidePrivacyPrompt = true;
#endif
}
// If the operating system does not have UI (e.g. SSH into Linux), you can fallback to device code, however this
// flow will not satisfy the "device is managed" CA policy.
return await this.PublicClientApplication.AcquireTokenWithDeviceCode(scopes, (dcr) =>
{
Console.WriteLine(dcr.Message);
return Task.CompletedTask;
}).ExecuteAsync().ConfigureAwait(false);
}
/// <summary>
/// Removes the first signed-in user's record from token cache
/// </summary>
public async Task SignOutUserAsync()
{
var existingUser = await FetchSignedInUserFromCache().ConfigureAwait(false);
await this.SignOutUserAsync(existingUser).ConfigureAwait(false);
}
/// <summary>
/// Removes a given user's record from token cache
/// </summary>
/// <param name="user">The user.</param>
public async Task SignOutUserAsync(IAccount user)
{
if (this.PublicClientApplication == null) return;
await this.PublicClientApplication.RemoveAsync(user).ConfigureAwait(false);
}
/// <summary>
/// Fetches the signed in user from MSAL's token cache (if available).
/// </summary>
/// <returns></returns>
public async Task<IAccount> FetchSignedInUserFromCache()
{
Exception<NullReferenceException>.ThrowOn(() => this.PublicClientApplication == null, PCANotInitializedExceptionMessage);
// get accounts from cache
IEnumerable<IAccount> accounts = await this.PublicClientApplication.GetAccountsAsync();
// Error corner case: we should always have 0 or 1 accounts, not expecting > 1
// This is just an example of how to resolve this ambiguity, which can arise if more apps share a token cache.
// Note that some apps prefer to use a random account from the cache.
if (accounts.Count() > 1)
{
foreach (var acc in accounts)
{
await this.PublicClientApplication.RemoveAsync(acc);
}
return null;
}
return accounts.SingleOrDefault();
}
}
}
And here's PublicClientSingleton.cs
:
using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Client;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace MyApp.Utils.MSALClient
{
public class PublicClientSingleton
{
/// <summary>
/// This is the configuration for the application found within the 'appsettings.json' file.
/// </summary>
IConfiguration AppConfiguration;
/// <summary>
/// This is the singleton used by Ux. Since PublicClientWrapper constructor does not have perf or memory issue, it is instantiated directly.
/// </summary>
public static PublicClientSingleton Instance { get; private set; } = new PublicClientSingleton();
/// <summary>
/// Gets the instance of MSALClientHelper.
/// </summary>
public DownstreamApiHelper DownstreamApiHelper { get; }
/// <summary>
/// Gets the instance of MSALClientHelper.
/// </summary>
public MSALClientHelper MSALClientHelper { get; }
/// <summary>
/// This will determine if the Interactive Authentication should be Embedded or System view
/// </summary>
public bool UseEmbedded { get; set; } = false;
//// Custom logger for sample
//private readonly IdentityLogger _logger = new IdentityLogger();
/// <summary>
/// Prevents a default instance of the <see cref="PublicClientSingleton"/> class from being created. or a private constructor for singleton
/// </summary>
[MethodImpl(MethodImplOptions.NoInlining)]
private PublicClientSingleton()
{
// Load config
// https://stackoverflow.com/questions/70280264/maui-what-build-action-for-appsettings-json-and-how-to-access-the-file-on-andro
var assembly = Assembly.GetExecutingAssembly();
string embeddedConfigfilename = $"{Assembly.GetCallingAssembly().GetName().Name}.appsettings.json";
using var stream = assembly.GetManifestResourceStream(embeddedConfigfilename);
AppConfiguration = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
AzureADB2CConfig azureADConfig = AppConfiguration.GetSection("AzureAdB2C").Get<AzureADB2CConfig>();
this.MSALClientHelper = new MSALClientHelper(azureADConfig);
DownStreamApiConfig downStreamApiConfig = AppConfiguration.GetSection("DownstreamApi").Get<DownStreamApiConfig>();
this.DownstreamApiHelper = new DownstreamApiHelper(downStreamApiConfig, this.MSALClientHelper);
}
/// <summary>
/// Acquire the token silently
/// </summary>
/// <returns>An access token</returns>
public async Task<string> AcquireTokenSilentAsync(bool shouldPromptLogin)
{
// Get accounts by policy
return await this.AcquireTokenSilentAsync(this.GetScopes(), shouldPromptLogin).ConfigureAwait(false);
}
/// <summary>
/// Acquire the token silently
/// </summary>
/// <param name="scopes">desired scopes</param>
/// <returns>An access token</returns>
public async Task<string> AcquireTokenSilentAsync(string[] scopes, bool shouldPromptLogin)
{
var output = await this.MSALClientHelper.SignInUserAndAcquireAccessToken(scopes, shouldPromptLogin).ConfigureAwait(false);
return output;
}
/// <summary>
/// Perform the interactive acquisition of the token for the given scope
/// </summary>
/// <param name="scopes">desired scopes</param>
/// <returns></returns>
internal async Task<AuthenticationResult> AcquireTokenInteractiveAsync(string[] scopes)
{
this.MSALClientHelper.UseEmbedded = this.UseEmbedded;
return await this.MSALClientHelper.SignInUserInteractivelyAsync(scopes).ConfigureAwait(false);
}
/// <summary>
/// It will sign out the user.
/// </summary>
/// <returns></returns>
internal async Task SignOutAsync()
{
await this.MSALClientHelper.SignOutUserAsync().ConfigureAwait(false);
}
/// <summary>
/// Gets scopes for the application
/// </summary>
/// <returns>An array of all scopes</returns>
internal string[] GetScopes()
{
return this.DownstreamApiHelper.DownstreamApiConfig.ScopesArray;
}
}
}
And here's the MainActivity.cs
:
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density, ScreenOrientation = ScreenOrientation.Portrait)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle savedInstanceState)
{
base.OnCreate(savedInstanceState);
// configure platform specific params
PlatformConfig.Instance.RedirectUri = $"msal{PublicClientSingleton.Instance.MSALClientHelper.AzureADB2CConfig.ClientId}://auth";
PlatformConfig.Instance.ParentWindow = this;
// Initialize MSAL and platformConfig is set
_ = Task.Run(async () => await PublicClientSingleton.Instance.MSALClientHelper.InitializePublicClientAppAsync()).Result;
}
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs(requestCode, resultCode, data);
}
}
And finally, here's the AndroidManifest.xml
:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.mycompany.myapp" android:versionCode="176" android:versionName="1.5.4">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" android:label="My App">
<activity android:name="microsoft.identity.client.BrowserTabActivity" android:configChanges="orientation|screenSize" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="msalMY_AADB2C_ID" android:host="auth" />
</intent-filter>
</activity>
<activity android:name="MauiAppBasic.Platforms.Android.Resources.MsalActivity" android:configChanges="orientation|screenSize" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="msalMY_AADB2C_ID" android:host="auth" />
</intent-filter>
</activity>
<meta-data android:name="com.google.android.play.billingclient.version" android:value="6.0.1" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
</queries>
</manifest>
I updated the NuGet packages for Microsoft.Identity.Client
and Microsoft.Identity.Client.Extensions.Msal
to 4.67.0
which fixed the issue. Since then, Microsoft released 4.67.1
and everything's still working as expected.
Prior to updating them, I was using version 4.66.2
for both packages.