Merge pull request #2378 from DuendeSoftware/dh/revert-host-cookie-prefix

Revert __Host- cookie prefix (PR #2373)
This commit is contained in:
Damian Hickey 2026-02-27 19:55:06 +01:00 committed by GitHub
commit cc5e631a34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3 additions and 618 deletions

View file

@ -1,102 +0,0 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Duende.IdentityServer.Configuration;
/// <summary>
/// Extension methods to support seamless cookie name migration.
/// </summary>
public static class CookieNameMigrationExtensions
{
/// <summary>
/// Adds middleware that transparently migrates requests using an old cookie name to a new
/// cookie name. This enables seamless migration when cookie names change (e.g., when
/// adopting the <c>__Host-</c> prefix in IdentityServer 8.0) without invalidating existing
/// user sessions.
/// </summary>
/// <remarks>
/// <para>
/// Register this middleware <b>before</b> <c>app.UseIdentityServer()</c> in the pipeline.
/// </para>
/// <para>
/// On each request where the old cookie is present but the new one is absent, the middleware:
/// <list type="number">
/// <item>Patches the incoming request so downstream auth handlers find the value under the new name.</item>
/// <item>On the response: issues a <c>Set-Cookie</c> for the new name and expires the old one.</item>
/// </list>
/// </para>
/// <para>
/// This is a transient migration aid. Once all active sessions have been re-issued under the
/// new cookie name the middleware can be removed.
/// </para>
/// <para>
/// To migrate from the IdentityServer 7.x defaults to the 8.0 defaults, register twice:
/// <code>
/// app.MigrateIdentityServerCookieName("idsrv", "__Host-idsrv");
/// app.MigrateIdentityServerCookieName("idsrv.external", "__Host-idsrv.external");
/// app.UseIdentityServer();
/// </code>
/// </para>
/// </remarks>
/// <param name="app">The application builder.</param>
/// <param name="oldCookieName">The old cookie name to migrate from.</param>
/// <param name="newCookieName">The new cookie name to migrate to.</param>
/// <returns>The application builder.</returns>
public static IApplicationBuilder MigrateIdentityServerCookieName(
this IApplicationBuilder app,
string oldCookieName,
string newCookieName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldCookieName);
ArgumentException.ThrowIfNullOrWhiteSpace(newCookieName);
return app.Use(async (context, next) =>
{
var oldValue = context.Request.Cookies[oldCookieName];
var newValue = context.Request.Cookies[newCookieName];
if (oldValue != null && newValue == null)
{
// Patch the request Cookie header so downstream auth handlers
// find the encrypted value under the new cookie name.
var existingHeader = context.Request.Headers.Cookie.ToString();
var newCookiePair = $"{newCookieName}={oldValue}";
context.Request.Headers.Cookie = string.IsNullOrWhiteSpace(existingHeader)
? newCookiePair
: existingHeader + "; " + newCookiePair;
// Once the response starts, re-issue the cookie under the new name
// and expire the old one so the migration happens transparently.
context.Response.OnStarting(() =>
{
var isHostPrefixed = newCookieName.StartsWith("__Host-", StringComparison.Ordinal);
var idsrvOptions = context.RequestServices.GetRequiredService<IdentityServerOptions>();
context.Response.Cookies.Append(newCookieName, oldValue, new CookieOptions
{
HttpOnly = true,
Secure = isHostPrefixed || context.Request.IsHttps,
Path = "/",
IsEssential = true,
SameSite = idsrvOptions.Authentication.CookieSameSiteMode,
Domain = isHostPrefixed ? null : default
});
context.Response.Cookies.Delete(oldCookieName, new CookieOptions
{
Path = "/",
Domain = null
});
return Task.CompletedTask;
});
}
await next(context);
});
}
}

View file

@ -5,7 +5,6 @@
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Stores.Default;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -27,10 +26,9 @@ internal class ConfigureInternalCookieOptions : IConfigureNamedOptions<CookieAut
{
options.SlidingExpiration = _idsrv.Authentication.CookieSlidingExpiration;
options.ExpireTimeSpan = _idsrv.Authentication.CookieLifetime;
options.Cookie.Name = _idsrv.Authentication.CookieName;
options.Cookie.Name = IdentityServerConstants.DefaultCookieAuthenticationScheme;
options.Cookie.IsEssential = true;
options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode;
EnforceHostPrefixRequirements(options.Cookie);
options.LoginPath = ExtractLocalUrl(_idsrv.UserInteraction.LoginUrl);
options.LogoutPath = ExtractLocalUrl(_idsrv.UserInteraction.LogoutUrl);
@ -44,7 +42,7 @@ internal class ConfigureInternalCookieOptions : IConfigureNamedOptions<CookieAut
if (name == IdentityServerConstants.ExternalCookieAuthenticationScheme)
{
options.Cookie.Name = _idsrv.Authentication.ExternalCookieName;
options.Cookie.Name = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.Cookie.IsEssential = true;
// https://github.com/IdentityServer/IdentityServer4/issues/2595
// need to set None because iOS 12 safari considers the POST back to the client from the
@ -53,17 +51,6 @@ internal class ConfigureInternalCookieOptions : IConfigureNamedOptions<CookieAut
// hold onto them and send on the next redirect to the callback page.
// see: https://brockallen.com/2019/01/11/same-site-cookies-asp-net-core-and-external-authentication-providers/
options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode;
EnforceHostPrefixRequirements(options.Cookie);
}
}
private static void EnforceHostPrefixRequirements(CookieBuilder cookie)
{
if (cookie.Name?.StartsWith("__Host-", StringComparison.Ordinal) == true)
{
cookie.SecurePolicy = CookieSecurePolicy.Always;
cookie.Path = "/";
cookie.Domain = null;
}
}

View file

@ -34,22 +34,6 @@ public class AuthenticationOptions
/// </summary>
public SameSiteMode CookieSameSiteMode { get; set; } = SameSiteMode.None;
/// <summary>
/// Gets or sets the name of the IdentityServer authentication cookie.
/// Defaults to "__Host-idsrv". The "__Host-" prefix enforces that the cookie
/// is only sent over HTTPS, with Path=/ and no Domain attribute.
/// Set to "idsrv" to use the legacy cookie name when upgrading from a previous version.
/// </summary>
public string CookieName { get; set; } = "__Host-idsrv";
/// <summary>
/// Gets or sets the name of the IdentityServer external/temporary authentication cookie.
/// Defaults to "__Host-idsrv.external". The "__Host-" prefix enforces that the cookie
/// is only sent over HTTPS, with Path=/ and no Domain attribute.
/// Set to "idsrv.external" to use the legacy cookie name when upgrading from a previous version.
/// </summary>
public string ExternalCookieName { get; set; } = "__Host-idsrv.external";
/// <summary>
/// Indicates if user must be authenticated to accept parameters to end session endpoint. Defaults to false.
/// </summary>

View file

@ -211,16 +211,6 @@ public static class IdentityServerApplicationBuilderExtensions
throw new InvalidOperationException("CheckSessionCookieName is not configured");
}
if (options.Authentication.CookieName.IsMissing())
{
throw new InvalidOperationException("CookieName is not configured");
}
if (options.Authentication.ExternalCookieName.IsMissing())
{
throw new InvalidOperationException("ExternalCookieName is not configured");
}
if (options.Cors.CorsPolicyName.IsMissing())
{
throw new InvalidOperationException("CorsPolicyName is not configured");

View file

@ -374,7 +374,7 @@ public class IdentityServerPipeline
BrowserClient.AllowAutoRedirect = old;
}
public void RemoveLoginCookie() => BrowserClient.RemoveCookie(BaseUrl, Options.Authentication.CookieName);
public void RemoveLoginCookie() => BrowserClient.RemoveCookie(BaseUrl, IdentityServerConstants.DefaultCookieAuthenticationScheme);
public void RemoveSessionCookie() => BrowserClient.RemoveCookie(BaseUrl, IdentityServerConstants.DefaultCheckSessionCookieName);
public Cookie GetSessionCookie() => BrowserClient.GetCookie(BaseUrl, IdentityServerConstants.DefaultCheckSessionCookieName);

View file

@ -1,300 +0,0 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace UnitTests.Configuration;
public class CookieNameMigrationExtensionsTests
{
/// <summary>
/// Sends a request through the migration middleware and returns:
/// - The cookie values visible to the downstream handler (captured during the request)
/// - The Set-Cookie response headers
/// </summary>
private static async Task<(IRequestCookieCollection downstreamCookies, IHeaderDictionary responseHeaders)> InvokeMiddleware(
string oldCookieName,
string newCookieName,
string[] requestCookies,
IdentityServerOptions idsrvOptions = null)
{
IRequestCookieCollection capturedCookies = null;
using var host = await new HostBuilder()
.ConfigureWebHost(webHost =>
{
webHost.UseTestServer();
webHost.ConfigureServices(services =>
{
services.AddSingleton(idsrvOptions ?? new IdentityServerOptions());
});
webHost.Configure(app =>
{
app.MigrateIdentityServerCookieName(oldCookieName, newCookieName);
app.Run(ctx =>
{
// Capture cookie values while the HttpContext is still alive
capturedCookies = ctx.Request.Cookies;
return Task.CompletedTask;
});
});
})
.StartAsync();
var testServer = host.GetTestServer();
var context = await testServer.SendAsync(ctx =>
{
ctx.Request.Headers.Cookie = string.Join("; ", requestCookies);
});
return (capturedCookies, context.Response.Headers);
}
// --- Old cookie migrated to new name when only old is present ---
[Fact]
public async Task when_old_cookie_present_and_new_absent_request_is_patched_with_new_name()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
var (downstreamCookies, _) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"]);
// The downstream handler should see the value under the new cookie name
downstreamCookies[newName].ShouldBe(cookieValue);
}
[Fact]
public async Task when_old_cookie_present_and_new_absent_response_sets_new_cookie()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"]);
var setCookieHeaders = responseHeaders["Set-Cookie"].ToList();
setCookieHeaders.ShouldContain(h => h.StartsWith(newName + "="));
}
[Fact]
public async Task when_old_cookie_present_and_new_absent_response_expires_old_cookie()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"]);
var setCookieHeaders = responseHeaders["Set-Cookie"].ToList();
// Old cookie should be deleted (expires=epoch / max-age=0)
setCookieHeaders.ShouldContain(h => h.StartsWith(oldName + "=") && h.Contains("expires="));
}
[Fact]
public async Task host_prefixed_new_cookie_has_secure_attribute()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"]);
var newCookieHeader = responseHeaders["Set-Cookie"]
.FirstOrDefault(h => h.StartsWith(newName + "="));
newCookieHeader.ShouldNotBeNull();
newCookieHeader.ShouldContain("secure", Case.Insensitive);
}
// --- When both cookies are present, no migration occurs ---
[Fact]
public async Task when_both_old_and_new_cookies_present_new_cookie_value_is_not_overwritten()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string oldValue = "old-encrypted-value";
const string newValue = "new-encrypted-value";
var (downstreamCookies, _) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={oldValue}", $"{newName}={newValue}"]);
// New cookie should remain unchanged
downstreamCookies[newName].ShouldBe(newValue);
}
[Fact]
public async Task when_both_cookies_present_no_set_cookie_headers_are_emitted()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}=old-value", $"{newName}=new-value"]);
responseHeaders["Set-Cookie"].Count.ShouldBe(0);
}
// --- When neither cookie is present, nothing happens ---
[Fact]
public async Task when_neither_cookie_present_downstream_sees_no_cookies()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
var (downstreamCookies, _) = await InvokeMiddleware(
oldName, newName,
[]);
downstreamCookies[oldName].ShouldBeNull();
downstreamCookies[newName].ShouldBeNull();
}
[Fact]
public async Task when_neither_cookie_present_no_set_cookie_headers_are_emitted()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[]);
responseHeaders["Set-Cookie"].Count.ShouldBe(0);
}
// --- Argument validation ---
[Fact]
public void null_old_cookie_name_throws_argument_exception()
{
var app = new ApplicationBuilder(null!);
Should.Throw<ArgumentException>(() => app.MigrateIdentityServerCookieName(null!, "__Host-idsrv"));
}
[Fact]
public void null_new_cookie_name_throws_argument_exception()
{
var app = new ApplicationBuilder(null!);
Should.Throw<ArgumentException>(() => app.MigrateIdentityServerCookieName("idsrv", null!));
}
[Fact]
public void empty_old_cookie_name_throws_argument_exception()
{
var app = new ApplicationBuilder(null!);
Should.Throw<ArgumentException>(() => app.MigrateIdentityServerCookieName("", "__Host-idsrv"));
}
[Fact]
public void empty_new_cookie_name_throws_argument_exception()
{
var app = new ApplicationBuilder(null!);
Should.Throw<ArgumentException>(() => app.MigrateIdentityServerCookieName("idsrv", ""));
}
// --- Patched Cookie header does not start with "; " when only old cookie is present ---
[Fact]
public async Task when_only_old_cookie_present_patched_header_does_not_start_with_semicolon()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
string patchedHeader = null;
using var host = await new HostBuilder()
.ConfigureWebHost(webHost =>
{
webHost.UseTestServer();
webHost.ConfigureServices(services =>
{
services.AddSingleton(new IdentityServerOptions());
});
webHost.Configure(app =>
{
app.MigrateIdentityServerCookieName(oldName, newName);
app.Run(ctx =>
{
patchedHeader = ctx.Request.Headers.Cookie.ToString();
return Task.CompletedTask;
});
});
})
.StartAsync();
await host.GetTestServer().SendAsync(ctx =>
{
ctx.Request.Headers.Cookie = $"{oldName}={cookieValue}";
});
patchedHeader.ShouldNotBeNull();
patchedHeader.ShouldNotStartWith("; ");
patchedHeader.ShouldContain($"{newName}={cookieValue}");
}
// --- SameSite mode is taken from IdentityServerOptions ---
[Fact]
public async Task same_site_mode_from_identity_server_options_is_applied_to_migrated_cookie()
{
const string oldName = "idsrv";
const string newName = "__Host-idsrv";
const string cookieValue = "encrypted-ticket-value";
var options = new IdentityServerOptions();
options.Authentication.CookieSameSiteMode = SameSiteMode.Strict;
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"],
options);
var newCookieHeader = responseHeaders["Set-Cookie"]
.FirstOrDefault(h => h.StartsWith(newName + "="));
newCookieHeader.ShouldNotBeNull();
newCookieHeader.ShouldContain("samesite=strict", Case.Insensitive);
}
// --- __Host- prefix check is case-sensitive per RFC 6265bis ---
[Fact]
public async Task wrong_case_host_prefix_does_not_set_secure_attribute()
{
// "__host-idsrv" (lowercase) must not trigger host-prefix enforcement per RFC 6265bis.
const string oldName = "idsrv-old";
const string newName = "__host-idsrv-new";
const string cookieValue = "encrypted-ticket-value";
var (_, responseHeaders) = await InvokeMiddleware(
oldName, newName,
[$"{oldName}={cookieValue}"]);
var newCookieHeader = responseHeaders["Set-Cookie"]
.FirstOrDefault(h => h.StartsWith(newName + "="));
newCookieHeader.ShouldNotBeNull();
newCookieHeader.ShouldNotContain("secure", Case.Insensitive);
}
}

