diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs b/src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs
index 564cf8bbe1ee53..e6e82a361d6bbb 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs
@@ -26,6 +26,11 @@ internal enum RequestRetryType
///
/// The proxy failed, so the request should be retried on the next proxy.
///
- RetryOnNextProxy
+ RetryOnNextProxy,
+
+ ///
+ /// The request received a session-based authentication challenge (e.g., NTLM or Negotiate) on HTTP/2 and should be retried on HTTP/1.1.
+ ///
+ RetryOnSessionAuthenticationChallenge
}
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
index d78cae1d9e2040..73b06d3ba0dd22 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
@@ -534,6 +534,21 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn
// Eat exception and try again on a lower protocol version.
request.Version = HttpVersion.Version11;
}
+ catch (HttpRequestException e) when (e.AllowRetry == RequestRetryType.RetryOnSessionAuthenticationChallenge)
+ {
+ // Server sent a session-based authentication challenge (Negotiate/NTLM) on HTTP/2.
+ // These authentication schemes don't work properly over HTTP/2, so we need to downgrade to HTTP/1.1.
+ // The version policy was already validated before throwing this exception.
+ Debug.Assert(request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower);
+
+ if (NetEventSource.Log.IsEnabled())
+ {
+ Trace($"Retrying request on HTTP/1.1 due to session-based authentication challenge on HTTP/2: {e}");
+ }
+
+ // Retry on HTTP/1.1 for session-based authentication.
+ request.Version = HttpVersion.Version11;
+ }
finally
{
// We never cancel both attempts at the same time. When downgrade happens, it's possible that both waiters are non-null,
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
index 6fde1077122f64..457f5b80599c52 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
@@ -2061,7 +2061,26 @@ await Task.WhenAny(requestBodyTask, responseHeadersTask).ConfigureAwait(false) =
// Wait for the response headers to complete if they haven't already, propagating any exceptions.
await responseHeadersTask.ConfigureAwait(false);
- return http2Stream.GetAndClearResponse();
+ HttpResponseMessage response = http2Stream.GetAndClearResponse();
+
+ // Check if this is a session-based authentication challenge (Negotiate/NTLM) on HTTP/2.
+ // These authentication schemes require a persistent connection and don't work properly over HTTP/2.
+ // We can only safely retry if there's no request content, as we cannot guarantee that we can
+ // rewind arbitrary content streams. Additionally, we only retry if the version policy allows downgrade.
+ if (AuthenticationHelper.IsSessionAuthenticationChallenge(response) &&
+ request.Content == null &&
+ request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower)
+ {
+ if (NetEventSource.Log.IsEnabled())
+ {
+ Trace($"Received session-based authentication challenge on HTTP/2, request will be retried on HTTP/1.1.");
+ }
+
+ response.Dispose();
+ throw new HttpRequestException(HttpRequestError.UserAuthenticationError, SR.net_http_authconnectionfailure, null, RequestRetryType.RetryOnSessionAuthenticationChallenge);
+ }
+
+ return response;
}
catch (HttpIOException e)
{
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs
index e0e08021cd8669..e35d4b7c908eda 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs
@@ -140,6 +140,139 @@ await server.AcceptConnectionAsync(async connection =>
});
}
+ [ConditionalTheory(nameof(IsNtlmAvailable))]
+ [InlineData(true)]
+ [InlineData(false)]
+ [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")]
+ public async Task Http2_FakeServer_SessionAuthChallenge_AutomaticallyDowngradesToHttp11(bool useNtlm)
+ {
+ // This test verifies that when an HTTP/2 request receives a session-based auth challenge (NTLM or Negotiate),
+ // the handler automatically retries on HTTP/1.1 when the version policy allows downgrade.
+ await LoopbackServer.CreateClientAndServerAsync(
+ async uri =>
+ {
+ HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, uri);
+ requestMessage.Version = HttpVersion.Version20;
+ requestMessage.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
+
+ SocketsHttpHandler handler = new SocketsHttpHandler() { Credentials = s_testCredentialRight };
+ using var client = new HttpClient(handler);
+
+ // The request should ultimately succeed (after downgrade to HTTP/1.1)
+ HttpResponseMessage response = await client.SendAsync(requestMessage);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ // Verify the final response came over HTTP/1.1, not HTTP/2
+ Assert.Equal(new Version(1, 1), response.Version);
+ },
+ async server =>
+ {
+ // First connection attempt will negotiate HTTP/2 via ALPN
+ // Send 401 with session-based auth to trigger downgrade
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ // Upgrade to HTTP/2
+ await connection.SendResponseAsync(HttpStatusCode.SwitchingProtocols, content: null, headers: new HttpHeaderData[] {
+ new HttpHeaderData("Upgrade", "h2c"),
+ new HttpHeaderData("Connection", "Upgrade")
+ });
+
+ // Now handle as HTTP/2 and send auth challenge
+ string authScheme = useNtlm ? "NTLM" : "Negotiate";
+ await connection.SendResponseAsync(HttpStatusCode.Unauthorized, content: null, headers: new HttpHeaderData[] {
+ new HttpHeaderData("WWW-Authenticate", authScheme)
+ });
+ }).ConfigureAwait(false);
+
+ // Second connection - handle auth on HTTP/1.1
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ await HandleAuthenticationRequestWithFakeServer(connection, useNtlm);
+ }).ConfigureAwait(false);
+ });
+ }
+
+ [ConditionalTheory(nameof(IsNtlmAvailable))]
+ [InlineData(true)]
+ [InlineData(false)]
+ [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")]
+ public async Task Http2_FakeServer_SessionAuthChallenge_ReturnsUnauthorized_WithNoRetryOnHttp2(bool useNtlm)
+ {
+ // This test verifies that when an HTTP/2 request receives a session-based auth challenge (NTLM or Negotiate)
+ // and we can't retry (e.g., wrong version policy), we get the 401 response.
+ await Http2LoopbackServer.CreateClientAndServerAsync(
+ async uri =>
+ {
+ HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, uri);
+ requestMessage.Version = HttpVersion.Version20;
+ // RequestVersionExact means we won't downgrade to HTTP/1.1
+ requestMessage.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
+
+ SocketsHttpHandler handler = new SocketsHttpHandler() { Credentials = s_testCredentialRight };
+ using var client = new HttpClient(handler);
+
+ // Should get 401 since we can't downgrade with RequestVersionExact
+ HttpResponseMessage response = await client.SendAsync(requestMessage);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal(HttpVersion.Version20, response.Version);
+ Assert.True(response.Headers.WwwAuthenticate.Count > 0, "Expected WWW-Authenticate header in 401 response");
+ },
+ async server =>
+ {
+ // Request over HTTP/2 - send auth challenge
+ Http2LoopbackConnection connection = await server.EstablishConnectionAsync();
+ int streamId = await connection.ReadRequestHeaderAsync();
+
+ // Send 401 with NTLM or Negotiate challenge
+ string authScheme = useNtlm ? "NTLM" : "Negotiate";
+ await connection.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized,
+ headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) });
+ });
+ }
+
+ [ConditionalTheory(nameof(IsNtlmAvailable))]
+ [InlineData(true)]
+ [InlineData(false)]
+ [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")]
+ public async Task Http2_FakeServer_SessionAuthChallenge_WithContent_ReturnsUnauthorized(bool useNtlm)
+ {
+ // This test verifies that when an HTTP/2 request with content receives a session-based auth challenge,
+ // we return the 401 response without retrying (since we can't safely retry with content).
+ await Http2LoopbackServer.CreateClientAndServerAsync(
+ async uri =>
+ {
+ HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, uri);
+ requestMessage.Version = HttpVersion.Version20;
+ requestMessage.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
+ requestMessage.Content = new StringContent("test content");
+
+ SocketsHttpHandler handler = new SocketsHttpHandler() { Credentials = s_testCredentialRight };
+ using var client = new HttpClient(handler);
+
+ // Should get 401 since we can't retry requests with content
+ HttpResponseMessage response = await client.SendAsync(requestMessage);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal(HttpVersion.Version20, response.Version);
+ Assert.True(response.Headers.WwwAuthenticate.Count > 0, "Expected WWW-Authenticate header in 401 response");
+ },
+ async server =>
+ {
+ // Request over HTTP/2 - send auth challenge
+ Http2LoopbackConnection connection = await server.EstablishConnectionAsync();
+ int streamId = await connection.ReadRequestHeaderAsync();
+
+ // Read and discard the request body
+ await connection.ReadBodyAsync();
+
+ // Send 401 with NTLM or Negotiate challenge
+ string authScheme = useNtlm ? "NTLM" : "Negotiate";
+ await connection.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized,
+ headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) });
+ });
+ }
+
[Fact]
[SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.Windows, "DefaultCredentials are unsupported for NTLM on Unix / Managed implementation")]
public async Task DefaultHandler_FakeServer_DefaultCredentials()