aspnetcore icon indicating copy to clipboard operation
aspnetcore copied to clipboard

Multiple ASP.NET Core OAuth Correlation cookies

Open danielbecroft opened this issue 2 years ago • 15 comments

Is there an existing issue for this?

  • [X] I have searched the existing issues

Describe the bug

We're using ASP.NET (v6) with Angular, using Cookie authentication to the back end, and proxying to an API (secured via OAuth). This is working the majority of time, but we are seeing occassions where the server is generating .AspNetCore.Correlation.oauth.$RANDOM cookies with a null expiry. Using Chrome, the header appears as:

set-cookie: .AspNetCore.Correlation.oauth.ativ9Y_r)bPnrqIOXsbWR5I.......; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/

A correctly generated cookie during the OAuth cycle looks like:

set-cookie: .AspNetCore.Correlation.oauth.BSPC3Ydwq95MYo0t-T4MxWMrsIVN_q_zTwR-FhdgizA=N; expires=Fri, 15 Jul 2022 05:03:46 GMT; path=/signin-oidc; secure; samesite=none; httponly

(So secure, httponly are missing, and the expires option is incorrect)

The cookie is attached (sometimes) after the OAuth cycle, but the cookie is rejected and the Signin cycle starts again. After a failure, each subsequent request to /signin-oidc gets an additional set-cookie header (resulting in multiple cookies).

Nothing appears in the Asp.Net server logs, and clearing the cache resolves the issue.

We are running in a load-balanced environment (k8s+Docker) with multiple containers behind the same ingress URL.

Expected Behavior

The cookie is generated and passed around correctly.

Steps To Reproduce

Exact reproduction steps are unknown.

Exceptions (if any)

No response

.NET Version

5.0

Anything else?

No response

danielbecroft avatar Jul 15 '22 04:07 danielbecroft

expires=Thu, 01 Jan 9170

😕

I'm assuming that's a typo for 1970? We should look at how we're generating these cookies. @Tratcher PTAL.

adityamandaleeka avatar Jul 15 '22 20:07 adityamandaleeka

expires=Thu, 01 Jan 9170

😕

I'm assuming that's a typo for 1970? We should look at how we're generating these cookies. @Tratcher PTAL.

Yeah, sorry it was a typo. Should be 1970. Had to transpose from a screenshot.

danielbecroft avatar Jul 16 '22 00:07 danielbecroft

expires=Thu, 01 Jan 1970 00:00:00 GMT; is generated to delete a cookie, which in this example always happens when visiting the /signin-oidc endpoint. Can you share a Fiddler trace, or at least the full Set-Cookie header?

That cookie shouldn't be included on future requests.

If secure missing that might be an issue with your proxy config. See https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-6.0

Tratcher avatar Jul 18 '22 18:07 Tratcher

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Jul 18 '22 18:07 ghost

Hey @Tratcher , thanks for the response. I'll try and capture a trace when we get the issue (it's very sporadic, and the symptoms that we're seeing now doesn't match the symptoms last week).

I've got a screenshot with the full set-cookie header on a previous occurrence, which is listed below. A new set-cookie header gets generated on each attempt to login, and all cookies get sent on the next request (they aren't getting cleared, based on your comments seems like something else).

image

I'll post back with more information if I can get a full stack trace and request history.

danielbecroft avatar Jul 19 '22 04:07 danielbecroft

Yeah, those are all delete cookies, see the empty value and expires. It's weird to see more than one per request, it should only be deleting the one it used for that cycle. I've seen custom workaround code designed to mass delete these cookies, do you have anything like that? The deletes could fail if the attributes don't match the original, like httponly and secure. What does the initial set-cookie header look like when these are created at the start of the oauth flow?

What OAuth provider are you using? Can you show the setup code for that?

Tratcher avatar Jul 22 '22 19:07 Tratcher

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Jul 22 '22 19:07 ghost

