mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
2eb37d7948
commit
a9fac3c483
2 changed files with 47 additions and 3 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue