Earlier today, I was struggling a bit to get a .NET Core application’s authentication mechanism to behave appropriately for both MVC (view) and API (ajax/json) requests. In .NET Core 2.x, handling this is not as straight-forward as it could be, but it’s doable. Effectively, we want a user requesting a view through a normal browser request to get an authentication challenge / login page, but we want API requests to receive a 401 response and end it there. Under normal circumstances, though, both types of requests would receive the login page.
Within my application, in my first pass to tackle this problem, I attempted to use the built-in status page middleware handlers like to intercept 401 (challenge) requests, but this is a pretty silly thing to do. The Cookie and OAuth middleware handlers already have events that we can intercept/handle directly.
I have a startup extension method to add all the security features to my application which looks kind of like this:
public static IServiceCollection AddSecurity(this IServiceCollection services, IConfigurationRoot configuration) { try { var provider = services.BuildServiceProvider(); var protectionProvider = provider.GetService<IDataProtectionProvider>(); var dataProtector = protectionProvider.CreateProtector("CookieAuthenticationMiddleware", "Cookie", "v2"); // Add authentication services .AddAuthentication(o => { o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; o.DefaultChallengeScheme = "Application"; }) .AddCookie(o => { o.LoginPath = new PathString("/SomeLoginPath"); o.LogoutPath = new PathString(/SomeLogoutPath"); o.Cookie = new CookieBuilder() { Name = ".AspNetCore.Cookies", Domain = "SomeCookieDomain", Path = "SomeCookiePath", Expiration = TimeSpan.FromMinutes(20), SecurePolicy = CookieSecurePolicy.Always, HttpOnly = true }; o.SlidingExpiration = true; o.ExpireTimeSpan = TimeSpan.FromMinutes(20); o.Events = new CookieAuthenticationEvents() { OnValidatePrincipal = async context => { await ValidateAsync(context); } }; o.TicketDataFormat = new TicketDataFormat(dataProtector); }) .AddOAuth(oauthSettings.AuthType, o => { OAuthMiddlewareOptions.SetOptions(o); }); return services; } catch (Exception ex) { throw ex; } }
The OAuthOptions looks like this (with the events that we aren’t interested in removed):
public class OAuthMiddlewareOptions { public static OAuthOptions SetOptions(OAuthOptions options, OAuthSettings oauthSettings) { options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.ClientId = "SomeClientId"; options.ClientSecret = "SomeClientSecret"; options.CallbackPath = new PathString("SomeCallbackPath"); options.AuthorizationEndpoint = "https://someserver/someAuthPath"; options.TokenEndpoint = "https://someserver/someAuthToken"; options.UserInformationEndpoint = "https://someserver/api/user"; options.Scope.Add("identity"); options.Scope.Add("roles"); options.Events = new OAuthEvents { OnCreatingTicket = async context => { await CreateAuthTicket(context, oauthSettings); }, OnTicketReceived = async context => { await TicketReceived(context); }, OnRedirectToAuthorizationEndpoint = async context => { await RedirectToAuthorizationEndpoint(context); }, OnRemoteFailure = async context => { await RequestFailed(context); } }; options.BackchannelHttpHandler = new HttpClientHandler() { ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => { if (oauthSettings.ValidateCert && errors != System.Net.Security.SslPolicyErrors.None) { return false; } return true; } }; return options; } private static async Task RedirectToAuthorizationEndpoint(RedirectContext<OAuthOptions> context) { // Ensure the redirect_uri is https context.Response.Redirect($"{context.RedirectUri.Replace("redirect_uri=http%3A%2F%2F", "redirect_uri=https%3A%2F%2F")}"); await Task.FromResult(0); } }
On the Cookie authentication, the event that we are interested in is OnRedirectToLogin and within the OAuth handler, it’s RedirectToAuthorizationEndpoint. The premise we’ll take is to examine the type of data requested. If “application/json” is requested, or it’s an AJAX request, then we will stop the presses and return a simply 401. To determine if the request is for JSON or it’s AJAX, we can inspect the headers.
For the Cookie auth redirect event, it will look like the below code. You can see that we inspect the request and respond accorindly.
OnRedirectToLogin = context => { var jsonMimeType = "application/json"; var isAjaxRequest = (context.HttpContext.Request?.Headers["X-Requested-With"] ?? string.Empty) == "XMLHttpRequest"; var contentTypeHeader = context.HttpContext.Request?.Headers[HeaderNames.ContentType].ToArray() ?? new string[0]; var acceptHeader = context.HttpContext.Request?.Headers[HeaderNames.Accept] ?? new string[0]; var isJsonRequest = contentTypeHeader.Contains(jsonMimeType) || acceptHeader.Contains(jsonMimeType); if (!redirectAjaxtoLogin && isAjaxRequest || isJsonRequest) { context.Response.Headers["Location"] = context.RedirectUri; context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; } else { context.Response.Redirect(context.RedirectUri); } return Task.CompletedTask; }
And, for the OAuth handler, we do something very similar. The modified method will look like this:
private static async Task RedirectToAuthorizationEndpoint(RedirectContext<OAuthOptions> context, bool automaticChallenge = false)
{
var jsonMimeType = "application/json";
var isAjaxRequest = (context.HttpContext.Request?.Headers["X-Requested-With"] ?? string.Empty) == "XMLHttpRequest";
var contentTypeHeader = context.HttpContext.Request?.Headers[HeaderNames.ContentType].ToArray() ?? new string[0];
var acceptHeader = context.HttpContext.Request?.Headers[HeaderNames.Accept] ?? new string[0];
var isJsonRequest = contentTypeHeader.Contains(jsonMimeType) || acceptHeader.Contains(jsonMimeType);
// Ensure the redirect_uri is https
if (automaticChallenge || !(isAjaxRequest || isJsonRequest))
{
context.Response.Redirect($"{context.RedirectUri.Replace("redirect_uri=http%3A%2F%2F", "redirect_uri=https%3A%2F%2F")}");
await Task.FromResult(0);
}
else
{
context.Response.Headers["Location"] = $"{context.RedirectUri.Replace("redirect_uri=http%3A%2F%2F", "redirect_uri=https%3A%2F%2F")}";
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
}
With both of those handlers in place, if, for example, I make a request to a controller via PostMan, and have no authentication token attached, the user with get a 401 and no login as intended. And, browser requests still get the authentication challenge / login page as expected. Using this methodology, our Cookie/OAuth authentication and MVC/API mechanisms can live in harmony within a single hosted application.