-
Notifications
You must be signed in to change notification settings - Fork 198
Preserve scopes defensively during token refresh #4427
Description
Bug description
resourceTokenSource exists because Go's standard oauth2 library doesn't support adding the RFC 8707 resource parameter during token refresh. The same gap applies to scope: while RFC 6749 section 6 says servers MUST preserve scopes when scope is omitted from a refresh request, not all servers comply. ToolHive could defend its users against this by explicitly including scope in the refresh request when config.Scopes is non-empty.
Without this, servers that don't preserve scopes silently strip all custom scopes from the refreshed token. The failure mode is confusing: the proxy remains healthy, tools/list works, but every actual tool call fails because the token no longer carries the required scopes. There is no self-healing since the proxy caches the bad token.
Steps to reproduce
This was discovered in a production environment with an OAuth authorization server that does not preserve scopes during token refresh (violating RFC 6749 section 6 MUST). Reproduction requires a non-compliant server.
- Configure a remote MCP server with OAuth that requires custom scopes
- Complete the browser-based authorization flow
- Wait for the access token to expire and auto-refresh
- Tool calls fail with scope-related errors because the refreshed token has no custom scopes
Expected behavior
The refresh request should include the scope parameter with the originally granted scopes (space-delimited per RFC 6749 section 3.3) when config.Scopes is non-empty. Sending scope during refresh is always spec-compliant. No compliant server will reject it.
Actual behavior
The refresh request omits scope. On non-compliant servers, all custom scopes are silently stripped from the refreshed token.
Environment (if relevant)
- ToolHive version: Reproduced on v0.13.1
- OS: macOS (likely all platforms — the issue is in Go code, not OS-specific)
Additional context
The fix is minimal: after building the refresh opts slice in refreshWithResource() (see pkg/auth/oauth/resource_token_source.go lines 73-77), conditionally append scope when config.Scopes is non-empty. The scopes are already available in the oauth2.Config passed to NewResourceTokenSource.