Skip to content

Preserve scopes defensively during token refresh #4427

@gkatz2

Description

@gkatz2

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.

  1. Configure a remote MCP server with OAuth that requires custom scopes
  2. Complete the browser-based authorization flow
  3. Wait for the access token to expire and auto-refresh
  4. 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.

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