microsoft-identity-web: Should clear session auth cookie if cache is missing account

from @onovotny and copied from https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2/issues/240

In the Microsoft.Identity.Web library, the system should automatically clear (RejectPrincipal()) the auth cookie if the corresponding account entry is missing from the token cache. This can happen if the cache expired, if it was a memory cache and the server bounced, etc.

The issue is that the system is now in an inconsistent state, where the user is considered logged-in, but any operations to call API’s won’t succeed because there’s no token cache, no refresh token, etc.

I’ve worked around this here: https://github.com/dotnet-foundation/membership

In order to do so, I had to make some changes to ITokenAquisition and the MsalAbstractCacheProvider’s GetCacheKey method.

ITokenAcquisition needed a method to check for a user’s token cache: https://github.com/dotnet-foundation/membership/blob/97b75e30e50aab76bfa5a21f1ab88bf31ae66da4/Microsoft.Identity.Web/TokenAcquisition.cs#L406-L426

In there, it takes the CookieValidatePrincipalContext to get the incoming ClaimsPrincpal as HtttpContext.User is not yet set at that point. It stores it in the HttpContext.Items via the StoreCookieValidatePrincipalContext extension method (used later by the GetCacheKey method so it can derive the appropriate key): https://github.com/dotnet-foundation/membership/blob/97b75e30e50aab76bfa5a21f1ab88bf31ae66da4/Microsoft.Identity.Web/TokenCacheProviders/MsalAbstractTokenCacheProvider.cs#L68-L69

Finally, the CookieAuthenticationOptions needs to be configured to check for and reject the incoming principal (this could/should be moved into the main IdentityPlatform AddMsal extension methods): https://github.com/dotnet-foundation/membership/blob/97b75e30e50aab76bfa5a21f1ab88bf31ae66da4/Membership/Startup.cs#L110-L123

I can submit these changes as PR if you’re in agreement with these changes.

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 12
  • Comments: 60 (1 by maintainers)

Most upvoted comments

@jennyf19 @clairernovotny

Here is the design I propose for this issue:

Add a new method .WithForceLoginWhenEmptyCache() on the MicrosoftIdentityAppCallsWebApiAuthenticationBuilder to force the users to login when there is a session cookie, but no account in the cache (for instance because the cache is an in memory token cache and the application was restarted).

This would be an opt-in method, used like this:

  services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                    .AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
                        .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
                           .WithForceLoginWhenEmptyCache()
                           .AddMicrosoftGraph(Configuration.GetSection("GraphBeta"))
                           .AddInMemoryTokenCaches();

It’s implementation could be something like the following:

public MicrosoftIdentityAppCallsWebApiAuthenticationBuilder WithForceLoginWhenEmptyCache()
{
 Services.Configure<CookieAuthenticationOptions>(cookieScheme, options => options.Events = new RejectSessionCookieWhenAccountNotInCacheEvents());
 return this;
}

and with

 internal class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents
    {
        public async override Task ValidatePrincipal(CookieValidatePrincipalContext context)
        {
            try
            {
                var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService<ITokenAcquisition>();
                string token = await tokenAcquisition.GetAccessTokenForUserAsync(
                    scopes: new[] { "profile" },
                    user: context.Principal);
            }
            catch (MicrosoftIdentityWebChallengeUserException ex)
               when (AccountDoesNotExitInTokenCache(ex))
            {
                context.RejectPrincipal();
            }
        }

        /// <summary>
        /// Is the exception thrown because there is no account in the token cache?
        /// </summary>
        /// <param name="ex">Exception thrown by <see cref="ITokenAcquisition"/>.GetTokenForXX methods.</param>
        /// <returns>A boolean telling if the exception was about not having an account in the cache</returns>
        private static bool AccountDoesNotExitInTokenCache(MicrosoftIdentityWebChallengeUserException ex)
        {
            return ex.InnerException is MsalUiRequiredException
                                      && (ex.InnerException as MsalUiRequiredException).ErrorCode == "user_null";
        }
    }

Alternatively AccountDoesNotExitInTokenCache could be a bool property surfaced on MicrosoftIdentityWebChallengeUserException

@clairernovotny : I provided the work around and tested it.

In Startup.cs:

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                    .AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
                        .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
                           .AddMicrosoftGraph(Configuration.GetSection("GraphBeta"))
                           .AddInMemoryTokenCaches();

Services.Configure<CookieAuthenticationOptions>(cookieScheme, options => options.Events = new RejectSessionCookieWhenAccountNotInCacheEvents());

And then (inspired by what you had)

