diff --git a/identity-server/src/IdentityServer/Configuration/CookieNameMigrationExtensions.cs b/identity-server/src/IdentityServer/Configuration/CookieNameMigrationExtensions.cs deleted file mode 100644 index c9353cf06..000000000 --- a/identity-server/src/IdentityServer/Configuration/CookieNameMigrationExtensions.cs +++ /dev/null @@ -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; - -/// -/// Extension methods to support seamless cookie name migration. -/// -public static class CookieNameMigrationExtensions -{ - /// - /// 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 __Host- prefix in IdentityServer 8.0) without invalidating existing - /// user sessions. - /// - /// - /// - /// Register this middleware before app.UseIdentityServer() in the pipeline. - /// - /// - /// On each request where the old cookie is present but the new one is absent, the middleware: - /// - /// Patches the incoming request so downstream auth handlers find the value under the new name. - /// On the response: issues a Set-Cookie for the new name and expires the old one. - /// - /// - /// - /// This is a transient migration aid. Once all active sessions have been re-issued under the - /// new cookie name the middleware can be removed. - /// - /// - /// To migrate from the IdentityServer 7.x defaults to the 8.0 defaults, register twice: - /// - /// app.MigrateIdentityServerCookieName("idsrv", "__Host-idsrv"); - /// app.MigrateIdentityServerCookieName("idsrv.external", "__Host-idsrv.external"); - /// app.UseIdentityServer(); - /// - /// - /// - /// The application builder. - /// The old cookie name to migrate from. - /// The new cookie name to migrate to. - /// The application builder. - 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(); - - 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); - }); - } -} diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs index ae457989a..45ec8daea 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs @@ -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 public SameSiteMode CookieSameSiteMode { get; set; } = SameSiteMode.None; - /// - /// 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. - /// - public string CookieName { get; set; } = "__Host-idsrv"; - - /// - /// 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. - /// - public string ExternalCookieName { get; set; } = "__Host-idsrv.external"; - /// /// Indicates if user must be authenticated to accept parameters to end session endpoint. Defaults to false. /// diff --git a/identity-server/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs b/identity-server/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs index cce916153..54a369998 100644 --- a/identity-server/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs +++ b/identity-server/src/IdentityServer/Configuration/IdentityServerApplicationBuilderExtensions.cs @@ -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"); diff --git a/identity-server/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/identity-server/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 4b0cdada7..7a4dd0ec9 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -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); diff --git a/identity-server/test/IdentityServer.UnitTests/Configuration/CookieNameMigrationExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Configuration/CookieNameMigrationExtensionsTests.cs deleted file mode 100644 index 8c025e29e..000000000 --- a/identity-server/test/IdentityServer.UnitTests/Configuration/CookieNameMigrationExtensionsTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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 - /// - 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(() => app.MigrateIdentityServerCookieName(null!, "__Host-idsrv")); - } - - [Fact] - public void null_new_cookie_name_throws_argument_exception() - { - var app = new ApplicationBuilder(null!); - Should.Throw(() => app.MigrateIdentityServerCookieName("idsrv", null!)); - } - - [Fact] - public void empty_old_cookie_name_throws_argument_exception() - { - var app = new ApplicationBuilder(null!); - Should.Throw(() => app.MigrateIdentityServerCookieName("", "__Host-idsrv")); - } - - [Fact] - public void empty_new_cookie_name_throws_argument_exception() - { - var app = new ApplicationBuilder(null!); - Should.Throw(() => 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); - } -} diff --git a/identity-server/test/IdentityServer.UnitTests/Configuration/DependencyInjection/ConfigureInternalCookieOptionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Configuration/DependencyInjection/ConfigureInternalCookieOptionsTests.cs deleted file mode 100644 index e0c306ef9..000000000 --- a/identity-server/test/IdentityServer.UnitTests/Configuration/DependencyInjection/ConfigureInternalCookieOptionsTests.cs +++ /dev/null @@ -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); - } -} diff --git a/identity-server/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj b/identity-server/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj index 369e46080..158349f66 100644 --- a/identity-server/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj +++ b/identity-server/test/IdentityServer.UnitTests/IdentityServer.UnitTests.csproj @@ -9,7 +9,6 @@ -