Skip to content

Set User-Agent on OAuth token refresh requests#5168

Merged
jhrozek merged 1 commit intostacklok:mainfrom
gkatz2:fix/token-refresh-user-agent-5167
May 5, 2026
Merged

Set User-Agent on OAuth token refresh requests#5168
jhrozek merged 1 commit intostacklok:mainfrom
gkatz2:fix/token-refresh-user-agent-5167

Conversation

@gkatz2
Copy link
Copy Markdown
Contributor

@gkatz2 gkatz2 commented May 2, 2026

Summary

  • ToolHive sets User-Agent: ToolHive/1.0 on the OAuth/OIDC requests it constructs directly (DCR, discovery, token introspection, GitHub provider), but token refresh requests go through golang.org/x/oauth2 and fall back to Go's default Go-http-client/<version> User-Agent. 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.
  • Adds oauthproto.UserAgentTransport (an http.RoundTripper that sets the ToolHive User-Agent when one is not already set) and oauthproto.NewHTTPClient() (a small constructor that bundles the transport with a 30s timeout).
  • Wires the helper into the three call sites that construct a refreshing oauth2.TokenSource: pkg/auth/oauth/non_caching_refresher.go, pkg/auth/oauth/flow.go, and pkg/auth/remote/persisting_token_source.go.

Fixes #5167

Type of change

  • Bug fix

Test plan

  • Unit tests (task test) — including a new TestUserAgentTransport_RoundTrip table test, a new TestUserAgentTransport_NilBase round-trip test, an extra User-Agent assertion on the existing TestResourceTokenSource_Token_ExpiredToken, and a new TestCreateTokenSourceFromCached_SetsUserAgent covering both refresh paths.
  • Linting (task lint-fix)
  • Manual testing — built thv from this branch and ran a standalone harness that imports pkg/auth/remote.CreateTokenSourceFromCached against an httptest.Server and captures the User-Agent on the wire. On main (8c90184) both refresh paths emit Go-http-client/1.1; on this branch both emit ToolHive/1.0.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Does this introduce a user-facing change?

OAuth token refresh requests now advertise User-Agent: ToolHive/1.0 instead of Go's default Go-http-client/<version>. Operators of upstream IdPs can identify ToolHive traffic in logs, WAF rules, and rate-limit dashboards.

Implementation plan

Approved implementation plan

Helperpkg/oauthproto/useragent.go:

  • UserAgentTransport struct with a public Base http.RoundTripper field (mirrors stdlib http.Transport and oauth2.Transport shape). Nil Base falls back to http.DefaultTransport. RoundTrip clones the request before mutating headers (per the RoundTripper contract) and sets User-Agent only when not already set, so layered transports can override.
  • NewHTTPClient() returns an *http.Client whose transport is &UserAgentTransport{} and whose Timeout is 30 * time.Second. Used at all three call sites to lock in a single timeout invariant.

Wire-up — three call sites:

  • pkg/auth/oauth/non_caching_refresher.gohttpClient: oauthproto.NewHTTPClient() on the NonCachingRefresher struct. Covers both standard and RFC 8707 resource-aware refresh (the refresher injects this client via oauth2.HTTPClient context value before each refresh). This also covers pkg/auth/oauth/resource_token_source.go, which delegates to NonCachingRefresher.
  • pkg/auth/oauth/flow.go (processToken non-resource branch) — build a *http.Client via oauthproto.NewHTTPClient() and inject it via oauth2.HTTPClient into the context passed to oauth2Config.TokenSource. Preserves the existing context.Background() reasoning (the OAuth callback ctx is cancelled when the callback server shuts down).
  • pkg/auth/remote/persisting_token_source.go (CreateTokenSourceFromCached non-resource branch) — same pattern; replaces the previous context.TODO().

Why the helper lives in pkg/oauthproto: co-located with the existing oauthproto.UserAgent constant; the package already owns "set the ToolHive UA on outbound OAuth requests" via pkg/oauthproto/dcr.go and pkg/oauthproto/discovery.go. A future PR that fixes non-OAuth gaps (e.g. webhook delivery, transparent proxy health checks, etc.) can import the transport from here or move it to pkg/networking if that ends up being the better long-term home.

Special notes for reviewers

  • The doc comment on UserAgentTransport calls out the WAF/attribution motivation so the rationale is durable in the source.
  • TestUserAgentTransport_NilBase exercises the nil → http.DefaultTransport fallback through a real httptest.Server round-trip rather than relying on a recorder, since a recorder would mask the fallback.
  • The standalone refresh paths are covered, but I deliberately did not add a dedicated unit test for flow.go's processToken change — that path requires a full OAuth callback flow to exercise. The non_caching_refresher and persisting_token_source tests verify the transport behavior; the flow.go wire-up is the same three-line pattern.
  • The fix is scoped to the OAuth token refresh path. ToolHive has other outbound HTTP code paths that are also missing the User-Agent (e.g. pkg/auth/discovery, pkg/authserver/upstream, pkg/webhook).

🤖 Generated with Claude Code

ToolHive sets User-Agent: ToolHive/1.0 on OAuth/OIDC requests it
constructs directly, but token refresh requests go through
golang.org/x/oauth2 and fall back to Go's default Go-http-client
User-Agent. Server operators investigating WAF blocks, rate limits,
or IP allowlists cannot identify the traffic as ToolHive, and the
default User-Agent is itself a common bot-detection signal that may
trigger false positives.

Fixes stacklok#5167

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Greg Katz <gkatz@indeed.com>
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label May 2, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 2, 2026

Codecov Report

❌ Patch coverage is 89.47368% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.58%. Comparing base (8c90184) to head (1ea8d93).

Files with missing lines Patch % Lines
pkg/oauthproto/useragent.go 85.71% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5168      +/-   ##
==========================================
+ Coverage   67.53%   67.58%   +0.04%     
==========================================
  Files         601      602       +1     
  Lines       61093    61109      +16     
==========================================
+ Hits        41262    41298      +36     
+ Misses      16714    16692      -22     
- Partials     3117     3119       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix is well-scoped: the oauth2.HTTPClient context key is the documented extension point for injecting a custom *http.Client into golang.org/x/oauth2's refresh path, and a wrapping http.RoundTripper is the canonical way to add a header to requests the library builds internally. Test coverage looks good — the table-driven tests cover both the "UA set when missing" and "caller-supplied UA preserved" cases, plus the nil-Base fallback through a real httptest server, and the persisting-token-source test exercises both the standard and resource-aware refresh paths end-to-end.

@jhrozek jhrozek merged commit 11fa6fb into stacklok:main May 5, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Token refresh requests do not advertise the ToolHive User-Agent

2 participants