Skip to content

Same-site cookies, ASP.NET Core, and external authentication providers

January 11, 2019

Recently Safari on iOS made changes to their same-site cookie implementation to be more stringent with lax mode (which is purportedly more in-line with the spec). In my testing, I noticed that using strict mode same-site cookies had the same behavior on both Chrome and FireFox running on Windows. This behavior affected ASP.NET Core’s handling of external authentication providers for any security protocol, including OpenID Connect, OAuth 2, google/facebook logins, etc. The solution was to unfortunately configure cookies to completely disable the same-site feature. Sad.

I was curious if we could really figure out what was happening and come up with a solution that allowed us to keep using same-site cookies for our application’s main authentication cookie. I think I have, and this solution could work with any server-side technology stack that works similarly to how ASP.NET Core does when processing authentication responses from external providers (cross-site).

Recap of what’s not working

Here’s a summary of the expected flow:

untitled

The step where the flow fails is on the last step, step4, where the user is not logged in. It turns out that’s not exactly what’s happening. Here are the details:

Step1: An anonymous user is in their browser on your application’s website. The user attempts to request a page that requires authorization, so a login request is created and the user is redirected to the authentication provider (which is cross-site).

Step2: The user is presented with a login page and they fill that in and submit. If the credentials are valid then the provider creates a token for the user, and this token needs to be delivered back to the client application. This delivery is performed by sending the token back into the browser and then having the browser deliver it to the application’s callback endpoint. This delivery could be via a redirect with a GET or a form submission via a POST. The problem with same-site cookies is not affected by the method of delivery back to the client application, so either of these triggers the issue.

The key point here is that, from the browser’s perspective, the user is starting a workflow from the login page in the provider’s domain. The response is then sending the user back to the client application (which is cross-site).

Step3: The client application will receive and validate the token, and then issue a local authentication cookie while redirecting the user back to the original page they requested.

This is the step that I think is easy to misunderstand. Because the request from the provider back to the app is cross-site, there is a belief that the issued cookie is ignored by the browser. It is not, though, and the browser will in fact maintain this cookie issued from your application.

Step4: The last redirect in the workflow sends the user back to the original page they requested. This is the step that fails from the end-user’s perspective. The cookie issued from step3 is not sent to the server, and so the user seems to not have been authenticated.

The reason this step fails is not because the cookie was not issued to the browser, but instead because the current redirect workflow started from the provider’s login page, which is cross-site so the browser refuses to send the cookie just issued in step3. If at this point the user were to refresh the page, or manually navigate their browser to the original page the browser would send the cookie and the user would be logged in. The reason is that a refresh or manual navigation is not a cross-site request.

The fix in general

The solution to this problem then is to change how the final redirect in step3 is performed. In ASP.NET Core it’s done with a 302 status code, but there’s another way. Instead the response in step3 could be a 200 OK and render this HTML:

<meta http-equiv='refresh' 
      content='0;url=https://yourapp.com/path_to_original_page' />

This response in step3 in essence ends the cross-site redirect workflow from the browser’s perspective, and then asks the browser to make a new request from the client-side. The trick is that this request is a new workflow and considered same-site since it’s from a page on the application’s website, and then the authentication cookie will be sent. Not Sad.

The fix specifically for ASP.NET Core

Given that the redirect in step3 is handled by ASP.NET Core’s authentication system, we need a way to hook into it and override the redirect. Unfortunately there’s no event that’s raised at the right time for us to change how the redirect is done. So instead we use middleware so we can catch the response before it leaves the pipeline:

public void Configure(IApplicationBuilder app)
{
   app.Use(async (ctx, next) =>
   {
      await next();

      if (ctx.Request.Path == "/signin-oidc" && 
          ctx.Response.StatusCode == 302)
      {
          var location = ctx.Response.Headers["location"];
          ctx.Response.StatusCode = 200;
          var html = $@"
             <html><head>
                <meta http-equiv='refresh' content='0;url={location}' />
             </head></html>";
          await ctx.Response.WriteAsync(html);
      }
   });
   app.UseAuthentication();
   app.UseMvc();
}

This puts a middleware in front of the authentication middleware. It will run after the rest of the pipeline and inspect responses on the way out. If the request was for the application’s authentication redirect callback from step3 (in this case the typical path when using OpenID Connect) and the response is a redirect, then we capture that redirect location and change how it’s done using the client-side <meta> tag approach instead.

Front-channel sign-out notification for OpenID Connect

It turns out there’s another type of request into your app from the external provider when using OpenID Connect, which is the front-channel sign-out notification request. This request is performed in an <iframe> and requires the user’s authentication cookie to perform the sign-out. Given that this is absolutely cross-site, this means the same-site cookie would be blocked by the browser. We need to perform the same sort of trick to get the browser to make this request originating from our application so the browser considers it same-site.

Here’s the additional code to handle this type of request:

public void Configure(IApplicationBuilder app)
{
   app.Use(async (ctx, next) =>
   {
      if (ctx.Request.Path == "/signout-oidc" && 
          !ctx.Request.Query["skip"].Any())
      {
         var location = ctx.Request.Path + 
            ctx.Request.QueryString + "&skip=1";
         ctx.Response.StatusCode = 200;
         var html = $@"
            <html><head>
               <meta http-equiv='refresh' content='0;url={location}' />
            </head></html>";
         await ctx.Response.WriteAsync(html);
         return;
      }

      await next();

      if (ctx.Request.Path == "/signin-oidc" &&
          ctx.Response.StatusCode == 302)
      {
          var location = ctx.Response.Headers["location"];
          ctx.Response.StatusCode = 200;
          var html = $@"
              <html><head>
                 <meta http-equiv='refresh' content='0;url={location}' />
              </head></html>";
          await ctx.Response.WriteAsync(html);
      }
   });
   app.UseAuthentication();
   app.UseMvc();
}

