mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
feat(sso): retain the SAML assertion at login as the saml2 subject token for oauth2_id_jag (#46745)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
3501e36fa9
commit
f02449ad38
25 changed files with 1720 additions and 200 deletions
|
|
@ -455,6 +455,7 @@ class MCPServerConfig(TypedDict, total=False):
|
|||
upstream_token_header: ReadOnly[str]
|
||||
id_jag_resource_token_endpoint: str
|
||||
id_jag_resource: str
|
||||
id_jag_subject_source: ReadOnly[str]
|
||||
id_jag_resource_client_id: ReadOnly[str]
|
||||
id_jag_resource_client_secret: ReadOnly[str]
|
||||
client_private_key: str
|
||||
|
|
@ -2789,6 +2790,7 @@ 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_subject_source=server_config.get("id_jag_subject_source", 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),
|
||||
|
|
@ -3380,6 +3382,7 @@ 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_subject_source=(credentials_dict.get("id_jag_subject_source") 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,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
HeaderCarrier,
|
||||
IdJagConfig,
|
||||
IdJagResourceClient,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
PassthroughConfig,
|
||||
PrivateKeyJwtAuth,
|
||||
|
|
@ -71,6 +72,7 @@ __all__ = [
|
|||
"HeaderCarrier",
|
||||
"IdJagConfig",
|
||||
"IdJagResourceClient",
|
||||
"IdJagSubjectSource",
|
||||
"NoOpAuth",
|
||||
"NoneConfig",
|
||||
"Ok",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
CredError,
|
||||
IdJagConfig,
|
||||
IdJagResourceClient,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
PassthroughConfig,
|
||||
PrivateKeyJwtAuth,
|
||||
|
|
@ -236,7 +237,8 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
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:
|
||||
subject_source: Final = _id_jag_subject_source(server)
|
||||
if resource_client is None or subject_source is None:
|
||||
return None
|
||||
return ServerSpec(
|
||||
server_id=server.server_id,
|
||||
|
|
@ -249,6 +251,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
client_auth=client_auth,
|
||||
resource_client=resource_client,
|
||||
subject_token_type=_id_jag_subject_token_type(server),
|
||||
subject_source=subject_source,
|
||||
audience=server.audience,
|
||||
resource=server.id_jag_resource,
|
||||
scopes=tuple(server.scopes or ()),
|
||||
|
|
@ -282,6 +285,18 @@ def _id_jag_client_auth(server: MCPServer) -> ClientAuth | None:
|
|||
return None
|
||||
|
||||
|
||||
def _id_jag_subject_source(server: MCPServer) -> IdJagSubjectSource | None:
|
||||
"""``None`` for a value that names no source, so the half-configured server defers like a missing
|
||||
endpoint does instead of silently presenting the wrong token."""
|
||||
configured: Final = server.id_jag_subject_source
|
||||
if not configured:
|
||||
return IdJagSubjectSource.login_assertion
|
||||
try:
|
||||
return IdJagSubjectSource(configured)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _id_jag_subject_token_type(server: MCPServer) -> str:
|
||||
"""ID-JAG asserts the user's id_token, so the token-exchange access_token default maps to id_token;
|
||||
an explicitly configured value (e.g. a SAML2 assertion type) is honored verbatim."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
"""Exchange the identity assertion captured at SSO login for a refresh token, at login.
|
||||
|
||||
An ``oauth2_id_jag`` server configured with ``id_jag_subject_source: refresh_token`` does not present the
|
||||
login assertion on ID-JAG leg 1. It presents a refresh token that the IdP issued for that assertion through
|
||||
one RFC 8693 exchange (``requested_token_type`` refresh_token), which is how a SAML requesting app works at
|
||||
an IdP whose ID-JAG exchange does not take a ``saml2`` subject directly. That exchange has to happen at
|
||||
login: a SAML assertion is valid for minutes, the first MCP call that needs it can come hours later, and
|
||||
the refresh token outlives the assertion by as long as the IdP decides.
|
||||
|
||||
The servers consulted are the two configuration authorities, the pod-local config declaration and the DB
|
||||
rows, like the retention gate, not the in-memory registry, which is a per-process snapshot that can lag a
|
||||
server added on another pod. The exchange authenticates as the ID-JAG server's own IdP client, since the
|
||||
refresh token is bound to the client that later spends it. One row holds one refresh token per user, so every server in this mode has
|
||||
to share one IdP client; a second client is logged and skipped rather than silently handed a token it
|
||||
cannot redeem. A refused or unreachable IdP never fails the login, the row keeps the plain assertion and
|
||||
the ID-JAG call reports the missing refresh token as the precondition it is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, Protocol
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok, Result
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
SSOIdentityAssertion,
|
||||
with_login_refresh_token,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ExchangedToken
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ClientAuth,
|
||||
CredError,
|
||||
IdJagConfig,
|
||||
IdJagSubjectSource,
|
||||
ServerSpec,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
_TOKEN_EXCHANGE_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
REFRESH_TOKEN_TOKEN_TYPE: Final = "urn:ietf:params:oauth:token-type:refresh_token"
|
||||
_LOGIN_EXCHANGE_DEADLINE_SECONDS: Final = 15.0
|
||||
|
||||
_REFRESH_TOKEN_SCOPE: Final = "openid offline_access"
|
||||
_RUNTIME_APPROVAL_STATES: Final = (None, "active", "approved") # the states get_runtime_mcp_server_rows serves
|
||||
|
||||
|
||||
class LoginExchangeCatalog(Protocol):
|
||||
"""The slice of the MCP server manager the login-time exchange reads."""
|
||||
|
||||
@property
|
||||
def config_mcp_servers(self) -> Mapping[str, MCPServer]: ...
|
||||
|
||||
async def build_mcp_server_from_table(
|
||||
self,
|
||||
mcp_server: LiteLLM_MCPServerTable,
|
||||
*,
|
||||
credentials_are_encrypted: bool = True,
|
||||
env_vars_are_encrypted: bool | None = None,
|
||||
register_oauth_discovery: bool = True,
|
||||
) -> MCPServer: ...
|
||||
|
||||
|
||||
class TokenExchangeEndpoint(Protocol):
|
||||
async def fetch(
|
||||
self, endpoint: str, client_id: str, grant_params: Mapping[str, str], client_auth: ClientAuth
|
||||
) -> Result[ExchangedToken, CredError]: ...
|
||||
|
||||
|
||||
def _is_runtime_id_jag_row(row: LiteLLM_MCPServerTable) -> bool:
|
||||
"""An ID-JAG row the runtime registry would serve. A pending or rejected submission never gets a
|
||||
login assertion posted to the token endpoint it names."""
|
||||
return row.auth_type == MCPAuth.oauth2_id_jag and row.approval_status in _RUNTIME_APPROVAL_STATES
|
||||
|
||||
|
||||
async def login_exchange_servers(
|
||||
catalog: LoginExchangeCatalog, db_rows: Sequence[LiteLLM_MCPServerTable]
|
||||
) -> tuple[MCPServer, ...]:
|
||||
"""Every server the exchange has to consider: the config-declared ones plus the approved ID-JAG rows
|
||||
in the DB, built the same way the catalog builds them so their credentials are decrypted. A DB row
|
||||
hides a config server with the same ``server_id``, exactly as the live catalog serves them."""
|
||||
id_jag_rows: Final = tuple(row for row in db_rows if _is_runtime_id_jag_row(row))
|
||||
db_servers: Final = tuple([await _built_or_skipped(catalog, row) for row in id_jag_rows])
|
||||
db_by_id: Final = {server.server_id: server for server in db_servers if server is not None}
|
||||
return tuple({**catalog.config_mcp_servers, **db_by_id}.values())
|
||||
|
||||
|
||||
async def _built_or_skipped(catalog: LoginExchangeCatalog, row: LiteLLM_MCPServerTable) -> MCPServer | None:
|
||||
"""One row that cannot be built (undecryptable credentials, a half-configured submission) is skipped
|
||||
with a warning. It must not cost every other refresh-token server its login exchange."""
|
||||
try:
|
||||
return await catalog.build_mcp_server_from_table(row, register_oauth_discovery=False)
|
||||
except Exception as exc: # noqa: BLE001 # a per-row build failure is logged and isolated, not propagated
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: MCP server row server_id=%s could not be built for the login-time refresh token exchange "
|
||||
"and is skipped: %s",
|
||||
row.server_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def refresh_token_exchange_targets(specs: Iterable[ServerSpec | None]) -> tuple[IdJagConfig, ...]:
|
||||
"""The ID-JAG configs that present a login refresh token, one per IdP client, in registration order."""
|
||||
configs: Final = tuple(
|
||||
spec.config
|
||||
for spec in specs
|
||||
if spec is not None
|
||||
and isinstance(spec.config, IdJagConfig)
|
||||
and spec.config.subject_source is IdJagSubjectSource.refresh_token
|
||||
)
|
||||
clients: Final = tuple((config.org_token_endpoint, config.client_id) for config in configs)
|
||||
return tuple(config for index, config in enumerate(configs) if clients.index(clients[index]) == index)
|
||||
|
||||
|
||||
async def exchange_login_assertion_for_refresh_token(
|
||||
assertion: SSOIdentityAssertion,
|
||||
targets: tuple[IdJagConfig, ...],
|
||||
token_endpoint: TokenExchangeEndpoint,
|
||||
now: datetime,
|
||||
deadline_seconds: float = _LOGIN_EXCHANGE_DEADLINE_SECONDS,
|
||||
) -> SSOIdentityAssertion:
|
||||
"""The assertion as captured when no server wants a refresh token, otherwise the assertion plus the
|
||||
refresh token the first target's IdP client exchanged it for. An OIDC login's own SSO refresh token
|
||||
does not count, it is bound to the SSO client and the ID-JAG client cannot redeem it."""
|
||||
if not targets or assertion.exchanged_refresh_token is not None:
|
||||
return assertion
|
||||
config: Final = targets[0]
|
||||
if len(targets) > 1:
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: %d IdP clients are configured with id_jag_subject_source refresh_token; the login refresh "
|
||||
"token is minted for client_id=%s at %s and servers using another client cannot redeem it.",
|
||||
len(targets),
|
||||
config.client_id,
|
||||
config.org_token_endpoint,
|
||||
)
|
||||
params: Final = {
|
||||
"grant_type": _TOKEN_EXCHANGE_GRANT_TYPE,
|
||||
"requested_token_type": REFRESH_TOKEN_TOKEN_TYPE,
|
||||
"subject_token": assertion.id_token.get_secret_value(),
|
||||
"subject_token_type": assertion.subject_token_type or config.subject_token_type,
|
||||
"scope": _REFRESH_TOKEN_SCOPE,
|
||||
}
|
||||
try:
|
||||
outcome: Final = await asyncio.wait_for(
|
||||
token_endpoint.fetch(config.org_token_endpoint, config.client_id, params, config.client_auth),
|
||||
timeout=deadline_seconds,
|
||||
)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: the IdP at %s did not answer the login-time refresh token exchange within %.0fs, so the login "
|
||||
"finishes with the captured assertion only. Servers with id_jag_subject_source: refresh_token will ask "
|
||||
"this user to sign in again.",
|
||||
config.org_token_endpoint,
|
||||
deadline_seconds,
|
||||
)
|
||||
return assertion
|
||||
match outcome:
|
||||
case Error(err):
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: the IdP at %s refused to exchange the login assertion for a refresh token (%s); the "
|
||||
"login goes on with the assertion alone and servers presenting a refresh token will ask the "
|
||||
"user to sign in again.",
|
||||
config.org_token_endpoint,
|
||||
err.summary,
|
||||
)
|
||||
return assertion
|
||||
case Ok(token):
|
||||
verbose_proxy_logger.info(
|
||||
"ID-JAG: exchanged the login assertion for a refresh token at %s (expires_in=%s)",
|
||||
config.org_token_endpoint,
|
||||
token.expires_in,
|
||||
)
|
||||
return with_login_refresh_token(
|
||||
assertion,
|
||||
token.access_token,
|
||||
token.expires_in,
|
||||
now,
|
||||
endpoint=config.org_token_endpoint,
|
||||
client_id=config.client_id,
|
||||
)
|
||||
|
||||
|
||||
async def exchange_login_assertion_if_configured(
|
||||
assertion: SSOIdentityAssertion | None,
|
||||
) -> SSOIdentityAssertion | None:
|
||||
"""The SSO-callback hook: consults the configured and DB-declared MCP servers and performs the
|
||||
exchange when one of them presents a login refresh token. Nothing here can fail the login."""
|
||||
if assertion is None:
|
||||
return None
|
||||
from litellm.proxy._experimental.mcp_server.db import get_all_mcp_servers # noqa: PLC0415 # import cycle
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids import cycle
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415 # import cycle
|
||||
to_server_spec,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( # noqa: PLC0415 # cycle
|
||||
TokenEndpointClient,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global
|
||||
|
||||
try:
|
||||
db_rows: Final = () if prisma_client is None else await get_all_mcp_servers(prisma_client)
|
||||
servers: Final = await login_exchange_servers(global_mcp_server_manager, db_rows)
|
||||
targets: Final = refresh_token_exchange_targets(to_server_spec(server) for server in servers)
|
||||
return await exchange_login_assertion_for_refresh_token(
|
||||
assertion, targets, TokenEndpointClient(), datetime.now(timezone.utc)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 # the login itself must not fail on an egress-side exchange
|
||||
verbose_proxy_logger.warning("ID-JAG: the login-time refresh token exchange failed unexpectedly: %s", exc)
|
||||
return assertion
|
||||
|
|
@ -20,6 +20,7 @@ v2: no imports from v1.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
|
@ -53,7 +54,10 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_r
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
AssertionStoreUnavailable,
|
||||
SSOAssertionStore,
|
||||
SSOIdentityAssertion,
|
||||
assertion_expired,
|
||||
exchanged_refresh_token_issued_for,
|
||||
login_refresh_token_expired,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import (
|
||||
ExchangedToken,
|
||||
|
|
@ -75,6 +79,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
ClientSecretAuth,
|
||||
CredError,
|
||||
IdJagConfig,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
PassthroughConfig,
|
||||
PrivateKeyJwtAuth,
|
||||
|
|
@ -88,6 +93,15 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
_TOKEN_EXCHANGE_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
_JWT_BEARER_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
_ID_JAG_REQUESTED_TOKEN_TYPE: Final = "urn:ietf:params:oauth:token-type:id-jag"
|
||||
_REFRESH_TOKEN_SUBJECT_TOKEN_TYPE: Final = "urn:ietf:params:oauth:token-type:refresh_token"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _IdJagSubjectToken:
|
||||
"""What ID-JAG leg 1 asserts and the RFC 8693 type it is declared as."""
|
||||
|
||||
value: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class _NullOAuthTokenStore:
|
||||
|
|
@ -200,13 +214,15 @@ class UpstreamCredentialProvider:
|
|||
async def _id_jag(
|
||||
self, subject: Subject, server: ServerSpec, config: IdJagConfig
|
||||
) -> Result[httpx2.Auth, CredError]:
|
||||
match await self._id_jag_subject_token(subject):
|
||||
match await self._id_jag_subject_token(subject, config):
|
||||
case Error(err):
|
||||
return Error(err)
|
||||
case Ok(subject_token):
|
||||
return await self._id_jag_exchange(subject, subject_token, server, config)
|
||||
|
||||
async def _id_jag_subject_token(self, subject: Subject) -> Result[str, CredError]:
|
||||
async def _id_jag_subject_token(
|
||||
self, subject: Subject, config: IdJagConfig
|
||||
) -> Result[_IdJagSubjectToken, CredError]:
|
||||
"""The identity token ID-JAG leg 1 asserts, from the request or from the SSO login it was captured at.
|
||||
|
||||
A caller that presents its own IdP identity token wins: that is the strongest available
|
||||
|
|
@ -223,7 +239,7 @@ class UpstreamCredentialProvider:
|
|||
a raised error so a DB blip cannot 500 the egress or the upstream-401 retry.
|
||||
"""
|
||||
if subject.inbound_token is not None:
|
||||
return Ok(subject.inbound_token.get_secret_value())
|
||||
return Ok(_IdJagSubjectToken(subject.inbound_token.get_secret_value(), config.subject_token_type))
|
||||
if not subject.subject_id:
|
||||
return Error(
|
||||
CredError.of_precondition_required(
|
||||
|
|
@ -232,7 +248,11 @@ class UpstreamCredentialProvider:
|
|||
)
|
||||
)
|
||||
try:
|
||||
assertion: Final = await self._sso_assertion_store.fetch(subject.subject_id)
|
||||
assertion: Final = await (
|
||||
self._sso_assertion_store.fetch_stored(subject.subject_id)
|
||||
if config.subject_source is IdJagSubjectSource.refresh_token
|
||||
else self._sso_assertion_store.fetch(subject.subject_id)
|
||||
)
|
||||
except AssertionStoreUnavailable as exc:
|
||||
# The driver's message can name hosts, schemas or connection details, and this summary
|
||||
# is returned to the caller verbatim as a 503 body. Operators get it from the log.
|
||||
|
|
@ -253,6 +273,8 @@ class UpstreamCredentialProvider:
|
|||
"Sign in through LiteLLM SSO so the gateway captures one."
|
||||
)
|
||||
)
|
||||
if config.subject_source is IdJagSubjectSource.refresh_token:
|
||||
return _login_refresh_token_subject(assertion, config, datetime.now(timezone.utc))
|
||||
if assertion_expired(assertion, datetime.now(timezone.utc)):
|
||||
return Error(
|
||||
CredError.of_precondition_required(
|
||||
|
|
@ -260,20 +282,24 @@ class UpstreamCredentialProvider:
|
|||
"LiteLLM SSO again to capture a current one."
|
||||
)
|
||||
)
|
||||
return Ok(assertion.id_token.get_secret_value())
|
||||
return Ok(
|
||||
_IdJagSubjectToken(
|
||||
assertion.id_token.get_secret_value(), assertion.subject_token_type or config.subject_token_type
|
||||
)
|
||||
)
|
||||
|
||||
async def _id_jag_exchange(
|
||||
self, subject: Subject, token: str, server: ServerSpec, config: IdJagConfig
|
||||
self, subject: Subject, token: _IdJagSubjectToken, server: ServerSpec, config: IdJagConfig
|
||||
) -> Result[httpx2.Auth, CredError]:
|
||||
slot: Final = _id_jag_slot_key(subject, server)
|
||||
fingerprint: Final = _id_jag_fingerprint(token, server.server_id, config)
|
||||
fingerprint: Final = _id_jag_fingerprint(token.value, server.server_id, config)
|
||||
|
||||
async def _exchange() -> Result[ExchangedToken, CredError]:
|
||||
leg1_params: Final = {
|
||||
"grant_type": _TOKEN_EXCHANGE_GRANT_TYPE,
|
||||
"requested_token_type": _ID_JAG_REQUESTED_TOKEN_TYPE,
|
||||
"subject_token": token,
|
||||
"subject_token_type": config.subject_token_type,
|
||||
"subject_token": token.value,
|
||||
"subject_token_type": token.token_type,
|
||||
**({"audience": config.audience} if config.audience else {}),
|
||||
**({"resource": config.resource} if config.resource else {}),
|
||||
**({"scope": " ".join(config.scopes)} if config.scopes else {}),
|
||||
|
|
@ -399,6 +425,41 @@ class UpstreamCredentialProvider:
|
|||
return None
|
||||
|
||||
|
||||
def _login_refresh_token_subject(
|
||||
assertion: SSOIdentityAssertion, config: IdJagConfig, now: datetime
|
||||
) -> Result[_IdJagSubjectToken, CredError]:
|
||||
"""The subject for a server configured with ``id_jag_subject_source: refresh_token``: the refresh
|
||||
token the ID-JAG client exchanged the login assertion for when the user signed in. Never the SSO
|
||||
refresh token an OIDC login holds, that one is bound to the SSO client. The assertion's own expiry
|
||||
does not apply here, a SAML assertion is spent minutes after login while the refresh token it
|
||||
bought lives as long as the IdP said."""
|
||||
if assertion.exchanged_refresh_token is None:
|
||||
return Error(
|
||||
CredError.of_precondition_required(
|
||||
"This server mints its ID-JAG from a refresh token exchanged at SSO login and none is stored "
|
||||
"for this user. Sign in through LiteLLM SSO again; if the login log shows the IdP refused the "
|
||||
"exchange, the IdP client has to allow a refresh token for this requesting app."
|
||||
)
|
||||
)
|
||||
if not exchanged_refresh_token_issued_for(assertion, config.org_token_endpoint, config.client_id):
|
||||
return Error(
|
||||
CredError.of_precondition_required(
|
||||
"The refresh token exchanged at SSO login was minted for a different IdP client than this server "
|
||||
"uses, so it cannot be presented here. Point every id_jag_subject_source: refresh_token server at "
|
||||
"the same IdP client, then sign in through LiteLLM SSO again."
|
||||
)
|
||||
)
|
||||
if login_refresh_token_expired(assertion, now):
|
||||
return Error(
|
||||
CredError.of_precondition_required(
|
||||
"The refresh token exchanged at SSO login for this user has expired. Sign in through LiteLLM SSO again."
|
||||
)
|
||||
)
|
||||
return Ok(
|
||||
_IdJagSubjectToken(assertion.exchanged_refresh_token.get_secret_value(), _REFRESH_TOKEN_SUBJECT_TOKEN_TYPE)
|
||||
)
|
||||
|
||||
|
||||
def _id_jag_slot_key(subject: Subject, server: ServerSpec) -> str:
|
||||
"""Which cache slot this caller's bearer for this upstream lives in.
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_s
|
|||
SSOIdentityAssertion,
|
||||
assertion_expired,
|
||||
assertion_from_sso_login,
|
||||
carry_exchanged_refresh_token,
|
||||
fetch_sso_identity_assertion,
|
||||
is_saml_assertion,
|
||||
persist_sso_identity_assertion,
|
||||
)
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
|
@ -293,6 +295,13 @@ class SSOAssertionRefresher:
|
|||
async def refresh(
|
||||
self, user_id: str, assertion: SSOIdentityAssertion
|
||||
) -> Result[SSOIdentityAssertion, RefreshFailure]:
|
||||
if is_saml_assertion(assertion):
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: the stored SAML assertion for user_id=%s has reached its NotOnOrAfter. A SAML assertion "
|
||||
"cannot be renewed, so the user has to sign in through SAML SSO again.",
|
||||
user_id,
|
||||
)
|
||||
return Error(RefreshFailure.of_rejected("a SAML assertion cannot be renewed; sign in again"))
|
||||
if assertion.refresh_token is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"ID-JAG: the stored IdP identity assertion for user_id=%s has expired and no refresh token was "
|
||||
|
|
@ -358,10 +367,11 @@ class SSOAssertionRefresher:
|
|||
user_id,
|
||||
)
|
||||
return Error(RefreshFailure.of_rejected("the IdP's refresh response carried no usable id_token"))
|
||||
failure: Final = await self._store_renewal(user_id, previous, renewed)
|
||||
carried: Final = carry_exchanged_refresh_token(previous, renewed)
|
||||
failure: Final = await self._store_renewal(user_id, previous, carried)
|
||||
if failure is not None:
|
||||
return Error(failure)
|
||||
return Ok(renewed)
|
||||
return Ok(carried)
|
||||
|
||||
async def _store_renewal(
|
||||
self, user_id: str, previous: SSOIdentityAssertion, renewed: SSOIdentityAssertion
|
||||
|
|
@ -436,9 +446,12 @@ class RefreshingSSOAssertionStore:
|
|||
self._skew = timedelta(seconds=expiry_skew_seconds)
|
||||
self._clock = clock
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return await self._inner.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
assertion: Final = await self._inner.fetch(user_id)
|
||||
if not self._expiring(assertion):
|
||||
if assertion is None or is_saml_assertion(assertion) or not self._expiring(assertion):
|
||||
return assertion
|
||||
await self._coordinator().run(
|
||||
user_id,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Store for the enterprise IdP identity assertion captured at SSO login (EMA).
|
||||
|
||||
The ``oauth2_id_jag`` egress arm needs the user's IdP ``id_token`` as its RFC 8693
|
||||
``subject_token``. A front-door client holds an identity-only ``llm_session_`` bearer, not an
|
||||
IdP assertion, so the assertion captured at the one SSO login is the only usable subject
|
||||
source for it. This module owns both sides of that state: the SSO callback persists here
|
||||
The ``oauth2_id_jag`` egress arm needs the user's IdP identity assertion as its RFC 8693
|
||||
``subject_token``: the OIDC ``id_token``, or the signed SAML assertion when the login was SAML.
|
||||
A front-door client holds an identity-only ``llm_session_`` bearer, not an IdP assertion, so
|
||||
the assertion captured at the one SSO login is the only usable subject source for it. This module owns both sides of that state: the SSO callback persists here
|
||||
(write-through to the DB so a login on one pod is visible to every pod) and the resolver
|
||||
seam reads back by ``user_id``. Retention is gated on an ``oauth2_id_jag`` server actually
|
||||
being registered, so a gateway with no EMA upstream never stores bearer material.
|
||||
|
|
@ -17,9 +17,10 @@ TTL ``MCP_SSO_ASSERTION_CACHE_TTL_SECONDS``; invalidation also guards against st
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ GOOGLE_REFRESH_TOKEN_PARAMS: Final = MappingProxyType({"access_type": "offline",
|
|||
|
||||
|
||||
_ASSERTION_DECRYPT_LOG_KEY: Final = "sso_identity_assertion"
|
||||
SAML2_SUBJECT_TOKEN_TYPE: Final = "urn:ietf:params:oauth:token-type:saml2"
|
||||
_STR_ADAPTER: Final[TypeAdapter[str]] = TypeAdapter(str)
|
||||
_MAYBE_STR_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
|
||||
|
||||
|
|
@ -77,8 +79,10 @@ def _mcp_server_table(prisma_client: PrismaClient) -> _MCPServerTable:
|
|||
|
||||
|
||||
class SSOIdentityAssertion(LiteLLMBaseModel):
|
||||
"""The IdP material an EMA exchange needs: ``id_token`` is the RFC 8693 subject token,
|
||||
``expires_at`` bounds its usefulness, and the refresh token renews it without re-login."""
|
||||
"""The IdP material an EMA exchange needs: ``id_token`` is the RFC 8693 subject token (the OIDC
|
||||
id_token, or the base64 signed ``<saml:Assertion>`` when ``subject_token_type`` names SAML),
|
||||
``expires_at`` bounds its usefulness, and the refresh token renews it without re-login.
|
||||
``subject_token_type`` is ``None`` when the exchange should send the operator-configured type."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
|
@ -86,6 +90,11 @@ class SSOIdentityAssertion(LiteLLMBaseModel):
|
|||
refresh_token: SecretStr | None = None
|
||||
issuer: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
subject_token_type: str | None = None
|
||||
exchanged_refresh_token: SecretStr | None = None
|
||||
exchanged_refresh_token_expires_at: datetime | None = None
|
||||
exchanged_refresh_token_endpoint: str | None = None
|
||||
exchanged_refresh_token_client_id: str | None = None
|
||||
|
||||
|
||||
class SSOAssertionCache:
|
||||
|
|
@ -138,6 +147,11 @@ class _StoredAssertionPayload(LiteLLMBaseModel):
|
|||
refresh_token: str | None = None
|
||||
issuer: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
subject_token_type: str | None = None
|
||||
exchanged_refresh_token: str | None = None
|
||||
exchanged_refresh_token_expires_at: datetime | None = None
|
||||
exchanged_refresh_token_endpoint: str | None = None
|
||||
exchanged_refresh_token_client_id: str | None = None
|
||||
|
||||
|
||||
def assertion_from_sso_login(id_token: object, refresh_token: object) -> SSOIdentityAssertion | None:
|
||||
|
|
@ -167,6 +181,85 @@ def assertion_from_sso_login(id_token: object, refresh_token: object) -> SSOIden
|
|||
)
|
||||
|
||||
|
||||
def assertion_from_saml_login(
|
||||
assertion_xml: bytes, not_on_or_after: int | None, issuer: str | None
|
||||
) -> SSOIdentityAssertion:
|
||||
"""The carrier for a SAML login: the signed ``<saml:Assertion>`` element the IdP issued, base64
|
||||
encoded the way RFC 8693 carries a ``saml2`` subject token, with its own validity window as
|
||||
the expiry and no refresh token, since SAML has none and the user has to sign in again."""
|
||||
return SSOIdentityAssertion(
|
||||
id_token=SecretStr(base64.urlsafe_b64encode(assertion_xml).rstrip(b"=").decode("ascii")),
|
||||
issuer=issuer,
|
||||
expires_at=datetime.fromtimestamp(not_on_or_after, tz=timezone.utc) if not_on_or_after is not None else None,
|
||||
subject_token_type=SAML2_SUBJECT_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
|
||||
def is_saml_assertion(assertion: SSOIdentityAssertion) -> bool:
|
||||
return assertion.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
|
||||
|
||||
def with_login_refresh_token(
|
||||
assertion: SSOIdentityAssertion,
|
||||
refresh_token: str,
|
||||
expires_in: int | None,
|
||||
now: datetime,
|
||||
*,
|
||||
endpoint: str | None = None,
|
||||
client_id: str | None = None,
|
||||
) -> SSOIdentityAssertion:
|
||||
"""The same login, now also carrying the refresh token the ID-JAG client exchanged the assertion
|
||||
for, with the lifetime the IdP gave that token. It is kept apart from ``refresh_token``, which an
|
||||
OIDC login holds for the SSO client and which only that client can redeem. The assertion and its
|
||||
own expiry stay as they were, so a server that presents the login assertion directly is unaffected."""
|
||||
return assertion.model_copy(
|
||||
update={
|
||||
"exchanged_refresh_token": SecretStr(refresh_token),
|
||||
"exchanged_refresh_token_expires_at": now + timedelta(seconds=expires_in)
|
||||
if expires_in is not None
|
||||
else None,
|
||||
"exchanged_refresh_token_endpoint": endpoint,
|
||||
"exchanged_refresh_token_client_id": client_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def carry_exchanged_refresh_token(
|
||||
previous: SSOIdentityAssertion, renewed: SSOIdentityAssertion
|
||||
) -> SSOIdentityAssertion:
|
||||
"""The renewed OIDC assertion still carrying the refresh token the ID-JAG client exchanged at login.
|
||||
Renewal only replaces the id_token and the SSO refresh token, the exchanged one is unrelated to
|
||||
them and stays valid for as long as the IdP said."""
|
||||
return renewed.model_copy(
|
||||
update={
|
||||
"exchanged_refresh_token": previous.exchanged_refresh_token,
|
||||
"exchanged_refresh_token_expires_at": previous.exchanged_refresh_token_expires_at,
|
||||
"exchanged_refresh_token_endpoint": previous.exchanged_refresh_token_endpoint,
|
||||
"exchanged_refresh_token_client_id": previous.exchanged_refresh_token_client_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def exchanged_refresh_token_issued_for(assertion: SSOIdentityAssertion, endpoint: str, client_id: str) -> bool:
|
||||
"""Whether the exchanged refresh token was minted at this token endpoint for this client. A refresh
|
||||
token is bound to the client that received it, so one minted for another server's IdP client must
|
||||
never be posted to this server's endpoint."""
|
||||
return (
|
||||
assertion.exchanged_refresh_token_endpoint == endpoint
|
||||
and assertion.exchanged_refresh_token_client_id == client_id
|
||||
)
|
||||
|
||||
|
||||
def login_refresh_token_expired(assertion: SSOIdentityAssertion, now: datetime) -> bool:
|
||||
"""Whether the refresh token exchanged at login has passed the lifetime the IdP gave it. One
|
||||
with no recorded lifetime is left for the IdP to judge, like an assertion with no ``exp``."""
|
||||
expires_at: Final = assertion.exchanged_refresh_token_expires_at
|
||||
if expires_at is None:
|
||||
return False
|
||||
normalized: Final = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc)
|
||||
return normalized <= now
|
||||
|
||||
|
||||
def assertion_expired(assertion: SSOIdentityAssertion, now: datetime) -> bool:
|
||||
"""Whether the assertion's ``exp`` has passed at ``now``. An assertion carrying no expiry is
|
||||
treated as usable and left for the IdP to reject, since the store records what the id_token
|
||||
|
|
@ -220,6 +313,27 @@ async def persist_sso_identity_assertion(
|
|||
**({"refresh_token": assertion.refresh_token.get_secret_value()} if assertion.refresh_token else {}),
|
||||
**({"issuer": assertion.issuer} if assertion.issuer else {}),
|
||||
**({"expires_at": assertion.expires_at.isoformat()} if assertion.expires_at else {}),
|
||||
**({"subject_token_type": assertion.subject_token_type} if assertion.subject_token_type else {}),
|
||||
**(
|
||||
{"exchanged_refresh_token": assertion.exchanged_refresh_token.get_secret_value()}
|
||||
if assertion.exchanged_refresh_token
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"exchanged_refresh_token_expires_at": assertion.exchanged_refresh_token_expires_at.isoformat()}
|
||||
if assertion.exchanged_refresh_token_expires_at
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"exchanged_refresh_token_endpoint": assertion.exchanged_refresh_token_endpoint}
|
||||
if assertion.exchanged_refresh_token_endpoint
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"exchanged_refresh_token_client_id": assertion.exchanged_refresh_token_client_id}
|
||||
if assertion.exchanged_refresh_token_client_id
|
||||
else {}
|
||||
),
|
||||
}
|
||||
encoded: Final = _STR_ADAPTER.validate_python(encrypt_value_helper(json.dumps(payload)))
|
||||
await _assertion_table(prisma_client).upsert(
|
||||
|
|
@ -258,6 +372,11 @@ async def _read_assertion_from_db(user_id: str) -> SSOIdentityAssertion | None:
|
|||
refresh_token=SecretStr(payload.refresh_token) if payload.refresh_token else None,
|
||||
issuer=payload.issuer,
|
||||
expires_at=payload.expires_at,
|
||||
subject_token_type=payload.subject_token_type,
|
||||
exchanged_refresh_token=SecretStr(payload.exchanged_refresh_token) if payload.exchanged_refresh_token else None,
|
||||
exchanged_refresh_token_expires_at=payload.exchanged_refresh_token_expires_at,
|
||||
exchanged_refresh_token_endpoint=payload.exchanged_refresh_token_endpoint,
|
||||
exchanged_refresh_token_client_id=payload.exchanged_refresh_token_client_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -298,6 +417,12 @@ class SSOAssertionStore(Protocol):
|
|||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: ...
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
"""The row as stored, never renewed on the way out. For a server that presents the refresh
|
||||
token exchanged at login, the SSO id_token's own lifetime is irrelevant and an SSO renewal
|
||||
failing must not stand between that server and a still valid refresh token."""
|
||||
...
|
||||
|
||||
|
||||
class DbSSOAssertionStore:
|
||||
"""The live store: the row the SSO callback wrote, read back by ``user_id``.
|
||||
|
|
@ -316,6 +441,9 @@ class DbSSOAssertionStore:
|
|||
except Exception as exc: # noqa: BLE001 # any driver/storage failure is an outage, not an absence
|
||||
raise AssertionStoreUnavailable(str(exc)) from exc
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch_uncached(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
try:
|
||||
return await _read_assertion_from_db(user_id)
|
||||
|
|
|
|||
|
|
@ -349,6 +349,14 @@ class IdJagResourceClient(LiteLLMBaseModel):
|
|||
client_auth: ClientAuth
|
||||
|
||||
|
||||
class IdJagSubjectSource(str, Enum):
|
||||
"""What ID-JAG leg 1 presents as the RFC 8693 subject_token: the identity assertion captured at SSO
|
||||
login, or the refresh token that assertion was exchanged for at the IdP when the user logged in."""
|
||||
|
||||
login_assertion = "login_assertion"
|
||||
refresh_token = "refresh_token"
|
||||
|
||||
|
||||
class IdJagConfig(HeaderCarrier):
|
||||
"""draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange").
|
||||
|
||||
|
|
@ -368,6 +376,7 @@ class IdJagConfig(HeaderCarrier):
|
|||
client_auth: ClientAuth
|
||||
resource_client: IdJagResourceClient
|
||||
subject_token_type: str = "urn:ietf:params:oauth:token-type:id_token"
|
||||
subject_source: IdJagSubjectSource = IdJagSubjectSource.login_assertion
|
||||
audience: str | None = None
|
||||
resource: str | None = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
|
|
|
|||
|
|
@ -34541,6 +34541,17 @@
|
|||
],
|
||||
"title": "Id Jag Resource Token Endpoint"
|
||||
},
|
||||
"id_jag_subject_source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id Jag Subject Source"
|
||||
},
|
||||
"redirect_uris": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -37358,6 +37369,17 @@
|
|||
],
|
||||
"title": "Id Jag Resource Token Endpoint"
|
||||
},
|
||||
"id_jag_subject_source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id Jag Subject Source"
|
||||
},
|
||||
"redirect_uris": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -40763,6 +40785,17 @@
|
|||
],
|
||||
"title": "Id Jag Resource Token Endpoint"
|
||||
},
|
||||
"id_jag_subject_source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id Jag Subject Source"
|
||||
},
|
||||
"redirect_uris": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -44843,6 +44876,17 @@
|
|||
],
|
||||
"title": "Id Jag Resource Token Endpoint"
|
||||
},
|
||||
"id_jag_subject_source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Id Jag Subject Source"
|
||||
},
|
||||
"redirect_uris": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
An ``oauth2_id_jag`` MCP server spends the ``id_token`` captured at SSO login as its RFC 8693
|
||||
subject token. The Google, Microsoft and generic OIDC login paths all reach a token response the
|
||||
gateway retains one from; SAML has no id_token to capture, and a deployment with no SSO never signs
|
||||
anyone in, so under either every store-sourced ID-JAG exchange fails for every user, however many
|
||||
times they sign in. Neither side can see that alone: the MCP registration knows nothing about SSO
|
||||
and the login knows nothing about MCP. This module is the one shared answer both warn from.
|
||||
gateway retains one from, and the SAML login retains the signed assertion itself as a ``saml2``
|
||||
subject token. A deployment with no SSO never signs anyone in, so every store-sourced ID-JAG
|
||||
exchange fails for every user. Neither side can see that alone: the MCP registration knows nothing
|
||||
about SSO and the login knows nothing about MCP. This module is the one shared answer both warn from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,8 +19,8 @@ from typing_extensions import assert_never
|
|||
from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler
|
||||
|
||||
_OIDC_REMEDY: Final = (
|
||||
"Point SSO at an OIDC provider (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID or GENERIC_CLIENT_ID), the login "
|
||||
"paths whose token response the gateway retains an id_token from"
|
||||
"Point SSO at an OIDC provider (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID or GENERIC_CLIENT_ID) or at SAML, "
|
||||
"the login paths the gateway retains an identity assertion from"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -56,19 +56,15 @@ def id_jag_assertion_capture_gap() -> str | None:
|
|||
or ``None`` when that provider does capture an assertion."""
|
||||
provider = active_sso_provider()
|
||||
match provider:
|
||||
case ActiveSSOProvider.google | ActiveSSOProvider.microsoft | ActiveSSOProvider.generic:
|
||||
case (
|
||||
ActiveSSOProvider.google | ActiveSSOProvider.microsoft | ActiveSSOProvider.generic | ActiveSSOProvider.saml
|
||||
):
|
||||
return None
|
||||
case ActiveSSOProvider.none:
|
||||
return (
|
||||
"no SSO provider is configured, so no IdP identity assertion is ever captured and "
|
||||
f"ID-JAG credential resolution fails for every user. {_OIDC_REMEDY}"
|
||||
)
|
||||
case ActiveSSOProvider.saml:
|
||||
return (
|
||||
f"the active SSO provider ({provider.value}) has no identity-assertion capture path, so no "
|
||||
"IdP id_token is ever stored and ID-JAG credential resolution fails for every user no matter "
|
||||
f"how often they sign in. {_OIDC_REMEDY}"
|
||||
)
|
||||
case _:
|
||||
assert_never(provider)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import hashlib
|
|||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, cast
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
|
@ -37,6 +38,10 @@ from pydantic import ValidationError
|
|||
from litellm._internal_context import with_service_target
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
SSOIdentityAssertion,
|
||||
assertion_from_saml_login,
|
||||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.management_endpoints.sso_helper_utils import SSO_SESSIONS_TARGET
|
||||
from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
|
||||
|
|
@ -212,7 +217,7 @@ class SAMLAuthHandler:
|
|||
return OneLogin_Saml2_IdPMetadataParser.merge_settings(sp_settings, idp_settings)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_request_data(request: Request, post_data: dict[str, str] | None = None) -> dict[str, object]:
|
||||
def _prepare_request_data(request: Request, post_data: Mapping[str, str] | None = None) -> dict[str, object]:
|
||||
base: Final = SAMLAuthHandler._base_url(request)
|
||||
scheme, _, host_part = base.partition("://")
|
||||
host: Final = host_part.split("/", 1)[0]
|
||||
|
|
@ -228,7 +233,7 @@ class SAMLAuthHandler:
|
|||
async def _build_auth(
|
||||
request: Request,
|
||||
cache: DualCache,
|
||||
post_data: dict[str, str] | None = None,
|
||||
post_data: Mapping[str, str] | None = None,
|
||||
) -> "OneLogin_Saml2_Auth":
|
||||
if not SAML_AVAILABLE:
|
||||
raise _saml_unavailable_error()
|
||||
|
|
@ -310,7 +315,9 @@ class SAMLAuthHandler:
|
|||
return dict(parse_qsl(body.decode("utf-8", "replace")))
|
||||
|
||||
@staticmethod
|
||||
async def handle_acs(request: Request, cache: DualCache, post_data: dict[str, str]) -> CustomOpenID:
|
||||
async def handle_acs(
|
||||
request: Request, cache: DualCache, post_data: Mapping[str, str]
|
||||
) -> tuple[CustomOpenID, SSOIdentityAssertion | None]:
|
||||
auth: Final = await SAMLAuthHandler._build_auth(request, cache, post_data=post_data)
|
||||
browser_request_id: Final = request.cookies.get(_SAML_AUTHN_STATE_COOKIE)
|
||||
try:
|
||||
|
|
@ -330,7 +337,50 @@ class SAMLAuthHandler:
|
|||
)
|
||||
|
||||
await SAMLAuthHandler._enforce_response_binding(auth, cache, browser_request_id)
|
||||
return SAMLAuthHandler._result_from_auth(auth)
|
||||
return SAMLAuthHandler._result_from_auth(auth), SAMLAuthHandler._retained_assertion(auth)
|
||||
|
||||
@staticmethod
|
||||
def _retained_assertion(auth: "OneLogin_Saml2_Auth") -> SSOIdentityAssertion | None:
|
||||
"""The one signed ``<saml:Assertion>`` the validated response carried, serialized on its own
|
||||
so its enveloped signature still verifies at the IdP's token endpoint. Retention is optional
|
||||
egress material, so a failure here is logged and the validated login goes on without it."""
|
||||
try:
|
||||
return SAMLAuthHandler._parse_retained_assertion(auth)
|
||||
except Exception as e: # noqa: BLE001 - lxml and python3-saml expose no common base; capture must not block login
|
||||
verbose_proxy_logger.warning(
|
||||
"SAML login validated but its assertion could not be retained for oauth2_id_jag MCP servers: %s", e
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_retained_assertion(auth: "OneLogin_Saml2_Auth") -> SSOIdentityAssertion | None:
|
||||
xml: Final = cast(bytes | None, auth.get_last_response_xml()) # cast-ok: untyped python3-saml
|
||||
if not xml:
|
||||
verbose_proxy_logger.warning(
|
||||
"SAML login validated but the response XML was not retained, so this user has no identity "
|
||||
"assertion for oauth2_id_jag MCP servers."
|
||||
)
|
||||
return None
|
||||
root: Final = OneLogin_Saml2_XML.to_etree(xml)
|
||||
assertions: Final = OneLogin_Saml2_XML.query(root, "//saml:Assertion")
|
||||
if len(assertions) != 1:
|
||||
encrypted: Final = OneLogin_Saml2_XML.query(root, "//saml:EncryptedAssertion")
|
||||
verbose_proxy_logger.warning(
|
||||
"SAML login validated but the response carried %d plaintext saml:Assertion elements and %d "
|
||||
"saml:EncryptedAssertion elements, so no identity assertion was retained for oauth2_id_jag MCP "
|
||||
"servers. Those servers need exactly one signed, unencrypted assertion from the IdP.",
|
||||
len(assertions),
|
||||
len(encrypted),
|
||||
)
|
||||
return None
|
||||
issuers: Final = OneLogin_Saml2_XML.query(assertions[0], "./saml:Issuer")
|
||||
issuer: Final = cast(str | None, issuers[0].text) if issuers else None # cast-ok: untyped python3-saml
|
||||
not_on_or_after: Final = auth.get_last_assertion_not_on_or_after()
|
||||
return assertion_from_saml_login(
|
||||
assertion_xml=cast(bytes, OneLogin_Saml2_XML.to_string(assertions[0], encoding="utf-8")), # cast-ok: saml
|
||||
not_on_or_after=not_on_or_after if isinstance(not_on_or_after, int) else None,
|
||||
issuer=issuer,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _replay_guard_ttl(auth: "OneLogin_Saml2_Auth") -> int:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.login_assertion_exchange import (
|
||||
exchange_login_assertion_if_configured,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
GOOGLE_REFRESH_TOKEN_PARAMS,
|
||||
MICROSOFT_REFRESH_TOKEN_SCOPE,
|
||||
|
|
@ -2343,7 +2346,7 @@ async def saml_callback(request: Request):
|
|||
if "SAMLResponse" not in post_data:
|
||||
raise HTTPException(status_code=400, detail="Missing SAMLResponse in callback request.")
|
||||
|
||||
result: Final = await SAMLAuthHandler.handle_acs(request=request, cache=saml_sso_cache, post_data=post_data)
|
||||
user, retained = await SAMLAuthHandler.handle_acs(request=request, cache=saml_sso_cache, post_data=post_data)
|
||||
|
||||
await _raise_if_sso_exceeds_free_user_limit(premium_user, prisma_client)
|
||||
|
||||
|
|
@ -2356,7 +2359,7 @@ async def saml_callback(request: Request):
|
|||
)
|
||||
|
||||
return await SSOAuthenticationHandler.get_redirect_response_from_openid(
|
||||
result=result,
|
||||
result=user,
|
||||
request=request,
|
||||
received_response=None,
|
||||
generic_client_id=None,
|
||||
|
|
@ -2364,6 +2367,7 @@ async def saml_callback(request: Request):
|
|||
access_token_payload=None,
|
||||
jwt_handler=jwt_handler,
|
||||
return_to=cp_return_to,
|
||||
sso_assertion=retained,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2480,7 +2484,8 @@ async def _complete_cli_sso_callback_session(
|
|||
raise HTTPException(status_code=500, detail="Failed to retrieve user information from SSO")
|
||||
resolved_user_id: Final = _require_sso_user_id(user_info.user_id)
|
||||
|
||||
await retain_sso_identity_assertion_for_ema(user_id=resolved_user_id, assertion=sso_assertion)
|
||||
retained_assertion: Final = await exchange_login_assertion_if_configured(sso_assertion)
|
||||
await retain_sso_identity_assertion_for_ema(user_id=resolved_user_id, assertion=retained_assertion)
|
||||
await warn_if_id_jag_assertion_uncaptured(sso_assertion)
|
||||
|
||||
teams: list[str] = []
|
||||
|
|
@ -3863,7 +3868,8 @@ class SSOAuthenticationHandler:
|
|||
)
|
||||
|
||||
if isinstance(user_id, str) and user_id:
|
||||
await retain_sso_identity_assertion_for_ema(user_id=user_id, assertion=sso_assertion)
|
||||
retained_assertion: Final = await exchange_login_assertion_if_configured(sso_assertion)
|
||||
await retain_sso_identity_assertion_for_ema(user_id=user_id, assertion=retained_assertion)
|
||||
await warn_if_id_jag_assertion_uncaptured(sso_assertion)
|
||||
|
||||
disabled_non_admin_personal_key_creation: Final = get_disabled_non_admin_personal_key_creation()
|
||||
|
|
|
|||
|
|
@ -248,6 +248,15 @@ class MCPCredentials(TypedDict, total=False):
|
|||
Optional RFC 8707 resource indicator sent on ID-JAG leg 1
|
||||
"""
|
||||
|
||||
id_jag_subject_source: ReadOnly[str | None]
|
||||
"""
|
||||
Which token ID-JAG leg 1 presents as its RFC 8693 subject_token. ``login_assertion`` (the default) is the
|
||||
identity assertion captured at SSO login, an OIDC id_token or a SAML assertion. ``refresh_token`` exchanges
|
||||
that assertion at the IdP token endpoint once, at login, for a refresh token and presents the refresh token
|
||||
on every leg 1 instead, which is how a SAML requesting app works at IdPs whose ID-JAG exchange does not take
|
||||
a saml2 subject directly.
|
||||
"""
|
||||
|
||||
id_jag_resource_client_id: ReadOnly[str | None]
|
||||
"""OAuth client identifier used at the ID-JAG resource authorization server."""
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ 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_subject_source: 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.",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
ClientSecretAuth,
|
||||
CredError,
|
||||
IdJagConfig,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
PassthroughConfig,
|
||||
PrivateKeyJwtAuth,
|
||||
|
|
@ -908,3 +909,25 @@ def test_incomplete_obo_keeps_exchange_ownership(client_secret: str | None, is_b
|
|||
assert isinstance(spec.config, TokenExchangeConfig)
|
||||
assert spec.config.client_id == "client"
|
||||
assert spec.config.client_secret is None
|
||||
assert spec.config.client_id == "client"
|
||||
assert spec.config.client_secret is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "expected"),
|
||||
[
|
||||
(None, IdJagSubjectSource.login_assertion),
|
||||
("login_assertion", IdJagSubjectSource.login_assertion),
|
||||
("refresh_token", IdJagSubjectSource.refresh_token),
|
||||
],
|
||||
)
|
||||
def test_id_jag_subject_source_maps_to_config(configured: str | None, expected: IdJagSubjectSource) -> None:
|
||||
spec = to_server_spec(_id_jag_server(id_jag_subject_source=configured))
|
||||
|
||||
assert spec is not None
|
||||
assert isinstance(spec.config, IdJagConfig)
|
||||
assert spec.config.subject_source is expected
|
||||
|
||||
|
||||
def test_id_jag_unknown_subject_source_defers_like_a_half_configured_server() -> None:
|
||||
assert to_server_spec(_id_jag_server(id_jag_subject_source="assertion")) is None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
"""The login-time exchange of a captured identity assertion for an IdP refresh token."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.login_assertion_exchange import (
|
||||
exchange_login_assertion_for_refresh_token,
|
||||
login_exchange_servers,
|
||||
refresh_token_exchange_targets,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok, Result
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
SAML2_SUBJECT_TOKEN_TYPE,
|
||||
SSOIdentityAssertion,
|
||||
assertion_from_saml_login,
|
||||
login_refresh_token_expired,
|
||||
with_login_refresh_token,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ExchangedToken
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ClientAuth,
|
||||
ClientSecretAuth,
|
||||
CredError,
|
||||
IdJagConfig,
|
||||
IdJagResourceClient,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
ServerSpec,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
_NOW = datetime(2026, 10, 16, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _config(
|
||||
*, source: IdJagSubjectSource = IdJagSubjectSource.refresh_token, client_id: str = "agent", endpoint: str = ""
|
||||
) -> IdJagConfig:
|
||||
return IdJagConfig(
|
||||
org_token_endpoint=endpoint or "https://idp.example.com/token",
|
||||
resource_token_endpoint="https://mcp-as.example.com/token",
|
||||
client_id=client_id,
|
||||
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
|
||||
resource_client=IdJagResourceClient(
|
||||
client_id=client_id, client_auth=ClientSecretAuth(client_secret=SecretStr("s"))
|
||||
),
|
||||
subject_source=source,
|
||||
)
|
||||
|
||||
|
||||
def _spec(config: IdJagConfig | NoneConfig, server_id: str = "srv") -> ServerSpec:
|
||||
return ServerSpec(server_id=server_id, resource="https://mcp.example.com", config=config)
|
||||
|
||||
|
||||
def _saml() -> SSOIdentityAssertion:
|
||||
return assertion_from_saml_login(b"<saml:Assertion/>", int(_NOW.timestamp()) + 300, "https://idp.example.com")
|
||||
|
||||
|
||||
class _Endpoint:
|
||||
def __init__(self, result: Result[ExchangedToken, CredError]) -> None:
|
||||
self._result = result
|
||||
self.calls: list[tuple[str, str, dict[str, str], ClientAuth]] = []
|
||||
|
||||
async def fetch(
|
||||
self, endpoint: str, client_id: str, grant_params: dict[str, str], client_auth: ClientAuth
|
||||
) -> Result[ExchangedToken, CredError]:
|
||||
self.calls.append((endpoint, client_id, dict(grant_params), client_auth))
|
||||
return self._result
|
||||
|
||||
|
||||
def test_targets_keep_only_refresh_token_sourced_id_jag_servers_one_per_idp_client():
|
||||
direct = _config(source=IdJagSubjectSource.login_assertion)
|
||||
first = _config(client_id="agent")
|
||||
twin = _config(client_id="agent")
|
||||
other = _config(client_id="other-agent")
|
||||
|
||||
targets = refresh_token_exchange_targets(
|
||||
[_spec(direct, "a"), None, _spec(NoneConfig(), "b"), _spec(first, "c"), _spec(other, "d"), _spec(twin, "e")]
|
||||
)
|
||||
|
||||
assert targets == (first, other)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_presents_the_captured_assertion_and_keeps_the_refresh_token():
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=3600)))
|
||||
config = _config()
|
||||
saml = _saml()
|
||||
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(saml, (config,), endpoint, _NOW)
|
||||
|
||||
((url, client_id, params, auth),) = endpoint.calls
|
||||
assert (url, client_id, auth) == (config.org_token_endpoint, config.client_id, config.client_auth)
|
||||
assert params == {
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token",
|
||||
"subject_token": saml.id_token.get_secret_value(),
|
||||
"subject_token_type": SAML2_SUBJECT_TOKEN_TYPE,
|
||||
"scope": "openid offline_access",
|
||||
}
|
||||
assert exchanged.exchanged_refresh_token is not None
|
||||
assert exchanged.exchanged_refresh_token.get_secret_value() == "rt-1"
|
||||
assert exchanged.exchanged_refresh_token_endpoint == "https://idp.example.com/token"
|
||||
assert exchanged.exchanged_refresh_token_client_id == "agent"
|
||||
assert exchanged.exchanged_refresh_token_expires_at == _NOW + timedelta(seconds=3600)
|
||||
assert exchanged.id_token == saml.id_token
|
||||
assert exchanged.expires_at == saml.expires_at
|
||||
assert exchanged.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
assert not login_refresh_token_expired(exchanged, _NOW + timedelta(seconds=3599))
|
||||
assert login_refresh_token_expired(exchanged, _NOW + timedelta(seconds=3600))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_without_a_lifetime_leaves_expiry_to_the_idp():
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=None)))
|
||||
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(_saml(), (_config(),), endpoint, _NOW)
|
||||
|
||||
assert exchanged.exchanged_refresh_token_expires_at is None
|
||||
assert not login_refresh_token_expired(exchanged, _NOW + timedelta(days=365))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_refused_exchange_keeps_the_plain_assertion(caplog: pytest.LogCaptureFixture):
|
||||
endpoint = _Endpoint(Error(CredError.of_upstream_unavailable("token exchange failed with status 400")))
|
||||
saml = _saml()
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(saml, (_config(),), endpoint, _NOW)
|
||||
|
||||
assert exchanged == saml
|
||||
assert exchanged.exchanged_refresh_token is None
|
||||
assert any("refused to exchange the login assertion" in message for message in caplog.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_exchange_without_a_refresh_token_sourced_server():
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=3600)))
|
||||
saml = _saml()
|
||||
|
||||
assert await exchange_login_assertion_for_refresh_token(saml, (), endpoint, _NOW) == saml
|
||||
assert endpoint.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_oidc_login_is_exchanged_at_the_id_jag_client_and_keeps_its_sso_refresh_token():
|
||||
"""The SSO refresh token belongs to the SSO client and cannot be redeemed by the ID-JAG client, so
|
||||
an OIDC login still exchanges its id_token and both tokens live side by side."""
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=3600)))
|
||||
oidc = SSOIdentityAssertion(id_token=SecretStr("id-token"), refresh_token=SecretStr("sso-rt"))
|
||||
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(oidc, (_config(),), endpoint, _NOW)
|
||||
|
||||
assert endpoint.calls[0][2]["subject_token"] == "id-token"
|
||||
assert endpoint.calls[0][2]["subject_token_type"] == "urn:ietf:params:oauth:token-type:id_token"
|
||||
assert exchanged.refresh_token is not None and exchanged.refresh_token.get_secret_value() == "sso-rt"
|
||||
assert exchanged.exchanged_refresh_token is not None
|
||||
assert exchanged.exchanged_refresh_token.get_secret_value() == "rt-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_login_already_exchanged_is_not_exchanged_again():
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-2", expires_in=3600)))
|
||||
done = with_login_refresh_token(_saml(), "rt-1", 3600, _NOW)
|
||||
|
||||
assert await exchange_login_assertion_for_refresh_token(done, (_config(),), endpoint, _NOW) == done
|
||||
assert endpoint.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_several_idp_clients_mint_for_the_first_and_warn(caplog: pytest.LogCaptureFixture):
|
||||
endpoint = _Endpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=3600)))
|
||||
first, second = _config(client_id="agent"), _config(client_id="other-agent")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(_saml(), (first, second), endpoint, _NOW)
|
||||
|
||||
assert [call[1] for call in endpoint.calls] == ["agent"]
|
||||
assert exchanged.exchanged_refresh_token is not None
|
||||
assert any("2 IdP clients" in message and "client_id=agent" in message for message in caplog.messages)
|
||||
|
||||
|
||||
class _FakeCatalog:
|
||||
def __init__(self, config_servers: dict[str, MCPServer]) -> None:
|
||||
self.config_mcp_servers = config_servers
|
||||
self.built: list[tuple[str, bool]] = []
|
||||
|
||||
async def build_mcp_server_from_table(
|
||||
self,
|
||||
mcp_server: LiteLLM_MCPServerTable,
|
||||
*,
|
||||
credentials_are_encrypted: bool = True,
|
||||
env_vars_are_encrypted: bool | None = None,
|
||||
register_oauth_discovery: bool = True,
|
||||
) -> MCPServer:
|
||||
self.built.append((mcp_server.server_id, register_oauth_discovery))
|
||||
return MCPServer(
|
||||
server_id=mcp_server.server_id, name=mcp_server.server_id, url=mcp_server.url, transport=MCPTransport.http
|
||||
)
|
||||
|
||||
|
||||
def _row(server_id: str, auth_type: MCPAuth, approval_status: str | None = None) -> LiteLLM_MCPServerTable:
|
||||
return LiteLLM_MCPServerTable(
|
||||
server_id=server_id,
|
||||
alias=server_id,
|
||||
url=f"https://{server_id}.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=auth_type,
|
||||
approval_status=approval_status,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_exchange_servers_reads_config_and_db_id_jag_rows_not_the_registry():
|
||||
"""A server added on another pod is in the DB before this pod's registry reloads, so the login
|
||||
exchange has to judge against the DB rows, like the retention gate, and only the ID-JAG ones."""
|
||||
config_server = MCPServer(
|
||||
server_id="from-config", name="from-config", url="https://cfg.example.com/mcp", transport=MCPTransport.http
|
||||
)
|
||||
catalog = _FakeCatalog({"from-config": config_server})
|
||||
|
||||
servers = await login_exchange_servers(
|
||||
catalog,
|
||||
[
|
||||
_row("db-id-jag", MCPAuth.oauth2_id_jag),
|
||||
_row("db-id-jag-approved", MCPAuth.oauth2_id_jag, "approved"),
|
||||
_row("db-oauth", MCPAuth.oauth2),
|
||||
],
|
||||
)
|
||||
|
||||
assert [server.server_id for server in servers] == ["from-config", "db-id-jag", "db-id-jag-approved"]
|
||||
assert catalog.built == [("db-id-jag", False), ("db-id-jag-approved", False)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_db_row_hides_the_config_server_with_the_same_id_like_the_live_catalog():
|
||||
"""The catalog serves ``config_mcp_servers | registry``, so a DB row wins its server_id. The login
|
||||
exchange must mint for that live server, not for the hidden config entry's IdP client."""
|
||||
hidden = MCPServer(
|
||||
server_id="shared", name="shared", url="https://cfg.example.com/mcp", transport=MCPTransport.http
|
||||
)
|
||||
catalog = _FakeCatalog({"shared": hidden})
|
||||
|
||||
servers = await login_exchange_servers(catalog, [_row("shared", MCPAuth.oauth2_id_jag)])
|
||||
|
||||
assert [(server.server_id, server.url) for server in servers] == [("shared", "https://shared.example.com/mcp")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("approval_status", ["pending_review", "rejected", "draft"])
|
||||
async def test_login_exchange_servers_skip_unapproved_submissions(approval_status: str):
|
||||
"""A team key can submit an ID-JAG server naming any token endpoint. Until an admin approves it,
|
||||
no login assertion is posted there."""
|
||||
catalog = _FakeCatalog({})
|
||||
|
||||
servers = await login_exchange_servers(catalog, [_row("submitted", MCPAuth.oauth2_id_jag, approval_status)])
|
||||
|
||||
assert servers == ()
|
||||
assert catalog.built == []
|
||||
|
||||
|
||||
class _StalledEndpoint(_Endpoint):
|
||||
"""An IdP that accepts the connection and never answers."""
|
||||
|
||||
async def fetch(
|
||||
self, endpoint: str, client_id: str, grant_params: dict[str, str], client_auth: ClientAuth
|
||||
) -> Result[ExchangedToken, CredError]:
|
||||
await asyncio.sleep(0.5)
|
||||
return await super().fetch(endpoint, client_id, grant_params, client_auth)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stalled_idp_does_not_hold_up_the_login(caplog: pytest.LogCaptureFixture):
|
||||
"""The exchange is optional. A user who only wants the dashboard must not wait on an MCP IdP that
|
||||
stopped answering, so the exchange has its own short deadline and the login keeps the plain assertion."""
|
||||
saml = _saml()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
exchanged = await exchange_login_assertion_for_refresh_token(
|
||||
saml,
|
||||
(_config(),),
|
||||
_StalledEndpoint(Ok(ExchangedToken(access_token="rt-1", expires_in=3600))),
|
||||
_NOW,
|
||||
deadline_seconds=0.05,
|
||||
)
|
||||
|
||||
assert exchanged == saml
|
||||
assert any("did not answer the login-time refresh token exchange" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
class _OneBadRowCatalog(_FakeCatalog):
|
||||
async def build_mcp_server_from_table(
|
||||
self,
|
||||
mcp_server: LiteLLM_MCPServerTable,
|
||||
*,
|
||||
credentials_are_encrypted: bool = True,
|
||||
env_vars_are_encrypted: bool | None = None,
|
||||
register_oauth_discovery: bool = True,
|
||||
) -> MCPServer:
|
||||
if mcp_server.server_id == "broken":
|
||||
raise ValueError("credentials cannot be decrypted")
|
||||
return await super().build_mcp_server_from_table(
|
||||
mcp_server,
|
||||
credentials_are_encrypted=credentials_are_encrypted,
|
||||
env_vars_are_encrypted=env_vars_are_encrypted,
|
||||
register_oauth_discovery=register_oauth_discovery,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_unbuildable_db_row_does_not_cost_the_other_servers_their_exchange(caplog: pytest.LogCaptureFixture):
|
||||
catalog = _OneBadRowCatalog({})
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
servers = await login_exchange_servers(
|
||||
catalog, [_row("broken", MCPAuth.oauth2_id_jag), _row("healthy", MCPAuth.oauth2_id_jag)]
|
||||
)
|
||||
|
||||
assert [server.server_id for server in servers] == ["healthy"]
|
||||
assert any("server_id=broken" in r.getMessage() for r in caplog.records)
|
||||
|
|
@ -32,6 +32,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import (
|
|||
Error,
|
||||
IdJagConfig,
|
||||
IdJagResourceClient,
|
||||
IdJagSubjectSource,
|
||||
NoneConfig,
|
||||
NoOpAuth,
|
||||
Ok,
|
||||
|
|
@ -55,8 +56,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_r
|
|||
SSOClientConfig,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
SAML2_SUBJECT_TOKEN_TYPE,
|
||||
AssertionStoreUnavailable,
|
||||
SSOIdentityAssertion,
|
||||
assertion_from_saml_login,
|
||||
with_login_refresh_token,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import (
|
||||
ExchangedToken,
|
||||
|
|
@ -111,6 +115,10 @@ class _FakeAssertionStore:
|
|||
self._assertions = dict(assertions or {})
|
||||
self.lookups: list[str] = []
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
self.lookups.append(user_id)
|
||||
return self._assertions.get(user_id)
|
||||
|
|
@ -539,12 +547,8 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer():
|
|||
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"
|
||||
)
|
||||
assert (
|
||||
leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag"
|
||||
)
|
||||
assert leg1_params["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
assert leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag"
|
||||
assert leg1_params["subject_token"] == "user-id-token"
|
||||
|
||||
leg2_endpoint, leg2_client_id, leg2_params = endpoint.calls[1]
|
||||
|
|
@ -563,9 +567,7 @@ async def test_id_jag_without_inbound_token_or_stored_assertion_is_precondition_
|
|||
endpoint = _FakeTokenEndpoint([])
|
||||
store = _FakeAssertionStore()
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
|
|
@ -581,9 +583,7 @@ async def test_id_jag_exchanges_the_stored_sso_assertion_when_the_caller_present
|
|||
store = _FakeAssertionStore({"alice": _assertion("alice-id-token")})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
assert _emitted(result.ok)["Authorization"] == "Bearer final-access"
|
||||
|
|
@ -593,6 +593,54 @@ async def test_id_jag_exchanges_the_stored_sso_assertion_when_the_caller_present
|
|||
assert leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_declares_a_stored_saml_assertion_as_a_saml2_subject_token():
|
||||
"""A SAML login retains the signed assertion, not an id_token, so leg 1 has to tell the IdP what
|
||||
it is being handed even though the server is configured with the id_token default."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) + 300, "https://idp.example.com")
|
||||
store = _FakeAssertionStore({"alice": saml})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
_, _, leg1_params = endpoint.calls[0]
|
||||
assert leg1_params["subject_token"] == saml.id_token.get_secret_value()
|
||||
assert leg1_params["subject_token_type"] == SAML2_SUBJECT_TOKEN_TYPE
|
||||
assert leg1_params["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
assert leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_sends_the_configured_type_for_a_stored_oidc_assertion():
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
store = _FakeAssertionStore({"alice": _assertion("alice-id-token")})
|
||||
config = _id_jag_config().model_copy(update={"subject_token_type": "urn:ietf:params:oauth:token-type:jwt"})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(config))
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
_, _, leg1_params = endpoint.calls[0]
|
||||
assert leg1_params["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_refuses_a_saml_assertion_past_its_not_on_or_after_without_calling_the_idp():
|
||||
endpoint = _FakeTokenEndpoint([])
|
||||
saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) - 1, "https://idp.example.com")
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_FakeAssertionStore({"alice": saml})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
assert endpoint.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_prefers_the_callers_own_token_over_the_stored_assertion():
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
|
|
@ -613,9 +661,7 @@ async def test_id_jag_refuses_an_expired_stored_assertion_without_calling_the_id
|
|||
store = _FakeAssertionStore({"alice": _assertion("stale-id-token", expires_in=-timedelta(seconds=1))})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
|
|
@ -646,6 +692,9 @@ async def test_id_jag_renews_an_expired_stored_assertion_instead_of_challenging(
|
|||
rows[user_id] = assertion
|
||||
|
||||
class _Inner:
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return await _read(user_id)
|
||||
|
||||
|
|
@ -672,9 +721,7 @@ async def test_id_jag_renews_an_expired_stored_assertion_instead_of_challenging(
|
|||
),
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
_, _, leg1_params = endpoint.calls[0]
|
||||
|
|
@ -686,7 +733,7 @@ def test_the_resolver_defaults_to_the_renewing_assertion_store():
|
|||
the plain database reader would strand every agent an id_token lifetime after its user's login."""
|
||||
provider = UpstreamCredentialProvider()
|
||||
|
||||
assert isinstance(provider._sso_assertion_store, RefreshingSSOAssertionStore) # noqa: SLF001 # the wiring is the assertion
|
||||
assert isinstance(provider._sso_assertion_store, RefreshingSSOAssertionStore) # the wiring is the assertion
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -695,9 +742,7 @@ async def test_id_jag_accepts_a_stored_assertion_that_declares_no_expiry():
|
|||
store = _FakeAssertionStore({"alice": _assertion("undated-id-token", expires_in=None)})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
_, _, leg1_params = endpoint.calls[0]
|
||||
|
|
@ -730,14 +775,10 @@ async def test_id_jag_keeps_store_sourced_bearers_partitioned_per_user():
|
|||
Ok(ExchangedToken(access_token="bob-bearer", expires_in=3600)),
|
||||
]
|
||||
)
|
||||
store = _FakeAssertionStore(
|
||||
{"alice": _assertion("alice-id-token"), "bob": _assertion("bob-id-token")}
|
||||
)
|
||||
store = _FakeAssertionStore({"alice": _assertion("alice-id-token"), "bob": _assertion("bob-id-token")})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
alice = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
alice = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
bob = await provider.resolve_credentials(Subject(tenant_id="", subject_id="bob"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(alice, Ok) and isinstance(bob, Ok)
|
||||
|
|
@ -754,6 +795,10 @@ class _OutageAssertionStore:
|
|||
def __init__(self) -> None:
|
||||
self.lookups: list[str] = []
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
self.lookups.append(user_id)
|
||||
raise AssertionStoreUnavailable(_DRIVER_DETAIL)
|
||||
|
|
@ -766,9 +811,7 @@ async def test_id_jag_maps_an_assertion_store_outage_to_upstream_unavailable():
|
|||
endpoint = _FakeTokenEndpoint([])
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=_OutageAssertionStore())
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "upstream_unavailable"
|
||||
|
|
@ -784,9 +827,7 @@ async def test_id_jag_store_outage_does_not_leak_driver_detail_to_the_caller(cap
|
|||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert _DRIVER_DETAIL not in result.error.summary
|
||||
|
|
@ -803,9 +844,7 @@ async def test_id_jag_invalidation_survives_an_assertion_store_outage():
|
|||
token_endpoint=_FakeTokenEndpoint([]), sso_assertion_store=_OutageAssertionStore()
|
||||
)
|
||||
|
||||
await provider.invalidate_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
await provider.invalidate_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
|
||||
class _FlakyAssertionStore:
|
||||
|
|
@ -815,6 +854,10 @@ class _FlakyAssertionStore:
|
|||
self._assertion = assertion
|
||||
self.down = False
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
if self.down:
|
||||
raise AssertionStoreUnavailable("connection refused")
|
||||
|
|
@ -855,6 +898,10 @@ class _SwitchableAssertionStore:
|
|||
def __init__(self, id_token: str) -> None:
|
||||
self.id_token = id_token
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return _assertion(self.id_token)
|
||||
|
||||
|
|
@ -899,6 +946,10 @@ class _SequentialAssertionStore:
|
|||
self.issued: list[str] = []
|
||||
self._n = 0
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
|
||||
return await self.fetch(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
await asyncio.sleep(0)
|
||||
if self.pinned is not None:
|
||||
|
|
@ -996,14 +1047,10 @@ async def test_id_jag_reads_a_naive_stored_expiry_as_utc():
|
|||
comparison would be a TypeError on the egress path, turning a 412 into a 500."""
|
||||
endpoint = _FakeTokenEndpoint([])
|
||||
naive_past = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
|
||||
store = _FakeAssertionStore(
|
||||
{"alice": SSOIdentityAssertion(id_token=SecretStr("stale"), expires_at=naive_past)}
|
||||
)
|
||||
store = _FakeAssertionStore({"alice": SSOIdentityAssertion(id_token=SecretStr("stale"), expires_at=naive_past)})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
|
|
@ -1032,13 +1079,9 @@ async def test_invalidate_evicts_a_store_sourced_id_jag_bearer():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_propagates_a_leg1_error_without_calling_leg2():
|
||||
endpoint = _FakeTokenEndpoint(
|
||||
[Error(CredError.of_upstream_unavailable("leg1 down"))]
|
||||
)
|
||||
endpoint = _FakeTokenEndpoint([Error(CredError.of_upstream_unavailable("leg1 down"))])
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint)
|
||||
result = await provider.resolve_credentials(
|
||||
_with_inbound("user-id-token"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "upstream_unavailable"
|
||||
|
|
@ -1055,9 +1098,7 @@ async def test_id_jag_propagates_a_leg2_error():
|
|||
]
|
||||
)
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint)
|
||||
result = await provider.resolve_credentials(
|
||||
_with_inbound("user-id-token"), _spec(_id_jag_config())
|
||||
)
|
||||
result = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(_id_jag_config()))
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "upstream_unavailable"
|
||||
|
|
@ -1065,7 +1106,7 @@ async def test_id_jag_propagates_a_leg2_error():
|
|||
assert len(endpoint.calls) == 2
|
||||
|
||||
|
||||
def _two_leg_ok(bearer: str) -> list:
|
||||
def _two_leg_ok(bearer: str) -> list[Result[ExchangedToken, CredError]]:
|
||||
return [
|
||||
Ok(ExchangedToken(access_token="the-id-jag", expires_in=300)),
|
||||
Ok(ExchangedToken(access_token=bearer, expires_in=3600)),
|
||||
|
|
@ -1256,8 +1297,18 @@ async def test_passthrough_ignores_the_carrier_and_keeps_the_callers_slot():
|
|||
(NoneConfig(), _SUBJECT, "no-auth", None),
|
||||
(PassthroughConfig(), _SUBJECT, "no-auth", None),
|
||||
(PassthroughConfig(), _with_inbound("Bearer caller-token"), "oauth2-passthrough", "Bearer caller-token"),
|
||||
(ApiKeyConfig(key_source=SharedKey(value=SecretStr("static-key"))), _SUBJECT, "static-token", "Bearer static-key"),
|
||||
(AuthorizationCodeConfig(), Subject(tenant_id="", subject_id="alice"), "stored-user-token", "Bearer stored-alice"),
|
||||
(
|
||||
ApiKeyConfig(key_source=SharedKey(value=SecretStr("static-key"))),
|
||||
_SUBJECT,
|
||||
"static-token",
|
||||
"Bearer static-key",
|
||||
),
|
||||
(
|
||||
AuthorizationCodeConfig(),
|
||||
Subject(tenant_id="", subject_id="alice"),
|
||||
"stored-user-token",
|
||||
"Bearer stored-alice",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_resolved_source_matches_the_credential_sent_upstream(
|
||||
|
|
@ -1279,7 +1330,9 @@ async def test_resolved_source_matches_the_credential_sent_upstream(
|
|||
async def test_resolved_source_preserves_missing_user_token_error() -> None:
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.resolver import resolve_credentials_with_source
|
||||
|
||||
result = await resolve_credentials_with_source(UpstreamCredentialProvider(), _SUBJECT, _spec(AuthorizationCodeConfig()))
|
||||
result = await resolve_credentials_with_source(
|
||||
UpstreamCredentialProvider(), _SUBJECT, _spec(AuthorizationCodeConfig())
|
||||
)
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "unauthorized"
|
||||
|
||||
|
|
@ -1289,7 +1342,9 @@ async def test_minted_token_sources_match_egress_and_do_not_fetch_twice() -> Non
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.resolver import resolve_credentials_with_source
|
||||
|
||||
source = _FakeM2MSource(Ok(OAuthToken(access_token="m2m-at")))
|
||||
m2m = await resolve_credentials_with_source(UpstreamCredentialProvider(client_credentials_source=source), _SUBJECT, _spec(_M2M))
|
||||
m2m = await resolve_credentials_with_source(
|
||||
UpstreamCredentialProvider(client_credentials_source=source), _SUBJECT, _spec(_M2M)
|
||||
)
|
||||
assert isinstance(m2m, Ok)
|
||||
headers, _ = await _emitted_async(m2m.ok.auth)
|
||||
assert headers["Authorization"] == "Bearer m2m-at"
|
||||
|
|
@ -1297,7 +1352,9 @@ async def test_minted_token_sources_match_egress_and_do_not_fetch_twice() -> Non
|
|||
assert source.gets == ["s"]
|
||||
|
||||
exchanger = _FakeExchanger(Ok(OAuthToken(access_token="exchanged-at")))
|
||||
exchanged = await resolve_credentials_with_source(UpstreamCredentialProvider(token_exchanger=exchanger), _with_inbound("subject"), _spec(_OBO))
|
||||
exchanged = await resolve_credentials_with_source(
|
||||
UpstreamCredentialProvider(token_exchanger=exchanger), _with_inbound("subject"), _spec(_OBO)
|
||||
)
|
||||
assert isinstance(exchanged, Ok)
|
||||
assert _emitted(exchanged.ok.auth)["Authorization"] == "Bearer exchanged-at"
|
||||
assert exchanged.ok.source.value == "token-exchange"
|
||||
|
|
@ -1321,9 +1378,14 @@ async def test_id_jag_source_describes_final_token_after_both_exchanges() -> Non
|
|||
async def test_client_credentials_subscription_identity_preserves_resolved_grant_scopes():
|
||||
async def resolve(token):
|
||||
source = _FakeM2MSource(Ok(token))
|
||||
result = await UpstreamCredentialProvider(client_credentials_source=source).resolve_credentials(_SUBJECT, _spec(_M2M))
|
||||
result = await UpstreamCredentialProvider(client_credentials_source=source).resolve_credentials(
|
||||
_SUBJECT, _spec(_M2M)
|
||||
)
|
||||
assert isinstance(result, Ok)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import ClientCredentialsBearerAuth
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
|
||||
ClientCredentialsBearerAuth,
|
||||
)
|
||||
|
||||
assert isinstance(result.ok, ClientCredentialsBearerAuth)
|
||||
return result.ok.subscription_identity
|
||||
|
||||
|
|
@ -1333,3 +1395,175 @@ async def test_client_credentials_subscription_identity_preserves_resolved_grant
|
|||
assert original is not None
|
||||
assert original == renewed
|
||||
assert original != reduced
|
||||
|
||||
|
||||
def _login_with_refresh_token(expires_in: int | None) -> SSOIdentityAssertion:
|
||||
saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) + 300, "https://idp.example.com")
|
||||
return with_login_refresh_token(
|
||||
saml,
|
||||
"rt-from-login",
|
||||
expires_in,
|
||||
datetime.now(timezone.utc),
|
||||
endpoint="https://idp.example.com/token",
|
||||
client_id="litellm",
|
||||
)
|
||||
|
||||
|
||||
def _refresh_token_source_config() -> IdJagConfig:
|
||||
return _id_jag_config().model_copy(update={"subject_source": IdJagSubjectSource.refresh_token})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_presents_the_login_refresh_token_when_the_server_asks_for_it():
|
||||
"""``id_jag_subject_source: refresh_token`` presents the refresh token the login assertion was
|
||||
exchanged for, typed as one, never the SAML assertion itself."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
store = _FakeAssertionStore({"alice": _login_with_refresh_token(3600)})
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
_, _, leg1_params = endpoint.calls[0]
|
||||
assert leg1_params["subject_token"] == "rt-from-login"
|
||||
assert leg1_params["subject_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token"
|
||||
assert leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_refresh_token_source_never_presents_the_sso_refresh_token():
|
||||
"""An OIDC login holds a refresh token for the SSO client. Only the token the ID-JAG client
|
||||
exchanged at login is presented, the SSO one cannot be redeemed there."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
oidc = SSOIdentityAssertion(id_token=SecretStr("id-token"), refresh_token=SecretStr("sso-rt"))
|
||||
row = with_login_refresh_token(
|
||||
oidc,
|
||||
"rt-from-login",
|
||||
3600,
|
||||
datetime.now(timezone.utc),
|
||||
endpoint="https://idp.example.com/token",
|
||||
client_id="litellm",
|
||||
)
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_FakeAssertionStore({"alice": row})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
assert endpoint.calls[0][2]["subject_token"] == "rt-from-login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_refresh_token_source_refuses_a_token_minted_for_another_idp_client():
|
||||
"""A refresh token is bound to the client that received it. One minted for another server's IdP
|
||||
client is never posted to this server's endpoint, the user is told to sign in again instead."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
row = with_login_refresh_token(
|
||||
_login_with_refresh_token(3600),
|
||||
"rt-for-other",
|
||||
3600,
|
||||
datetime.now(timezone.utc),
|
||||
endpoint="https://other-idp.example.com/token",
|
||||
client_id="other-agent",
|
||||
)
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_FakeAssertionStore({"alice": row})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
assert "different IdP client" in str(result.error)
|
||||
assert endpoint.calls == []
|
||||
|
||||
|
||||
class _RenewalFailsStore(_FakeAssertionStore):
|
||||
"""An SSO renewal that is timing out: ``fetch`` raises the way RefreshingSSOAssertionStore does,
|
||||
while the row itself is still readable."""
|
||||
|
||||
async def fetch_stored(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
return self._assertions.get(user_id)
|
||||
|
||||
async def fetch(self, user_id: str) -> SSOIdentityAssertion | None:
|
||||
raise AssertionStoreUnavailable("IdP token endpoint timed out")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_refresh_token_source_does_not_wait_on_an_sso_renewal():
|
||||
"""The exchanged refresh token has its own lifetime. A failing renewal of the unrelated SSO
|
||||
id_token must not turn into a 503 for a server that never presents that id_token."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_RenewalFailsStore({"alice": _login_with_refresh_token(3600)})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
assert endpoint.calls[0][2]["subject_token"] == "rt-from-login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_id_jag_refresh_token_source_outlives_the_saml_assertion():
|
||||
"""The SAML assertion is spent minutes after login; the refresh token it bought is what counts."""
|
||||
endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access"))
|
||||
expired_saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) - 60, "https://idp.example.com")
|
||||
row = with_login_refresh_token(
|
||||
expired_saml,
|
||||
"rt-from-login",
|
||||
3600,
|
||||
datetime.now(timezone.utc),
|
||||
endpoint="https://idp.example.com/token",
|
||||
client_id="litellm",
|
||||
)
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_FakeAssertionStore({"alice": row})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Ok)
|
||||
assert endpoint.calls[0][2]["subject_token"] == "rt-from-login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("row", "expected"),
|
||||
[
|
||||
(
|
||||
assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) + 300, "https://idp.example.com"),
|
||||
"none is stored",
|
||||
),
|
||||
(_login_with_refresh_token(-1), "has expired"),
|
||||
],
|
||||
ids=["no refresh token", "expired refresh token"],
|
||||
)
|
||||
async def test_id_jag_refresh_token_source_is_a_precondition_without_a_live_refresh_token(
|
||||
row: SSOIdentityAssertion, expected: str
|
||||
):
|
||||
endpoint = _FakeTokenEndpoint([])
|
||||
provider = UpstreamCredentialProvider(
|
||||
token_endpoint=endpoint, sso_assertion_store=_FakeAssertionStore({"alice": row})
|
||||
)
|
||||
|
||||
result = await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"), _spec(_refresh_token_source_config())
|
||||
)
|
||||
|
||||
assert isinstance(result, Error)
|
||||
assert result.error.tag == "precondition_required"
|
||||
assert expected in result.error.summary
|
||||
assert "Sign in through LiteLLM SSO" in result.error.summary
|
||||
assert endpoint.calls == []
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_r
|
|||
sso_client_config,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
with_login_refresh_token,
|
||||
AssertionStoreUnavailable,
|
||||
SSOIdentityAssertion,
|
||||
assertion_from_saml_login,
|
||||
)
|
||||
|
||||
SIGNING_KEY = "test-idp-signing-key-32-bytes-long-xxxx"
|
||||
|
|
@ -318,6 +320,53 @@ async def test_a_missing_refresh_token_names_the_scope_the_operator_has_to_set(c
|
|||
assert "offline_access" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_expired_saml_assertion_is_never_redeemed_and_names_the_re_login(caplog: pytest.LogCaptureFixture):
|
||||
"""SAML has no refresh grant. Even a row that somehow carries a refresh token must not be posted
|
||||
to the generic OIDC client, and the operator hint about GENERIC_SCOPE does not apply."""
|
||||
saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) - 1, "https://idp.example.com")
|
||||
with_refresh_token = saml.model_copy(update={"refresh_token": SecretStr("rt_saml")})
|
||||
rows = _FakeRows({"alice": with_refresh_token})
|
||||
transport = _FakeTransport(_renewal(_id_token()))
|
||||
|
||||
served = await _store(rows, transport).fetch("alice")
|
||||
refresher = SSOAssertionRefresher(transport, client_config=lambda: _CLIENT, read=rows.fetch, write=rows.write)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
outcome = await refresher.refresh("alice", with_refresh_token)
|
||||
|
||||
assert served == with_refresh_token
|
||||
assert transport.calls == []
|
||||
assert rows.writes == []
|
||||
match outcome:
|
||||
case Error(failure):
|
||||
assert failure.kind == "rejected"
|
||||
assert "sign in" in failure.detail
|
||||
case Ok(_):
|
||||
pytest.fail("a SAML assertion must not be renewable")
|
||||
assert "sign in" in caplog.text
|
||||
assert "GENERIC_SCOPE" not in caplog.text
|
||||
|
||||
|
||||
class _ForbiddenCoordinator:
|
||||
async def run(self, user_id: str, key: str, *, refresh: object, reread: object) -> None:
|
||||
raise AssertionError(f"a SAML assertion must never enter the refresh path (user_id={user_id}, key={key})")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_saml_assertion_inside_the_expiry_skew_is_served_as_is_without_electing_a_renewal():
|
||||
"""SAML cannot be renewed, so a still-valid assertion near its NotOnOrAfter is handed out untouched
|
||||
instead of running the single-flight election that would 503 the losers."""
|
||||
saml = assertion_from_saml_login(b"<saml:Assertion/>", int(time.time()) + 30, "https://idp.example.com")
|
||||
rows = _FakeRows({"alice": saml})
|
||||
transport = _FakeTransport(_renewal(_id_token()))
|
||||
|
||||
served = await _store(rows, transport, coordinator_factory=_ForbiddenCoordinator).fetch("alice")
|
||||
|
||||
assert served == saml
|
||||
assert transport.calls == []
|
||||
assert rows.writes == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unconfigured_sso_client_never_calls_the_idp(caplog):
|
||||
rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)})
|
||||
|
|
@ -853,3 +902,43 @@ async def test_a_successful_grant_is_handed_back_as_the_parsed_body():
|
|||
|
||||
assert isinstance(outcome, Ok)
|
||||
assert outcome.ok["id_token"] == "idt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_renewal_keeps_the_refresh_token_exchanged_at_login_in_the_stored_row():
|
||||
"""Renewal replaces the id_token and the SSO refresh token. The refresh token the ID-JAG client
|
||||
exchanged at login is unrelated to either and must still be in the row the next MCP call reads."""
|
||||
stale, fresh = _id_token(exp_offset=-1), _id_token()
|
||||
row = with_login_refresh_token(
|
||||
_stored(stale, expires_in=-1),
|
||||
"rt-from-login",
|
||||
3600,
|
||||
datetime.now(timezone.utc),
|
||||
endpoint="https://idp.example.com/token",
|
||||
client_id="agent",
|
||||
)
|
||||
rows = _FakeRows({"alice": row})
|
||||
|
||||
served = await _store(rows, _FakeTransport(_renewal(fresh, "rt_2"))).fetch("alice")
|
||||
|
||||
stored = rows.rows["alice"]
|
||||
assert stored.id_token.get_secret_value() == fresh
|
||||
assert stored.refresh_token is not None and stored.refresh_token.get_secret_value() == "rt_2"
|
||||
assert stored.exchanged_refresh_token is not None
|
||||
assert stored.exchanged_refresh_token.get_secret_value() == "rt-from-login"
|
||||
assert stored.exchanged_refresh_token_client_id == "agent"
|
||||
assert served is not None and served.exchanged_refresh_token is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_stored_hands_back_the_row_without_renewing_it():
|
||||
"""The read a refresh-token server uses: the row as stored, no IdP round trip even when the SSO
|
||||
id_token is about to expire."""
|
||||
rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)})
|
||||
transport = _FakeTransport(_renewal(_id_token()))
|
||||
|
||||
served = await _store(rows, transport).fetch_stored("alice")
|
||||
|
||||
assert served is not None
|
||||
assert served.id_token.get_secret_value() == rows.rows["alice"].id_token.get_secret_value()
|
||||
assert transport.calls == []
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ rotation re-encrypts stored rows like the sibling per-user credential tables.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt as pyjwt
|
||||
|
|
@ -18,15 +20,20 @@ import pytest
|
|||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
_ASSERTION_CACHE,
|
||||
SAML2_SUBJECT_TOKEN_TYPE,
|
||||
AssertionStoreUnavailable,
|
||||
DbSSOAssertionStore,
|
||||
SSOAssertionCache,
|
||||
assertion_from_saml_login,
|
||||
assertion_from_sso_login,
|
||||
carry_exchanged_refresh_token,
|
||||
ema_assertion_retention_enabled,
|
||||
fetch_sso_identity_assertion,
|
||||
login_refresh_token_expired,
|
||||
persist_sso_identity_assertion,
|
||||
retain_sso_identity_assertion_for_ema,
|
||||
rotate_sso_identity_assertions_master_key,
|
||||
with_login_refresh_token,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, encrypt_value_helper
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
|
@ -111,6 +118,41 @@ def test_assertion_from_sso_login_happy_path():
|
|||
assert assertion.expires_at.timestamp() == pytest.approx(time.time() + 3600, abs=5)
|
||||
|
||||
|
||||
SAML_ASSERTION_XML = (
|
||||
b'<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_a1" Version="2.0">'
|
||||
b"<saml:Issuer>https://idp.example.com/metadata</saml:Issuer></saml:Assertion>"
|
||||
)
|
||||
|
||||
|
||||
def _saml_subject_bytes(encoded: str) -> bytes:
|
||||
assert set(encoded) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), encoded[:40]
|
||||
return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
|
||||
|
||||
def test_assertion_from_saml_login_carries_the_base64url_assertion_as_a_saml2_subject_token():
|
||||
not_on_or_after = int(time.time()) + 300
|
||||
assertion = assertion_from_saml_login(SAML_ASSERTION_XML, not_on_or_after, "https://idp.example.com/metadata")
|
||||
assert _saml_subject_bytes(assertion.id_token.get_secret_value()) == SAML_ASSERTION_XML
|
||||
assert assertion.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
assert assertion.refresh_token is None
|
||||
assert assertion.issuer == "https://idp.example.com/metadata"
|
||||
assert assertion.expires_at is not None
|
||||
assert assertion.expires_at.tzinfo is not None
|
||||
assert assertion.expires_at.timestamp() == not_on_or_after
|
||||
|
||||
|
||||
def test_assertion_from_saml_login_without_not_on_or_after_has_no_expiry():
|
||||
assertion = assertion_from_saml_login(SAML_ASSERTION_XML, None, None)
|
||||
assert assertion.expires_at is None
|
||||
assert assertion.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
|
||||
|
||||
def test_oidc_login_assertion_leaves_the_subject_token_type_to_the_operator():
|
||||
assertion = assertion_from_sso_login(_make_id_token(), None)
|
||||
assert assertion is not None
|
||||
assert assertion.subject_token_type is None
|
||||
|
||||
|
||||
def test_assertion_repr_never_leaks_token_material():
|
||||
token = _make_id_token()
|
||||
assertion = assertion_from_sso_login(token, "rt_secret_value")
|
||||
|
|
@ -226,6 +268,37 @@ async def test_persist_and_fetch_round_trip_encrypted_at_rest():
|
|||
assert json.loads(decrypted)["id_token"] == token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saml_assertion_round_trips_with_its_subject_token_type():
|
||||
stored: dict[str, str] = {}
|
||||
prisma = _make_prisma(stored)
|
||||
assertion = assertion_from_saml_login(SAML_ASSERTION_XML, int(time.time()) + 300, "https://idp.example.com")
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
await persist_sso_identity_assertion("user-saml", assertion)
|
||||
fetched = await fetch_sso_identity_assertion("user-saml")
|
||||
assert fetched is not None
|
||||
assert fetched.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
assert _saml_subject_bytes(fetched.id_token.get_secret_value()) == SAML_ASSERTION_XML
|
||||
assert fetched.expires_at == assertion.expires_at
|
||||
assert fetched.refresh_token is None
|
||||
assert SAML_ASSERTION_XML.decode() not in stored["user-saml"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oidc_row_written_before_subject_token_types_reads_back_without_one():
|
||||
stored: dict[str, str] = {}
|
||||
prisma = _make_prisma(stored)
|
||||
token = _make_id_token()
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(token, None))
|
||||
fetched = await fetch_sso_identity_assertion("user-a")
|
||||
assert fetched is not None
|
||||
assert fetched.subject_token_type is None
|
||||
decrypted = decrypt_value_helper(stored["user-a"], "test", exception_type="debug")
|
||||
assert decrypted is not None
|
||||
assert "subject_token_type" not in json.loads(decrypted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_overwrites_previous_login():
|
||||
stored = {}
|
||||
|
|
@ -248,7 +321,9 @@ async def test_fetch_serves_second_read_from_cache_without_db_read():
|
|||
cache = SSOAssertionCache()
|
||||
token = _make_id_token()
|
||||
assertion = assertion_from_sso_login(token, "rt_1")
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
await persist_sso_identity_assertion("user-a", assertion, cache=cache)
|
||||
first = await fetch_sso_identity_assertion("user-a", cache=cache)
|
||||
second = await fetch_sso_identity_assertion("user-a", cache=cache)
|
||||
|
|
@ -266,7 +341,9 @@ async def test_persist_busts_cache_so_relogin_is_visible_immediately():
|
|||
cache = SSOAssertionCache()
|
||||
first_token = _make_id_token(exp_offset=100)
|
||||
second_token = _make_id_token(exp_offset=7200)
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache)
|
||||
first = await fetch_sso_identity_assertion("user-a", cache=cache)
|
||||
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(second_token, "rt_new"), cache=cache)
|
||||
|
|
@ -295,7 +372,9 @@ async def test_fetch_racing_a_relogin_does_not_cache_the_previous_assertion():
|
|||
return row
|
||||
|
||||
prisma.db.litellm_ssoidentityassertion.find_unique = _paused_find_unique
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache)
|
||||
racing_fetch = asyncio.create_task(fetch_sso_identity_assertion("user-a", cache=cache))
|
||||
await db_read_started.wait()
|
||||
|
|
@ -324,7 +403,9 @@ async def test_fetch_missing_row_returns_none():
|
|||
async def test_fetch_does_not_cache_a_missing_row():
|
||||
prisma = _make_prisma({})
|
||||
cache = SSOAssertionCache()
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
first = await fetch_sso_identity_assertion("nobody", cache=cache)
|
||||
second = await fetch_sso_identity_assertion("nobody", cache=cache)
|
||||
assert first is None
|
||||
|
|
@ -353,7 +434,9 @@ async def test_cached_assertion_expires_after_ttl():
|
|||
cache = SSOAssertionCache(ttl_seconds=1)
|
||||
first_token = _make_id_token(exp_offset=100)
|
||||
second_token = _make_id_token(exp_offset=7200)
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache)
|
||||
first = await fetch_sso_identity_assertion("user-a", cache=cache)
|
||||
await persist_sso_identity_assertion(
|
||||
|
|
@ -477,7 +560,9 @@ async def test_db_store_uses_injected_cache():
|
|||
prisma = _make_prisma(stored)
|
||||
cache = SSOAssertionCache()
|
||||
token = _make_id_token()
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
): # test-quality-ok: fake DB seam has no injection boundary
|
||||
await persist_sso_identity_assertion("alice", assertion_from_sso_login(token, None), cache=cache)
|
||||
store = DbSSOAssertionStore(cache=cache)
|
||||
first = await store.fetch("alice")
|
||||
|
|
@ -496,3 +581,58 @@ async def test_db_store_returns_none_for_a_user_with_no_stored_assertion():
|
|||
with patch.dict(os.environ, {"LITELLM_SALT_KEY": SALT_KEY}):
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", _make_prisma({})):
|
||||
assert await DbSSOAssertionStore().fetch("nobody") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_refresh_token_round_trips_beside_the_saml_assertion():
|
||||
"""A SAML login exchanged for a refresh token keeps the assertion, its type and its expiry, and adds
|
||||
the refresh token with the lifetime the IdP gave it, all encrypted at rest."""
|
||||
stored: dict[str, str] = {}
|
||||
prisma = _make_prisma(stored)
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
saml = assertion_from_saml_login(SAML_ASSERTION_XML, int(time.time()) + 300, "https://idp.example.com")
|
||||
assertion = with_login_refresh_token(
|
||||
saml, "rt-from-login", 3600, now, endpoint="https://idp.example.com/token", client_id="agent"
|
||||
)
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
await persist_sso_identity_assertion("user-rt", assertion)
|
||||
fetched = await fetch_sso_identity_assertion("user-rt")
|
||||
assert fetched is not None
|
||||
assert fetched.exchanged_refresh_token is not None
|
||||
assert fetched.exchanged_refresh_token.get_secret_value() == "rt-from-login"
|
||||
assert fetched.exchanged_refresh_token_expires_at == now + timedelta(seconds=3600)
|
||||
assert fetched.exchanged_refresh_token_endpoint == "https://idp.example.com/token"
|
||||
assert fetched.exchanged_refresh_token_client_id == "agent"
|
||||
assert fetched.refresh_token is None
|
||||
assert fetched.id_token == saml.id_token
|
||||
assert fetched.expires_at == saml.expires_at
|
||||
assert fetched.subject_token_type == SAML2_SUBJECT_TOKEN_TYPE
|
||||
assert "rt-from-login" not in stored["user-rt"]
|
||||
assert not login_refresh_token_expired(fetched, now)
|
||||
assert login_refresh_token_expired(fetched, now + timedelta(seconds=3600))
|
||||
|
||||
|
||||
def test_renewal_carries_the_exchanged_refresh_token_forward():
|
||||
"""An OIDC renewal rebuilds the row from the new id_token. The refresh token the ID-JAG client
|
||||
exchanged at login is unrelated to that renewal and must survive it."""
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
previous = with_login_refresh_token(
|
||||
assertion_from_sso_login(_make_id_token(), "sso-rt"),
|
||||
"rt-from-login",
|
||||
3600,
|
||||
now,
|
||||
endpoint="https://idp.example.com/token",
|
||||
client_id="agent",
|
||||
)
|
||||
renewed = assertion_from_sso_login(_make_id_token(7200), "sso-rt-2")
|
||||
assert previous is not None and renewed is not None
|
||||
|
||||
carried = carry_exchanged_refresh_token(previous, renewed)
|
||||
|
||||
assert carried.id_token == renewed.id_token
|
||||
assert carried.refresh_token is not None and carried.refresh_token.get_secret_value() == "sso-rt-2"
|
||||
assert carried.exchanged_refresh_token is not None
|
||||
assert carried.exchanged_refresh_token.get_secret_value() == "rt-from-login"
|
||||
assert carried.exchanged_refresh_token_expires_at == now + timedelta(seconds=3600)
|
||||
assert carried.exchanged_refresh_token_endpoint == "https://idp.example.com/token"
|
||||
assert carried.exchanged_refresh_token_client_id == "agent"
|
||||
|
|
|
|||
|
|
@ -1641,7 +1641,8 @@ class TestMCPServerManager:
|
|||
assert server.oauth2_flow is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_warns_for_id_jag_with_saml_sso(self, monkeypatch, caplog):
|
||||
async def test_load_servers_from_config_is_silent_for_id_jag_with_saml_sso(self, monkeypatch, caplog):
|
||||
"""A SAML login retains the signed assertion as the saml2 subject token, so SAML is not a gap."""
|
||||
self._clear_sso_env(monkeypatch)
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
|
||||
manager = MCPServerManager()
|
||||
|
|
@ -1652,11 +1653,7 @@ class TestMCPServerManager:
|
|||
):
|
||||
await manager.load_servers_from_config(self._id_jag_config())
|
||||
|
||||
warnings = [message for message in caplog.messages if "oauth2_id_jag" in message]
|
||||
assert len(warnings) == 1
|
||||
assert "idjag_server" in warnings[0]
|
||||
assert "saml" in warnings[0]
|
||||
assert "MICROSOFT_CLIENT_ID" in warnings[0]
|
||||
assert [message for message in caplog.messages if "oauth2_id_jag" in message] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("client_id_env", ["GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "GENERIC_CLIENT_ID"])
|
||||
|
|
|
|||
|
|
@ -74,14 +74,11 @@ class TestIdJagAssertionCaptureGap:
|
|||
monkeypatch.setenv(env_var, "configured")
|
||||
assert id_jag_assertion_capture_gap() is None
|
||||
|
||||
def test_saml_is_named_with_the_oidc_remedy(self, monkeypatch):
|
||||
def test_saml_has_no_gap(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""The SAML callback retains the signed assertion as the saml2 subject token, so a SAML
|
||||
deployment is a capturing one and must not be told to switch to an OIDC provider."""
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
|
||||
gap = id_jag_assertion_capture_gap()
|
||||
assert gap is not None
|
||||
assert "saml" in gap
|
||||
assert "GOOGLE_CLIENT_ID" in gap
|
||||
assert "MICROSOFT_CLIENT_ID" in gap
|
||||
assert "GENERIC_CLIENT_ID" in gap
|
||||
assert id_jag_assertion_capture_gap() is None
|
||||
|
||||
def test_no_sso_configured_reports_a_gap(self):
|
||||
gap = id_jag_assertion_capture_gap()
|
||||
|
|
@ -100,12 +97,9 @@ class TestIdJagAssertionCaptureGapAtStartup:
|
|||
def test_no_provider_at_startup_is_not_yet_a_gap(self):
|
||||
assert id_jag_assertion_capture_gap_at_startup() is None
|
||||
|
||||
def test_saml_at_startup_reports_the_capture_gap(self, monkeypatch):
|
||||
def test_saml_at_startup_has_no_gap(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
|
||||
startup_gap = id_jag_assertion_capture_gap_at_startup()
|
||||
callback_gap = id_jag_assertion_capture_gap()
|
||||
assert startup_gap is not None
|
||||
assert startup_gap == callback_gap
|
||||
assert id_jag_assertion_capture_gap_at_startup() is None
|
||||
|
||||
@pytest.mark.parametrize("env_var", ["GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "GENERIC_CLIENT_ID"])
|
||||
def test_an_oidc_provider_at_startup_has_no_gap(self, monkeypatch, env_var):
|
||||
|
|
|
|||
|
|
@ -4530,7 +4530,6 @@ class TestIdJagRegistrationWarnsAboutTheSSOGap:
|
|||
@pytest.mark.parametrize(
|
||||
"provider_env, expected_fragment",
|
||||
[
|
||||
({"SAML_IDP_METADATA_URL": "https://idp.example.com/metadata"}, "saml"),
|
||||
({}, "no SSO provider is configured"),
|
||||
],
|
||||
)
|
||||
|
|
@ -4564,7 +4563,8 @@ class TestIdJagRegistrationWarnsAboutTheSSOGap:
|
|||
)
|
||||
|
||||
self._clear_sso_env(monkeypatch)
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
|
||||
for var in ("MICROSOFT_CLIENT_ID", "GOOGLE_CLIENT_ID", "GENERIC_CLIENT_ID", "SAML_IDP_METADATA_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.update_server = AsyncMock()
|
||||
|
|
@ -4601,7 +4601,7 @@ class TestIdJagRegistrationWarnsAboutTheSSOGap:
|
|||
|
||||
warnings = self._id_jag_warnings(caplog)
|
||||
assert len(warnings) == 1
|
||||
assert "saml" in str(warnings[0])
|
||||
assert "no SSO provider is configured" in str(warnings[0])
|
||||
|
||||
|
||||
class TestHealthCheckServers:
|
||||
|
|
|
|||
|
|
@ -9,15 +9,17 @@ makes a test fail.
|
|||
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
pytest.importorskip(
|
||||
"onelogin", reason="python3-saml (saml extra) is required for SAML SSO tests"
|
||||
)
|
||||
pytest.importorskip("onelogin", reason="python3-saml (saml extra) is required for SAML SSO tests")
|
||||
|
||||
from typing import cast
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
|
@ -26,11 +28,10 @@ from cryptography.x509.oid import NameOID
|
|||
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from typing import cast
|
||||
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_cache import RedisCache
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import SSOIdentityAssertion
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.management_endpoints.sso.saml_sso import (
|
||||
_SAML_AUTHN_REQUEST_CACHE_PREFIX,
|
||||
|
|
@ -40,15 +41,17 @@ from litellm.proxy.management_endpoints.sso.saml_sso import (
|
|||
_SAML_REPLAY_GUARD_MAX_TTL_SECONDS,
|
||||
SAMLAuthHandler,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.types import CustomOpenID
|
||||
|
||||
|
||||
def _shared_cache(store=None):
|
||||
def _shared_cache(store: InMemoryCache | None = None) -> DualCache:
|
||||
"""A DualCache whose replay guard is backed by a shared, atomic store.
|
||||
|
||||
An InMemoryCache instance stands in for Redis; passing the same instance to
|
||||
two DualCaches simulates two workers sharing one atomic backend."""
|
||||
return DualCache(redis_cache=cast(RedisCache, store or InMemoryCache()))
|
||||
|
||||
|
||||
IDP_ENTITY = "https://idp.example.com/metadata"
|
||||
SP_ENTITY = "https://proxy.example.com/sso/saml/metadata"
|
||||
ACS = "https://proxy.example.com/sso/saml/callback"
|
||||
|
|
@ -56,7 +59,7 @@ SSO_URL = "https://idp.example.com/sso"
|
|||
PROXY_BASE_URL = "https://proxy.example.com"
|
||||
|
||||
|
||||
def _make_idp_keypair():
|
||||
def _make_idp_keypair() -> tuple[str, str]:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "idp.example.com")])
|
||||
cert = (
|
||||
|
|
@ -79,9 +82,7 @@ def _make_idp_keypair():
|
|||
|
||||
|
||||
def _idp_metadata_xml(cert_pem):
|
||||
cert_body = "".join(
|
||||
line for line in cert_pem.splitlines() if "CERTIFICATE" not in line
|
||||
)
|
||||
cert_body = "".join(line for line in cert_pem.splitlines() if "CERTIFICATE" not in line)
|
||||
return (
|
||||
'<?xml version="1.0"?>'
|
||||
f'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{IDP_ENTITY}">'
|
||||
|
|
@ -101,17 +102,17 @@ def _saml_time(delta_seconds):
|
|||
|
||||
|
||||
def _build_signed_response(
|
||||
key_pem,
|
||||
cert_pem,
|
||||
key_pem: str,
|
||||
cert_pem: str,
|
||||
*,
|
||||
in_response_to=None,
|
||||
response_level_in_response_to=True,
|
||||
email="alice@example.com",
|
||||
attributes=None,
|
||||
not_before_delta=-60,
|
||||
not_on_or_after_delta=300,
|
||||
sign=True,
|
||||
):
|
||||
in_response_to: str | None = None,
|
||||
response_level_in_response_to: bool = True,
|
||||
email: str = "alice@example.com",
|
||||
attributes: dict[str, list[str]] | None = None,
|
||||
not_before_delta: int = -60,
|
||||
not_on_or_after_delta: int = 300,
|
||||
sign: bool = True,
|
||||
) -> str:
|
||||
if attributes is None:
|
||||
attributes = {
|
||||
"email": [email],
|
||||
|
|
@ -157,9 +158,7 @@ def _build_signed_response(
|
|||
|
||||
if sign:
|
||||
signed = OneLogin_Saml2_Utils.add_sign(assertion, key_pem, cert_pem)
|
||||
assertion = (signed.decode() if isinstance(signed, bytes) else signed).replace(
|
||||
'<?xml version="1.0"?>', ""
|
||||
)
|
||||
assertion = (signed.decode() if isinstance(signed, bytes) else signed).replace('<?xml version="1.0"?>', "")
|
||||
|
||||
return (
|
||||
'<?xml version="1.0"?>'
|
||||
|
|
@ -174,11 +173,11 @@ def _build_signed_response(
|
|||
)
|
||||
|
||||
|
||||
def _b64(xml):
|
||||
def _b64(xml: str) -> str:
|
||||
return base64.b64encode(xml.encode()).decode()
|
||||
|
||||
|
||||
def _fake_request(cookies=None):
|
||||
def _fake_request(cookies: dict[str, str] | None = None):
|
||||
return type(
|
||||
"Req",
|
||||
(),
|
||||
|
|
@ -190,14 +189,18 @@ def _fake_request(cookies=None):
|
|||
)()
|
||||
|
||||
|
||||
async def _acs_login(
|
||||
b64: str, cache: DualCache, cookies: dict[str, str] | None = None
|
||||
) -> tuple[CustomOpenID, SSOIdentityAssertion | None]:
|
||||
return await SAMLAuthHandler.handle_acs(_fake_request(cookies), cache, {"SAMLResponse": b64})
|
||||
|
||||
|
||||
async def _acs(b64, cache, cookies=None):
|
||||
return await SAMLAuthHandler.handle_acs(
|
||||
_fake_request(cookies), cache, {"SAMLResponse": b64}
|
||||
)
|
||||
return (await _acs_login(b64, cache, cookies))[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saml_env(monkeypatch):
|
||||
def saml_env(monkeypatch: pytest.MonkeyPatch) -> tuple[str, str]:
|
||||
key_pem, cert_pem = _make_idp_keypair()
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_XML", _idp_metadata_xml(cert_pem))
|
||||
monkeypatch.setenv("SAML_SP_ENTITY_ID", SP_ENTITY)
|
||||
|
|
@ -214,7 +217,7 @@ def saml_env(monkeypatch):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def saml_env_idp_initiated(saml_env, monkeypatch):
|
||||
def saml_env_idp_initiated(saml_env: tuple[str, str], monkeypatch: pytest.MonkeyPatch) -> tuple[str, str]:
|
||||
monkeypatch.setenv("SAML_ALLOW_UNSOLICITED", "true")
|
||||
return saml_env
|
||||
|
||||
|
|
@ -234,6 +237,89 @@ async def test_valid_idp_initiated_login_maps_assertion_to_user(saml_env_idp_ini
|
|||
assert result.provider == "saml"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_login_retains_the_signed_assertion_as_a_saml2_subject_token(
|
||||
saml_env_idp_initiated: tuple[str, str],
|
||||
):
|
||||
"""The retained material is the inner <saml:Assertion>, still carrying a verifiable signature, with
|
||||
the assertion's own NotOnOrAfter as its expiry and no refresh token, since SAML has none."""
|
||||
key_pem, cert_pem = saml_env_idp_initiated
|
||||
resp = _build_signed_response(key_pem, cert_pem, not_on_or_after_delta=300)
|
||||
|
||||
user, assertion = await _acs_login(_b64(resp), _shared_cache())
|
||||
|
||||
assert assertion is not None
|
||||
assert assertion.subject_token_type == "urn:ietf:params:oauth:token-type:saml2"
|
||||
assert assertion.refresh_token is None
|
||||
assert assertion.issuer == IDP_ENTITY
|
||||
retained = _retained_bytes(assertion.id_token.get_secret_value())
|
||||
assert retained.startswith(b"<saml:Assertion")
|
||||
assert b"<samlp:Response" not in retained
|
||||
assert _signature_valid(retained, cert_pem)
|
||||
assert assertion.expires_at == datetime.datetime.strptime(
|
||||
_conditions_not_on_or_after(resp), "%Y-%m-%dT%H:%M:%SZ"
|
||||
).replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def _conditions_not_on_or_after(response_xml: str) -> str:
|
||||
match = re.search(r'<saml:Conditions[^>]*NotOnOrAfter="([^"]+)"', response_xml)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _signature_valid(xml: bytes, cert_pem: str) -> bool:
|
||||
return bool(OneLogin_Saml2_Utils.validate_sign(xml, cert_pem, raise_exceptions=True, xpath="//ds:Signature"))
|
||||
|
||||
|
||||
def _retained_bytes(encoded: str) -> bytes:
|
||||
assert set(encoded) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), encoded[:40]
|
||||
return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retained_assertion_keeps_non_ascii_subject_bytes_verbatim(saml_env_idp_initiated: tuple[str, str]):
|
||||
key_pem, cert_pem = saml_env_idp_initiated
|
||||
resp = _build_signed_response(key_pem, cert_pem, email="zo\u00eb@example.com")
|
||||
|
||||
user, assertion = await _acs_login(_b64(resp), _shared_cache())
|
||||
|
||||
assert assertion is not None
|
||||
retained = _retained_bytes(assertion.id_token.get_secret_value())
|
||||
assert "zo\u00eb@example.com".encode() in retained
|
||||
assert b"&#" not in retained
|
||||
assert _signature_valid(retained, cert_pem)
|
||||
|
||||
|
||||
class _UnparseableResponseAuth:
|
||||
def get_last_response_xml(self) -> bytes:
|
||||
return b"<samlp:Response><saml:Assertion"
|
||||
|
||||
def get_last_assertion_not_on_or_after(self) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def test_assertion_retention_failure_does_not_fail_the_validated_login(caplog: pytest.LogCaptureFixture):
|
||||
"""Retention is optional egress material, so a response the retention step cannot parse logs and yields
|
||||
no assertion instead of turning an IdP-validated login into a 500."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
retained = SAMLAuthHandler._retained_assertion(_UnparseableResponseAuth())
|
||||
|
||||
assert retained is None
|
||||
assert "could not be retained for oauth2_id_jag" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retained_assertion_is_the_one_the_user_was_mapped_from(saml_env_idp_initiated: tuple[str, str]):
|
||||
key_pem, cert_pem = saml_env_idp_initiated
|
||||
resp = _build_signed_response(key_pem, cert_pem, email="bob@example.com")
|
||||
|
||||
user, assertion = await _acs_login(_b64(resp), _shared_cache())
|
||||
|
||||
assert user.email == "bob@example.com"
|
||||
assert assertion is not None
|
||||
assert b"bob@example.com" in _retained_bytes(assertion.id_token.get_secret_value())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_assertion_is_rejected(saml_env):
|
||||
key_pem, cert_pem = saml_env
|
||||
|
|
@ -269,9 +355,7 @@ async def test_signature_from_untrusted_key_is_rejected(saml_env):
|
|||
@pytest.mark.asyncio
|
||||
async def test_expired_assertion_is_rejected(saml_env):
|
||||
key_pem, cert_pem = saml_env
|
||||
resp = _build_signed_response(
|
||||
key_pem, cert_pem, not_before_delta=-7200, not_on_or_after_delta=-3600
|
||||
)
|
||||
resp = _build_signed_response(key_pem, cert_pem, not_before_delta=-7200, not_on_or_after_delta=-3600)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _acs(_b64(resp), DualCache())
|
||||
|
|
@ -293,9 +377,7 @@ async def test_sp_initiated_known_request_succeeds_once_then_replay_rejected(sam
|
|||
key_pem, cert_pem = saml_env
|
||||
cache = DualCache()
|
||||
request_id = "_authn_req_known"
|
||||
cache.set_cache(
|
||||
key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
|
||||
)
|
||||
cache.set_cache(key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600)
|
||||
resp = _build_signed_response(key_pem, cert_pem, in_response_to=request_id)
|
||||
cookies = {_SAML_AUTHN_STATE_COOKIE: request_id}
|
||||
|
||||
|
|
@ -312,9 +394,7 @@ async def test_sp_initiated_response_not_bound_to_browser_is_rejected(saml_env):
|
|||
key_pem, cert_pem = saml_env
|
||||
cache = DualCache()
|
||||
request_id = "_authn_req_known"
|
||||
cache.set_cache(
|
||||
key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
|
||||
)
|
||||
cache.set_cache(key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600)
|
||||
resp = _build_signed_response(key_pem, cert_pem, in_response_to=request_id)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
|
|
@ -322,9 +402,7 @@ async def test_sp_initiated_response_not_bound_to_browser_is_rejected(saml_env):
|
|||
assert exc.value.status_code == 401
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _acs(
|
||||
_b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: "_attacker_request"}
|
||||
)
|
||||
await _acs(_b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: "_attacker_request"})
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
|
|
@ -338,9 +416,7 @@ async def test_subjectconfirmation_only_in_response_to_without_cookie_is_rejecte
|
|||
key_pem, cert_pem = saml_env_idp_initiated
|
||||
cache = DualCache()
|
||||
request_id = "_authn_req_known"
|
||||
cache.set_cache(
|
||||
key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
|
||||
)
|
||||
cache.set_cache(key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600)
|
||||
resp = _build_signed_response(
|
||||
key_pem,
|
||||
cert_pem,
|
||||
|
|
@ -358,9 +434,7 @@ async def test_subjectconfirmation_only_in_response_to_with_cookie_succeeds(saml
|
|||
key_pem, cert_pem = saml_env
|
||||
cache = DualCache()
|
||||
request_id = "_authn_req_known"
|
||||
cache.set_cache(
|
||||
key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600
|
||||
)
|
||||
cache.set_cache(key=f"{_SAML_AUTHN_REQUEST_CACHE_PREFIX}:{request_id}", value="1", ttl=600)
|
||||
resp = _build_signed_response(
|
||||
key_pem,
|
||||
cert_pem,
|
||||
|
|
@ -368,9 +442,7 @@ async def test_subjectconfirmation_only_in_response_to_with_cookie_succeeds(saml
|
|||
response_level_in_response_to=False,
|
||||
)
|
||||
|
||||
result = await _acs(
|
||||
_b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: request_id}
|
||||
)
|
||||
result = await _acs(_b64(resp), cache, cookies={_SAML_AUTHN_STATE_COOKIE: request_id})
|
||||
assert result.email == "alice@example.com"
|
||||
|
||||
|
||||
|
|
@ -415,9 +487,7 @@ async def test_assertion_without_id_is_rejected(saml_env_idp_initiated):
|
|||
return None
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await SAMLAuthHandler._enforce_response_binding(
|
||||
_AuthNoAssertionId(), _shared_cache(), None
|
||||
)
|
||||
await SAMLAuthHandler._enforce_response_binding(_AuthNoAssertionId(), _shared_cache(), None)
|
||||
assert exc.value.status_code == 401
|
||||
assert "ID" in exc.value.detail
|
||||
|
||||
|
|
@ -450,9 +520,7 @@ async def test_invalid_email_in_assertion_is_rejected_cleanly(saml_env_idp_initi
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_less_assertion_rejected_when_domain_restriction_configured(
|
||||
saml_env_idp_initiated, monkeypatch
|
||||
):
|
||||
async def test_email_less_assertion_rejected_when_domain_restriction_configured(saml_env_idp_initiated, monkeypatch):
|
||||
key_pem, cert_pem = saml_env_idp_initiated
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "example.com")
|
||||
resp = _build_signed_response(
|
||||
|
|
@ -558,11 +626,7 @@ async def test_build_login_redirect_targets_idp_and_caches_request_id(saml_env):
|
|||
location = redirect.headers["location"]
|
||||
assert location.startswith(SSO_URL)
|
||||
assert "SAMLRequest=" in location
|
||||
cached = [
|
||||
k
|
||||
for k in cache.in_memory_cache.cache_dict
|
||||
if k.startswith(_SAML_AUTHN_REQUEST_CACHE_PREFIX)
|
||||
]
|
||||
cached = [k for k in cache.in_memory_cache.cache_dict if k.startswith(_SAML_AUTHN_REQUEST_CACHE_PREFIX)]
|
||||
assert len(cached) == 1
|
||||
|
||||
request_id = cached[0].split(":", 1)[1]
|
||||
|
|
@ -761,9 +825,7 @@ class TestSAMLAuthnCookieSecureFlag:
|
|||
{"use_x_forwarded_for": True, "mcp_trusted_proxy_ranges": ["10.0.0.0/8"]},
|
||||
)
|
||||
cache = DualCache()
|
||||
request = _fake_request_with_scheme(
|
||||
"http", headers={"X-Forwarded-Proto": "https"}, client_host="10.0.0.5"
|
||||
)
|
||||
request = _fake_request_with_scheme("http", headers={"X-Forwarded-Proto": "https"}, client_host="10.0.0.5")
|
||||
redirect = await SAMLAuthHandler.build_login_redirect(request, cache)
|
||||
cookie = redirect.headers["set-cookie"]
|
||||
assert "Secure" in cookie
|
||||
|
|
@ -773,9 +835,32 @@ class TestSAMLAuthnCookieSecureFlag:
|
|||
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
cache = DualCache()
|
||||
request = _fake_request_with_scheme(
|
||||
"http", headers={"X-Forwarded-Proto": "https"}, client_host="203.0.113.5"
|
||||
)
|
||||
request = _fake_request_with_scheme("http", headers={"X-Forwarded-Proto": "https"}, client_host="203.0.113.5")
|
||||
redirect = await SAMLAuthHandler.build_login_redirect(request, cache)
|
||||
cookie = redirect.headers["set-cookie"]
|
||||
assert "Secure" not in cookie
|
||||
|
||||
|
||||
def test_an_encrypted_assertion_is_not_retained_and_the_login_log_says_why(caplog: pytest.LogCaptureFixture):
|
||||
"""An IdP that encrypts its assertion still signs the user in, but the ID-JAG arm has nothing to
|
||||
present. The login log has to name that, otherwise every later 412 looks like a bug re-login cannot fix."""
|
||||
|
||||
class _Encrypted:
|
||||
def get_last_response_xml(self) -> bytes:
|
||||
return (
|
||||
b'<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" '
|
||||
b'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">'
|
||||
b'<saml:EncryptedAssertion><xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#"/>'
|
||||
b"</saml:EncryptedAssertion></samlp:Response>"
|
||||
)
|
||||
|
||||
def get_last_assertion_not_on_or_after(self) -> int | None:
|
||||
return None
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
retained = SAMLAuthHandler._retained_assertion(_Encrypted()) # pyright: ignore[reportArgumentType] # stub auth # pyright: ignore[reportPrivateUsage] # the retention seam under test
|
||||
|
||||
assert retained is None
|
||||
assert any(
|
||||
"1 saml:EncryptedAssertion" in r.getMessage() and "oauth2_id_jag" in r.getMessage() for r in caplog.records
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
import respx
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
|
@ -9004,7 +9005,8 @@ async def test_debug_page_survives_a_store_outage(monkeypatch, caplog):
|
|||
not the page."""
|
||||
from litellm.proxy.management_endpoints.ui_sso import warn_if_id_jag_capture_gap
|
||||
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
|
||||
for var in ("MICROSOFT_CLIENT_ID", "GOOGLE_CLIENT_ID", "GENERIC_CLIENT_ID", "SAML_IDP_METADATA_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
retention_check = AsyncMock(side_effect=Exception("db down"))
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
assert await warn_if_id_jag_capture_gap(retention_enabled=retention_check) is None
|
||||
|
|
@ -9130,7 +9132,7 @@ async def test_saml_callback_enforces_free_sso_user_limit_after_validation():
|
|||
|
||||
async def _fake_handle_acs(**kwargs):
|
||||
call_order.append("validate")
|
||||
return CustomOpenID(
|
||||
return (CustomOpenID(
|
||||
id="dana@litellm.ai",
|
||||
email="dana@litellm.ai",
|
||||
first_name=None,
|
||||
|
|
@ -9140,7 +9142,7 @@ async def test_saml_callback_enforces_free_sso_user_limit_after_validation():
|
|||
provider="saml",
|
||||
team_ids=[],
|
||||
user_role=None,
|
||||
)
|
||||
), None)
|
||||
|
||||
async def _fake_count_billable_users():
|
||||
call_order.append("count")
|
||||
|
|
@ -9662,6 +9664,81 @@ async def test_redirect_from_openid_persists_assertion_under_canonical_user_id()
|
|||
assert response is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_from_openid_persists_the_login_exchanged_assertion():
|
||||
"""A server with ``id_jag_subject_source: refresh_token`` needs the refresh token the login
|
||||
assertion was exchanged for, so the funnel persists what the login-time exchange returns, not
|
||||
the raw capture."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
|
||||
assertion_from_sso_login,
|
||||
)
|
||||
|
||||
assertion = assertion_from_sso_login(_ema_id_token(), "rt_1")
|
||||
assert assertion is not None
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.scope = {}
|
||||
mock_request.base_url = "http://localhost:4000/"
|
||||
mock_request.cookies = {}
|
||||
|
||||
retain_mock = AsyncMock()
|
||||
exchanged = assertion.model_copy(update={"exchanged_refresh_token": SecretStr("rt-from-login")})
|
||||
exchange_mock = AsyncMock(return_value=exchanged)
|
||||
with (
|
||||
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||||
patch("litellm.proxy.proxy_server.premium_user", False),
|
||||
patch("litellm.proxy.proxy_server.user_custom_sso", None),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||||
AsyncMock(
|
||||
return_value={"token": "sk-ui-key", "user_id": "canonical-user-id"}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.ui_sso.check_and_update_if_proxy_admin_id",
|
||||
AsyncMock(return_value="internal_user"),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.ui_sso.retain_sso_identity_assertion_for_ema",
|
||||
retain_mock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.ui_sso.exchange_login_assertion_if_configured",
|
||||
exchange_mock,
|
||||
),
|
||||
):
|
||||
response = await SSOAuthenticationHandler.get_redirect_response_from_openid(
|
||||
result=CustomOpenID(
|
||||
id="raw-idp-subject",
|
||||
email="u@example.com",
|
||||
first_name="U",
|
||||
last_name="Ser",
|
||||
display_name="U Ser",
|
||||
provider="generic",
|
||||
team_ids=[],
|
||||
user_role=None,
|
||||
),
|
||||
request=mock_request,
|
||||
received_response=None,
|
||||
generic_client_id="cid",
|
||||
ui_access_mode=None,
|
||||
access_token_payload=None,
|
||||
jwt_handler=None,
|
||||
sso_assertion=assertion,
|
||||
)
|
||||
exchange_mock.assert_awaited_once_with(assertion)
|
||||
retain_mock.assert_awaited_once()
|
||||
assert retain_mock.await_args.kwargs["assertion"] is exchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"provider_env, sso_class, payload, needs_graph",
|
||||
|
|
@ -9889,7 +9966,6 @@ def _id_jag_gap_warnings(caplog) -> list[str]:
|
|||
@pytest.mark.parametrize(
|
||||
"provider_env, expected_fragment",
|
||||
[
|
||||
({"SAML_IDP_METADATA_URL": "https://idp.example.com/metadata"}, "saml"),
|
||||
({}, "no SSO provider is configured"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -37292,6 +37292,8 @@ export interface components {
|
|||
id_jag_resource_client_secret?: string | null;
|
||||
/** Id Jag Resource Token Endpoint */
|
||||
id_jag_resource_token_endpoint?: string | null;
|
||||
/** Id Jag Subject Source */
|
||||
id_jag_subject_source?: string | null;
|
||||
/** Redirect Uris */
|
||||
redirect_uris?: string[] | null;
|
||||
/** Scopes */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue