fix(mcp): carry the requested scope forward when the upstream omits it on a bridge refresh

The prior fix sent the sealed scope on a refresh, but the re-minted refresh envelope re-seals scope from the upstream response, and RFC 6749 section 5.1 lets an upstream omit scope when it is unchanged. So after one refresh whose response omitted scope, the new envelope sealed scope=None and every subsequent refresh dropped it, letting a stricter upstream narrow the renewed token

When the upstream omits scope on a bridge refresh, seal the scope we requested (which RFC 6749 section 5.1 defines as the granted scope when omitted) into the renewed access and refresh envelopes, so the scope survives the whole refresh chain. The regression test refreshes against an upstream that omits scope, asserts the new refresh envelope still carries it, and refreshes again off that envelope to prove the chain does not lose it, mutation-checked
This commit is contained in:
Tin Chi Lo 2026-07-13 16:04:23 -07:00
parent 2eb37d7948
commit a9fac3c483
2 changed files with 47 additions and 3 deletions

View file

@ -1376,6 +1376,7 @@ async def exchange_token_with_server(
bridge_mint_ready: _BridgeMintReady | None = None
bridge_upstream_refresh: SecretStr | None = None
bridge_upstream_scope: str | None = None
refresh_request_scope: str | None = None
is_bridge = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge
if grant_type == "refresh_token":
@ -1404,9 +1405,9 @@ async def exchange_token_with_server(
"refresh_token": upstream_refresh_token,
**client_auth.body,
}
effective_scope = scope or bridge_upstream_scope
if effective_scope:
token_data["scope"] = effective_scope
refresh_request_scope = scope or bridge_upstream_scope
if refresh_request_scope:
token_data["scope"] = refresh_request_scope
else:
if not code:
raise HTTPException(
@ -1533,6 +1534,8 @@ async def exchange_token_with_server(
# upstream token) instead of the raw upstream token, so the one bearer both admits the caller and
# forwards the upstream credential. Only this mode mints; every other server returns the raw token.
if bridge_mint_ready is not None:
if refresh_request_scope and isinstance(token_response, dict) and not token_response.get("scope"):
token_response = {**token_response, "scope": refresh_request_scope}
# Phase 3: seal the upstream grant into the client-held envelope; failures map through the same
# OAuth-shaped response as the phase-1 preconditions.
minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc))

View file

@ -5011,6 +5011,47 @@ async def test_bridge_refresh_re_requests_the_sealed_scope_when_client_omits_it(
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "read:tools write:tools"
@pytest.mark.asyncio
async def test_bridge_refresh_re_seals_scope_when_upstream_omits_it_so_the_chain_keeps_it():
"""RFC 6749 5.1 lets an upstream omit scope in a refresh response when it is unchanged. The re-minted
refresh envelope must still seal the scope that was requested, otherwise the NEXT refresh loses it and
a stricter upstream could narrow the token. The returned refresh envelope carries the scope even though
the upstream response had none, and a second refresh off it still re-requests the scope."""
from datetime import datetime, timezone
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
OpenedRefreshEnvelope,
open_refresh_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(
server_id=server.server_id, upstream_refresh="UP-1", scope="mcp:read mcp:write"
)
# the upstream rotates the refresh token but OMITS scope (valid when unchanged)
upstream_no_scope = {"access_token": "NEW", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "UP-2"}
captured: dict = {}
r1 = await _refresh_for_bridge_server(server, refresh_env, upstream_no_scope, None, fake_client_out=captured)
assert r1.status_code == 200
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
new_env = json.loads(r1.body)["refresh_token"]
opened = open_refresh_envelope(new_env, keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedRefreshEnvelope)
assert opened.refresh.scope == "mcp:read mcp:write"
captured2: dict = {}
r2 = await _refresh_for_bridge_server(server, new_env, upstream_no_scope, None, fake_client_out=captured2)
assert r2.status_code == 200
assert captured2["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
@pytest.mark.asyncio
async def test_bridge_refresh_grant_with_deactivated_user_is_invalid_grant_before_upstream():
"""A user_id-subject refresh envelope whose user has since been deactivated (SCIM offboarding, or