Skip to content

HttpRequestor: do not reject credentials on HTTP 400 - #2088

Draft
tyrielv wants to merge 2 commits into
microsoft:masterfrom
tyrielv:user/tyvella/no-reject-credentials-on-400
Draft

HttpRequestor: do not reject credentials on HTTP 400#2088
tyrielv wants to merge 2 commits into
microsoft:masterfrom
tyrielv:user/tyvella/no-reject-credentials-on-400

Conversation

@tyrielv

@tyrielv tyrielv commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

GVFS erased a valid credential when an object-download response was HTTP 400, which
triggered a storm of Git Credential Manager popups.

Most 400s are not authentication failures. A present-but-expired or invalid
credential returns 401 (or 302, the Azure DevOps sign-in redirect). A
malformed request — for example a corrupt object SHA that produces an invalid
loose-object URL — returns a 400 that has nothing to do with credentials.
Rejecting credentials on every 400 erased good credentials.

There is one genuine exception, confirmed in the Azure DevOps cache-server
source: the cache server returns a 400 (not a 401) when the request carried no
parseable Basic Authorization header, with the body
"A valid Basic Authorization header is required.". That single 400 really does
mean "authentication required". microsoft/git's git-gvfs-helper maps the same
cache-server 400 to a 401 for this reason, and its own TODO says to confirm the
response body — which is exactly what this change does.

Change

  • In HttpRequestor.SendRequest, the credential-rejection decision is now
    body-aware via ShouldRejectCredentials(HttpStatusCode, string responseBody):
    • 401 and 302 always reject credentials.
    • 400 rejects credentials only when the response body matches the cache
      server's auth-required message (case-insensitive substring).
    • Every other 400 (and 404 / 5xx / timeouts) does not reject credentials and
      flows through the generic, non-auth error path. 400 remains non-retryable
      (ShouldRetry never included it).
  • The auth-required message is kept as a named constant
    (CacheServerAuthRequiredBadRequestMessage) that mirrors the cache server's own
    string (GvfsHttpHandler.PrepareContextAsync).

Why 400 is (almost) never an auth failure — but sometimes is

Verified against the Azure DevOps GVFS cache-server source and its L2 tests:

  • No / unparseable Authorization header400 "A valid Basic Authorization header is required." — genuinely "auth required" (this is the case we still reject on).
  • Present but bad / expired / unauthorized credential401 (cache server surfaces the /gvfs/auth result).
  • Valid credential + corrupt object SHA400 "…Invalid ObjectId in the URI." — a request problem, not auth. This is the storm trigger; it must not erase the credential.
  • Valid credential + missing object404.

The cache server's own L2 test asserts exactly this: no auth header → BadRequest
(400); bad PAT → Unauthorized (401).

Tests

GVFS.UnitTests/Http/HttpRequestorTests.cs:

  • 401 and 302 reject credentials.
  • 400 with the cache server's auth-required body rejects credentials (and the match
    is a case-insensitive substring).
  • 400 with a non-auth body (e.g. "Invalid ObjectId in the URI") does not reject.
  • 400 with no body does not reject.
  • 404 / 500 / 408 do not reject.

All new tests pass (7/7); full unit-test suite passes.

GVFS erased a valid credential when an object-download response was HTTP 400,
which triggered a storm of Git Credential Manager popups.

A 400 is a request or formatting problem, not an authentication failure. An
expired or invalid credential always returns 401 (Unauthorized) or 302 (the
Azure DevOps sign-in redirect), never 400. Treating 400 as an auth failure
erased good credentials and produced the misleading "Your PAT may be expired"
message.

Remove BadRequest (400) from the credential-rejection branch in SendRequest.
Only 401 and 302 now reject credentials; 400 flows through the generic,
non-auth error path (unchanged retry / circuit-breaker behavior, and 400
remains non-retryable). Extract the decision into ShouldRejectCredentials so it
is unit tested: 400 does not reject credentials, while 401 and 302 still do.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
tyrielv marked this pull request as ready for review August 13, 2026 18:35
@tyrielv
tyrielv enabled auto-merge August 13, 2026 18:35
@tyrielv
tyrielv disabled auto-merge August 14, 2026 14:59
@tyrielv
tyrielv marked this pull request as draft August 14, 2026 15:00
@tyrielv

tyrielv commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Back to draft - git-gvfs-helper in microsoft/git also has the same conversion, so I probably just didn't repro the status 400 with bad credential correctly. This will need to be addressed some other way.

…"auth required" body

An earlier change dropped HTTP 400 from the credential-rejection branch entirely,
on the premise that a 400 is never an authentication failure. That premise is
incomplete. The Azure DevOps GVFS cache server returns a 400 (not a 401) in one
genuine authentication case: when the request carried no parseable Basic
Authorization header. Its response body is "A valid Basic Authorization header is
required." microsoft/git's git-gvfs-helper maps that same cache-server 400 to a
401 for this reason, and its own TODO says to confirm the response body - which is
what this change does.

A present-but-expired or invalid credential still returns 401, and a malformed
request (for example a corrupt object SHA in the loose-object URL) returns a 400
that has nothing to do with credentials. So the decision is now body-aware:

- 401 and 302 always reject credentials.
- 400 rejects credentials only when the body matches the cache server's
  auth-required message (case-insensitive substring).
- Every other 400 (and 404/5xx/timeouts) does not reject credentials.

This stops the credential-manager popup storm caused by rejecting a valid
credential on a non-auth 400, while preserving credential refresh for the one 400
that really does mean "authentication required", keeping the behavior consistent
with git-gvfs-helper.

Tests updated for the new body-aware signature and cases.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants