Skip to content

OAuth PKCE token exchange fails with invalid_grant against strict OAuth 2.1 servers #4149

@gkatz2

Description

@gkatz2

Bug description

Connecting ToolHive to a remote MCP server that uses a strict OAuth 2.1 implementation (such as Datadog's mcp.datadoghq.com) fails during the PKCE token exchange. The user completes the browser-based authorization successfully, but the token exchange silently fails.

When constructing oauth2.Endpoint structs, ToolHive does not set AuthStyle, which defaults to oauth2.AuthStyleAutoDetect (the zero value). Go's golang.org/x/oauth2 library then uses the following auto-detect strategy:

  1. First attempt: HTTP Basic Auth — sends Authorization: Basic base64(client_id:) (empty password for public clients)
  2. If that fails: Retry with client_id in the POST body

For public PKCE clients registered with token_endpoint_auth_method=none, strict OAuth 2.1 servers reject the Basic Auth attempt — but consume the single-use authorization code in the process. The library's retry with client_id in the POST body then fails with invalid_grant because the code was already burned by the first attempt.

This affects three code paths where oauth2.Endpoint is constructed without AuthStyle:

  • pkg/auth/oauth/flow.go — authorization code exchange (the primary failure path)
  • pkg/auth/remote/handler.go — token refresh from cached tokens
  • pkg/registry/auth/oauth_token_source.go — registry auth token refresh

Steps to reproduce

  1. Run a remote MCP server that uses a strict OAuth 2.1 implementation (e.g., Datadog):
    thv run --name datadog-mcp https://mcp.datadoghq.com/api/unstable/mcp-server/mcp
    
  2. Complete the browser-based OAuth authorization when prompted
  3. Observe the result of the token exchange

Expected behavior

Token exchange succeeds on the first attempt by sending client_id in the POST body, matching the token_endpoint_auth_method=none registration.

Actual behavior

Token exchange fails with:

invalid_grant: authorization code has already been used

The auto-detect logic sends HTTP Basic Auth first, which the strict server rejects while consuming the authorization code. The retry with the correct auth style fails because the code is single-use.

Environment

  • ToolHive version: Reproduced on v0.12.1
  • OS: macOS (likely all platforms — the issue is in Go library behavior, not OS-specific)

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenticationbugSomething isn't workinggoPull requests that update go code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions