androidmauiazure-ad-b2cazure-ad-msal

MSAL login getting stuck in .NET MAUI app


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>

Solution

  • 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.