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