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()