microsoft-authentication-library-for-dotnet: [Bug] Inconsistent results using OBO flow

Logs and network traces Without logs or traces, it is unlikely that the team can investigate your issue. Capturing logs and network traces is described in Logging wiki.

Which version of MSAL.NET are you using? Microsoft.Identity.Client 4.34.0 Microsoft.Identity.Web 1.14.0

Platform .Net Framework 4.7.2

What authentication flow has the issue?

  • Mobile
    • Interactive
    • Integrated Windows Authentication
    • Username Password
    • Device code flow (browserless)
  • Web app
    • Authorization code
    • On-Behalf-Of
  • Daemon app
    • Service to Service calls

Other?

  • Sign in with mobile app
  • Call private API which creates confidential client application and calls AquireTokenOnBehalfOf
  • Call downstream graph API (outlook)

Is this a new or existing app? a. The app is in production, and I have upgraded to a new version of MSAL.

Repro

    public class MicrosoftService : IMicrosoftService
    {
        public IConfidentialClientApplication ConfidentialClientApplication { get; set; }
        private readonly ICompanyUserRepo _userRepo;
        private readonly IAuthenticationSettings _authenticationSettings;
        private readonly IDatabaseSettings _databaseSettings;

        public MicrosoftService(
            ICompanyUserRepo userRepo,
            IAuthenticationSettings configuration,
            IDatabaseSettings databaseSettings)
        {
            _userRepo = userRepo;
            _authenticationSettings = configuration;
            _databaseSettings = databaseSettings;

            ConfidentialClientApplication = BuildApp();
        }

        public async Task GetAccessTokenAndUpdateUser(string userId, IEnumerable<string> scopes, string jwt)
        {
            await AquireTokenOnBehalfOf(scopes, jwt);

            var user = _userRepo.GetByIdAndPartitionKeyOrDefault(userId, userId);
            user.MicrosoftIdentity = jwt;

            _userRepo.Update(user);
        }

        public async Task<AuthenticationResult> GetToken(string userId, IEnumerable<string> scopes)
        {
            var user = _userRepo.GetByIdAndPartitionKeyOrDefault(userId, userId);
            var jwt = user.MicrosoftIdentity;

            return await AquireTokenOnBehalfOf(scopes, jwt);
        }

        public async Task RevokeToken(string userId, IEnumerable<string> scopes)
        {
            var user = _userRepo.GetByIdAndPartitionKeyOrDefault(userId, userId);
            var authResult = await AquireTokenOnBehalfOf(scopes, user.MicrosoftIdentity);

            await ConfidentialClientApplication.RemoveAsync(authResult.Account);
            user.MicrosoftIdentity = null;

            _userRepo.Update(user);
        }

        private async Task<AuthenticationResult> AquireTokenOnBehalfOf(IEnumerable<string> scopes, string jwt)
        {
            var userAssertion = new UserAssertion(jwt);
            var res = await ConfidentialClientApplication.AcquireTokenOnBehalfOf(scopes, userAssertion).ExecuteAsync();

            return res;
        }

        private IConfidentialClientApplication BuildApp()
        {
            // The application which retreives the auth token must also use the same client id and redirect url when requesting the token. The auth token from the response is then passed to the AquireTokenByAuthorisationCode method below. 

            IConfidentialClientApplication app = ConfidentialClientApplicationBuilder
                .Create(_authenticationSettings.MicrosoftRevokeAppId)
                .WithRedirectUri(_authenticationSettings.EmailScanWebAppBaseUrl + _authenticationSettings.MicrosoftRedirectAction)
                .WithClientSecret(_authenticationSettings.MicrosoftRevokeAppSecret)
                .WithTenantId(_authenticationSettings.MicrosoftTenantId)
                .Build();

            IMsalTokenCacheProvider cosmosTokenCacheProvider = CreateCosmosTokenCacheSerialiser();
            cosmosTokenCacheProvider.Initialize(app.UserTokenCache);

            return app;
        }

        private IMsalTokenCacheProvider CreateCosmosTokenCacheSerialiser()
        {
            IServiceCollection services = new ServiceCollection().AddLogging();
            var cosmosConnectionString = string.Format("AccountEndpoint={0};AccountKey={1};", _databaseSettings.RevokeGlobalDatabaseUri, _databaseSettings.RevokeGlobalDatabaseKey);

            services.AddDistributedTokenCaches();
            services.AddCosmosCache((CosmosCacheOptions cacheOptions) =>
            {
                cacheOptions.DatabaseName = _databaseSettings.RevokeGlobalDatabase;
                cacheOptions.ContainerName = _authenticationSettings.CosmosMsalContainer;
                cacheOptions.CreateIfNotExists = true;
                cacheOptions.ClientBuilder = new CosmosClientBuilder(cosmosConnectionString);
            });

            IServiceProvider serviceProvider = services.BuildServiceProvider();
            IMsalTokenCacheProvider msalTokenCacheProvider = serviceProvider.GetRequiredService<IMsalTokenCacheProvider>();

            return msalTokenCacheProvider;
        }
    }
}

Expected behavior A token is retrieved which can be used to call the downstream api

Actual behavior Sometimes the token is retrieved, sometimes it returns this error instead:

AADSTS70000: The request was denied because one or more scopes requested are unauthorized or expired. The user must first sign in and grant the client application access to the requested scope.\r\nTrace ID: 19a4f1a4-09cd-4729-8ccb-b1299cc37600\r\nCorrelation ID: 34dc13fe-7c6e-49af-acdc-e85b62088927\r\nTimestamp: 2021-07-15 13:37:24Z

Possible solution

Additional context / logs / screenshots Add any other context about the problem here, such as logs and screenshots.

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Comments: 35 (15 by maintainers)

Most upvoted comments

There may be a different way consent and actual tokens are handled for Mail.Read between AAD (work and school) and MSA (personal) accounts. I’ll try to repro.

@SirElTomato @JoshB82 the scopes look good. In fact MSAL.NET always requests “openid profile offline_access” so that the cache works correctly, so you could use only “mail.read” if you wish.

My api service to call downstream api (outlook)

public class OutlookService : IOutlookService
    {
        private readonly List<string> _scopes = new List<string> { "offline_access", "mail.read", "openid" };
        private readonly IMicrosoftService _microsoftService;
        private readonly GraphServiceClient _graphClient;

        public OutlookService(IMicrosoftService microsoftService)
        {
            _microsoftService = microsoftService;

            var authProvider = new OnBehalfOfProvider(_microsoftService.ConfidentialClientApplication, _scopes);
            _graphClient = new GraphServiceClient(authProvider);
        }

        public async Task<RevokeEmailPagingContent> GetSenders(string userId, string pagingLink, int numberOfEmailsToGetPerPage, string folderToScanDisplayName)
        {
            var token = await _revokeMicrosoftService.GetToken(userId, _scopes);

            var mailFolders = await _graphClient.Me.MailFolders.Request().GetAsync();
            MailFolder mailFolder = mailFolders.ToList().Find(x => x.DisplayName == folderToScanDisplayName);

            if (mailFolder == null)
            {
                throw new ArgumentException("Folder to scan display name does not exist.");
            }

            var queryOptions = new List<QueryOption>();

            if (!string.IsNullOrEmpty(pagingLink))
            {
                new List<QueryOption> {
                    (new QueryOption("$skiptoken", pagingLink)),
                };
            }

            var messages = await _graphClient.Me.MailFolders[mailFolder.Id].Messages.Request(queryOptions)
                .Top(numberOfEmailsToGetPerPage)
                .OrderBy("receivedDateTime desc")
                .GetAsync();

            var emailContent = messages.Select(x => new EmailContent { Id = x.Id, Sender = x.Sender.EmailAddress.Address }).ToList();
            pagingLink = messages.NextPageRequest?
                .QueryOptions?
                .FirstOrDefault(x => string.Equals("$skiptoken", x.Name, StringComparison.InvariantCultureIgnoreCase))?
                .Value;

            return new EmailPagingContent()
            {
                EmailContent = emailContent,
                PagingLink = pagingLink,
            };
        }
    }

@jmprieur I’ve just found out that this error is happening with personal microsoft accounts only, it works fine with organisational accounts.

I have had this issues previously but fixed it by adding the extra scopes to the acquire token interactively method from the mobile app. I believe it is because the scopes required are already consented to for the whole of the organisation for the organisational account I am using.

currently from the app I am using the api://xxxxx as the scope and “mail.read” as the extra scope, are there any more scopes I need to consent to from the initial interactive sign in?

Thanks