AzureAD / microsoft-authentication-library-for-dotnet

Microsoft Authentication Library (MSAL) for .NET
https://aka.ms/msal-net
MIT License
1.39k stars 341 forks source link

[Bug] My MSAL Public client application is not able to get tokens from the token cache. #4849

Open acrigney opened 2 months ago

acrigney commented 2 months ago

Library version used

Microsoft.Identity.Client 4.61.3 and 4.62.0.Preview and Microsoft.Identity.Client.Extensions.Msal 4.61.3 and 4.62.0.Preview

.NET version

.net 8

Scenario

PublicClient - desktop app, PublicClient - mobile app

Is this a new or an existing app?

This is a new app or experiment

Issue description and reproduction steps

I am able to cache tokens, although some of my developers are not able to. But when trying to get a token from the token cache no token is returned even after 20 retries with 2 secs between retries. This was working when I was using a confidential client but as confidential clients are not ment to be used for win or mobile apps. The user has to login interactively again to get a token for the web api calls.

Relevant code snippets

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Client;
using System.Reflection;
using System.Runtime.CompilerServices;
using static Microsoft.Maui.ApplicationModel.Permissions;

using Microsoft.Extensions.Logging;

namespace MAUIClientServices
{
    public class PublicClientSingleton
    {
        public static PublicClientSingleton Instance { get; private set; } = new PublicClientSingleton();
        private static IConfiguration AppConfiguration;
        public DownstreamApiHelper DownstreamApiHelper { get; }
        public MSALClientHelper MSALClientHelper { get; }
        public bool UseEmbedded { get; set; } = false;
        private AzureAdConfig _azureAdConfig;
        //private readonly ILogger<PublicClientSingleton> _logger;

        public NetworkAccess NetworkStatus  { get; set; }

        //public ILoggerFactory LoggerFactory { get; set; }

        private PublicClientSingleton()
        {
            //_azureAdConfig = azureAdConfig;
            //_logger = LoggerFactory.CreateLogger<PublicClientSingleton>(); // Maybe TODO the LoggerFactory is not available yet make it available across platform
            _azureAdConfig = GetAzureAdConfig();
            this.MSALClientHelper = new MSALClientHelper(_azureAdConfig);
            DownStreamApiConfig downStreamApiConfig = AppConfiguration.GetSection("DownstreamApi").Get<DownStreamApiConfig>();
            this.DownstreamApiHelper = new DownstreamApiHelper(downStreamApiConfig, this.MSALClientHelper);            
        }

        public static AzureAdConfig GetAzureAdConfig()
        {
            var assembly = Assembly.GetEntryAssembly();
            using var stream = assembly.GetManifestResourceStream("TREESMobileMAUIApp3.appsettings.json");
            AppConfiguration = new ConfigurationBuilder().AddJsonStream(stream).Build();
            return AppConfiguration.GetSection("AzureAd").Get<AzureAdConfig>();
        }

        public async Task<string> AcquireTokenSilentAsync()
        {
            return await AcquireTokenSilentAsync(GetScopes()).ConfigureAwait(false);
        }

        //public async Task<string> AcquireTokenSilentAsync(string[] scopes)
        //{
        //    var accounts = await MSALClientHelper.PublicClientApplication.GetAccountsAsync().ConfigureAwait(false);
        //    var account = accounts.FirstOrDefault();

        //    if (account != null)
        //    {
        //        var tokenCache = MSALClientHelper.PublicClientApplication.UserTokenCache;
        //        var accessTokenItem = tokenCache.FindAccessToken(new AuthenticationRequestParameters
        //        {
        //            Account = account,
        //            Scopes = scopes,
        //            Authority = MSALClientHelper.PublicClientApplication.Authority,
        //        });

        //        if (accessTokenItem != null && accessTokenItem.ExpiresOn > DateTimeOffset.UtcNow)
        //        {
        //            return accessTokenItem.Secret;
        //        }
        //    }

        //    if (IsNetworkAvailable())
        //    {
        //        try
        //        {
        //            return await MSALClientHelper.SignInUserAndAcquireAccessToken(scopes).ConfigureAwait(false);
        //        }
        //        catch (MsalUiRequiredException)
        //        {
        //            if (IsNetworkAvailable())
        //            {
        //                return await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);
        //            }
        //            else
        //            {
        //                NotifyUserNoNetwork();
        //                return null;
        //            }
        //        }
        //    }
        //    else
        //    {
        //        NotifyUserNoNetwork();
        //        return null;
        //    }
        //}

        public async Task<IAccount> GetFirstAccountAsync()
        {
            var accounts = await MSALClientHelper.PublicClientApplication.GetAccountsAsync().ConfigureAwait(false);
            return accounts.FirstOrDefault();
        }

        //public async Task<string> AcquireTokenSilentAsync(string[] scopes)
        //{
        //    var accounts = await MSALClientHelper.PublicClientApplication.GetAccountsAsync().ConfigureAwait(false);
        //    var account = accounts.FirstOrDefault();

        //    if (account != null)
        //    {
        //        try
        //        {
        //            var result = await MSALClientHelper.PublicClientApplication.AcquireTokenSilent(scopes, account).ExecuteAsync().ConfigureAwait(false);

        //            // Check if the token is expired
        //            if (IsTokenExpired(result))
        //            {
        //                if (IsNetworkAvailable())
        //                {
        //                    var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

        //                    if (token != null)
        //                    {
        //                        Console.WriteLine($"Token expired and new token obtained interactively.");
        //                    }
        //                    else
        //                    {
        //                        Console.WriteLine($"Token expired and new token could not be obtained interactively.");
        //                    }
        //                    return token;
        //                }
        //                else
        //                {
        //                    Console.WriteLine($"Token expired and new token could not be obtained as the network was not available.");
        //                    NotifyUserNoNetwork();
        //                    return null;
        //                }
        //            }

        //            return result.AccessToken;
        //        }
        //        catch (MsalUiRequiredException ex)
        //        {
        //            Console.WriteLine($"MSALUI exception {ex.GetBaseException().Message}.");
        //            if (IsNetworkAvailable())
        //            {
        //                Console.WriteLine($"Attempting to acquire new token interactively.");
        //                var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

        //                if (token != null)
        //                {
        //                    Console.WriteLine($"New token obtained interactively after error {ex.GetBaseException().Message}.");
        //                }
        //                else
        //                {
        //                    Console.WriteLine($"Unable to obtained new token interactively.");
        //                }
        //                return token;
        //            }
        //            else
        //            {
        //                Console.WriteLine($"Unable to obtained new token after error as network not available.");
        //                NotifyUserNoNetwork();
        //                return null;
        //            }
        //        }
        //    }
        //    else
        //    {
        //        Console.WriteLine($"No account available attempting to obtain token interactively.");
        //        //if (IsNetworkAvailable())
        //        {                    
        //            var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

        //            if (token != null)
        //            {
        //                Console.WriteLine($"No previous account available but new token obtained interactively.");
        //            }
        //            else
        //            {
        //                Console.WriteLine($"No previous account available and unable to obtained new token interactively.");
        //            }
        //            return token;
        //        }
        //        //else
        //        //{
        //        //    NotifyUserNoNetwork();
        //        //    return null;
        //        //}
        //    }
        //}

        public async Task<string> AcquireTokenSilentAsync(string[] scopes)
        {
            var account = await GetFirstAccountAsync();

            if (account != null)
            {
                try
                {
                    var result = await MSALClientHelper.PublicClientApplication.AcquireTokenSilent(scopes, account).ExecuteAsync().ConfigureAwait(false);

                    if (IsTokenExpired(result))
                    {
                        if (IsNetworkAvailable())
                        {
                            var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

                            var firstAccount = await GetFirstAccountAsync();
                            if (token != null)
                            {
                                Console.WriteLine($"Token expired and new token obtained interactively.");
                            }
                            else
                            {
                                Console.WriteLine($"Token expired and new token could not be obtained interactively.");
                            }
                            return token;
                        }
                        else
                        {
                            Console.WriteLine($"Token expired and new token could not be obtained as the network was not available.");
                            NotifyUserNoNetwork();
                            return null;
                        }
                    }

                    return result.AccessToken;
                }
                catch (MsalUiRequiredException ex)
                {
                    Console.WriteLine($"MSALUI exception {ex.GetBaseException().Message}.");
                    if (IsNetworkAvailable())
                    {
                        Console.WriteLine($"Attempting to acquire new token interactively.");
                        var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

                        var firstAccount = await PublicClientSingleton.Instance.GetFirstAccountAsync();

                        if (token != null)
                        {
                            Console.WriteLine($"New token obtained interactively after error {ex.GetBaseException().Message}.");
                        }
                        else
                        {
                            Console.WriteLine($"Unable to obtain new token interactively.");
                        }
                        return token;
                    }
                    else
                    {
                        Console.WriteLine($"Unable to obtain new token after error as network not available.");
                        NotifyUserNoNetwork();
                        return null;
                    }
                }
            }
            else
            {
                Console.WriteLine($"No account available attempting to obtain token interactively.");
                var token = await AcquireTokenInteractiveAsync(scopes).ConfigureAwait(false);

                var firstAccount = await PublicClientSingleton.Instance.GetFirstAccountAsync();

                if (token != null)
                {
                    Console.WriteLine($"No previous account available but new token obtained interactively.");
                }
                else
                {
                    Console.WriteLine($"No previous account available and unable to obtain new token interactively.");
                }
                return token;
            }
        }

        private bool IsTokenExpired(AuthenticationResult result)
        {
            var isTokenExpired = result.ExpiresOn <= DateTimeOffset.UtcNow;
            Console.WriteLine($"Has MSAL token expired = {isTokenExpired}.");
            return isTokenExpired;
        }

        internal async Task<string> AcquireTokenInteractiveAsync(string[] scopes = null)
        {
            MSALClientHelper.UseEmbedded = UseEmbedded;
            scopes ??= GetScopes();

            var result = await MSALClientHelper.SignInUserInteractivelyAsync(scopes).ConfigureAwait(false);

            // Explicitly get the accounts after interactive login
            var accounts = await MSALClientHelper.PublicClientApplication.GetAccountsAsync().ConfigureAwait(false);
            var firstAccount = accounts.FirstOrDefault();

            if (firstAccount != null)
            {
                Console.WriteLine($"Account is cached successfully after interactive login.");
            }
            else
            {
                Console.WriteLine($"Account is NOT cached after interactive login.");
            }

            return result.AccessToken;
        }

        //internal async Task<string> AcquireTokenInteractiveAsync(string[] scopes = null)
        //{
        //    MSALClientHelper.UseEmbedded = UseEmbedded;
        //    scopes ??= GetScopes();

        //    //if (IsNetworkAvailable())
        //    {
        //        var result = await MSALClientHelper.SignInUserInteractivelyAsync(scopes).ConfigureAwait(false);
        //        return result.AccessToken;
        //    }
        //    //else
        //    //{
        //    //    NotifyUserNoNetwork();
        //    //    return null;
        //    //}
        //}

        public async Task SignOutAsync()
        {
            await MSALClientHelper.SignOutUserAsync().ConfigureAwait(false);
        }

        internal string[] GetScopes()
        {
            return DownstreamApiHelper.DownstreamApiConfig.ScopesArray;
        }

        private bool IsNetworkAvailable()
        {
            // For example, you could any other network checking library.

            bool networkStatus = (NetworkStatus == NetworkAccess.Internet);
            //bool networkStatus = true;
            Console.WriteLine($"Network status is {NetworkStatus} which is {((networkStatus == true) ? "is" : "is not")} available.");
            return networkStatus; 
        }

        private void NotifyUserNoNetwork()
        {
            // Implement user notification logic.
            // For example, you could use a pop-up dialog or a toast notification.
            Console.WriteLine("Network is not available. Please check your connection and try again.");            
        }
    }
}

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensions.Msal;
using Microsoft.IdentityModel.Abstractions;
using System.Diagnostics;

namespace MAUIClientServices
{
    /// <summary>
    /// Contains methods that initialize and use the MSAL SDK
    /// </summary>
    public class MSALClientHelper
    {
        public string UserName { get; set; }
        /// <summary>
        /// As for the Tenant, you can use a name as obtained from the azure portal, e.g. kko365.onmicrosoft.com"
        /// </summary>
        public AzureAdConfig AzureAdConfig;

        /// <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 = "Contoso.MyProduct";

        public static readonly string KeyChainAccountName = "MSALCache";

        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(AzureAdConfig azureAdConfig)
        {
            AzureAdConfig = azureAdConfig;

            this.InitializePublicClientApplicationBuilder();
        }

        /// <summary>
        /// Initializes the MSAL's PublicClientApplication builder from config.
        /// </summary>
        /// <autogeneratedoc />
        private void InitializePublicClientApplicationBuilder()
        {
            var authority = String.Format("{0}/{1}", AzureAdConfig.Authority, AzureAdConfig.TenantId);
            //var authority = String.Format("{0}", AzureAdConfig.Authority);
            this.PublicClientApplicationBuilder = PublicClientApplicationBuilder.Create(AzureAdConfig.ClientId)
                .WithExperimentalFeatures() // this is for upcoming logger
                .WithAuthority(authority)
                .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.msalcache");

            // Set redirect URI based on platform

        }

        /// <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(PlatformConfig.Instance.RedirectUri)
                .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(AzureAdConfig.CacheFileName, AzureAdConfig.CacheDir, AzureAdConfig.ClientId)
                 //.WithLinuxKeyring(
                 //    AzureAdConfig.LinuxKeyRingSchema,
                 //    AzureAdConfig.LinuxKeyRingCollection,
                 //    AzureAdConfig.LinuxKeyRingLabel,
                 //    AzureAdConfig.LinuxKeyRingAttr1,
                 //    AzureAdConfig.LinuxKeyRingAttr2)
                 //.WithMacKeyChain(
                 //    AzureAdConfig.KeyChainServiceName,
                 //    AzureAdConfig.KeyChainAccountName)
                 .Build();
            //var storageProperties = new StorageCreationPropertiesBuilder(AzureAdConfig.CacheFileName, AzureAdConfig.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)
        {
            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
                {
                    this.AuthResult = await SignInUserInteractivelyAsync(scopes);
                }
            }
            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}");

                this.AuthResult = await this.PublicClientApplication
                    .AcquireTokenInteractive(scopes)
                    .ExecuteAsync()
                    .ConfigureAwait(false);
            }
            catch (MsalException msalEx)
            {
                Debug.WriteLine($"Error Acquiring Token interactively:{Environment.NewLine}{msalEx}");
            }

            UserName = this.AuthResult.Account.Username;

            return this.AuthResult.AccessToken;
        }

        /// <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())
            {
                return await this.PublicClientApplication.AcquireTokenInteractive(scopes)
                    .WithParentActivityOrWindow(PlatformConfig.Instance.ParentWindow)
                    .ExecuteAsync()
                    .ConfigureAwait(false);
            }

            // 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 ClearTokenCacheAsync()
        {
            var accounts = await this.PublicClientApplication.GetAccountsAsync();

            foreach (var account in accounts)
            {
                await this.PublicClientApplication.RemoveAsync(account);
            }

            accounts = await this.PublicClientApplication.GetAccountsAsync();

        }
        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();
        }
    }
}

Expected behavior

A token should be retrieved at least after a few retries.

Identity provider

Microsoft Entra ID (Work and School accounts and Personal Microsoft accounts)

Regression

No response

Solution and workarounds

No work around yet.

GerryOnGithub commented 1 month ago

I also can't get await publicClientApplication.AcquireTokenInteractive(scopes).ExecuteAsync(); working. The browser comes up and all is well, except AcquireTokenInteractive never returns.

GerryOnGithub commented 1 month ago

I tried the code at https://github.com/jstedfast/MailKit/blob/master/Documentation/Examples/OAuth2ExchangeExample.cs No Luck.