oauth-2.0microsoft-graph-apiazure-ad-msal

MSAL.NET OBO refresh token problems


I am trying to implement an OBO flow through to the graph API on a middle-tier API (.NET 5.0) using MSAL.NET. I'm running into two frustrating problems, and I can't find anyone having similar problems, so I think I'm misunderstanding something!

Problem 1: Whenever I call MSAL's GetAccountAsync, it always returns null when there should be an account loaded.

Problem 2: Whenever I call MSAL's AcquireTokenSilent, I always get the error "No refresh token found in the cache." even though I got one.

Here's what I have:

Once the web app authenticates, it passes through the token to a graph auth endpoint on the API:

            var authenticationResult = await ClaimHelper.ClientApplication.AcquireTokenByAuthorizationCode(GraphHelpers.BasicGraphScopes, context.Code).ExecuteAsync();

            var apiUserSession = await CouncilWiseAPIHelper.APIClient.Graph.AuthoriseUserAsync(authenticationResult.AccessToken);

which seems to work fine, and passes through a JWT to the API auth endpoint. The API implements an MSAL Confidential Client application and uses the SetBeforeAccess/SetAfterAccess token cache methods to save the cache to a database.

         _msalClient = ConfidentialClientApplicationBuilder.Create(_graphConfig.ClientId)
            .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs)
            .WithClientSecret(_graphConfig.ClientSecret)
            .Build();

        SetSerialiser(serialiser);
        public void SetSerialiser(MSALTokenCacheSerialiser serialiser)
        {
            _msalClient.UserTokenCache.SetBeforeAccessAsync(serialiser.BeforeAccessCallbackAsync);
            _msalClient.UserTokenCache.SetAfterAccessAsync(serialiser.AfterAccessCallbackAsync);
        }

And the serialiser methods look like this:

public async Task BeforeAccessCallbackAsync(TokenCacheNotificationArgs notification)
        {
            GraphUserTokenCache tokenCache = await _graphUserTokenCacheRepository.GetByUserIdentifier(notification.SuggestedCacheKey);
            if (tokenCache == null)
            {
                tokenCache = await _graphUserTokenCacheRepository.Get(notification.SuggestedCacheKey);
            }

            if (tokenCache != null)
            {
                notification.TokenCache.DeserializeMsalV3(tokenCache.Value);
            }
        }

        public async Task AfterAccessCallbackAsync(TokenCacheNotificationArgs notification)
        {
            if (!notification.HasTokens)
            {
                // Delete from the cache
                await _graphUserTokenCacheRepository.Delete(notification.SuggestedCacheKey);
            }

            if (!notification.HasStateChanged)
            {
                return;
            }
            GraphUserTokenCache tokenCache;
            if (notification.SuggestedCacheKey == notification.Account.HomeAccountId.Identifier)
            {
                tokenCache = await _graphUserTokenCacheRepository.GetByUserIdentifier(notification.SuggestedCacheKey);
            }
            else
            {
                tokenCache = await _graphUserTokenCacheRepository.Get(notification.SuggestedCacheKey);
            }
            if (tokenCache == null)
            {
                var cache = notification.TokenCache.SerializeMsalV3();
                tokenCache = new GraphUserTokenCache
                {
                    Id = notification.SuggestedCacheKey,
                    AccountIdentifier = notification.Account.HomeAccountId.ToString(),
                    Value = cache
                };

                await _graphUserTokenCacheRepository.Add(tokenCache);
            }
            else
            {
                await _graphUserTokenCacheRepository.Update(tokenCache.Id, notification.TokenCache.SerializeMsalV3());
            }
        }

I can see the token BeforeAccess and AfterAccess methods being called, and I can see the caches being created in the database (encryption has been removed while I'm trying to track down this issue). If I inspect the serialised token cache being saved, it NEVER has a refresh token populated, but if I inspect the requests with fiddler I can see a refresh token was indeed provided. Finally, here is the code for retrieving the access token which is called whenever a graph request is made:

public async Task<AuthenticationResult> GetAccessToken(string accountId, string jwtBearerToken)
        {
            try
            {
                IAccount account = null;
                if (accountId.IsNotNullOrEmpty())
                {
                    account = await _msalClient.GetAccountAsync(accountId);
                }

                var scope = _graphConfig.Scopes.Split(' ');

                if (account == null)
                {
                    var result = await _msalClient.AcquireTokenOnBehalfOf(scope,
                        new UserAssertion(jwtBearerToken))
                        .ExecuteAsync();

                    return result;
                }
                else
                {
                    var result = await _msalClient.AcquireTokenSilent(scope, account)
                    .ExecuteAsync();

                    return result;
                }
            }
            catch (MsalClientException ex)
            {
                ex.CwApiLog();
                return null;
            }
            catch(Exception ex)
            {
                ex.CwApiLog();
                return null;
            }
        }

When it's called with the jwtBearerToken, it will successfully call AcquireTokenOnBehalfOf() and the token is cached and a result returned, but when I come back to retrieve the account via GetAccountAsync() it always returns null even though I can see the token cache was loaded in BeforeAccessCallbackAsync().

Also, even if I call AcquireTokenSilent() immediately after acquiring the obo token with the account it just returned, I will get an exception saying there is no refresh token in the cache.

What am I doing wrong here?


Solution

  • I recently ran into the same problem while running a long runing OBO flow, MSAL has recently implemented an interface ILongRunningWebApi for these use cases you can go and see this new documentation

    Here is an extract:

    One OBO scenario is when a web API runs long running processes on behalf of the user (for example, OneDrive which creates albums for you). Starting with MSAL.NET 4.38.0, this can be implemented as such:

    Before you start a long running process, call:

    string sessionKey = // custom key or null
    var authResult = await ((ILongRunningWebApi)confidentialClientApp)
             .InitiateLongRunningProcessInWebApi(
                  scopes,
                  userToken,
                  ref sessionKey)
             .ExecuteAsync();
    

    userToken is a user token used to call this web API. sessionKey will be used as a key when caching and retrieving the OBO token. If set to null, MSAL will set it to the assertion hash of the passed-in user token. It can also be set by the developer to something that identifies a specific user session, like the optional sid claim from the user token (for more information, see Provide optional claims to your app). If the cache already contains a valid OBO token with this sessionKey, InitiateLongRunningProcessInWebApi will return it. Otherwise, the user token will be used to acquire a new OBO token from AAD, which will then be cached and returned.

    In the long-running process, whenever OBO token is needed, call:

    var authResult = await ((ILongRunningWebApi)confidentialClientApp)
             .AcquireTokenInLongRunningProcess(
                  scopes,
                  sessionKey)
             .ExecuteAsync();
    

    Pass the sessionKey which is associated with the current user's session and will be used to retrieve the related OBO token. If the token is expired, MSAL will use the cached refresh token to acquire a new OBO access token from AAD and cache it. If no token is found with this sessionKey, MSAL will throw a MsalClientException. Make sure to call InitiateLongRunningProcessInWebApi first.