HTTP/2: Add automatic downgrade to HTTP/1.1 for Windows authentication#123827
HTTP/2: Add automatic downgrade to HTTP/1.1 for Windows authentication#123827
Conversation
src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
Outdated
Show resolved
Hide resolved
.../System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
Show resolved
Hide resolved
|
The PR change as-is would only handle requests without a body, and automatically retry on 1.1.
Alternatively, we could disable HTTP/2 on the connection pool if we reach this case, so future requests would just start with 1.1. This also has problems:
I'm leaning towards trying out the alternative approach. |
When an HTTP/2 request receives a session-based auth challenge (NTLM/Negotiate), disable HTTP/2 on the connection pool so all future requests use HTTP/1.1 directly. This avoids extra roundtrips for subsequent requests and ensures POST requests work after the initial downgrade. - Add DisableHttp2() method to HttpConnectionPool - Catch RetryOnSessionAuthenticationChallenge in SendWithVersionDetectionAndRetryAsync and call DisableHttp2() before retrying - Rewrite tests using HttpAgnosticLoopbackServer for proper HTTP/2→1.1 testing - Add tests for: pool downgrade, subsequent requests, exact version policy, content requests, and non-auth HTTP/2 responses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of disabling HTTP/2 entirely (which would break HTTP/2-only requests), add a _http2SessionAuthSeen flag that only affects requests with RequestVersionOrLower policy. HTTP/2-only requests (RequestVersionExact, RequestVersionOrHigher) continue to work on HTTP/2 as before. Also update GetSslOptionsForRequest to avoid negotiating h2 ALPN for downgradeable requests after session auth has been seen. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Make _http2SessionAuthSeen volatile for cross-thread visibility - Always set the pool flag when a session auth challenge is detected, even for requests that can't be retried (e.g. POST with content). This ensures subsequent downgradeable requests benefit immediately. - Move flag-setting from catch handler to Http2Connection.SendAsync - Add test: POST triggers flag, subsequent GET uses HTTP/1.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of inspecting the request to decide ALPN, pass the intended protocol version into ConnectAsync. This avoids the bug where a queued HTTP/2 connection establishment could pick up a downgradeable request and negotiate HTTP/1.1 ALPN, which would trigger HandleHttp11Downgrade and disable HTTP/2 for the entire pool. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f355c6e to
bcd9d4b
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates SocketsHttpHandler to automatically fall back from HTTP/2 to HTTP/1.1 when an HTTP/2 response indicates a session-based Windows authentication challenge (NTLM/Negotiate), enabling successful authentication for retryable requests while preserving behavior when downgrade is not allowed.
Changes:
- Added a new retry signal (
RequestRetryType.RetryOnSessionAuthenticationChallenge) to represent auth-driven protocol fallback. - Detected NTLM/Negotiate challenges in
Http2Connection.SendAsync()and triggered an HTTP/1.1 retry (when safe), while also marking the pool to prefer HTTP/1.1 for future downgradeable requests. - Updated connection establishment to explicitly control ALPN selection based on whether the connection is intended for HTTP/2, and added functional tests for downgrade behavior and constraints.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs | Adds functional tests verifying HTTP/2-to-HTTP/1.1 downgrade behavior and non-downgrade constraints. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs | Detects session-auth challenges on HTTP/2 and signals retry/pool behavior updates. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs | Skips HTTP/2 after session-auth is seen (for downgradeable requests) and handles the new retry type by retrying on HTTP/1.1. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs | Adds pool flag tracking for session-auth challenges. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs | Updates connection creation to pass the new “isForHttp2” signal into shared connect logic. |
| src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs | Adds a new retry enum value for session-auth downgrade retries. |
You can also share your feedback on Copilot code review. Take the survey.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
Show resolved
Hide resolved
...m.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs
Show resolved
Hide resolved
.../System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
Show resolved
Hide resolved
|
Note This review was generated by Copilot. 🤖 Copilot Code Review — PR #123827Holistic AssessmentMotivation: The PR addresses a real problem — NTLM/Negotiate authentication schemes require a persistent, connection-bound handshake that doesn't work over HTTP/2 (which multiplexes streams). When an HTTP/2 response contains a session-based auth challenge, the client needs to retry on HTTP/1.1. This is a genuine gap in the current implementation. Approach: The PR uses the existing Summary: Detailed Findings✅ Retry Pattern — Correct and consistentThe exception-based retry flow follows the existing pattern precisely:
The guard conditions are sound: only retry when ✅ ALPN Negotiation Fix — Important and correctThreading ✅ Test Coverage — ThoroughSix test methods cover the key scenarios:
This covers the major code paths and edge cases well.
|
When auth is disabled on a request (e.g., after a redirect with NetworkCredential), the handler should not retry/downgrade even if other conditions are met. This test verifies the 401 is returned. Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/7f52da7d-bfc5-4f5d-846b-836ce47c5cc5 Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
Added in 681fdea. The test verifies that when auth is disabled after a redirect (using |
| // 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 && |
There was a problem hiding this comment.
Should we retry any content-less request? What about non-idempotent POST without content?
And does it matter if we've received an error anyway?
There was a problem hiding this comment.
I think since it is 401 AuthChallenge that it guarantee server has not yet interpret the request, so retrying requests which was not yet accepted is most probably OK. There could be rare cases like rate limitations which could now count one request as two, but line 2075 somehow deals with it.
There was a problem hiding this comment.
Pull request overview
Adds an automatic HTTP/2 → HTTP/1.1 fallback path in SocketsHttpHandler when an HTTP/2 response indicates a session-based Windows auth challenge (NTLM/Negotiate), and adds functional tests validating downgrade behavior and pool-level avoidance of HTTP/2 for future downgradeable requests.
Changes:
- Introduces a new retry signal (
RequestRetryType.RetryOnSessionAuthenticationChallenge) and throws it fromHttp2Connectionwhen a session-auth 401 is detected and the request is safe to retry. - Updates
HttpConnectionPool.SendWithVersionDetectionAndRetryAsyncto catch that retry type, retry on HTTP/1.1, and to skip HTTP/2 forRequestVersionOrLowerrequests after a challenge has been seen. - Adds functional tests covering downgrade, persistence of the pool flag, and non-downgrade cases (exact version policy, request content, auth-disabled-after-redirect).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs | Adds new loopback-based tests asserting HTTP/2 session-auth challenges trigger HTTP/1.1 behavior and that pool behavior persists. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs | Detects NTLM/Negotiate 401 challenges on HTTP/2, sets the pool flag, and triggers an HTTP/1.1 retry when safe. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs | Introduces a pool flag indicating session-auth was seen and provides a setter method. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs | Threads an isForHttp2 parameter through connection establishment to control TLS/ALPN selection. |
| src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs | Skips HTTP/2 after session-auth is seen for downgradeable requests; retries on HTTP/1.1 for the new retry type; adds isForHttp2 to connection establishment to control TLS/ALPN options. |
| src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs | Adds RetryOnSessionAuthenticationChallenge. |
...m.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs
Show resolved
Hide resolved
.../System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
Show resolved
Hide resolved
| // 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( |
There was a problem hiding this comment.
This test comment says the pool "disable[s] HTTP/2", but the implementation only skips HTTP/2 for downgradeable requests (and still allows HTTP/2 for RequestVersionExact). Consider rewording the comment to avoid implying HTTP/2 is globally disabled for the pool.
| (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 |
There was a problem hiding this comment.
NIT:
When request has Version=1.1 with RequestVersionOrHigher on HTTPS, it can get upgraded to HTTP/2 (because "or higher" allows that). But after pool flag is set, the new check only blocks RequestVersionOrLower requests from going to HTTP/2 - it doesn't block RequestVersionOrHigher ones.
So this request still goes to HTTP/2, gets 401 with NTLM/Negotiate, and then retry logic also only kicks in for RequestVersionOrLower so retry doesn't happen and raw 401 is returned. To fix that the retry logic shloud allow RequestVersionOrLower && request.Version < 2
Probably fine to not handle this for now -> the combination of Version=1.1 + RequestVersionOrHigher + Windows auth + HTTP/2 server is pretty unusual, and behavior is same as what happens today without the fix. Could be addressed later if someone actually hits it.
| // 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 && |
There was a problem hiding this comment.
I think since it is 401 AuthChallenge that it guarantee server has not yet interpret the request, so retrying requests which was not yet accepted is most probably OK. There could be rare cases like rate limitations which could now count one request as two, but line 2075 somehow deals with it.
Description
Session-based authentication (NTLM/Negotiate) requires persistent connections and fails over HTTP/2. When SocketsHttpHandler receives a 401 with these schemes on HTTP/2, it now automatically retries on HTTP/1.1 if the request is retryable. Additionally, the connection pool is marked so that future downgradeable requests go directly to HTTP/1.1 without an extra roundtrip.
Changes
Core retry mechanism:
RequestRetryType.RetryOnSessionAuthenticationChallengeto signal auth-driven downgradesHttp2Connection.SendAsync()detects session auth challenges (401 + NTLM/Negotiate) and checks all safety conditions before attempting retryHttpConnectionPool.SendWithVersionDetectionAndRetryAsync()catches and retries on HTTP/1.1Pool-level HTTP/2 disable:
_http2SessionAuthSeen) so future downgradeable requests (RequestVersionOrLower) skip HTTP/2 entirely and go directly to HTTP/1.1Volatile.Read/Volatile.Writefor thread-safe flag accessRequestVersionExact) are unaffected and continue using HTTP/2Safety constraints:
request.Content == null)request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower)!request.IsAuthDisabled())Test coverage:
RequestVersionExact)IsAuthDisabled)Example
Limitations
This implementation only automatically retries requests without content (e.g., GET, HEAD, DELETE without body) when the version policy allows downgrade and auth is enabled. POST and PUT requests with content will receive a 401 response, but the pool flag will be set so subsequent downgradeable requests go directly to HTTP/1.1. Requests with
RequestVersionExactpolicy or disabled auth are unaffected.Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.