@Tratcher We're using an in-house OAuth identity server, but using the .AddOAuth() extension from Microsoft.AspNetCore.Authentication.OAuth, combined with cookie authentication, in a BFFE model . Our configuration code for the various providers is below.

services.AddAuthentication(options =>
{
	options.DefaultScheme = "cookies";
	options.DefaultChallengeScheme = "oauth";
})
.AddCookie("cookies", o =>
{
	o.Cookie.Name = "__Host-applicationWeb";
	o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
	o.Cookie.SameSite = SameSiteMode.Strict;
	o.Events = new CookieAuthenticationEvents
	{
		OnValidatePrincipal = async context =>
		{
			// snipped - code to refresh `access_token` using stored `refresh_token`
		}
	};
})
.AddOAuth("oauth", o =>
{
	o.AuthorizationEndpoint = new Uri(_settings.BaseUrl, _settings.AuthorizeEndpointPath).ToString();
	o.TokenEndpoint = new Uri(_settings.BaseUrl, _settings.TokenEndpointPath).ToString();
	o.ClientId = _settings.ClientId;
	o.ClientSecret = _settings.ClientSecret;
	o.CallbackPath = new PathString("/signin-oidc");
	o.SaveTokens = true;
	o.UserInformationEndpoint = new Uri(_settings.BaseUrl, "/api/v1/user").ToString();

	o.Events.OnCreatingTicket = async context => { await CreateAuthTicket(context); };
	o.Events.OnTicketReceived = context =>
	{
		/*
		 * In case Cookie is removed by browser automatically and user click on logout link. Then the ReturnUri
		 * will be keep as '/account/logout' instead of '/'. That is reason why we got the second login after
		 * the first success login. To fix it we will update ReturnUri to '/' to avoid the duplicate login issue.
		 */
		context.ReturnUri = context.ReturnUri.Contains("/account/logout") ? "/" : context.ReturnUri;
		return Task.CompletedTask;
	};
	o.Events.OnRemoteFailure = context =>
	{
		_logger.LogError($"On remote failure: {context.Failure.Message}");
		context.Response.Redirect("/");
		context.HandleResponse();
		return Task.CompletedTask;
	};
});

danielbecroft avatar Jul 24 '22 23:07 danielbecroft

Is there any way you can try this without loadbalancing and see if this is perhaps related to requests going to different containers?

HaoK avatar Jul 28 '22 00:07 HaoK

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Jul 29 '22 22:07 ghost

This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for 4 days. It will be closed if no further activity occurs within 3 days of this comment. If it is closed, feel free to comment when you are able to provide the additional information and we will re-investigate.

See our Issue Management Policies for more information.

ghost avatar Aug 02 '22 22:08 ghost

Hi @HaoK , I've tried to enable load balancing on a duplicate environment, and it occasionally fails with the same result (but not always), so load balancing might be a potential problem. There's minimal output in the logs when this issue occurs, so it's hard to tell where the issue lies.

We are pushing out a change that ensures the /signin-oidc request bypasses the Angular service worker, but since we've had inconsistent reproductions both with and without this change, it's hard to know if this will resolve the issue.

Are there any events that I can hook into and add logging to try and get more information?

danielbecroft avatar Aug 05 '22 05:08 danielbecroft

Our configuration code for the various providers is below.

Do you have multiple AddOAuth calls? In that case you must use unique CallbackPath values per provider. Otherwise multiple would be executed on a single request, and they'd all try to delete the cookie.

Tratcher avatar Aug 09 '22 21:08 Tratcher

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Aug 10 '22 22:08 ghost

@Tratcher No, we only have a single .AddCookies() and .AddOAuth() call in our configuration.

danielbecroft avatar Aug 10 '22 23:08 danielbecroft

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/logging/?view=aspnetcore-6.0#configure-logging If you turn the existing logging up to Information or Debug that will help.

Reading back through this I realized it's not just the Secure attribute that's missing from the delete cookie, it's also missing samesite=none and httponly. That's suspicious. Delete used to leave those values off but that was fixed ~5 years ago. Can you confirm which framework version you're using? Your initial post said both 5 and 6.

Tratcher avatar Aug 17 '22 23:08 Tratcher

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Aug 17 '22 23:08 ghost

This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for 4 days. It will be closed if no further activity occurs within 3 days of this comment. If it is closed, feel free to comment when you are able to provide the additional information and we will re-investigate.

See our Issue Management Policies for more information.

ghost avatar Aug 22 '22 00:08 ghost

Hi @Tratcher , sorry we're currently running .NET 5 in production (upgrading to .NET 6 in development at the moment). Once we've pushed v6 into production, we'll try and observe any changes.

Looking through other logs, we have noticed that our Web Application Firewall (WAF)_ is triggering on some AspNetCore.Correlation.oauth cookie value due to "SQL comment format structure", specifically where it has -- inside the string. This isn't causing a 403 error to be returned (which is what we'd expect), and also some of our users are using the application without going through the WAF so we're not sure if this is a red herring or not.

We'll increase the logging in production and see if there are any information that can be gleaned from it.

danielbecroft avatar Aug 22 '22 01:08 danielbecroft

One interesting observation from the failing requests (only 2 today), the /signin-oidc call after the authentication is still sending the old __Host-supermateWeb cookie in the header. Login and timeout flows that don't send this request are not sending this cookie.

On working authentication flows, the Browser is still reporting that this cookie is valid (appears in the Cookie and Site data section with the old value, "session" expiry, everything). All these settings imply that it probably should be getting sent on every request to the app.example.com domain, but something is stopping it being applied (and I can't figure out what). There is no Set-Cookie response header to remove the header.

For example:

GET app.example.com
Cookie: __Host-myApplication=hunter2

HTTP 302 Found
Location: auth.example.com/oauth/v1/authorize
Set-Cookie: .AspNetCore.Correlation.oauth.RANDOM=N; expires=Mon, 22 Aug 2022 06:48:41 GMT; path=/signin-oidc; secure; samesite=none; httponly

(user authorizes)

POST auth.example.com/oauth/v1/authorize
HTTP 302 Found
Location: app.example.com/signin-oidc

GET app.example.com/signin-oidc
Cookie: .AspNetCore.Correlation.oauth.RANDOM=N; __Host-myApplication=hunter2

(Note the __Host-myApplication=hunter2 being sent on the /signin-oidc request).

My theory is that the presence of this cookie is triggering the authentication cycle again (as the cookie has expired), but I can't work out where this cookie gets removed during a working authentication flow. But I can't work out what's removing the cookie and/or stopping it from being sent on the /signin-oidc request?

danielbecroft avatar Aug 22 '22 07:08 danielbecroft

What are the attributes on __Host-myApplication when it's created? E.g. if it has a Strict SameSite setting then it won't be included after a remote login flow. It would need to be set to Lax or None.

Tratcher avatar Aug 22 '22 15:08 Tratcher

Looking through other logs, we have noticed that our Web Application Firewall (WAF)_ is triggering on some AspNetCore.Correlation.oauth cookie value due to "SQL comment format structure", specifically where it has -- inside the string. This isn't causing a 403 error to be returned (which is what we'd expect), and also some of our users are using the application without going through the WAF so we're not sure if this is a red herring or not.

That's unrelated as long as WAF isn't rejecting the requests. Their rules are unrealistic.

Tratcher avatar Aug 22 '22 15:08 Tratcher

Hi @danielbecroft. We have added the "Needs: Author Feedback" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.

ghost avatar Aug 22 '22 22:08 ghost

What are the attributes on __Host-myApplication when it's created? E.g. if it has a Strict SameSite setting then it won't be included after a remote login flow. It would need to be set to Lax or None.

The __Host cookie currently has SameSite=strict. The full Set-Cookie value is below:

Set-Cookie: __Host-supermateWeb=*****; path=/; secure; samesite=strict; httponly

You have raised an interesting point, though. The only place we can reliably replicate the issue is in production. In production, both the OAuth provider and application are subdomains of the same registered domain (api.example.com and app.example.com). All our other environments have the application and authentication provider on different registered domains (review-app-123.example.net and api-dev.example.com).

Reading up on SameSite=Strict v SameSite=Lax options, this could be at least part of the problem, if not the entire problem. Even though both URLs are on different subdomains, the handling of SameSite only looks at the registered domain (example.com). This means that if they are both in the same registered domain, then the HTTP 302 Found response after authenticating, is no longer consiered a "cross-site" request, and the cookie gets sent on the /signin-oidc request.

I've proven this concept by changing our testing environments to use the SameSite=Lax setting, and we know reliably trigger the issue. The ExpireTimeSpan runs out, the authentication flow is triggered, and the __Host cookie is send on the /signin-oidc request.

This then all seems to be related to our use of ExpireTimeSpan on the cookie configuration:

o.ExpireTimeSpan = TimeSpan.FromMinutes(30);

(Why this is only happening for some users, on some browsers, is still unclear)

We used ExpireTimeSpan instead of CookieOptions.MaxAge, as we need the user to login after 30 minutes of inactivity, and we wanted the cookie to be removed when the browser window closes. The caveat to using that option, though, is that we now end up with a cookie who's authentication ticket has expired, but never gets deleted by the browser, and the user is never able to renew without deleting the cookie.

It seems that we can't have both, and unfortunately the 30-minute expiry is a regulatory requirement, so we'll switch to using CookieOptions.MaxAge instead of ExpireTimeSpan, and see if that resolves the issue.

Update: CookieOptions.MaxAge doesn't have the sliding facility, so we're locked to a 30-minute window before forcing a login, even if there has been activity.

danielbecroft avatar Aug 23 '22 05:08 danielbecroft

What issue are you having with expired tickets? They should be ignored and trigger a new auth flow, replacing the cookie.

Tratcher avatar Aug 23 '22 17:08 Tratcher

Hi @Tratcher , I think the core of the problem is that we are expiring the authentication ticket portion of the cookie, but not removing the cookie itself. This leaves the (now invalid) cookie in the browser and sends it on the second GET /signin-oidc request. We're solving this by setting CookieOptions.MaxAge and CookieAuthenticationOptions.ExpireTimeSpan like so:

o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
o.SlidingExpiration = true;
o.Cookie.MaxAge = o.ExpireTimeSpan;
o.Cookie.SameSite = SameSiteMode.Strict;

This ensures the max-age property on the cookie is set to the same time as the authentication ticket expiry, so when it expires, the cookie doesn't get sent, and we get a new cookie back.

The more I've looked into this issue, the more I think it's actually linked to some incorrect code we have implemented checking the context.User.Identity.IsAuthenticated property during the pipeline. We're going to reexamine that after we've fixed the login loop problem.

I'll report back if this actually fixes the problem in production and the issue can be closed.

danielbecroft avatar Aug 26 '22 05:08 danielbecroft

A cookie containing an expired auth ticket should be harmless, context.User will be populated with an anonymous user.

Tratcher avatar Aug 26 '22 15:08 Tratcher

Thanks @Tratcher , it is getting populated with an anonymous user, but some of our code checks this, and issues a challenge immediately. It's this logic that I now think is actually incorrect, so we'll look at fixing that later.

Our change to use MaxAge as well as ExpireTimeSpan has resolved the issue. The MaxAge stops the cookie getting sent on the subsequent /signin-oidc request, and the loop and multiple cookies no longer occurs.

I'm going to close this issue now, as we have been able to resolve it. Thanks @Tratcher and @HaoK for your assistance.

danielbecroft avatar Sep 07 '22 23:09 danielbecroft