Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Making authenticated cross-origin requests with ASP.NET Core Identity

$
0
0

In this post I tackle a cross-origin scenario a colleague was struggling with recently. They had logged into an ASP.NET Core app that uses ASP.NET Core Identity, and they were trying to allow cross-origin requests from a fixed set of domains. They had configured cross-origin resource sharing (CORS) correctly, but the ASP.NET Core app was always showing the requests as unauthenticated.

I'll start by describing the situation in more detail, discuss how they had configured CORS (correctly I'd like to point out!), and finally look at what the problem was, and the solution!

tl;dr / spoiler alert: you must make sure authentication cookies are set to SameSite.None when making cross-origin requests with fetch().

The scenario: making cross-origin calls to an ASP.NET Core Identity app

In the scenario I'm describing in this post, we have a traditional ASP.NET Core Razor Pages app, which uses ASP.NET Core Identity for authentication and handling user accounts. It uses traditional cookie-based authentication.

The application also has an API, which is allowed to be called from several known cross-origin hosts, not only from the hosting origin. The API requests need to be authenticated, so the authentication cookies need to be sent in the API calls.

An example of the various requests that need to be made in the scenario

For now this is the default application you get when creating an app with identity:

dotnet new webapp -au Individual

You can register a new account and login, and the home page shows your registered email address in the title bar, as shown in the following:

Default ASP.NET Core Identity app after logging in, highlighting that the email address is shown in the address bad

We're going to add a simple minimal API endpoint to this app for our testing purposes. This API simply prints the name of the currently authenticated user which by default is the user's email address. If the user is not authenticated, it prints "<unknown>":

app.MapGet("/api", (ClaimsPrincipal user) => user.Identity?.Name ?? "<unknown>");

This minimal API injects a ClaimsPrinicpal object, which is automatically bound to the HttpContext.User property. When we call this API by navigating to it directly in the browser, the browser automatically includes the cookies, so the user is authenticated, and the email is returned in the response:

Calling an ASP.NET Core minimal API by navigating directly in the browser. The browser sends cookies, so the request is authenticated and returns the user email

Navigating directly in the browser is an easy way to test the API, but we're going to be calling the API from JavaScript, so we'll test using the fetch() API instead:

let result = await fetch('https://localhost:7168/api', {method: 'GET'});
console.log(await result.text());

For simplicity in testing this, I opened the app at the homepage, at https://localhost:7168, and then opened up the dev tools. Pasting the above JavaScript in the console, this runs within the context of the website, and prints the user name as expected:

Calling the API using JavaScript running in the developer tools console

Where things get trickier is if you try to make the same request from a different origin. In the following example I open my blog https://andrewlock.net, and run the same JavaScript code, trying to send a request to https://localhost:7168/api.

Making cross-origin requests to the API using JavaScript

As you can see in the above screenshot, the request fails entirely. Browsers block requests across origins for security reasons, so if you need to make API calls like this, then you need to set up cross-origin resource sharing.

Adding CORS support to your ASP.NET Core app

Cross-origin resource sharing (CORS) is a topic that strikes fear in the heart of web developers everywhere 😅 As you've seen, browsers block cross-origin requests by default; CORS is a way of telling the browser that it's ok for apps on other domains to send requests to your app.

An origin is the combination of protocol (http/https), hostname (example.org), and port (80, 443). If any of those values are different when you make a request from a browser, then you're making a cross-origin request.

For "simple" requests (GET, HEAD, and POST), browsers make a request as normal, but before passing the response to the JavaScript code that initiated the request, it checks for the presence of the response header, Access-Control-Allow-Origin. If the response doesn't contain this header (or the header value doesn't match the required origin value), the browser rejects the request, as you saw in the previous section.

For other requests, such as PUT or DELETE, the browser first sends a preflight request, using the OPTIONS verb, to check if the request is safe to send. The response to the preflight request is checked for Access-Control-Allow-Origin headers (among others), and if they're valid, the browser makes the real request.

How CORS works, from ASP.NET Core in Action
How CORS works. Taken from ASP.NET Core in Action, Second Edition

ASP.NET Core has built-in support for handling CORS requests. It's important to remember that enabling CORS in your app reduces security, as you're opening up a potential whole where previously there wasn't one. For that reason never add CORS until you need it, and always carefully consider if what you're allowing is necessary and safe.

As described in the documentation, there are various approaches you can take to add CORS to your app. In this post we will:

  • Add the CORS services to our app
  • Configure a named policy to allow GET requests from a specific domain
  • Apply the policy to our minimal API endpoint
  • Add the CORS middleware to apply the CORS headers as appropriate

First, we add the required CORS services to our app, and define a policy call "AllowAndrewlock" inside Program.cs

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: "AllowAndrewLock", policy
        => policy.WithOrigins("https://andrewlock.net")
            .AllowCredentials()
            .WithMethods("GET"));
});

// ... remaining app config

This defines a CORS policy which

  • Only allows requests from https://andrewlock.net
  • Only allows GET requests
  • Allows sending credentials (i.e. the cookies) with the request made from the browser.

For our use case, where we need an authenticated user, it's important you use specific domains in WithOrigins(), and don't call .AllowAnyOrigin().

Next, we apply the CORS policy to our minimal API endpoint by calling RequireCors with our policy name:

app.MapGet("/api", (ClaimsPrincipal user) => user.Identity?.Name ?? "<unknown>")
    .RequireCors("AllowAndrewLock"); //👈 Add this

Finally, we add the CORS middleware to our app, by calling UseCors()

// ...
app.UseRouting();

app.UseCors(); // 👈 IMPORTANT: must be after UseRouting and before UseAuthentication
app.UseAuthentication();

app.UseAuthorization();
// ...

The location of the CORS middleware is important, it must be:

  • After the UseRouting() call
  • Before calls to UseAuthentication(), UseAuthorization(), and UseResponseCaching()

That's all the changes we need to configure CORS in the backend. However we need to adjust our JavaScript code slightly. Browsers won't send credentials by default, so we need to indicate that it's ok in our fetch() call, by setting credentials: 'include' and `mode: 'cors':

let result = await fetch('https://localhost:7168/api',
  {method: 'GET', credentials: 'include', mode: 'cors'}); // 👈 Add 
console.log(await result.text());

With this configuration we can now try making the CORS request from the https://andrewlock.net console window again:

Sending a CORS request after configuring CORS in the app. The request completes successfully, but the user was not authenticated

Aaaaand…it doesn't work🤔

In this case, the request was made successfully and wasn't blocked by the browser, but the response returned "<unknown>" indicating that the app had failed to authenticate the user.

This is where we were when my colleague reached out looking for help. My assumption was that there was something wrong with the CORS configuration, either the policy's configuration or the middleware.

But everything was configured correctly, and the fetch() call was being made correctly. So what was going on!?

Debugging the cookies issue

Once we'd ruled out a misconfiguration issue, I took a step back to think about where the problem was happening. I could see two potential reasons we were seeing the unauthenticated behaviour.

  1. The fetch call was not sending the authentication cookie.
  2. The app was failing to authenticate the request cookie, even though it was present in the request.

The second point would have been easy to verify with a debugger if I was running the app locally, but in this case the test app was deployed to Azure App Services, so figuring that out wasn't as easy. So I turned my attention to the first point—did the fetch request include the authentication cookie.

This is where I lucked out.

Inspecting the outgoing fetch request I could see that it was definitely attaching cookies to the outgoing request, as I could see the ai_user and ai_session cookies associated with Application Insights being attached. However, there was no sign of the ASP.NET Core Identity cookie (.AspNetCore.Identity.Application) in the request!

Based on this, I could conclude:

  • The CORS configuration in the ASP.NET Core app was not at fault. As GET requests don't have preflight checks, there is not mechanism for the app to be blocking the sending of the cookie
  • There was no issue with the fetch() call, because it was attaching cookies, as required.

So the final question was: why is the fetch() call attaching some of the cookies, but not all of them? There must be some difference in the cookies themselves.

Going back to the dev tools, I opened the application tab and looked under Storage > Cookies at the cookies for the app. And there was the answer: The ai_session and ai_user cookies were marked as SameSite=None, whereas the Identity cookie was marked as SameSite=Lax:

The cookies stored in the browser. Showing that the ai_session and ai_user cookies are marked as SameSite=None whereas the Identity cookie is marked SameSite=Lax

As a quick test of the theory, I edited the SameSite mode of the Identity cookie to set it to None instead of Lax and did another test. And voila, success!

Demonstrating successfully sending a CORS request and confirming authentication succeeded after editing the Identity cookie to SameSite=None

With the root cause identified, the next step was to work out how to change the SameSite mode for the default Identity cookie.

SameSite cookies and the default Identity configuration

SameSite mode is a draft IETF standard that is designed to provide some protection against CSRF attacks. The standard allows setting a SameSite mode for cookies, which can be either None, Lax, or Strict. This post is already long, so I'm not going to go into detail on SameSite cookies (I'll use a separate post for that!).

The important point for us is that cookies will never be sent cross-origin unless you have set SameSite=None. This clearly explains the behaviour we were seeing: only the SameSite=None cookies (ai_user and ai_session) were sent; the SameSite=Lax Identity cookies were not sent.

The Microsoft documentation has a whole page on the confusing subject of SameSite cookies. It describes how the standard introduced in 2016 had breaking changes in 2019, and how to set the SameSite mode for various cookies. However, it also says this:

ASP.NET Core Identity is largely unaffected by SameSite cookies except for advanced scenarios like IFrames or OpenIdConnect integration.

When using Identity, do not add any cookie providers or call services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme), Identity takes care of that.

Unfortunately, it doesn't describe anywhere how you should set the SameSite mode for the Identity cookies 😩

At that point, I started spelunking through the code. ASP.NET Core libraries heavily leverage the Options pattern, so I was confident if I could work out which options to set, I could override the default.

I started with the code in the templates that adds the default Identity UI:

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

AddDefaultIdentity is an extension method which calls several methods, among which is AddIdentityCookies(). This method configures all the cookies that Identity uses:

  • The "Application" cookie—the main auth cookie I'm interested in
  • The "External" cookie—used for external logins with third-party providers
  • The "TwoFactorRememberMe" cookie—used for remembering the browser when using two-factor authentication
  • The "TwoFactorUserId" cookie—used for doing two-factor authentication

Of these cookies, I only need to set SameSite=None for the application cookie, which is configured in the AddApplicationCookie() method, reproduced below:

public static OptionsBuilder<CookieAuthenticationOptions> AddApplicationCookie(this AuthenticationBuilder builder)
{
    builder.AddCookie(IdentityConstants.ApplicationScheme, o =>
    {
        o.LoginPath = new PathString("/Account/Login");
        o.Events = new CookieAuthenticationEvents
        {
            OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
        };
    });
    return new OptionsBuilder<CookieAuthenticationOptions>(builder.Services, IdentityConstants.ApplicationScheme);
}

On the basis of this code I now knew what I needed to do to set SameSite=None on the Identity application cookie.

Finally, we come to the answer. But first, a word of warning:

SameSite was literally designed to prevent you from sending authentication cookies cross-origin for security reasons. Think long and hard about whether this is something you should allow! If you do allow it, you must make sure to rigorously prevent against CSRF attacks using other methods, for example by using antiforgery tokens.

So assuming that you definitely want to set SameSite=None for the Identity authentication cookie, add the following in Program.cs, after the call to AddDefaultIdentity() (or AddIdentityCookies() if you're calling it directly:)

builder.Services.Configure<CookieAuthenticationOptions>(
  IdentityConstants.ApplicationScheme,
    x => x.Cookie.SameSite = SameSiteMode.None);

You'll also need to add an extra using statement:

using Microsoft.AspNetCore.Authentication.Cookies;

This line adds a configuration for the named options for the Identity application cookie.

And that's all there is to it. Users will need to log out from your site and back in again, but when they do, you'll see that the Identity cookie is set with with SameSite=None:

The cookies stored in the browser. Showing that the Identity cookie is now marked as SameSite=None

and with that, we can finally make a cross-origin, authenticated, request

Demonstrating successfully sending a CORS request and confirming authentication succeeded after changing the Identity cookie to SameSite=None

Just to finish, ending with a reminder that you probably shouldn't be doing this, and to be very careful if you do! 😉

Summary

In this post I described a scenario I ran into where I needed to make an authenticated cross-origin request to an ASP.NET Core Identity application that was using cookie authentication. I showed how to configure the app to allow CORS requests, and how to use the JavaScript fetch() API to call the request. However, this still doesn't work as the ASP.NET Core Identity cookie is marked as SameSite=Lax (for good security reasons). In the final section I showed how to configure Identity to mark the cookie as SameSite=None. This has security implications, so you should be wary about doing this is in your production applications!


Viewing all articles
Browse latest Browse all 743

Trending Articles