Skip to content

Token refresh requests do not advertise the ToolHive User-Agent #5167

@gkatz2

Description

@gkatz2

Bug description

OAuth token refresh requests sent by ToolHive advertise User-Agent: Go-http-client/1.1 (or 2.0) instead of User-Agent: ToolHive/1.0. Server operators investigating WAF blocks, rate limits, or IP allowlists cannot identify the traffic as ToolHive, and Go-http-client/* is itself a common bot-detection signal that may trigger false positives.

ToolHive already sets User-Agent: ToolHive/1.0 (the oauthproto.UserAgent constant) on the OAuth/OIDC requests it constructs directly — DCR registration (pkg/oauthproto/dcr.go), discovery (pkg/oauthproto/discovery.go), token introspection (pkg/auth/token.go), the GitHub provider (pkg/auth/github_provider.go). Token refresh is the gap: those requests are constructed by golang.org/x/oauth2, which uses http.DefaultClient (and therefore the default Go-http-client/<version> User-Agent) unless a custom *http.Client is injected via the oauth2.HTTPClient context value. None of the three call sites that create a refreshing oauth2.TokenSource do that today.

Steps to reproduce

The snippet below uses the actual pkg/auth/remote.CreateTokenSourceFromCached function and captures the User-Agent on the wire for both the standard and RFC 8707 resource-aware refresh paths. Drop it into a checkout of the repo (e.g. _repro/main.go) and go run it:

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"golang.org/x/oauth2"

	"github.com/stacklok/toolhive/pkg/auth/remote"
)

func main() {
	for _, resource := range []string{"", "https://api.example.com"} {
		var got string
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			got = r.Header.Get("User-Agent")
			w.Header().Set("Content-Type", "application/json")
			_ = json.NewEncoder(w).Encode(map[string]any{
				"access_token": "x", "refresh_token": "y", "token_type": "Bearer", "expires_in": 3600,
			})
		}))
		cfg := &oauth2.Config{ClientID: "c", ClientSecret: "s", Endpoint: oauth2.Endpoint{TokenURL: srv.URL}}
		ts := remote.CreateTokenSourceFromCached(cfg, "old", time.Now().Add(-time.Hour), resource)
		_, _ = ts.Token()
		srv.Close()
		fmt.Printf("resource=%q  User-Agent: %q\n", resource, got)
	}
}

On main (8c90184) this prints:

resource=""                          User-Agent: "Go-http-client/1.1"
resource="https://api.example.com"   User-Agent: "Go-http-client/1.1"

In production the same gap manifests as User-Agent: Go-http-client/<n> reaching the IdP's token endpoint, where <n> is 1.1 or 2.0 depending on the negotiated HTTP version.

Expected behavior

OAuth token refresh requests should advertise User-Agent: ToolHive/1.0, matching every other outbound OAuth/OIDC request originated by ToolHive.

Actual behavior

Refresh requests advertise Go's default User-Agent: Go-http-client/1.1 (HTTP/1.1) or Go-http-client/2.0 (HTTP/2). The oauthproto.UserAgent constant is defined and used by every other outbound OAuth/OIDC code path, but is never sent on refresh.

Environment

  • OS/version: any (the gap is in pure Go code paths, not OS-dependent).
  • ToolHive version: current main (8c90184); the latest release is v0.26.1.

Additional context

Three call sites construct a refreshing oauth2.TokenSource without injecting a User-Agent-aware *http.Client:

  • pkg/auth/oauth/non_caching_refresher.go:50 — the *http.Client stored on NonCachingRefresher has no User-Agent transport. Both the standard and RFC 8707 resource-aware refresh paths flow through this client (via oauth2.HTTPClient context injection in Token()).
  • pkg/auth/oauth/flow.go:507processToken returns a long-lived TokenSource from f.oauth2Config.TokenSource(context.Background(), token) with no client injected.
  • pkg/auth/remote/persisting_token_source.go:101CreateTokenSourceFromCached calls config.TokenSource(context.TODO(), token) for the non-resource branch.

Each falls back to http.DefaultClient when the oauth2 library performs the refresh; http.DefaultClient uses http.DefaultTransport, which has no User-Agent header set, so net/http synthesizes the default Go-http-client/<version> value.

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