mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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>
This commit is contained in:
parent
aa0279e0a2
commit
c85e562a52
16 changed files with 335 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue