Fix missing tokens when using downstream APIs and Microsoft Identity in ASP.NET Core

This article shows how a secure ASP.NET Core application can use Microsoft Entra ID downstream APIs and an in-memory cache. When using in-memory cache and after restarting an application, the tokens are missing for a value session stored in the cookie. The application needs to recover.

Code: https://github.com/damienbod/bff-aspnetcore-angular

OpenID Connect client setup

The ASP.NET Core application is secured using OpenID code code with PKCE and the Microsoft Entra ID identity provider. The client is implemented using the Microsoft.Identity.Web Nuget package. The application also requires data from Microsoft Graph. This is implemented using the OBO flow from Microsoft. This uses the delegated access token to acquire a graph access token on behalf of the application and the user. The UI application stores the session in a secure cookie. The downstream API tokens are stored in a cache. If the application is restarted, the tokens are missing and the application needs to recover. You can solve this by forcing a login, or using a persistent cache.

The AddMicrosoftIdentityWebAppAuthentication method is used to setup the Microsoft Identity client.

var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
string[] initialScopes = scopes!.Split(' ');

services.AddMicrosoftIdentityWebAppAuthentication(configuration)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph("https://graph.microsoft.com/v1.0", initialScopes)
    .AddInMemoryTokenCaches();

Microsoft Graph downstream API

The MsGraphService class implements the Microsoft Graph delegated client using the Microsoft.Identity.Web.GraphServiceClient Nuget package. This uses the Microsoft Graph V5 APIs. As this is a delegated client, the GraphServiceClient can be directly injected into the service and no token acquisition is required. In the original UI client setup, an in-memory cache was used to store the downstream APIs.

public class MsGraphService
{
    private readonly GraphServiceClient _graphServiceClient;
    private readonly string[] _scopes;

    public MsGraphService(GraphServiceClient graphServiceClient, 
         IConfiguration configuration)
    {
        _graphServiceClient = graphServiceClient;
        var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
        _scopes = scopes!.Split(' ');
    }

    public async Task<User?> GetGraphApiUser()
    {
        return await _graphServiceClient.Me
            .GetAsync(b => b.Options.WithScopes(_scopes));
    }

Revoke the session when the tokens are missing

The RejectSessionCookieWhenAccountNotInCacheEvents class implements the CookieAuthenticationEvents class. This checks the cache if a token exists for the defined scopes. If the token is missing. the cookie session is invalidated and the user must login again. This prevents unwanted exceptions for clients which are hard to recover from.

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;

namespace BffAzureAD.Server;

public class RejectSessionCookieWhenAccountNotInCacheEvents 
       : CookieAuthenticationEvents
{
    private readonly string[] _downstreamScopes;

    public RejectSessionCookieWhenAccountNotInCacheEvents(string[] downstreamScopes)
    {
        _downstreamScopes = downstreamScopes;
    }

    public async override Task ValidatePrincipal(
      CookieValidatePrincipalContext context)
    {
        try
        {
            var tokenAcquisition = context.HttpContext.RequestServices
                .GetRequiredService<ITokenAcquisition>();

            string token = await tokenAcquisition.GetAccessTokenForUserAsync(
                scopes: _downstreamScopes, user: context.Principal);
        }
        catch (MicrosoftIdentityWebChallengeUserException ex)
           when (AccountDoesNotExitInTokenCache(ex))
        {
            context.RejectPrincipal();
        }
    }

    private static bool AccountDoesNotExitInTokenCache(
          MicrosoftIdentityWebChallengeUserException ex)
    {
        return ex.InnerException is MsalUiRequiredException 
            && (ex.InnerException as MsalUiRequiredException)!.ErrorCode 
                   == "user_null";
    }
}

The service is added to the applicaiton and the user will only have cookies with valid in-memory tokens.

// If using downstream APIs and in memory cache, you need to reset the cookie session if the cache is missing
// If you use persistent cache, you do not require this.
// You can also return the 403 with the required scopes, this needs special handling for ajax calls
// The check is only for single scopes
services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme, 
    options =>  options.Events = new RejectSessionCookieWhenAccountNotInCacheEvents(initialScopes));

Alternative solutions

You can also solve this problem in different ways. One quick way would be to use a persistent cache like Redis or a user session. See the cache link at the bottom from the Microsoft.Identity.Web Wiki. You could also return a HTTP 403 response with the missing tokens and force the UI to reauthenticate requesting the access token for the scopes. This can be complicated when sending ajax requests.

Other ways of solving this problem:

  • Use a persistent cache
  • Don’t use downstream APIs
  • Return a 403 challenge which requests the missing scopes and this response needs to be handling correctly

Per default using in-memory cache and downstream APIs require extra logic and implementation.

Links

https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization

https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access

https://github.com/AzureAD/microsoft-identity-web/issues/13#issuecomment-878528492

2 comments

  1. […] Fix missing tokens when using downstream APIs and Microsoft Identity in ASP.NET Core (Damien Bowden) […]

  2. I am looking for a way to manage AzureKeyVault secrets via custom API. You mention the Microsoft.Graph API but it has a special “services.AddMicrosoftGraph” to help with tokens etc. (https://github.com/damienbod/bff-aspnetcore-angular/blob/main/server/Program.cs#L39)

    Can you do a post on calling a custom API endpoint, that is auth’d under the same B2C Tenant?

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.