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:
What is happening:
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 tl/dr questions are, using the Microsoft Identity libray/MSAL, how do I:
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.
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: