How do you handle authentication and token refresh with Microsoft Identity and Azure AD

691 Views Asked by At

I'm attempting to secure a .Net 6.0 / Razor Page web application against Azure AD. I was able to complete the application registration with Azure AD and successfully authenticate users. The issue I'm facing occurs when the issued token expires. I have some experience working with Angular and IdentityServer implementations, but Razor Page/Microsoft Identity is still new to me.

What I would like to happen:

  • The user logs in with their Microsoft account
  • The user's session is uninterrupted for up to 12 hours (with all token management happening behind the scenes)
  • After 12 hours the session/cookies will expire and the user will need to log in again

What is happening:

  • The user logs in and is authenticated
  • After approximately one hour, the application triggers a call to the /authorize endpoint the next time the user takes any action (such as trying to navigate to a new page)
  • This causes the application to reload on the page the user was currently on (thus interrupting their experience)

Additional Issue: I am also receiving a CORS error under similar circumstances as above. The difference here is this is occurring when the user is in the middle of form data entry when the (presumed) token expiration occurs. When they click submit to post the form, a 302 xhr / Redirect to the /authorize endpoint is triggered. This call results in a CORS error. Refreshing the page is required to trigger a successful call (and they need to start over on their form). Update: This is occurring due to an AJAX call (nothing to do with the form/post specifically). See the edit at the end.

Ideally, I would like the token to be automatically (and silently) refreshed via a refresh token once it is nearing expiration. I would also, of course, like to avoid the scenario of the CORS error when they are attempting to post when the token has expired.

Some code snippets (note: I'm manually adding authentication to an existing app, I did not use any scaffolding/templates for the initial project creation).

Note: I initially tried the below implementation without defining custom authOptions, but during debugging and different attempts at resolution, it exists in the below state. Results were consistent either way.

Program.cs

        var builder = WebApplication.CreateBuilder(args);
        var config = builder.Configuration;
        var services = builder.Services;

        services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
           .AddMicrosoftIdentityWebApp(
            authOptions =>
           {
               config.Bind("AzureAD", authOptions);
               authOptions.MaxAge = TimeSpan.FromHours(12);
               authOptions.SaveTokens = true;
           },
            sessionOptions =>
           {
               sessionOptions.Cookie.MaxAge = TimeSpan.FromHours(12);
               sessionOptions.Cookie.Name = "Custom-Cookie-Name";
               sessionOptions.ExpireTimeSpan = TimeSpan.FromHours(12);
               sessionOptions.SlidingExpiration = false;
           })
           .EnableTokenAcquisitionToCallDownstreamApi(config.GetValue<string>("GraphApi:Scopes")?.Split(' '))
           .AddMicrosoftGraph(config.GetSection("GraphApi"))
           .AddSessionTokenCaches();

        services.AddRazorPages(options =>
        {
            options.Conventions.AddPageRoute("/Disclaimer", "/");
        })
        .AddMvcOptions(options =>
        {
            var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
            options.Filters.Add(new AuthorizeFilter(policy));
        });

        services.AddHttpContextAccessor();
      ........
        var app = builder.Build();

        if (!app.Environment.IsDevelopment())
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseRouting();

        app.UseSession();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
            endpoints.MapRazorPages();
        });

        app.UseSaveUserDetailsOnAuthentication();
        app.UseIdentityPageInitialization();

        app.MapRazorPages();
        app.MapControllers();

        app.Run();

I also have some middleware that is using the graph service to hit the /me endpoint and store some user details under specific conditions (in case this is relevant):

Graph Middleware

    public async Task InvokeAsync(HttpContext context, UserManager<ApplicationUser> userManager, GraphServiceClient graphServiceClient)
    {
        var page = context.GetRouteValue("page")?.ToString();

        if (!page.IsNullOrEmpty() && page.Equals("/Disclaimer") && context.User.Identity?.IsAuthenticated == true)
        {
            var user = await graphServiceClient.Me
            .Request()
            .GetAsync()
            .ConfigureAwait(false);

The below snippet is what occurs when attempting the post scenario above. The CORS error after posting

The tl/dr questions are, using the Microsoft Identity libray/MSAL, how do I:

  • Silently refresh a user's token
  • Avoid reloading the page to get a new token (i.e.: calling /authorize and redirecting to obtain a new token)
  • Handle token expiration from the client-side (avoid the CORS error when posting a form). Do I need to add an additionally client-side js library to manage this?

I've tried scouring Microsoft's documentation, but nothing I've found goes into detail on this. The closest I found was MSAL's documentation mentioning that it handles token refresh for you (but it seemingly isn't happening in my case).

I'm expecting that the token will be silently refreshed by the underlying MSAL library, but that does not appear to be happening. Additionally, I'm expecting to avoid CORS errors on the front-end related to token expiration.

EDIT: While my main question still remains, I believe I found the resolution for the secondary issue: the CORS issue which is actually triggered via an AJAX call to the API. This article outlines that Microsoft.Identity.Web v1.2.0+ now handles this scenario. I now have a vague idea on how to handle it, but still need to attempt the implementation.

1

There are 1 best solutions below

0
On BEST ANSWER

I found a reference here explaining that these session token caches have a scoped lifetime and should not be used when TokenAcquisition is used as a singleton, which I believe is the case with the use of the Microsoft Graph API ("AddMicrosoftGraph").

I switched the session token cache to a distributed SQL token cache. However, I do not believe any of this was actually the root issue.

I've identified an issue causing my server (clustered behind a LB without sticky sessions) encryption keys to not be correctly stored/shared in a distributed store. What was happening is any idle timeout in ISS would reset them, causing the auth cookie to be unusable. Additionally, any time the app would hit a different web server behind the LB, the existing auth cookie to be unusable by the new server (because they were using separate keys). So in both scenarios the application would redirect the user for authentication.

The fix for this was simply implementing a distributed key store as described here. The provided stores did not work for me, due to restrictions put in place by my client, so I just implemented a custom IXmlRepository and registered it:

services.Configure<KeyManagementOptions>(options => options.XmlRepository = new CustomXmlRepository());

So at the end of the day I had the following issues:

  1. The auth cookie was becoming invalidated due to changing/lost keys as described above: Resolved by adding a distributed key store
  2. The Microsoft GraphServiceClient was unable to obtain access tokens/refresh tokens (resulting in MSAL errors), due to a lack of a distributed token store as well as due to changing/lost keys (when I was storing tokens in the cookies): Resolved by adding a distributed token store (described here)