Skip to content

AzureDevOpsProvider reports an expired or missing PAT as GitHostingRequestException with an HTML redirect page as its message #176

Open

Description

@matt-edmondson

What's wrong

GitProvider.CreateDefaultHandler sets AllowAutoRedirect = false (GitIntegration/GitProvider.cs ~L510). When a REST call has bad credentials or none at all, Azure DevOps does not answer 401. It answers with a 302 redirect to a sign-in page, unless the request sends a header asking it not to.

The following was checked live against dev.azure.com:

  • curl -D - -u ":badtoken123" "https://dev.azure.com/microsoft/_apis/git/repositories?api-version=7.1" returns:
    • HTTP/2 302
    • content-type: text/html
    • location: https://spsprodwus24.vssps.visualstudio.com/_signin?...
    • body <html><head><title>Object moved</title>...
  • Sending no credential at all gives the same 302.
  • The same two requests with X-TFS-FedAuthRedirect: Suppress return HTTP/2 401 with application/json and body {"message":"TF400813: The user '...' is not authorized to access this resource.", ...}.

Here is how the 302 flows through the provider (Hosting/AzureDevOpsProvider.cs):

  1. GetRepositoriesAsync, GetPullRequestsCoreAsync and CreatePullRequestCoreAsync see a non-success status and call Translate.
  2. Status 302 matches none of the 429/401/403/404 arms in Translate, so it falls through to new GitHostingRequestException(...).
  3. ExtractMessage cannot parse the HTML as JSON, so the exception message is the raw HTML page.

As a result, a caller's catch (GitHostingAuthenticationException) never fires for the most common Azure DevOps auth failure, an expired PAT. On GitHub the equivalent failure does map to GitHostingAuthenticationException. CLAUDE.md says auth failures map to that exception on both hosts.

Repro

Call GetRepositoriesAsync() on an AzureDevOpsProvider for a private org, using a persona whose PAT has expired. It throws GitHostingRequestException, and its Message starts with <html><head><title>Object moved</title>.

Suggested fix

  • Send X-TFS-FedAuthRedirect: Suppress on every Azure DevOps request, for example in ApplyAuthentication. The service then answers 401 with a JSON message, which ExtractMessage already handles.
  • As defence in depth, have Translate map two more cases to GitHostingAuthenticationException:
    • a 3xx whose Location points at a sign-in endpoint (_signin)
    • a 203 Non-Authoritative response, which otherwise reaches DeserializeSuccessBody
  • Optional: when the error body is not JSON, use a short summary as the exception message (status, reason phrase and content type) and keep the full text only on ResponseBody. Azure DevOps' 404 for an unknown organisation is about 133 KB of HTML.

Acceptance criteria

  • A FakeHttpMessageHandler test returns a 302 with Location: .../_signin?... and an HTML body, and the provider throws GitHostingAuthenticationException.
  • A test asserts that the suppress header is sent on the repositories, list-PRs and create-PR request paths.

Activity

  1. matt-edmondson commented on Oct 6, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: High. An expired PAT is the most common Azure DevOps failure. It currently skips the documented GitHostingAuthenticationException and surfaces as a generic exception whose message is an HTML page. Callers can't detect it and prompt for a new token.
    • Area: GitIntegration/Hosting/AzureDevOpsProvider.cs (ApplyAuthentication, Translate, ExtractMessage)
    • Duplicates / in progress: Not a duplicate, and no open PR covers it.
    • Next step: Send X-TFS-FedAuthRedirect: Suppress on every Azure DevOps request. As a fallback, map a 3xx redirect to _signin, and a 203 response, to GitHostingAuthenticationException. Add FakeHttpMessageHandler tests for the redirect and for the header on all three request paths.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions