mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
fix(mcp): point the Agent 365 sign-in challenge at the metadata for the route the client used
The connect-time 401 always named /.well-known/oauth-protected-resource/mcp/{server}, so a client on the
/{server}/mcp URL fetched a document whose resource was a different URL and RFC 9728 strict clients
(newer Claude Code) stopped before opening the browser. Reuse get_passthrough_resource_metadata_url so
the challenge is absolute and matches the inbound path, on both the connect challenge and OBO preflight
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
de2f6b2f85
commit
8af770ca83
5 changed files with 72 additions and 8 deletions
|
|
@ -3946,6 +3946,7 @@ class MCPServerManager:
|
|||
oauth2_headers: dict[str, str] | None,
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
raw_headers: Mapping[str, str] | None = None,
|
||||
resource_metadata_url: str | None = None,
|
||||
) -> None:
|
||||
"""Mint an exchange-backed server's upstream credential at the transport edge.
|
||||
|
||||
|
|
@ -3983,7 +3984,9 @@ class MCPServerManager:
|
|||
if spec is None or not isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)):
|
||||
return
|
||||
if subject_token is None and isinstance(spec.config, TokenExchangeConfig):
|
||||
raise_token_exchange_challenge(resolved_server, root_path=get_request_root_path())
|
||||
raise_token_exchange_challenge(
|
||||
resolved_server, root_path=get_request_root_path(), resource_metadata_url=resource_metadata_url
|
||||
)
|
||||
match await self._cred_provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec):
|
||||
case Ok(_):
|
||||
return
|
||||
|
|
@ -3993,6 +3996,7 @@ class MCPServerManager:
|
|||
resolved_server,
|
||||
root_path=get_request_root_path(),
|
||||
claims=err.unauthorized.claims,
|
||||
resource_metadata_url=resource_metadata_url,
|
||||
)
|
||||
raise_public(err)
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ def raise_token_exchange_challenge(
|
|||
*,
|
||||
root_path: str,
|
||||
claims: str | None = None,
|
||||
resource_metadata_url: str | None = None,
|
||||
) -> NoReturn:
|
||||
"""Raise the RFC 9728 / RFC 6750 challenge an OBO (``token_exchange``) server returns when the
|
||||
caller's subject token is missing or the IdP rejected it.
|
||||
|
|
@ -375,8 +376,13 @@ def raise_token_exchange_challenge(
|
|||
``error="invalid_token"`` and is byte-identical to the static one. Both the error value (one of
|
||||
two literals) and the base64 claims draw from a fixed alphabet, so nothing from the IdP body
|
||||
reaches the header unescaped.
|
||||
|
||||
``resource_metadata_url`` overrides the derived path with the absolute metadata URL matching the
|
||||
route spelling the request arrived on: RFC 9728 §3.3 clients reject a ``resource`` that differs
|
||||
from the URL they connected to, and a ``/{server}/mcp`` connect must not be sent to the
|
||||
``/mcp/{server}`` document.
|
||||
"""
|
||||
resource_metadata: Final = oauth_protected_resource_path(root_path, server)
|
||||
resource_metadata: Final = resource_metadata_url or oauth_protected_resource_path(root_path, server)
|
||||
encoded_claims: Final = base64.b64encode(claims.encode()).decode() if claims else None
|
||||
error: Final = "insufficient_claims" if encoded_claims else "invalid_token"
|
||||
error_description: Final = (
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ from litellm.proxy._experimental.mcp_server.mcp_debug import (
|
|||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
_redact_mcp_resource_url,
|
||||
get_passthrough_resource_metadata_url,
|
||||
get_passthrough_www_authenticate,
|
||||
get_route_relative_request_path,
|
||||
well_known_root_suffix,
|
||||
|
|
@ -4155,7 +4156,11 @@ if MCP_AVAILABLE:
|
|||
get_request_root_path,
|
||||
)
|
||||
|
||||
raise_token_exchange_challenge(server, root_path=get_request_root_path())
|
||||
raise_token_exchange_challenge(
|
||||
server,
|
||||
root_path=get_request_root_path(),
|
||||
resource_metadata_url=get_passthrough_resource_metadata_url(scope=scope, server_name=server_name),
|
||||
)
|
||||
|
||||
# Exchange-backed modes (token_exchange's OBO mint, id_jag's stored-assertion mint): run
|
||||
# the exchange here at the transport edge, so a rejected subject raises the RFC 9728
|
||||
|
|
@ -4180,6 +4185,7 @@ if MCP_AVAILABLE:
|
|||
oauth2_headers=oauth2_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
raw_headers=raw_headers,
|
||||
resource_metadata_url=get_passthrough_resource_metadata_url(scope=scope, server_name=server_name),
|
||||
)
|
||||
|
||||
# Pass-through OAuth: when the admin has opted a server into
|
||||
|
|
|
|||
|
|
@ -584,6 +584,22 @@ def test_raise_token_exchange_challenge_is_rfc9728_invalid_token():
|
|||
assert "error_description=" in www
|
||||
|
||||
|
||||
def test_raise_token_exchange_challenge_explicit_resource_metadata_url_wins():
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
|
||||
raise_token_exchange_challenge,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
raise_token_exchange_challenge(
|
||||
_server(alias="obo-srv"),
|
||||
root_path="/",
|
||||
resource_metadata_url="https://gw.example.com/.well-known/oauth-protected-resource/obo-srv/mcp",
|
||||
)
|
||||
www = exc_info.value.headers["WWW-Authenticate"]
|
||||
assert 'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/obo-srv/mcp"' in www
|
||||
assert "/mcp/obo-srv" not in www
|
||||
|
||||
|
||||
def test_raise_token_exchange_challenge_includes_server_root_path(monkeypatch):
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
|
||||
raise_token_exchange_challenge,
|
||||
|
|
|
|||
|
|
@ -8962,7 +8962,9 @@ class TestAgent365ChallengeAtConnect:
|
|||
litellm.callbacks, guardrail, require_self=False
|
||||
)
|
||||
|
||||
async def _connect(self, server: MCPServer, oauth2_headers: dict[str, str] | None) -> HTTPException | None:
|
||||
async def _connect(
|
||||
self, server: MCPServer, oauth2_headers: dict[str, str] | None, path: str = "/mcp/tools"
|
||||
) -> HTTPException | None:
|
||||
from litellm.proxy._experimental.mcp_server import server as server_module
|
||||
|
||||
with (
|
||||
|
|
@ -8975,7 +8977,14 @@ class TestAgent365ChallengeAtConnect:
|
|||
):
|
||||
try:
|
||||
await server_module._raise_preemptive_401_for_unauthenticated_servers(
|
||||
scope={"type": "http", "method": "POST", "path": "/mcp/tools", "headers": []},
|
||||
scope={
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"scheme": "https",
|
||||
"server": ("gw.example.com", 443),
|
||||
"headers": [],
|
||||
},
|
||||
mcp_servers=["tools"],
|
||||
oauth2_headers=oauth2_headers,
|
||||
mcp_server_auth_headers=None,
|
||||
|
|
@ -8993,7 +9002,23 @@ class TestAgent365ChallengeAtConnect:
|
|||
assert challenge is not None and challenge.status_code == 401
|
||||
www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "")
|
||||
assert 'error="invalid_token"' in www_authenticate
|
||||
assert 'resource_metadata="/.well-known/oauth-protected-resource/mcp/tools"' in www_authenticate
|
||||
assert (
|
||||
'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/mcp/tools"'
|
||||
in www_authenticate
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_route_challenge_points_at_its_own_metadata(self, agent_365_guardrail):
|
||||
"""RFC 9728 3.3: the metadata's ``resource`` must equal the URL the client connected to, so a
|
||||
``/{server}/mcp`` connect is sent to the ``/{server}/mcp`` document, not the ``/mcp/{server}`` one."""
|
||||
challenge = await self._connect(self._server([self.GATEWAY_SCOPE]), None, path="/tools/mcp")
|
||||
|
||||
assert challenge is not None and challenge.status_code == 401
|
||||
www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "")
|
||||
assert (
|
||||
'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/tools/mcp"'
|
||||
in www_authenticate
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entra_assertion_present_connects(self, agent_365_guardrail):
|
||||
|
|
@ -9011,7 +9036,10 @@ class TestAgent365ChallengeAtConnect:
|
|||
assert challenge is not None and challenge.status_code == 401
|
||||
www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "")
|
||||
assert 'error="invalid_token"' in www_authenticate
|
||||
assert 'resource_metadata="/.well-known/oauth-protected-resource/mcp/tools"' in www_authenticate
|
||||
assert (
|
||||
'resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/mcp/tools"'
|
||||
in www_authenticate
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scopeless_server_is_still_challenged(self, agent_365_guardrail):
|
||||
|
|
@ -9096,7 +9124,11 @@ class TestOboPreflightScopedToAllowedServers:
|
|||
_, preflight = await self._run(requested, allowed=[requested], user_api_key_auth=key)
|
||||
|
||||
preflight.assert_awaited_once_with(
|
||||
server=requested, oauth2_headers=self.SUBJECT_HEADERS, user_api_key_auth=key, raw_headers=None
|
||||
server=requested,
|
||||
oauth2_headers=self.SUBJECT_HEADERS,
|
||||
user_api_key_auth=key,
|
||||
raw_headers=None,
|
||||
resource_metadata_url="/.well-known/oauth-protected-resource/mcp/obo_tools",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue