Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<IOptionsMonitor<C.AuthProxy>>();
config.CurrentValue.Returns(new C.AuthProxy());

var endSessionEndpointResolver = Substitute.For<IEndSessionEndpointResolver>();
endSessionEndpointResolver.Resolve(Arg.Any<string?>(), Arg.Any<CancellationToken>()).Returns((string?)null);

_middleware = new LogoutMiddleware(_ => Task.CompletedTask, config, endSessionEndpointResolver, Substitute.For<ILogger<LogoutMiddleware>>());

_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<IAuthenticationService>();
authenticationService.AuthenticateAsync(Arg.Any<HttpContext>(), CookieAuthenticationDefaults.AuthenticationScheme)
.Returns(AuthenticateResult.Success(ticket));

var serviceProvider = Substitute.For<IServiceProvider>();
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=;");
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList<C.Oid
options.NonceCookie.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.None;

// Keep the transient handshake cookies at the root path (they otherwise default to the
// callback path) so the browser sends them on the logout request and they can be cleared
// there instead of accumulating from abandoned sign-in attempts.
options.CorrelationCookie.Path = "/";
options.NonceCookie.Path = "/";

options.Events = new OpenIdConnectEvents
{
OnTicketReceived = HandleTicketReceived
Expand Down Expand Up @@ -209,6 +215,11 @@ static void RegisterOAuthProviders(AuthenticationBuilder authBuilder, IList<C.OA
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.None;

// Keep the transient correlation cookie at the root path (it otherwise defaults to the
// callback path) so the browser sends it on the logout request and it can be cleared there
// instead of accumulating from abandoned sign-in attempts.
options.CorrelationCookie.Path = "/";

foreach (var scope in capturedProvider.Scopes)
{
options.Scope.Add(scope);
Expand Down
14 changes: 14 additions & 0 deletions Source/AuthProxy/Cookies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,18 @@ public static class Cookies
/// logout callback (<see cref="WellKnownPaths.LogoutCallback"/>).
/// </summary>
public const string LogoutRedirect = ".cratis-logout";

/// <summary>
/// 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.
/// </summary>
public const string CorrelationPrefix = ".AspNetCore.Correlation.";

/// <summary>
/// Name prefix of the transient nonce cookie the ASP.NET Core OpenID Connect middleware writes for every
/// sign-in handshake. Like <see cref="CorrelationPrefix"/> it carries a random suffix and is cleared on
/// logout by matching this prefix.
/// </summary>
public const string NoncePrefix = ".AspNetCore.OpenIdConnect.Nonce.";
}
20 changes: 20 additions & 0 deletions Source/AuthProxy/LogoutMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> whose response the delete cookies are written to.</param>
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)
Expand Down
Loading