View file

@ -1,173 +0,0 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer;
using Duende.IdentityServer.Configuration;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
namespace UnitTests.Configuration.DependencyInjection;
public class ConfigureInternalCookieOptionsTests
{
private static CookieAuthenticationOptions ConfigureMainCookie(IdentityServerOptions idsrvOptions)
{
var sut = new ConfigureInternalCookieOptions(idsrvOptions);
var cookieOptions = new CookieAuthenticationOptions();
sut.Configure(IdentityServerConstants.DefaultCookieAuthenticationScheme, cookieOptions);
return cookieOptions;
}
private static CookieAuthenticationOptions ConfigureExternalCookie(IdentityServerOptions idsrvOptions)
{
var sut = new ConfigureInternalCookieOptions(idsrvOptions);
var cookieOptions = new CookieAuthenticationOptions();
sut.Configure(IdentityServerConstants.ExternalCookieAuthenticationScheme, cookieOptions);
return cookieOptions;
}
// --- Default cookie names ---
[Fact]
public void main_cookie_name_defaults_to_host_prefixed_idsrv()
{
var options = ConfigureMainCookie(new IdentityServerOptions());
options.Cookie.Name.ShouldBe("__Host-idsrv");
}
[Fact]
public void external_cookie_name_defaults_to_host_prefixed_idsrv_external()
{
var options = ConfigureExternalCookie(new IdentityServerOptions());
options.Cookie.Name.ShouldBe("__Host-idsrv.external");
}
// --- Custom cookie names are applied ---
[Fact]
public void custom_main_cookie_name_is_applied()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.CookieName = "my-custom-cookie";
var options = ConfigureMainCookie(idsrvOptions);
options.Cookie.Name.ShouldBe("my-custom-cookie");
}
[Fact]
public void custom_external_cookie_name_is_applied()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.ExternalCookieName = "my-custom-external-cookie";
var options = ConfigureExternalCookie(idsrvOptions);
options.Cookie.Name.ShouldBe("my-custom-external-cookie");
}
[Fact]
public void legacy_main_cookie_name_can_be_restored()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.CookieName = "idsrv";
var options = ConfigureMainCookie(idsrvOptions);
options.Cookie.Name.ShouldBe("idsrv");
}
[Fact]
public void legacy_external_cookie_name_can_be_restored()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.ExternalCookieName = "idsrv.external";
var options = ConfigureExternalCookie(idsrvOptions);
options.Cookie.Name.ShouldBe("idsrv.external");
}
// --- __Host- prefix enforces SecurePolicy, Path, and no Domain ---
[Fact]
public void host_prefixed_main_cookie_sets_secure_policy_to_always()
{
var options = ConfigureMainCookie(new IdentityServerOptions());
options.Cookie.SecurePolicy.ShouldBe(CookieSecurePolicy.Always);
}
[Fact]
public void host_prefixed_main_cookie_sets_path_to_root()
{
var options = ConfigureMainCookie(new IdentityServerOptions());
options.Cookie.Path.ShouldBe("/");
}
[Fact]
public void host_prefixed_main_cookie_clears_domain()
{
var options = ConfigureMainCookie(new IdentityServerOptions());
options.Cookie.Domain.ShouldBeNull();
}
[Fact]
public void host_prefixed_external_cookie_sets_secure_policy_to_always()
{
var options = ConfigureExternalCookie(new IdentityServerOptions());
options.Cookie.SecurePolicy.ShouldBe(CookieSecurePolicy.Always);
}
[Fact]
public void host_prefixed_external_cookie_sets_path_to_root()
{
var options = ConfigureExternalCookie(new IdentityServerOptions());
options.Cookie.Path.ShouldBe("/");
}
[Fact]
public void host_prefixed_external_cookie_clears_domain()
{
var options = ConfigureExternalCookie(new IdentityServerOptions());
options.Cookie.Domain.ShouldBeNull();
}
// --- Non-__Host- names do NOT override SecurePolicy/Path/Domain ---
[Fact]
public void non_host_prefixed_main_cookie_does_not_force_secure_policy()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.CookieName = "idsrv";
var options = ConfigureMainCookie(idsrvOptions);
// Default CookieSecurePolicy is SameAsRequest, not Always
options.Cookie.SecurePolicy.ShouldBe(CookieSecurePolicy.SameAsRequest);
}
[Fact]
public void non_host_prefixed_external_cookie_does_not_force_secure_policy()
{
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.ExternalCookieName = "idsrv.external";
var options = ConfigureExternalCookie(idsrvOptions);
options.Cookie.SecurePolicy.ShouldBe(CookieSecurePolicy.SameAsRequest);
}
// --- __Host- prefix check is case-sensitive per RFC 6265bis ---
[Fact]
public void wrong_case_host_prefix_does_not_enforce_secure_policy()
{
// "__host-idsrv" (lowercase) must not be treated as host-prefixed per RFC 6265bis.
var idsrvOptions = new IdentityServerOptions();
idsrvOptions.Authentication.CookieName = "__host-idsrv";
var options = ConfigureMainCookie(idsrvOptions);
options.Cookie.SecurePolicy.ShouldNotBe(CookieSecurePolicy.Always);
}
}

View file

@ -9,7 +9,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.Testing" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />