Adventures in Single-Sign-On: SameSite Doomsday
If your web application is dependent on an external identity provider for single-sign-on, you may already be aware of the SameSite Doomsday.
The short of it is that in February, 2020 Google — the most dominant browser vendor by market share — will push a change in Chrome which will probably break your current OpenID Connect and SAML2 SSO flows.
There are far better and more in depth resources on this topic from folks much more knowledgeable than me and I strongly suggest that you take the time to review the writing:
- Auth0 blog post – I think this one was the best and most insightful article on the topic
- Chromium blog post – Also a good starting point to understand the changes coming, but doesn’t really break down the issue
But if you’re like me, you want the “Explain Like I’m 5” version of the issue.
What follows is a super-duper over-simplification of a really complex topic, but the point is to help you on your road to fixing your imminent problem.
The gist of it is that in an SSO flow, when you redirect the users’ browser to the remote identity provider, you leave a cookie with the user in your domain right before sending them to the remote identity provider to complete the authentication flow in another domain. When the remote identity provider completes the identity verification, it redirects the user back to your domain. Without SameSite Doomsday, what would happen is that the very first cookie your application left with the user will be reloaded by the browser and included with the request back to your domain from the remote identity provider, even though the request is originating in another domain.
With SameSite Doomsday, the browser now will no longer send this cookie back and your request fails:
…unless your app is sending back the cookie to the user with SameSite=None; Secure
The technical reason is that it’s more secure (I recommend either of the articles above for a better breakdown). To solve this, you need to now send your cookie to the browser with the aforementioned flag:
So when your application is writing this cookie, it needs to provide this declaration to allow the browser to return the cookie when returning from the rIdP.
What does this look like in practice? Here’s an example:
1 2 3 4 5 6 7 8 |
Set-Cookie: Saml2.X7XKxlJuVi4o4EA6Iow12KTS=CfDJ8Nl9CNjTy8ZOm9wXM85hvMS3YgGSHizU9jAX2tU_ec15 aSKAFKZ_vw2lkgVxUPnYvM5trZzh7OLXogxTmYBNSyDI96iTSZsTxvHYA7mOH8YMMT22Uls6BzmBlMaHBwN9c6l0DtI 3EHt4dy8a0cV3Nf-ltOhEieev77_-jswMPhZoBqnAg8Y5ifvZJTmnlwcq6TOkwS-VfddXfldBjDqMqgHCR-atO7wcPU GEvkePc-RFfnonKS0NznH6aAKIHTLAWaeO9xDkrLZVs7t2cVohpxU_5awcZbm0P1Kut2f2PNCZBQ8J7Aeenk7ZpgwdO rH2s6wk9IH3pRxBhbH80dM5BE_HoVi2ktatwkzF-MQHS9T6zXk5XUhLTKrEH1ImReshvZZOeY56t488XGqHiC-HJepe s2W-PWIjNz_V75eLhZP4Vb2AkL5bFRFus_d5b_OAasLLL-hh8lxPCcbo0dT3EzJXS0u9gLmnpIz7dUjjoiUvUwz4ZPG V_I7FKfUgfpayPgbCbMy-r_VxyAn93PehS8uPN0Dc7jsp9CaKPsnPFNsQsRCRW7TjzBIHzc6A-5XEnQ..; path=/; httponly; SameSite=None; Secure |
Without the SameSite=None; Secure, this cookie will not be returned to the application when the remote identity provider flow completes.
The question then is: what is the cheapest, simplest, lowest risk, lowest effort fix to this issue given that your SSO flows for a large portion of your users are going to fail in the coming days?
What if you don’t even own or control the code that is issuing the cookie?!? What if you’re a product or system owner and the vendor won’t have a fix available in time? What if you’re managing/maintaining a legacy system?
There are numerous articles and approaches on how to fix this if you have source access:
But all are seemingly more complicated than they need to be, especially Barry’s. The problem with Barry’s approach, as called out by the Auth0 team, is that User-Agent sniffing is notoriously inaccurate, imprecise, and super brittle. Barry even admits that the code provided is not comprehensive because it’s not really practical to solve the issue this way. See, the thing is that some browsers will work with SameSite=None, some will not (again, the reasons are outlined in the Auth0 article). The Auth0 team provided a brilliant conceptual workaround: send two cookies — one with SameSite=None; Secure and one without. Browsers that do not understand SameSite=None will continue to pass along the one without.
But again, this assumes that you have the option of modifying the source code, test, and release the solution. What if you don’t have that luxury?
The solution is actually quite simple: since we’re just dealing with text (HTTP is just text, after all), we just need to intercept the outbound response and the inbound request and inject what we need. There are various ways that this could be achieved; for example, using a reverse proxy of some type or request/response rewrite module. If you’re using IIS like we are, we can simply add a super simple IHttpModule which will do the dirty work! Our identity federation gateway is built on IdentityServer which is running .NET Core 2.1. But of course, it’s being proxied through IIS; all we need to do — conceptually — is to do exactly what the Auth0 team did but do it in the IIS HTTP request pipeline.
At a high level, our solution looks like this:
When the application sends the response, we’re going to intercept it with the IHttpModule and add Cookie’ (“Cookie Prime”) which is the same exact cookie with a modified name. For example, if your original cookie is Saml2.X7XK, we’ll simply add a duplicate cookie called Saml2.X7XK-same-site. When we send Cookie’, we add SameSite=None; Secure. Now the users’ browser will have both cookies and will simply pass back whichever one it understands or both.
Our job on the receiving end when the cookies comes back is to simply keep one of the cookies.
- If Cookie is present, we simply use Cookie.
- If Cookie’ is present, we check to see if Cookie is present.
- if Cookie’ is present and Cookie is not present, we rewrite Cookie’ as Cookie
- If Cookie’ is present and so is Cookie, we do nothing and pass both through (the application will only consume Cookie)
Could it be any simpler?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
using System; using System.Linq; using System.Web; namespace SameSiteHttpModule { public class SameSiteDoomsdayModule : IHttpModule { /// <summary> /// Set up the event handlers. /// </summary> public void Init(HttpApplication context) { // This one is the OUTBOUND side; we add the extra cookie context.PreSendRequestHeaders += OnEndRequest; // This one is the INBOUND side; we coalesce the cookies. context.BeginRequest += OnBeginRequest; } /// <summary> /// The OUTBOUND LEG; we add the extra cookie. /// </summary> private void OnEndRequest(object sender, EventArgs e) { HttpApplication application = (HttpApplication)sender; HttpContext context = application.Context; // IF NEEDED: Add URL filter here for (int i = 0; i < context.Response.Cookies.Count; i++) { HttpCookie responseCookie = context.Response.Cookies[i]; context.Response.Headers.Add("Set-Cookie", $"{responseCookie.Name}-same-site={responseCookie.Value};SameSite=None; Secure"); } } /// <summary> /// The INBOUND LEG; we coalesce the cookies. /// </summary> private void OnBeginRequest(object sender, EventArgs e) { HttpApplication application = (HttpApplication)sender; HttpContext context = application.Context; // IF NEEDED: Add URL filter here string[] keys = context.Request.Cookies.AllKeys; for (int i = 0; i < context.Request.Cookies.Count; i++) { HttpCookie inboundCookie = context.Request.Cookies[i]; if (!inboundCookie.Name.Contains("-same-site")) { continue; // Not interested in this cookie. } // Check to see if we have a root cookie without the -same-site string actualName = inboundCookie.Name.Replace("-same-site", string.Empty); if (keys.Contains(actualName)) { continue; // We have the actual key, so we are OK; just continue. } // We don't have the actual name, so we need to inject it as if it were the original // https://support.microsoft.com/en-us/help/2666571/cookies-added-by-a-managed-httpmodule-are-not-available-to-native-ihtt // HttpCookie expectedCookie = new HttpCookie(actualName, inboundCookie.Value); context.Request.Headers.Add("Cookie", $"{actualName}={inboundCookie.Value}"); } } public void Dispose() { } } } |
To configure this in your application, you add it like any other module:
1 2 3 4 5 6 7 8 9 10 11 12 |
<?xml version="1.0" encoding="utf-8"?> <configuration> <system.webServer> <modules> <add type="SameSiteHttpModule.SameSiteDoomsdayModule, SameSiteHttpModule" name="SameSiteDoomsdayModule"/> </modules> <handlers> <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" /> </handlers> <aspNetCore processPath=".\IC.He.IdentityServices.exe" arguments="" forwardWindowsAuthToken="false" requestTimeout="00:10:00" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" /> </system.webServer> </configuration> |
And one last step: enable managed code for the application pool:
It’s not going to work if your application pool is set to No Managed Code as would be the case by default with .NET Core web applications proxied through IIS.
Now on that initial outbound request, you’ll see two cookies:
This approach will work for all flavors of .NET, all versions of .NET, all versions of IIS, and can be side-loaded in any IIS web application without recompiling, reinstalling, or rebuilding your application.
To test this, I strongly recommend using Firefox as all versions after 69 have two flags to turn the future-state behavior on for the browser. First, type about:config in the URL and then update these two settings to true:
This is way easier than fiddling with various builds of Chrome.
And problem solved; Doomsday averted!
Nice post
Probably a bit late now, but you can set toggle same site feature flags in Chrome as well. Go to chrome://flags and search for #cookies-without-same-site-must-be-secure and #same-site-by-default-cookies
Tom, thanks for the tip. We tested with both Chrome and FF internally, but found that dependent on the build of Chrome, the flags would/would not work. We found that with the build from the beta channel and a command line flag at startup, we could get the desired future-state behavior.
For anyone interested:
“C:\Program Files (x86)\Google\Chrome Beta\Application\chrome.exe” –enable-audio-service-sandbox –flag-switches-begin –enable-features=CookiesWithoutSameSiteMustBeSecure,SameSiteByDefaultCookies,SameSiteDefaultChecksMethodRigorously –flag-switches-end –enable-audio-service-sandbox
This is great help 🙂 I have been searching for a solution that can work with both Web forms and MVC projects for my customer.
Btw, for MVC projects only, we have success with using an ActionFilter to do browser sniffing: we set the relevant cookies to either None or empty depending on what the in-use browser is. In order to add the custom ActionFilter to GlobalFilter, we used Owin startup code which can be plugged in using web.config.
Thuan, I’m very glad that this was able to help you. I encourage you to spread the word on the issue in general as I think that there are likely many teams and product and project owners who are not even aware of this coming change. For me, it popped up last year, but only in mid January did our team realize it was going to be flipping in February!
I am having problems catching cookie: “.AspNet.ExternalCookie” this is the cookie that is used by Owin for Microsoft login (via OpenIdConnectAuthenticationOptions).
As far as I can tell it must be written *after* your handler is called. I was also unable to get the code on https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/ where this particular (and crucial) cookie is just not written as part of the expected flow.
Any magic insights?
Aaron,
If you must, you can try to set up an ARR reverse proxy and put the IHttpModule at the proxy. This way, it’s entirely in front of your application.
I suspect that it may be simpler; you may need to play around with the order of your modules if you have other modules. Depending on the modules that you have configured, you may need to break the module out into two modules: one that handles the inbound traffic and one that handles the outbound traffic. This way, you can configure the module in different orders around an existing module to either run before or after the existing module.
This article and code outline were very helpful for me. I did find some issues with the code which caused it not to work for me (e.g. ASP.NET session did not work; cookies were lost due to Path attribute not copied.)
Using the code presented here as a starting point, I made an updated version which addressed the issues I ran into. The updated code is available here: https://stackoverflow.com/a/59995976/422345
Phil,
Excellent!
Thanks for sharing your extended fix.