From c85e562a5279dc876fee64444dce0250f93fb273 Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Wed, 14 Oct 2026 10:04:31 -0400 Subject: [PATCH] feat(mcp): separate confidential client for ID-JAG leg 2 (#46211) * feat(mcp): separate ID-JAG leg-2 client identity, support public resource clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(mcp): format ID-JAG credential changes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): satisfy lint gates and regenerate API snapshot for ID-JAG resource client Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mcp): require a confidential ID-JAG resource client, drop none auth method Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(mcp): drop formatting churn from ID-JAG resource client tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: retrigger after flaky test_mcp_tool_search timeout Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rerun against main after duplicate gemini-4-argon price keys were fixed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_experimental/mcp_server/db.py | 7 ++ .../mcp_server/mcp_server_manager.py | 19 +++- .../outbound_credentials/__init__.py | 2 + .../outbound_credentials/adapter.py | 18 ++++ .../outbound_credentials/resolver.py | 14 +-- .../mcp_server/outbound_credentials/types.py | 14 ++- .../_experimental/mcp_server/upstream.py | 5 +- litellm/proxy/_lazy_openapi_snapshot.json | 88 +++++++++++++++++++ litellm/types/mcp.py | 6 ++ .../types/mcp_server/mcp_server_manager.py | 8 ++ .../outbound_credentials/test_adapter.py | 60 +++++++++++++ .../outbound_credentials/test_resolver.py | 53 +++++++++-- .../outbound_credentials/test_types.py | 13 +++ .../mcp_server/test_db_credentials.py | 10 ++- .../mcp_server/test_mcp_server_manager.py | 31 +++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 16 files changed, 335 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 8ab0d0118f4..4e568a1b090 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -534,6 +534,12 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: str | None) value=client_private_key, new_encryption_key=encryption_key, ) + id_jag_resource_client_secret: Final = credentials.get("id_jag_resource_client_secret") + if id_jag_resource_client_secret is not None: + credentials["id_jag_resource_client_secret"] = encrypt_value_helper( # rebind-ok: encrypt before persist + value=id_jag_resource_client_secret, + new_encryption_key=encryption_key, + ) # AWS SigV4 credential fields aws_access_key_id: Final = credentials.get("aws_access_key_id") if aws_access_key_id is not None: @@ -820,6 +826,7 @@ def decrypt_credentials( "client_id", "client_secret", "client_private_key", + "id_jag_resource_client_secret", "aws_access_key_id", "aws_secret_access_key", "aws_session_token", diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index a7375b4f17c..de0a94ba869 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -448,6 +448,8 @@ class MCPServerConfig(TypedDict, total=False): upstream_token_header: ReadOnly[str] id_jag_resource_token_endpoint: str id_jag_resource: str + id_jag_resource_client_id: ReadOnly[str] + id_jag_resource_client_secret: ReadOnly[str] client_private_key: str client_private_key_id: str client_assertion_signing_alg: str @@ -1504,7 +1506,14 @@ def _consumes_caller_authorization(server: MCPServer) -> bool: _REGISTRY_DUMP_SECRET_FIELDS: Final = frozenset( - {"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"} + { + "authentication_token", + "client_secret", + "client_private_key", + "id_jag_resource_client_secret", + "aws_secret_access_key", + "aws_session_token", + } ) @@ -2754,6 +2763,8 @@ class MCPServerManager: # ID-JAG fields id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None), id_jag_resource=server_config.get("id_jag_resource", None), + id_jag_resource_client_id=server_config.get("id_jag_resource_client_id", None), + id_jag_resource_client_secret=server_config.get("id_jag_resource_client_secret", None), client_private_key=server_config.get("client_private_key", None), client_private_key_id=server_config.get("client_private_key_id", None), client_assertion_signing_alg=server_config.get("client_assertion_signing_alg", "RS256"), @@ -3343,6 +3354,12 @@ class MCPServerManager: credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None ), id_jag_resource=(credentials_dict.get("id_jag_resource") if credentials_dict else None), + id_jag_resource_client_id=(credentials_dict.get("id_jag_resource_client_id") if credentials_dict else None), + id_jag_resource_client_secret=self._decrypt_credential_field( + credentials_dict.get("id_jag_resource_client_secret") if credentials_dict else None, + "id_jag_resource_client_secret", + credentials_are_encrypted, + ), client_private_key=self._decrypt_credential_field( credentials_dict.get("client_private_key") if credentials_dict else None, "client_private_key", diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py index d61f8395677..20fac28a55b 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py @@ -38,6 +38,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( CredError, HeaderCarrier, IdJagConfig, + IdJagResourceClient, NoneConfig, PassthroughConfig, PrivateKeyJwtAuth, @@ -69,6 +70,7 @@ __all__ = [ "Error", "HeaderCarrier", "IdJagConfig", + "IdJagResourceClient", "NoOpAuth", "NoneConfig", "Ok", diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 42947e39530..91c771933c7 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -33,6 +33,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ClientSecretAuth, CredError, IdJagConfig, + IdJagResourceClient, NoneConfig, PassthroughConfig, PrivateKeyJwtAuth, @@ -234,6 +235,9 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None: client_auth: Final = _id_jag_client_auth(server) if not org_token_endpoint or not resource_token_endpoint or not client_id or client_auth is None: return None + resource_client: Final = _id_jag_resource_client(server, client_id, client_auth) + if resource_client is None: + return None return ServerSpec( server_id=server.server_id, resource=resource, @@ -243,6 +247,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None: resource_token_endpoint=resource_token_endpoint, client_id=client_id, client_auth=client_auth, + resource_client=resource_client, subject_token_type=_id_jag_subject_token_type(server), audience=server.audience, resource=server.id_jag_resource, @@ -251,6 +256,19 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None: ) +def _id_jag_resource_client(server: MCPServer, client_id: str, client_auth: ClientAuth) -> IdJagResourceClient | None: + match (server.id_jag_resource_client_id, server.id_jag_resource_client_secret): + case (None, None): + return IdJagResourceClient(client_id=client_id, client_auth=client_auth) + case (resource_client_id, resource_client_secret) if resource_client_id and resource_client_secret: + return IdJagResourceClient( + client_id=resource_client_id, + client_auth=ClientSecretAuth(client_secret=SecretStr(resource_client_secret)), + ) + case _: + return None + + def _id_jag_client_auth(server: MCPServer) -> ClientAuth | None: """Private-key JWT when a key is configured, else client_secret, else None (defer to v1).""" if server.client_private_key: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index e71353e479c..981843f2234 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -293,9 +293,9 @@ class UpstreamCredentialProvider: } return await self._token_endpoint.fetch( config.resource_token_endpoint, - config.client_id, + config.resource_client.client_id, leg2_params, - config.client_auth, + config.resource_client.client_auth, ) match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint): @@ -418,10 +418,10 @@ def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig) Stored beside the bearer and compared on every read, so a rotated assertion or an edited server config reads as a miss and re-mints instead of serving a bearer authorized under the old policy. - Every exchange parameter derives from the config (endpoints, audience, resource, scopes, client - auth), so a server update that changes any of them must change the key; otherwise the old bearer, - authorized under the old policy, keeps being served until its TTL. Everything is hashed, so no - secret is held in the key. + Every exchange parameter derives from the config (endpoints, audience, resource, scopes, and both + client identities and auth methods), so a server update that changes any of them must change the + key; otherwise the old bearer, authorized under the old policy, keeps being served until its TTL. + Everything is hashed, so no secret is held in the key. """ material: Final = "\x00".join( ( @@ -431,6 +431,8 @@ def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig) config.resource_token_endpoint, config.client_id, _client_auth_fingerprint(config.client_auth), + config.resource_client.client_id, + _client_auth_fingerprint(config.resource_client.client_auth), config.subject_token_type, config.audience or "", config.resource or "", diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index 55a73687b6f..71745a560f3 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -341,14 +341,23 @@ class ClientSecretAuth(LiteLLMBaseModel): ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")] +class IdJagResourceClient(LiteLLMBaseModel): + """The identity the gateway presents to the resource AS on leg 2.""" + + model_config = ConfigDict(frozen=True) + client_id: str + client_auth: ClientAuth + + class IdJagConfig(HeaderCarrier): """draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange"). Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that swaps the caller's identity token for an ID-JAG assertion; leg 2 is an RFC 7523 jwt-bearer at the upstream resource AS (`resource_token_endpoint`) that swaps the assertion for the access - token. The gateway authenticates to both endpoints as `client_id` via `client_auth`. Required - fields are enforced at construction so a half-configured server cannot reach the arm. + token. The gateway authenticates to the org AS as `client_id` via `client_auth`, and to the + resource AS as `resource_client`. Required fields are enforced at construction so a + half-configured server cannot reach the arm. """ model_config = ConfigDict(frozen=True) @@ -357,6 +366,7 @@ class IdJagConfig(HeaderCarrier): resource_token_endpoint: str client_id: str client_auth: ClientAuth + resource_client: IdJagResourceClient subject_token_type: str = "urn:ietf:params:oauth:token-type:id_token" audience: str | None = None resource: str | None = None diff --git a/litellm/proxy/_experimental/mcp_server/upstream.py b/litellm/proxy/_experimental/mcp_server/upstream.py index 89f433093e6..b19f904d81d 100644 --- a/litellm/proxy/_experimental/mcp_server/upstream.py +++ b/litellm/proxy/_experimental/mcp_server/upstream.py @@ -86,8 +86,9 @@ def to_server_spec_fail_closed(server: MCPServer) -> ServerSpec | None: raise_public( CredError.of_misconfigured( "oauth2_id_jag requires token_exchange_endpoint, id_jag_resource_token_endpoint, " - "client_id, and a client_secret or client_private_key; refusing to fall back to " - "a static credential." + "client_id, and a client_secret or client_private_key; id_jag_resource_client_* " + "overrides must set both id_jag_resource_client_id and id_jag_resource_client_secret, or neither. " + "Refusing to fall back to a static credential." ) ) return spec diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 479c766b211..effb30fe8cb 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -34006,6 +34006,28 @@ ], "title": "Id Jag Resource" }, + "id_jag_resource_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Id" + }, + "id_jag_resource_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Secret" + }, "id_jag_resource_token_endpoint": { "anyOf": [ { @@ -36767,6 +36789,28 @@ ], "title": "Id Jag Resource" }, + "id_jag_resource_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Id" + }, + "id_jag_resource_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Secret" + }, "id_jag_resource_token_endpoint": { "anyOf": [ { @@ -40150,6 +40194,28 @@ ], "title": "Id Jag Resource" }, + "id_jag_resource_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Id" + }, + "id_jag_resource_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Secret" + }, "id_jag_resource_token_endpoint": { "anyOf": [ { @@ -44190,6 +44256,28 @@ ], "title": "Id Jag Resource" }, + "id_jag_resource_client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Id" + }, + "id_jag_resource_client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Client Secret" + }, "id_jag_resource_token_endpoint": { "anyOf": [ { diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index e104f74faf7..5dcbf77993a 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -246,6 +246,12 @@ class MCPCredentials(TypedDict, total=False): Optional RFC 8707 resource indicator sent on ID-JAG leg 1 """ + id_jag_resource_client_id: ReadOnly[str | None] + """OAuth client identifier used at the ID-JAG resource authorization server.""" + + id_jag_resource_client_secret: str | None # writable-ok: encrypt_credentials updates this field + """OAuth client secret used at the ID-JAG resource authorization server.""" + upstream_resource: str | None """ Optional RFC 8707 resource indicator sent on the upstream oauth2 legs (authorize, both token diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 9b37a63adb1..f7c4d329781 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -182,6 +182,14 @@ class MCPServer(LiteLLMBaseModel): # posts the ID-JAG assertion to id_jag_resource_token_endpoint. id_jag_resource_token_endpoint: str | None = None id_jag_resource: str | None = None + id_jag_resource_client_id: str | None = Field( + default=None, + description="OAuth client identifier used at the ID-JAG resource authorization server.", + ) + id_jag_resource_client_secret: str | None = Field( + default=None, + description="OAuth client secret used at the ID-JAG resource authorization server.", + ) client_private_key: str | None = None client_private_key_id: str | None = None client_assertion_signing_alg: str = "RS256" diff --git a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index 141260db700..023d3aa0b5b 100644 --- a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -707,6 +707,66 @@ def test_id_jag_private_key_wins_over_client_secret(): assert isinstance(spec.config.client_auth, PrivateKeyJwtAuth) +@pytest.mark.parametrize( + ("server", "expected_resource_client_id", "expected_secret", "expected_private_key"), + [ + (_id_jag_server(), "litellm-client-id", "litellm-client-secret", None), + ( + _id_jag_server(client_secret=None, client_private_key="LEG1-PRIVATE-KEY"), + "litellm-client-id", + None, + "LEG1-PRIVATE-KEY", + ), + ( + _id_jag_server( + id_jag_resource_client_id="resource-confidential-client", + id_jag_resource_client_secret="resource-client-secret", + ), + "resource-confidential-client", + "resource-client-secret", + None, + ), + ], + ids=["legacy-client-secret", "legacy-private-key", "confidential-override"], +) +def test_id_jag_resource_client_uses_its_configured_identity_and_auth( + server: MCPServer, + expected_resource_client_id: str, + expected_secret: str | None, + expected_private_key: str | None, +) -> None: + spec: Final = to_server_spec(server) + + assert spec is not None and isinstance(spec.config, IdJagConfig) + resource_client: Final = spec.config.resource_client + assert resource_client.client_id == expected_resource_client_id + if expected_secret is not None: + assert isinstance(resource_client.client_auth, ClientSecretAuth) + assert resource_client.client_auth.client_secret.get_secret_value() == expected_secret + if server.id_jag_resource_client_id is not None: + assert isinstance(spec.config.client_auth, ClientSecretAuth) + assert ( + resource_client.client_auth.client_secret.get_secret_value() + != spec.config.client_auth.client_secret.get_secret_value() + ) + else: + assert isinstance(resource_client.client_auth, PrivateKeyJwtAuth) + assert resource_client.client_auth.private_key.get_secret_value() == expected_private_key + + +@pytest.mark.parametrize( + "server", + [ + _id_jag_server(id_jag_resource_client_id="resource-client"), + _id_jag_server(id_jag_resource_client_secret="resource-secret"), + _id_jag_server(id_jag_resource_client_id="", id_jag_resource_client_secret="resource-secret"), + ], + ids=["id-only", "secret-only", "empty-id-with-secret"], +) +def test_id_jag_incomplete_resource_client_overrides_return_none(server: MCPServer) -> None: + assert to_server_spec(server) is None + + def test_id_jag_honors_explicit_subject_token_type(): spec = to_server_spec(_id_jag_server(subject_token_type="urn:ietf:params:oauth:token-type:saml2")) assert spec is not None and isinstance(spec.config, IdJagConfig) diff --git a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 0e47bbb9bb1..0eee7334a26 100644 --- a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -10,7 +10,9 @@ returning the stub. import asyncio import logging import time +from collections.abc import Mapping from datetime import datetime, timedelta, timezone +from typing import Final import httpx2 import jwt as pyjwt @@ -23,11 +25,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( AuthorizationCodeConfig, AwsSigV4Config, Byok, + ClientAuth, ClientCredentialsConfig, ClientSecretAuth, CredError, Error, IdJagConfig, + IdJagResourceClient, NoneConfig, NoOpAuth, Ok, @@ -67,6 +71,10 @@ def _id_jag_config() -> IdJagConfig: resource_token_endpoint="https://mcp-as.example.com/token", client_id="litellm", client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + resource_client=IdJagResourceClient( + client_id="litellm", + client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + ), audience="api://mcp", scopes=("mcp.read",), ) @@ -78,9 +86,17 @@ class _FakeTokenEndpoint: def __init__(self, results: list[Result[ExchangedToken, CredError]]) -> None: self._results = list(results) self.calls: list[tuple[str, str, dict[str, str]]] = [] + self.auths: list[ClientAuth] = [] - async def fetch(self, endpoint, client_id, grant_params, client_auth): + async def fetch( + self, + endpoint: str, + client_id: str, + grant_params: Mapping[str, str], + client_auth: ClientAuth, + ) -> Result[ExchangedToken, CredError]: self.calls.append((endpoint, client_id, dict(grant_params))) + self.auths.append(client_auth) return self._results.pop(0) @@ -509,15 +525,20 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer(): ] ) provider = UpstreamCredentialProvider(token_endpoint=endpoint) - result = await provider.resolve_credentials( - _with_inbound("user-id-token"), _spec(_id_jag_config()) + resource_auth: Final = ClientSecretAuth(client_secret=SecretStr("resource-secret")) + config: Final = _id_jag_config().model_copy( + update={"resource_client": IdJagResourceClient(client_id="resource-client", client_auth=resource_auth)} ) + result: Final = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(config)) assert isinstance(result, Ok) assert _emitted(result.ok)["Authorization"] == "Bearer final-access" - leg1_endpoint, _, leg1_params = endpoint.calls[0] + leg1_endpoint, leg1_client_id, leg1_params = endpoint.calls[0] assert leg1_endpoint == "https://idp.example.com/token" + assert leg1_client_id == "litellm" + assert isinstance(endpoint.auths[0], ClientSecretAuth) + assert endpoint.auths[0].client_secret.get_secret_value() == "s" assert ( leg1_params["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" ) @@ -526,8 +547,12 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer(): ) assert leg1_params["subject_token"] == "user-id-token" - leg2_endpoint, _, leg2_params = endpoint.calls[1] + leg2_endpoint, leg2_client_id, leg2_params = endpoint.calls[1] assert leg2_endpoint == "https://mcp-as.example.com/token" + assert leg2_client_id == "resource-client" + assert isinstance(endpoint.auths[1], ClientSecretAuth) + assert endpoint.auths[1].client_secret.get_secret_value() == "resource-secret" + assert endpoint.auths[1].client_secret.get_secret_value() != endpoint.auths[0].client_secret.get_secret_value() assert leg2_params["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" # The leg-1 token is forwarded verbatim as the leg-2 assertion. assert leg2_params["assertion"] == "the-id-jag" @@ -1071,6 +1096,22 @@ async def test_id_jag_reuses_the_cached_bearer_for_an_unchanged_config(): _id_jag_config().model_copy(update={"resource_token_endpoint": "https://mcp-as.example.com/v2/token"}), _id_jag_config().model_copy(update={"client_id": "litellm-rotated"}), _id_jag_config().model_copy(update={"client_auth": ClientSecretAuth(client_secret=SecretStr("rotated"))}), + _id_jag_config().model_copy( + update={ + "resource_client": IdJagResourceClient( + client_id="resource-client-rotated", + client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + ) + } + ), + _id_jag_config().model_copy( + update={ + "resource_client": IdJagResourceClient( + client_id="litellm", + client_auth=ClientSecretAuth(client_secret=SecretStr("resource-secret-rotated")), + ) + } + ), _id_jag_config().model_copy(update={"subject_token_type": "urn:ietf:params:oauth:token-type:saml2"}), ], ids=[ @@ -1081,6 +1122,8 @@ async def test_id_jag_reuses_the_cached_bearer_for_an_unchanged_config(): "resource_token_endpoint", "client_id", "client_auth", + "resource_client_id", + "resource_client_secret", "subject_token_type", ], ) diff --git a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_types.py b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_types.py index bacbb5c1236..7f5c17de8b4 100644 --- a/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_types.py +++ b/tests/unit/proxy/_experimental/mcp_server/outbound_credentials/test_types.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( CredError, Error, IdJagConfig, + IdJagResourceClient, NoneConfig, Ok, PrivateKeyJwtAuth, @@ -42,6 +43,10 @@ _ID_JAG_MINIMAL = { "resource_token_endpoint": "https://mcp-as.example.com/token", "client_id": "litellm", "client_auth": {"source": "client_secret", "client_secret": "s"}, + "resource_client": { + "client_id": "litellm", + "client_auth": {"source": "client_secret", "client_secret": "s"}, + }, } @@ -230,6 +235,10 @@ def test_id_jag_secrets_do_not_leak_in_repr(): resource_token_endpoint="https://mcp-as.example.com/token", client_id="litellm", client_auth=PrivateKeyJwtAuth(private_key=SecretStr("super-secret-pem")), + resource_client=IdJagResourceClient( + client_id="resource-client", + client_auth=PrivateKeyJwtAuth(private_key=SecretStr("super-secret-pem")), + ), ) assert "super-secret-pem" not in repr(config) @@ -255,6 +264,10 @@ _CARRIER_CONFIGS = ( resource_token_endpoint="https://mcp-as.example.com/token", client_id="litellm", client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + resource_client=IdJagResourceClient( + client_id="litellm", + client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + ), **kw, ), ), diff --git a/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py index 6b7c4350426..ce31a83075c 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py @@ -458,16 +458,24 @@ def test_client_private_key_encrypted_at_rest(): private_key = ( "-----BEGIN PRIVATE KEY-----\nsensitive-rsa-material\n-----END PRIVATE KEY-----" ) - credentials = {"client_secret": "shh", "client_private_key": private_key} + resource_client_secret: Final = "resource-client-secret" + credentials = { + "client_secret": "shh", + "client_private_key": private_key, + "id_jag_resource_client_secret": resource_client_secret, + } encrypted = encrypt_credentials(dict(credentials), encryption_key=None) assert encrypted["client_private_key"] != private_key assert private_key not in encrypted["client_private_key"] assert encrypted["client_secret"] != "shh" + assert encrypted["id_jag_resource_client_secret"] != resource_client_secret + assert resource_client_secret not in encrypted["id_jag_resource_client_secret"] decrypted = decrypt_credentials(dict(encrypted)) assert decrypted["client_private_key"] == private_key assert decrypted["client_secret"] == "shh" + assert decrypted["id_jag_resource_client_secret"] == resource_client_secret @pytest.fixture(params=["xsalsa20-poly1305", "aes-256-gcm"]) diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 711b357a0ea..3683de6ae5a 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -741,6 +741,8 @@ class TestMCPServerManager: "client_id": "gateway-client", "client_secret": "SECRET-CLIENT-SECRET", "client_private_key": "-----BEGIN PRIVATE KEY-----SECRET-PEM-----END PRIVATE KEY-----", + "id_jag_resource_client_id": "resource-client-id", + "id_jag_resource_client_secret": "SECRET-RESOURCE-CLIENT-SECRET", "token_exchange_endpoint": "https://org-idp.example/oauth2/token", "id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token", "authentication_token": "SECRET-STATIC-TOKEN", @@ -750,11 +752,16 @@ class TestMCPServerManager: with caplog.at_level(logging.DEBUG, logger="LiteLLM"): await manager.load_servers_from_config(config) + server: Final = next(iter(manager.config_mcp_servers.values())) + assert server.id_jag_resource_client_id == "resource-client-id" + assert server.id_jag_resource_client_secret == "SECRET-RESOURCE-CLIENT-SECRET" dump = next(m for m in caplog.messages if "Loaded MCP Servers" in m) assert "SECRET-PEM" not in dump assert "SECRET-CLIENT-SECRET" not in dump + assert "SECRET-RESOURCE-CLIENT-SECRET" not in dump assert "SECRET-STATIC-TOKEN" not in dump assert "gateway-client" in dump + assert "resource-client-id" in dump assert "https://org-idp.example/oauth2/token" in dump async def test_load_servers_from_config_warns_on_invalid_alias(self, config_only_mcp_manager_factory, caplog): @@ -9798,6 +9805,30 @@ class TestMCPServerTokenExchangeColumns: assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:access_token" + @pytest.mark.asyncio + async def test_build_mcp_server_from_table_reads_id_jag_resource_client_credentials(self): + resource_secret: Final = "resource-secret" + manager: Final = MCPServerManager() + table_record: Final = LiteLLM_MCPServerTable( + server_id="id-jag-resource-client", + server_name="id_jag_resource_client", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_id_jag, + credentials={ + "id_jag_resource_client_id": "resource-client", + "id_jag_resource_client_secret": resource_secret, + }, + ) + + mcp_server: Final = await manager.build_mcp_server_from_table( + table_record, + credentials_are_encrypted=False, + ) + + assert mcp_server.id_jag_resource_client_id == "resource-client" + assert mcp_server.id_jag_resource_client_secret == resource_secret + @pytest.mark.asyncio async def test_round_trip_token_exchange_columns_preserved(self): """The three fields survive LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b5b164e62cc..6da601efeec 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -37068,6 +37068,10 @@ export interface components { dcr_server_url?: string | null; /** Id Jag Resource */ id_jag_resource?: string | null; + /** Id Jag Resource Client Id */ + id_jag_resource_client_id?: string | null; + /** Id Jag Resource Client Secret */ + id_jag_resource_client_secret?: string | null; /** Id Jag Resource Token Endpoint */ id_jag_resource_token_endpoint?: string | null; /** Redirect Uris */