internal class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents
    {
        public async override Task ValidatePrincipal(CookieValidatePrincipalContext context)
        {
            try
            {
                var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService<ITokenAcquisition>();
                string token = await tokenAcquisition.GetAccessTokenForUserAsync(
                    scopes: new[] { "profile" },
                    user: context.Principal);
            }
            catch (MicrosoftIdentityWebChallengeUserException ex)
               when (AccountDoesNotExitInTokenCache(ex))
            {
                context.RejectPrincipal();
            }
        }

        /// <summary>
        /// Is the exception thrown because there is no account in the token cache?
        /// </summary>
        /// <param name="ex">Exception thrown by <see cref="ITokenAcquisition"/>.GetTokenForXX methods.</param>
        /// <returns>A boolean telling if the exception was about not having an account in the cache</returns>
        private static bool AccountDoesNotExitInTokenCache(MicrosoftIdentityWebChallengeUserException ex)
        {
            return ex.InnerException is MsalUiRequiredException
                                      && (ex.InnerException as MsalUiRequiredException).ErrorCode == "user_null";
        }
    }

Feature request: Request new access token instead of throwing exception

See https://github.com/AzureAD/microsoft-identity-web/issues/588 for details of setup.

It would really help if this (default in-memory cache with API requests) worked after application restarts. It is a really bad dev experience having to clear out the cache before you can test when you have a valid session but no token. Would it be possible to add a feature where if the access token is missing and the identity session is present and valid, then the application gets a new access token instead of throwing an exception. This would help loads

Greetings Damien

This is not constructive in any way, but I’m going to share anyway… This functionality led to a couple of days trying to figure out why my app was losing the token that I expected to be there. I’m building new Blazor Server/ASP.NET Core Web API projects and trying to secure them with Azure B2C. This is my first time doing this, so the documentation wasn’t super helpful. I feel like you need to have a grasp on Microsoft Identity before you can decipher the information in the provided docs.

I eventually stumbled upon https://github.com/Azure-Samples/ms-identity-blazor-server/blob/main/WebApp-OIDC/B2C/README.md, which got me 90% of the way there, but I screwed up by wrapping the call to ‘GetAccessTokenForUserAsync()’ in a try-catch to log whatever the exception was. Today, I finally realized that this exception needed to be left unhandled so that it would bubble up to the ‘ConsentHandler.HandleException()’ call.

I would just like to echo the sentiments of others: this functionality is very unintuitive. It would be nice if the VS2022 Blazor Server template would use a different cache method for tokens (if that would indeed solve the issue). This functionality nearly pushed me away from Azure AD B2C all together in favor of something like an API key auth model. I’m glad I stuck with it and followed the rabbit hole to the bottom, but still. It shouldn’t have been this hard to get Blazor Server querying an API that is protected with Microsoft Identity.

I feel that I wouldn’t have encountered this issue if I was using Azure AD (non-B2C). That development experience was always pretty intuitive and easy to build.

I’ll shut up. Thanks for listening.

Any update here as this seems to be a big blocker for my Blazor server side app atm

This issue stole 3-4 days of my week with great confusion and frustration. Please ensure it gets fixed!

@jmprieur Thanks for your answer 😃 Now Im confused. At the moment when I run the application for the first time everything works fine. I use in-memory cache. When I stop my application and then start it again, it no longer works and returns an exception. I would like that no exception is thrown and that on the second start with in-memory cache it just works. (ie gets a new access token)

Is this possible? I don’t see this configuration in the doc, maybe I missed something.

With the default in-memory implementation, I have to delete the cookies after every start to test. This is in my opinion not good.

I also think that as in-memory is the default, I should be able to test (stop-start, stop-start) without error.

Greetings Damien

@damienbod. Unless I mis-understand, I’m confused. We already have this feature: The authorizeForScopes attribute and the MicrosoftIdentityConsentAndConditionalAccessHandler class.

Did you read this: https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access ?

What else would you have in mind? would you think that we don’t even need to give a chance to the the developer to handle the exception?

Clunky workaround for Blazor Server Apps.

https://github.com/wmgdev/BlazorGraphApi

I had the same issue with blazor in the past but not in the last month. During development I could bypass this error by starting an inprivate browser session and close all inprivate sessions before starting a new one.

At the moment I don’t have this issue and I don’t know if this helps in it. This part of my code it stupid copy/paste without knowing why it works

` // Token acquisition service based on MSAL.NET // and chosen token cache implementation services.Configure<CookiePolicyOptions>(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => false; options.MinimumSameSitePolicy = SameSiteMode.Strict; // Handling SameSite cookie according to https://docs.microsoft.com/en-us/aspnet/core/security/samesite?view=aspnetcore-3.1 options.HandleSameSiteCookieCompatibility(); });

        services.AddOptions();

        services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
            .AddSignIn(x =>
                {
                    Configuration.Bind("AzureAD", x);
                    x.UseTokenLifetime = true;
                    x.GetClaimsFromUserInfoEndpoint = true;
                    x.Events.OnTokenValidated = async ctx =>
                    {

// do something }; }, y => { Configuration.Bind(“AzureAD”, y); });

        // Token acquisition service based on MSAL.NET
        // and chosen token cache implementation
        services.AddWebAppCallsProtectedWebApi(Configuration,
                new[] {Constants.ScopeUserRead})
            .AddInMemoryTokenCaches();

`