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)