Skip to content

feat(core): bind MCP OAuth refresh tokens to their issuing authorization server - #47814

Open
rekram1-node wants to merge 2 commits into
v2from
issuer-binding
Open

feat(core): bind MCP OAuth refresh tokens to their issuing authorization server#47814
rekram1-node wants to merge 2 commits into
v2from
issuer-binding

Conversation

@rekram1-node

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #47743. Records the authorization server's issuer when an MCP OAuth credential is obtained, and refuses to present the stored refresh token if a later connection discovers a different issuer for the same MCP server. Mirrors Codex's validate_refresh_token_issuer (codex-rs/rmcp-client/src/oauth/issuer_binding.rs).

Why

A refresh token must only go back to the server that issued it. With Dynamic Client Registration this was implicitly enforced: the client_id was minted by one specific authorization server, so if the MCP server switched auth providers the old id was simply unknown and refresh failed. A CIMD client_id is a public URL that is valid at any authorization server. If an MCP server's /.well-known/oauth-protected-resource starts pointing somewhere else, nothing in the current flow stops us from sending the refresh token to the new (possibly hostile) server. Storing the issuer at login and checking it before refresh closes that.

Behavior

  • McpOAuth.authorize stores metadata.issuer on the credential from the discovery it already performs.
  • The connect-time provider records the issuer the SDK discovers (via the SDK's saveDiscoveryState hook) and passes it to toTokens. If the stored issuer is set and differs, refresh_token is omitted from what the SDK sees, so the SDK skips refresh and falls through to re-authorization → UnauthorizedErrorneeds_auth. The access token is still presented, so an unexpired session keeps working until it lapses. A warning is logged with expected/actual issuer.
  • Credentials from before this change have no stored issuer, so they are never blocked; the first successful refresh adopts the current issuer.
  • Nothing is cached beyond the live connection; discovery still runs as before.

Changes

  • packages/core/src/mcp/oauth.tsissuerFromCredential, issuer on toCredential, issuer-aware toTokens, onDiscovery provider option
  • packages/core/src/mcp/index.ts — connect-time provider records the discovered issuer, withholds refresh on mismatch, carries the issuer forward on refresh
  • packages/core/test/mcp-oauth.test.ts — login stores the issuer; refresh is withheld and no /token request is made when discovery names a different issuer

Test plan

  • bun test test/mcp-oauth.test.ts in packages/core (10 pass)
  • bun typecheck in packages/core

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant