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:507 — processToken returns a long-lived TokenSource from f.oauth2Config.TokenSource(context.Background(), token) with no client injected.
pkg/auth/remote/persisting_token_source.go:101 — CreateTokenSourceFromCached 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.
Bug description
OAuth token refresh requests sent by ToolHive advertise
User-Agent: Go-http-client/1.1(or2.0) instead ofUser-Agent: ToolHive/1.0. Server operators investigating WAF blocks, rate limits, or IP allowlists cannot identify the traffic as ToolHive, andGo-http-client/*is itself a common bot-detection signal that may trigger false positives.ToolHive already sets
User-Agent: ToolHive/1.0(theoauthproto.UserAgentconstant) 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 bygolang.org/x/oauth2, which useshttp.DefaultClient(and therefore the defaultGo-http-client/<version>User-Agent) unless a custom*http.Clientis injected via theoauth2.HTTPClientcontext value. None of the three call sites that create a refreshingoauth2.TokenSourcedo that today.Steps to reproduce
The snippet below uses the actual
pkg/auth/remote.CreateTokenSourceFromCachedfunction 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) andgo runit:On
main(8c90184) this prints:In production the same gap manifests as
User-Agent: Go-http-client/<n>reaching the IdP's token endpoint, where<n>is1.1or2.0depending 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) orGo-http-client/2.0(HTTP/2). Theoauthproto.UserAgentconstant is defined and used by every other outbound OAuth/OIDC code path, but is never sent on refresh.Environment
main(8c90184); the latest release isv0.26.1.Additional context
Three call sites construct a refreshing
oauth2.TokenSourcewithout injecting a User-Agent-aware*http.Client:pkg/auth/oauth/non_caching_refresher.go:50— the*http.Clientstored onNonCachingRefresherhas no User-Agent transport. Both the standard and RFC 8707 resource-aware refresh paths flow through this client (viaoauth2.HTTPClientcontext injection inToken()).pkg/auth/oauth/flow.go:507—processTokenreturns a long-livedTokenSourcefromf.oauth2Config.TokenSource(context.Background(), token)with no client injected.pkg/auth/remote/persisting_token_source.go:101—CreateTokenSourceFromCachedcallsconfig.TokenSource(context.TODO(), token)for the non-resource branch.Each falls back to
http.DefaultClientwhen the oauth2 library performs the refresh;http.DefaultClientuseshttp.DefaultTransport, which has no User-Agent header set, so net/http synthesizes the defaultGo-http-client/<version>value.