From f1e25be50c16356c23e287831d29ac18888c785a Mon Sep 17 00:00:00 2001 From: Einar Date: Fri, 24 Jul 2026 07:34:29 +0200 Subject: [PATCH] Clear transient sign-in handshake cookies on logout The OAuth/OIDC middleware writes a correlation cookie (and, for OIDC, a nonce cookie) per sign-in handshake. An abandoned handshake leaves one behind, so they accumulate in the browser and were never removed by logout, which cleared only the session and identity cookies. Keep these cookies at the root path so the browser sends them on the logout request, then delete every cookie matching their well-known prefixes when signing out. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ogging_out_with_stale_handshake_cookies.cs | 55 +++++++++++++++++++ ...thenticationServiceCollectionExtensions.cs | 11 ++++ Source/AuthProxy/Cookies.cs | 14 +++++ Source/AuthProxy/LogoutMiddleware.cs | 20 +++++++ 4 files changed, 100 insertions(+) create mode 100644 Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_stale_handshake_cookies.cs diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_stale_handshake_cookies.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_stale_handshake_cookies.cs new file mode 100644 index 0000000..e111f34 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_stale_handshake_cookies.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_with_stale_handshake_cookies : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + + void Establish() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + + var endSessionEndpointResolver = Substitute.For(); + endSessionEndpointResolver.Resolve(Arg.Any(), Arg.Any()).Returns((string?)null); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config, endSessionEndpointResolver, Substitute.For>()); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + + // Two correlation cookies and a nonce cookie left behind by abandoned sign-in handshakes, alongside an + // unrelated cookie that must be preserved. + _context.Request.Headers.Cookie = + $"{Cookies.CorrelationPrefix}github.abc=one; {Cookies.CorrelationPrefix}xyz=two; {Cookies.NoncePrefix}def=three; keep-me=value"; + _context.Response.Body = new MemoryStream(); + + var properties = new AuthenticationProperties(); + properties.Items[AuthenticationServiceCollectionExtensions.AuthenticationSchemeStateKey] = "github"; + var ticket = new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("test")), properties, CookieAuthenticationDefaults.AuthenticationScheme); + + var authenticationService = Substitute.For(); + authenticationService.AuthenticateAsync(Arg.Any(), CookieAuthenticationDefaults.AuthenticationScheme) + .Returns(AuthenticateResult.Success(ticket)); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_clear_the_first_correlation_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.CorrelationPrefix}github.abc=;"); + [Fact] void should_clear_the_second_correlation_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.CorrelationPrefix}xyz=;"); + [Fact] void should_clear_the_nonce_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.NoncePrefix}def=;"); + [Fact] void should_not_clear_unrelated_cookies() => _context.Response.Headers.SetCookie.ToString().ShouldNotContain("keep-me=;"); +} diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 9d56a60..b090756 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -180,6 +180,12 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList). /// public const string LogoutRedirect = ".cratis-logout"; + + /// + /// Name prefix of the transient correlation cookie the ASP.NET Core OAuth/OIDC middleware writes for + /// every sign-in handshake. Each carries a random per-attempt suffix, so an abandoned handshake leaves + /// one behind; they are cleared on logout by matching this prefix. + /// + public const string CorrelationPrefix = ".AspNetCore.Correlation."; + + /// + /// Name prefix of the transient nonce cookie the ASP.NET Core OpenID Connect middleware writes for every + /// sign-in handshake. Like it carries a random suffix and is cleared on + /// logout by matching this prefix. + /// + public const string NoncePrefix = ".AspNetCore.OpenIdConnect.Nonce."; } diff --git a/Source/AuthProxy/LogoutMiddleware.cs b/Source/AuthProxy/LogoutMiddleware.cs index 60a3b4d..0dc1a4b 100644 --- a/Source/AuthProxy/LogoutMiddleware.cs +++ b/Source/AuthProxy/LogoutMiddleware.cs @@ -140,6 +140,26 @@ static async Task SignOutAndClearCookies(HttpContext context) context.Response.Cookies.Delete(Cookies.InviteToken); context.Response.Cookies.Delete(Cookies.Registration); context.Response.Cookies.Delete(Cookies.Providers); + ClearTransientAuthenticationCookies(context); + } + + /// + /// Deletes the correlation and nonce cookies the OAuth/OIDC middleware leaves behind when a sign-in + /// handshake is abandoned. They carry random per-attempt names, so they are matched by their well-known + /// prefixes rather than by an exact name; the provider registration keeps them at the root path so the + /// browser still sends them on the logout request and they can be enumerated and cleared here. + /// + /// The whose response the delete cookies are written to. + static void ClearTransientAuthenticationCookies(HttpContext context) + { + var transientCookies = context.Request.Cookies.Keys + .Where(name => name.StartsWith(Cookies.CorrelationPrefix, StringComparison.Ordinal) + || name.StartsWith(Cookies.NoncePrefix, StringComparison.Ordinal)); + + foreach (var cookie in transientCookies) + { + context.Response.Cookies.Delete(cookie, new CookieOptions { Path = "/" }); + } } static void SetLogoutRedirectCookie(HttpContext context, string target)