Make HTTP 303 redirects unconditional, remove UseHttp303Redirects opt-in flag

HTTP 303 (See Other) is now the mandatory redirect status code for all authorization
and end-session redirects. The UserInteractionOptions.UseHttp303Redirects flag and the
RedirectWithStatusCode helper have been removed. The four call sites in AuthorizeResult,
AuthorizeInteractionPageResult, and EndSessionResult now set the status code and
Location header directly. MinVer bumped to 8.0 to signal the breaking change.
This commit is contained in:
Damian Hickey 2026-02-18 17:37:00 +01:00
parent 30373631c7
commit cf51c4f7b2
8 changed files with 21 additions and 162 deletions

View file

@ -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

View file

@ -10,7 +10,7 @@
<Product>Duende IdentityServer</Product>
<DisableImplicitNamespaceImports>true</DisableImplicitNamespaceImports>
<MinVerTagPrefix>is-</MinVerTagPrefix>
<MinVerMinimumMajorMinor>7.0</MinVerMinimumMajorMinor>
<MinVerMinimumMajorMinor>8.0</MinVerMinimumMajorMinor>
<IsIdSrvProject>true</IsIdSrvProject>
</PropertyGroup>

View file

@ -141,13 +141,4 @@ public class UserInteractionOptions
/// handle those values.
/// </summary>
public ICollection<string> PromptValuesSupported { get; set; } = new HashSet<string>(Constants.SupportedPromptModes);
/// <summary>
/// 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)
/// </summary>
public bool UseHttp303Redirects { get; set; }
}

View file

@ -126,6 +126,7 @@ internal class AuthorizeInteractionPageHttpWriter : IHttpResponseWriter<Authoriz
}
url = url.AddQueryString(result.ReturnUrlParameterName, returnUrl);
context.Response.RedirectWithStatusCode(_urls.GetAbsoluteUrl(url), _options.UserInteraction.UseHttp303Redirects);
context.Response.StatusCode = StatusCodes.Status303SeeOther;
context.Response.Headers.Location = _urls.GetAbsoluteUrl(url);
}
}

View file

@ -131,7 +131,8 @@ public class AuthorizeHttpWriter : IHttpResponseWriter<AuthorizeResult>
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<AuthorizeResult>
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<ErrorMessage> CreateErrorMessage(AuthorizeResponse response, HttpContext context)

View file

@ -83,6 +83,7 @@ internal class EndSessionHttpWriter : IHttpResponseWriter<EndSessionResult>
redirect = redirect.AddQueryString(_options.UserInteraction.LogoutIdParameter, id);
}
context.Response.RedirectWithStatusCode(redirect, _options.UserInteraction.UseHttp303Redirects);
context.Response.StatusCode = StatusCodes.Status303SeeOther;
context.Response.Headers.Location = redirect;
}
}

View file

@ -109,25 +109,4 @@ public static class HttpResponseExtensions
headers.Append("X-Content-Security-Policy", cspHeader);
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="response">The HTTP response.</param>
/// <param name="url">The URL to redirect to.</param>
/// <param name="useHttp303">If true, uses HTTP 303; otherwise uses HTTP 302.</param>
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);
}
}
}

View file

@ -8,11 +8,6 @@ using Duende.IdentityServer.Test;
namespace Duende.IdentityServer.IntegrationTests.Endpoints.Authorize;
/// <summary>
/// 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.
/// </summary>
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;