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.Http1.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs index af78d4d4cb9a9c..32bac6218eec0e 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs @@ -293,7 +293,7 @@ private async Task InjectNewHttp11ConnectionAsync(RequestQueue.Q internal async ValueTask CreateHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) { - (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(request, async, cancellationToken).ConfigureAwait(false); + (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(request, async, isForHttp2: false, cancellationToken).ConfigureAwait(false); return await ConstructHttp11ConnectionAsync(async, stream, transportContext, request, activity, remoteEndPoint, cancellationToken).ConfigureAwait(false); } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index 33155f12d8b73b..39470595acdf01 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -27,6 +27,7 @@ internal sealed partial class HttpConnectionPool private RequestQueue _http2RequestQueue; private bool _http2Enabled; + private bool _http2SessionAuthSeen; private byte[]? _http2AltSvcOriginUri; internal readonly byte[]? _http2EncodedAuthorityHostHeader; @@ -184,7 +185,7 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue. CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource(waiter); try { - (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false); + (Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, isForHttp2: true, cts.Token).ConfigureAwait(false); if (IsSecure) { @@ -286,6 +287,18 @@ private void HandleHttp2ConnectionFailure(HttpConnectionWaiter } } + /// + /// Marks this pool as having seen a session-based authentication challenge on HTTP/2. + /// Future requests that allow downgrade () + /// will skip HTTP/2 and go directly to HTTP/1.1. + /// Requests that require HTTP/2 (e.g., ) + /// continue to use HTTP/2 as before. + /// + internal void OnSessionAuthenticationChallengeSeen() + { + _http2SessionAuthSeen = true; + } + private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, CancellationToken cancellationToken) { if (NetEventSource.Log.IsEnabled()) Trace("Server does not support HTTP2; disabling HTTP2 use and proceeding with HTTP/1.1 connection"); 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..e9ff86a05dd671 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 @@ -440,7 +440,8 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn // Use HTTP/2 if possible. if (_http2Enabled && (request.Version.Major >= 2 || (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrHigher && IsSecure)) && - (request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower || IsSecure)) // prefer HTTP/1.1 if connection is not secured and downgrade is possible + (request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower || IsSecure) && // prefer HTTP/1.1 if connection is not secured and downgrade is possible + !(_http2SessionAuthSeen && request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower)) // skip HTTP/2 for downgradeable requests after session auth { if (!TryGetPooledHttp2Connection(request, out Http2Connection? connection, out http2ConnectionWaiter) && http2ConnectionWaiter != null) @@ -534,6 +535,17 @@ 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 require a persistent connection and don't work properly over HTTP/2. + // The pool flag was already set in Http2Connection.SendAsync so future downgradeable + // requests will go directly to HTTP/1.1. Retry this request on HTTP/1.1. + Debug.Assert(request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower); + Debug.Assert(_http2SessionAuthSeen); + + 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, @@ -545,7 +557,7 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn } } - private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken) + private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, bool isForHttp2, CancellationToken cancellationToken) { Stream? stream = null; IPEndPoint? remoteEndPoint = null; @@ -606,7 +618,7 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn SslStream? sslStream = stream as SslStream; if (sslStream == null) { - sslStream = await ConnectHelper.EstablishSslConnectionAsync(GetSslOptionsForRequest(request), request, async, stream, cancellationToken).ConfigureAwait(false); + sslStream = await ConnectHelper.EstablishSslConnectionAsync(GetSslOptionsForRequest(request, isForHttp2), request, async, stream, cancellationToken).ConfigureAwait(false); } else { @@ -698,9 +710,11 @@ private async ValueTask ConnectToTcpHostAsync(string host, int port, Htt } } - private SslClientAuthenticationOptions GetSslOptionsForRequest(HttpRequestMessage request) + private SslClientAuthenticationOptions GetSslOptionsForRequest(HttpRequestMessage request, bool isForHttp2) { - if (_http2Enabled) + // Even if a request could use HTTP/2, we may have chosen to establish an HTTP/1.1 connection + // for it instead (e.g. when _http2SessionAuthSeen is set for downgradeable requests). + if (_http2Enabled && isForHttp2) { if (request.Version.Major >= 2 && request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower) { 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 dada2fb6855de3..e6f819cc3ae4ee 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 @@ -2063,7 +2063,35 @@ 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. + if (AuthenticationHelper.IsSessionAuthenticationChallenge(response)) + { + // Mark the pool so future downgradeable requests go directly to HTTP/1.1. + // This is set regardless of whether we can retry this particular request, + // so that subsequent requests benefit from the downgrade. + _pool.OnSessionAuthenticationChallengeSeen(); + + // 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 (request.Content is null && + request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower && + !request.IsAuthDisabled()) + { + 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 804751eb6048e5..4f1d6740505a63 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Linq; using System.Net.Security; using System.Net.Test.Common; @@ -17,6 +18,8 @@ public partial class NtAuthTests : IClassFixture public static bool IsNtlmAvailable => Capability.IsNtlmInstalled() || OperatingSystem.IsAndroid() || OperatingSystem.IsTvOS(); + public static bool IsNtlmAndAlpnAvailable => IsNtlmAvailable && PlatformDetection.SupportsAlpn; + private static NetworkCredential s_testCredentialRight = new NetworkCredential("rightusername", "rightpassword"); internal static async Task HandleAuthenticationRequestWithFakeServer(LoopbackServer.Connection connection, bool useNtlm) @@ -112,6 +115,25 @@ internal static async Task HandleAuthenticationRequestWithFakeServer(LoopbackSer await connection.SendResponseAsync(HttpStatusCode.OK); } + private static HttpAgnosticOptions CreateHttpAgnosticOptions() => new HttpAgnosticOptions + { + UseSsl = true, + SslApplicationProtocols = new List + { + SslApplicationProtocol.Http2, + SslApplicationProtocol.Http11 + } + }; + + private static SocketsHttpHandler CreateCredentialHandler() => new SocketsHttpHandler + { + Credentials = s_testCredentialRight, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = delegate { return true; } + } + }; + [ConditionalTheory(typeof(NtAuthTests), nameof(IsNtlmAvailable))] [InlineData(true)] [InlineData(false)] @@ -140,6 +162,305 @@ await server.AcceptConnectionAsync(async connection => }); } + [ConditionalTheory(nameof(IsNtlmAndAlpnAvailable))] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")] + public async Task Http2_SessionAuthChallenge_DowngradesPoolToHttp11(bool useNtlm) + { + // When an HTTP/2 request receives a session-based auth challenge (NTLM/Negotiate), + // the pool should disable HTTP/2 and retry the request on HTTP/1.1. + await HttpAgnosticLoopbackServer.CreateClientAndServerAsync( + async uri => + { + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + HttpResponseMessage response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version11, response.Version); + }, + async server => + { + // First connection: HTTP/2 (via ALPN). Read request, send 401 with session auth challenge. + await server.AcceptConnectionAsync(async connection => + { + var h2 = (Http2LoopbackConnection)connection; + int streamId = await h2.ReadRequestHeaderAsync(); + + string authScheme = useNtlm ? "NTLM" : "Negotiate"; + await h2.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized, + headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) }); + }); + + // Second connection: HTTP/1.1 (pool disabled HTTP/2). Handle auth. + await server.AcceptConnectionAsync(async connection => + { + Assert.IsType(connection); + await HandleAuthenticationRequestWithFakeServer((LoopbackServer.Connection)connection, useNtlm); + }); + }, + httpOptions: CreateHttpAgnosticOptions()); + } + + [ConditionalTheory(nameof(IsNtlmAndAlpnAvailable))] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")] + public async Task Http2_SessionAuthChallenge_SubsequentRequestsUseHttp11(bool useNtlm) + { + // After the pool downgrades to HTTP/1.1 due to session auth, subsequent requests + // should also use HTTP/1.1 without attempting HTTP/2. + await HttpAgnosticLoopbackServer.CreateClientAndServerAsync( + async uri => + { + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + // First request: triggers pool downgrade. + var request1 = new HttpRequestMessage(HttpMethod.Get, uri); + request1.Version = HttpVersion.Version20; + request1.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + HttpResponseMessage response1 = await client.SendAsync(request1); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + Assert.Equal(HttpVersion.Version11, response1.Version); + + // Second request on the same handler: should go directly to HTTP/1.1 + // without trying HTTP/2 first (no extra roundtrip). + var request2 = new HttpRequestMessage(HttpMethod.Get, uri); + request2.Version = HttpVersion.Version20; + request2.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + HttpResponseMessage response2 = await client.SendAsync(request2); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + Assert.Equal(HttpVersion.Version11, response2.Version); + }, + async server => + { + // First connection: HTTP/2. Read request, send 401 auth challenge. + await server.AcceptConnectionAsync(async connection => + { + var h2 = (Http2LoopbackConnection)connection; + int streamId = await h2.ReadRequestHeaderAsync(); + + string authScheme = useNtlm ? "NTLM" : "Negotiate"; + await h2.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized, + headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) }); + }); + + // Second connection: HTTP/1.1. Handle auth for first request. + await server.AcceptConnectionAsync(async connection => + { + Assert.IsType(connection); + await HandleAuthenticationRequestWithFakeServer((LoopbackServer.Connection)connection, useNtlm); + }); + + // Third connection: HTTP/1.1 for second request (no auth needed, new connection). + await server.AcceptConnectionAsync(async connection => + { + Assert.IsType(connection); + await connection.HandleRequestAsync(HttpStatusCode.OK); + }); + }, + httpOptions: CreateHttpAgnosticOptions()); + } + + [ConditionalTheory(nameof(IsNtlmAvailable))] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")] + public async Task Http2_SessionAuthChallenge_ExactVersionPolicy_Returns401(bool useNtlm) + { + // When the version policy is RequestVersionExact, we can't downgrade. + // The 401 response should be returned as-is. + await Http2LoopbackServer.CreateClientAndServerAsync( + async uri => + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + HttpResponseMessage response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.True(response.Headers.WwwAuthenticate.Count > 0); + }, + async server => + { + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(); + + 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_SessionAuthChallenge_WithContent_Returns401(bool useNtlm) + { + // Requests with content can't be safely retried, so the 401 should be returned. + await Http2LoopbackServer.CreateClientAndServerAsync( + async uri => + { + var request = new HttpRequestMessage(HttpMethod.Post, uri); + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + request.Content = new StringContent("test content"); + + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + HttpResponseMessage response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + Assert.True(response.Headers.WwwAuthenticate.Count > 0); + }, + async server => + { + Http2LoopbackConnection connection = await server.EstablishConnectionAsync(); + int streamId = await connection.ReadRequestHeaderAsync(expectEndOfStream: false); + await connection.ReadBodyAsync(); + + string authScheme = useNtlm ? "NTLM" : "Negotiate"; + await connection.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized, + headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) }); + }); + } + + [ConditionalTheory(nameof(IsNtlmAndAlpnAvailable))] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")] + public async Task Http2_SessionAuthChallenge_PostSetsFlag_SubsequentGetUsesHttp11(bool useNtlm) + { + // A POST with content that gets a session auth challenge can't be retried, + // but it should still set the pool flag so that subsequent downgradeable + // requests (like GET) go directly to HTTP/1.1. + await HttpAgnosticLoopbackServer.CreateClientAndServerAsync( + async uri => + { + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + // First request: POST with content gets 401. Can't retry, but sets the flag. + var postRequest = new HttpRequestMessage(HttpMethod.Post, uri); + postRequest.Version = HttpVersion.Version20; + postRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + postRequest.Content = new StringContent("test content"); + + HttpResponseMessage postResponse = await client.SendAsync(postRequest); + Assert.Equal(HttpStatusCode.Unauthorized, postResponse.StatusCode); + Assert.Equal(HttpVersion.Version20, postResponse.Version); + + // Second request: GET without content. Should go directly to HTTP/1.1 + // because the pool flag was set by the POST's auth challenge. + var getRequest = new HttpRequestMessage(HttpMethod.Get, uri); + getRequest.Version = HttpVersion.Version20; + getRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + HttpResponseMessage getResponse = await client.SendAsync(getRequest); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.Equal(HttpVersion.Version11, getResponse.Version); + }, + async server => + { + // First connection: HTTP/2. POST with content gets 401 auth challenge. + await server.AcceptConnectionAsync(async connection => + { + var h2 = (Http2LoopbackConnection)connection; + int streamId = await h2.ReadRequestHeaderAsync(expectEndOfStream: false); + await h2.ReadBodyAsync(); + + string authScheme = useNtlm ? "NTLM" : "Negotiate"; + await h2.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized, + headers: new[] { new HttpHeaderData("WWW-Authenticate", authScheme) }); + }); + + // Second connection: HTTP/1.1 for the GET (pool flag was set by POST). + await server.AcceptConnectionAsync(async connection => + { + Assert.IsType(connection); + await HandleAuthenticationRequestWithFakeServer((LoopbackServer.Connection)connection, useNtlm); + }); + }, + httpOptions: CreateHttpAgnosticOptions()); + } + + [ConditionalFact(nameof(IsNtlmAndAlpnAvailable))] + [SkipOnPlatform(TestPlatforms.Browser, "Credentials and HttpListener is not supported on Browser")] + public async Task Http2_SessionAuthChallenge_Http2OnlyRequestsStillWork() + { + // After a session auth challenge triggers the downgrade flag, + // HTTP/2-only requests (RequestVersionExact) should still use HTTP/2 normally. + await HttpAgnosticLoopbackServer.CreateClientAndServerAsync( + async uri => + { + using SocketsHttpHandler handler = CreateCredentialHandler(); + using var client = new HttpClient(handler); + + // First request: downgradeable, triggers the session auth flag. + var request1 = new HttpRequestMessage(HttpMethod.Get, uri); + request1.Version = HttpVersion.Version20; + request1.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + HttpResponseMessage response1 = await client.SendAsync(request1); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + Assert.Equal(HttpVersion.Version11, response1.Version); + + // Second request: HTTP/2-only. Should still use HTTP/2 despite the flag. + var request2 = new HttpRequestMessage(HttpMethod.Get, uri); + request2.Version = HttpVersion.Version20; + request2.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + + HttpResponseMessage response2 = await client.SendAsync(request2); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + Assert.Equal(HttpVersion.Version20, response2.Version); + }, + async server => + { + // First connection: HTTP/2. Send 401 auth challenge. + await server.AcceptConnectionAsync(async connection => + { + var h2 = (Http2LoopbackConnection)connection; + int streamId = await h2.ReadRequestHeaderAsync(); + + await h2.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.Unauthorized, + headers: new[] { new HttpHeaderData("WWW-Authenticate", "NTLM") }); + }); + + // Second connection: HTTP/1.1. Handle auth for the downgradeable request. + await server.AcceptConnectionAsync(async connection => + { + Assert.IsType(connection); + await HandleAuthenticationRequestWithFakeServer((LoopbackServer.Connection)connection, useNtlm: true); + }); + + // Third connection: HTTP/2 for the HTTP/2-only request. Serve normally. + await server.AcceptConnectionAsync(async connection => + { + var h2 = (Http2LoopbackConnection)connection; + int streamId = await h2.ReadRequestHeaderAsync(); + await h2.SendResponseHeadersAsync(streamId, endStream: true, HttpStatusCode.OK); + }); + }, + httpOptions: CreateHttpAgnosticOptions()); + } + [Fact] [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.Windows, "DefaultCredentials are unsupported for NTLM on Unix / Managed implementation")] public async Task DefaultHandler_FakeServer_DefaultCredentials()