The workflow for this request is simply re-issuing the request to the sign-out notification endpoint, with the difference being that it will now be same-site. The “skip” flag is needed to ensure we don’t re-issue the request again on that next request.

More general ASP.NET Core solution

The above code is fine if you’re willing to hand-code (and know) the endpoints that you need to convert the cross-site redirect into same-site redirects. But if you have several endpoints because you’re dealing with several external providers, then this might be tedious. Here’s a more generalized solution to the problem:

public void Configure(IApplicationBuilder app)
{
   app.Use(async (ctx, next) =>
   {
        var schemes = ctx.RequestServices.GetRequiredService<IAuthenticationSchemeProvider>();
        var handlers = ctx.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
        foreach (var scheme in await schemes.GetRequestHandlerSchemesAsync())
        {
            var handler = await handlers.GetHandlerAsync(ctx, scheme.Name) as IAuthenticationRequestHandler;
            if (handler != null && await handler.HandleRequestAsync())
            {
                // start same-site cookie special handling
                string location = null;
                if (ctx.Response.StatusCode == 302)
                {
                    location = ctx.Response.Headers["location"];
                }
                else if (ctx.Request.Method == "GET" && !ctx.Request.Query["skip"].Any())
                {
                    location = ctx.Request.Path + ctx.Request.QueryString + "&skip=1";
                }

                if (location != null)
                {
                    ctx.Response.StatusCode = 200;
                    var html = $@"
                        <html><head>
                            <meta http-equiv='refresh' content='0;url={location}' />
                        </head></html>";
                    await ctx.Response.WriteAsync(html);
                }
                // end same-site cookie special handling

                return;
            }
        }

      await next();
   });
   app.UseAuthentication();
   app.UseMvc();
}

The above code is, in essence, the same code from ASP.NET Core’s UseAuthentication for dealing with requests from external providers. I have simply weaved the redirect handling logic into the normal processing that was being done for normal ASP.NET authentication. Perhaps this type of behavior might make its way into ASP.NET Core in the future.

HTH

 

14 Comments leave one →
  1. rmbrunet permalink
    January 12, 2019 11:46 am

    This is fantastic. Thanks! Tested and worked like a charm!. There is a missing parenthesis in the first code snippet.

  2. rmbrunet permalink
    January 12, 2019 12:18 pm

    Yes I did. iPad running iOS 12.1.1

  3. rmbrunet permalink
    January 13, 2019 10:39 am

    Hi Brock, an update…

    Yesterday when I tested I included the middleware and removed the configuration

    options.Cookie.SameSite = SameSiteMode.None;

    and life was good.

    But today I noticed that I still had a CookiePolicyOption configuration of

    options.MinimumSameSitePolicy = SameSiteMode.None;

    and after removing it things stop working again (continuous looping). Then, had to put it back.

    Regards,

    R.

  4. rmbrunet permalink
    January 22, 2019 12:40 pm

    One small additional update…

    Enforcing X-Content-Type-Options=nosniff creates a problem with the code as it is… easily solved specifying ctx.Response.CotentType = “text/html”.

  5. Gennadii Kurabko permalink
    February 10, 2019 1:06 pm

    Hey, will we see this code in IdentityModel2 library soon?

    • February 10, 2019 1:07 pm

      No, as IdentityModel doesn’t have a dependency on ASP.NET Core.

  6. Gennadii Kurabko permalink
    February 11, 2019 10:33 am

    Tested this approach today, and from what I can see – issue remains. We have chunked cookies, and on sign out only first cookie is deleted, similar to that issue: https://github.com/aspnet/Security/issues/1779

    So far I see that ‘Set-Cookie’ happens on /signin-oidc request that initiated by IdS, and only after that browser doing request initiated by tag. That request already contains cookies set by prev. step. Probably that is the issue, as on sign-out all those cookies not sent, and MVC Cookie handler does not sees that there are chunks, so it just initiates clean-up of main cookie.

    Will investigate it more..

  7. Gennadii Kurabko permalink
    February 11, 2019 12:41 pm

    Still no luck, just a little more details: front channel sign out definitely does not work on Chrome 69.0.3497.100 and on Opera 58.0.3135.63 (that identifies as Chrome/71.0.3578.98).

    Tricky part here: in case your cookies are small and not chunked – ASP.NET OIDC handler issues Set-Cookie to clean it up even if no cookies were sent to it, and it can trick you that it works. But when you will look into ‘Network’ trace you will see that no cookies were sent to the server indeed.

    In case of chunked cookies it is clear that no cookies were sent in SameSite.Strict mode.

  8. March 25, 2019 6:06 am

    Thanks a lot! This seems to have fixed a mysterious issue in our app where iOS and OSX clients would not get properly authenticated.

    By the way, there’s another paren and semicolon missing near the end of your final sample.

  9. synergetic permalink
    July 25, 2019 1:56 am

    Very nice explanation. It seems to me, await next() must be called at the end; and there is also missing return statement in if block:
    app.Use(async (ctx, next) =>
    {
    if (ctx.Request.Path == “/signin-oidc” &&
    ctx.Response.StatusCode == 302)
    {
    var location = ctx.Response.Headers[“location”];
    ctx.Response.StatusCode = 200;
    var html = $@”

    “;
    await ctx.Response.WriteAsync(html);
    return;
    }
    await next();
    });

Leave a reply to rmbrunet Cancel reply