diff --git a/identity-server/CHANGELOG.md b/identity-server/CHANGELOG.md index f145cf4c9..26fbc2ab9 100644 --- a/identity-server/CHANGELOG.md +++ b/identity-server/CHANGELOG.md @@ -1,5 +1,10 @@ # IdentityServer Changelog +# 8.0.0 + +## Breaking Changes +- HTTP 303 (See Other) is now the unconditional redirect status code for all authorization and end-session redirects. The `UserInteractionOptions.UseHttp303Redirects` opt-in flag has been removed. This aligns IdentityServer with the FAPI 2.0 Security Profile (Section 5.3.2.2, item 11). + # 7.4.0-preview.1 ## Breaking Changes diff --git a/identity-server/src/Directory.Build.props b/identity-server/src/Directory.Build.props index fdddb3731..e7c2fe3f6 100644 --- a/identity-server/src/Directory.Build.props +++ b/identity-server/src/Directory.Build.props @@ -10,7 +10,7 @@ Duende IdentityServer true is- - 7.0 + 8.0 true diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/UserInteractionOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/UserInteractionOptions.cs index f2c649097..c59f4819f 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/UserInteractionOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/UserInteractionOptions.cs @@ -141,13 +141,4 @@ public class UserInteractionOptions /// handle those values. /// public ICollection PromptValuesSupported { get; set; } = new HashSet(Constants.SupportedPromptModes); - - /// - /// When enabled, uses HTTP 303 (See Other) status code for redirects instead of HTTP 302 (Found). - /// This is recommended by the FAPI 2.0 Security Profile (Section 5.3.2.2, item 11) to prevent - /// user agents from resubmitting POST data when following redirects. - /// See: https://openid.net/specs/fapi-security-profile-2_0-final.html - /// Default: false (uses HTTP 302 for backward compatibility) - /// - public bool UseHttp303Redirects { get; set; } } diff --git a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeInteractionPageResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeInteractionPageResult.cs index 8ba0cd40e..4b3757250 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeInteractionPageResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeInteractionPageResult.cs @@ -126,6 +126,7 @@ internal class AuthorizeInteractionPageHttpWriter : IHttpResponseWriter response.Request.ResponseMode == OidcConstants.ResponseModes.Fragment) { context.Response.SetNoCache(); - context.Response.RedirectWithStatusCode(BuildRedirectUri(response), _options.UserInteraction.UseHttp303Redirects); + context.Response.StatusCode = StatusCodes.Status303SeeOther; + context.Response.Headers.Location = BuildRedirectUri(response); } else if (response.Request.ResponseMode == OidcConstants.ResponseModes.FormPost) { @@ -231,7 +232,8 @@ public class AuthorizeHttpWriter : IHttpResponseWriter var errorUrl = _options.UserInteraction.ErrorUrl; var url = errorUrl.AddQueryString(_options.UserInteraction.ErrorIdParameter, id); - context.Response.RedirectWithStatusCode(_urls.GetAbsoluteUrl(url), _options.UserInteraction.UseHttp303Redirects); + context.Response.StatusCode = StatusCodes.Status303SeeOther; + context.Response.Headers.Location = _urls.GetAbsoluteUrl(url); } protected virtual Task CreateErrorMessage(AuthorizeResponse response, HttpContext context) diff --git a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionResult.cs index cda630d5e..2a98f5132 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionResult.cs @@ -83,6 +83,7 @@ internal class EndSessionHttpWriter : IHttpResponseWriter redirect = redirect.AddQueryString(_options.UserInteraction.LogoutIdParameter, id); } - context.Response.RedirectWithStatusCode(redirect, _options.UserInteraction.UseHttp303Redirects); + context.Response.StatusCode = StatusCodes.Status303SeeOther; + context.Response.Headers.Location = redirect; } } diff --git a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs index 630bbeafd..29b4d42dd 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs @@ -109,25 +109,4 @@ public static class HttpResponseExtensions headers.Append("X-Content-Security-Policy", cspHeader); } } - - /// - /// Redirects the response to the specified URL using the appropriate HTTP status code. - /// When useHttp303 is true, uses HTTP 303 (See Other) instead of HTTP 302 (Found). - /// HTTP 303 is recommended by FAPI 2.0 Security Profile to prevent POST data resubmission. - /// - /// The HTTP response. - /// The URL to redirect to. - /// If true, uses HTTP 303; otherwise uses HTTP 302. - public static void RedirectWithStatusCode(this HttpResponse response, string url, bool useHttp303) - { - if (useHttp303) - { - response.StatusCode = StatusCodes.Status303SeeOther; - response.Headers.Location = url; - } - else - { - response.Redirect(url); - } - } } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/Http303RedirectTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/Http303RedirectTests.cs index 31409bf29..0f6e61209 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/Http303RedirectTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Authorize/Http303RedirectTests.cs @@ -8,11 +8,6 @@ using Duende.IdentityServer.Test; namespace Duende.IdentityServer.IntegrationTests.Endpoints.Authorize; -/// -/// Tests for HTTP 303 redirect behavior per FAPI 2.0 Security Profile. -/// When UseHttp303Redirects is enabled, the server should use HTTP 303 (See Other) -/// instead of HTTP 302 (Found) for authorization redirects. -/// public class Http303RedirectTests { private const string Category = "HTTP 303 Redirect Tests"; @@ -56,47 +51,14 @@ public class Http303RedirectTests Username = "bob", IsActive = true }); - } - private void InitializeWithHttp303(bool enableHttp303) - { - _pipeline.OnPostConfigureServices += services => { }; - _pipeline.OnPreConfigure += app => { }; - _pipeline.OnPostConfigure += app => { }; _pipeline.Initialize(); - _pipeline.Options.UserInteraction.UseHttp303Redirects = enableHttp303; } [Fact] [Trait("Category", Category)] - public async Task Authorize_WithHttp303Disabled_Returns302Redirect() + public async Task Authorize_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: false); - - await _pipeline.LoginAsync("bob"); - _pipeline.BrowserClient.AllowAutoRedirect = false; - - var url = _pipeline.CreateAuthorizeUrl( - clientId: "code_client", - responseType: "code", - scope: "openid", - redirectUri: "https://client/callback", - state: "123_state", - nonce: "123_nonce"); - - var response = await _pipeline.BrowserClient.GetAsync(url); - - response.StatusCode.ShouldBe(HttpStatusCode.Redirect); - response.Headers.Location.ShouldNotBeNull(); - response.Headers.Location!.ToString().ShouldStartWith("https://client/callback"); - } - - [Fact] - [Trait("Category", Category)] - public async Task Authorize_WithHttp303Enabled_Returns303Redirect() - { - InitializeWithHttp303(enableHttp303: true); - await _pipeline.LoginAsync("bob"); _pipeline.BrowserClient.AllowAutoRedirect = false; @@ -117,10 +79,8 @@ public class Http303RedirectTests [Fact] [Trait("Category", Category)] - public async Task Authorize_WithHttp303Enabled_ImplicitFlow_Returns303Redirect() + public async Task Authorize_ImplicitFlow_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: true); - await _pipeline.LoginAsync("bob"); _pipeline.BrowserClient.AllowAutoRedirect = false; @@ -141,34 +101,8 @@ public class Http303RedirectTests [Fact] [Trait("Category", Category)] - public async Task AuthorizeRedirectToLogin_WithHttp303Disabled_Returns302Redirect() + public async Task Authorize_RedirectToLogin_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: false); - - // User is not logged in, so authorize should redirect to login page - _pipeline.BrowserClient.AllowAutoRedirect = false; - - var url = _pipeline.CreateAuthorizeUrl( - clientId: "code_client", - responseType: "code", - scope: "openid", - redirectUri: "https://client/callback", - state: "123_state", - nonce: "123_nonce"); - - var response = await _pipeline.BrowserClient.GetAsync(url); - - response.StatusCode.ShouldBe(HttpStatusCode.Redirect); - response.Headers.Location.ShouldNotBeNull(); - response.Headers.Location!.ToString().ShouldContain("/account/login"); - } - - [Fact] - [Trait("Category", Category)] - public async Task AuthorizeRedirectToLogin_WithHttp303Enabled_Returns303Redirect() - { - InitializeWithHttp303(enableHttp303: true); - // User is not logged in, so authorize should redirect to login page _pipeline.BrowserClient.AllowAutoRedirect = false; @@ -189,10 +123,8 @@ public class Http303RedirectTests [Fact] [Trait("Category", Category)] - public async Task Authorize_ErrorRedirectToErrorPage_WithHttp303Enabled_Returns303Redirect() + public async Task Authorize_ErrorRedirectToErrorPage_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: true); - await _pipeline.LoginAsync("bob"); _pipeline.BrowserClient.AllowAutoRedirect = false; @@ -209,32 +141,13 @@ public class Http303RedirectTests response.StatusCode.ShouldBe(HttpStatusCode.SeeOther); response.Headers.Location.ShouldNotBeNull(); - var location = response.Headers.Location!.ToString(); - location.ShouldContain("/home/error"); + response.Headers.Location!.ToString().ShouldContain("/home/error"); } [Fact] [Trait("Category", Category)] - public async Task EndSession_WithHttp303Disabled_Returns302Redirect() + public async Task EndSession_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: false); - - await _pipeline.LoginAsync("bob"); - _pipeline.BrowserClient.AllowAutoRedirect = false; - - var response = await _pipeline.BrowserClient.GetAsync(IdentityServerPipeline.EndSessionEndpoint); - - response.StatusCode.ShouldBe(HttpStatusCode.Redirect); - response.Headers.Location.ShouldNotBeNull(); - response.Headers.Location!.ToString().ShouldContain("/account/logout"); - } - - [Fact] - [Trait("Category", Category)] - public async Task EndSession_WithHttp303Enabled_Returns303Redirect() - { - InitializeWithHttp303(enableHttp303: true); - await _pipeline.LoginAsync("bob"); _pipeline.BrowserClient.AllowAutoRedirect = false; @@ -247,41 +160,8 @@ public class Http303RedirectTests [Fact] [Trait("Category", Category)] - public async Task EndSession_WithIdTokenHint_WithHttp303Disabled_Returns302Redirect() + public async Task EndSession_WithIdTokenHint_Returns303Redirect() { - InitializeWithHttp303(enableHttp303: false); - - await _pipeline.LoginAsync("bob"); - _pipeline.BrowserClient.AllowAutoRedirect = false; - - // First get an id_token - var url = _pipeline.CreateAuthorizeUrl( - clientId: "implicit_client", - responseType: "id_token", - scope: "openid", - redirectUri: "https://implicit/callback", - state: "123_state", - nonce: "123_nonce"); - - var authorizeResponse = await _pipeline.BrowserClient.GetAsync(url); - var authorization = new Duende.IdentityModel.Client.AuthorizeResponse(authorizeResponse.Headers.Location!.ToString()); - var idToken = authorization.IdentityToken; - - // Now call end session with the id_token_hint - var response = await _pipeline.BrowserClient.GetAsync( - IdentityServerPipeline.EndSessionEndpoint + "?id_token_hint=" + idToken); - - response.StatusCode.ShouldBe(HttpStatusCode.Redirect); - response.Headers.Location.ShouldNotBeNull(); - response.Headers.Location!.ToString().ShouldContain("/account/logout"); - } - - [Fact] - [Trait("Category", Category)] - public async Task EndSession_WithIdTokenHint_WithHttp303Enabled_Returns303Redirect() - { - InitializeWithHttp303(enableHttp303: true); - await _pipeline.LoginAsync("bob"); _pipeline.BrowserClient.AllowAutoRedirect = false;