mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(mcp): preserve provider access token lifetime
Co-Authored-By: Codex
This commit is contained in:
parent
04818a3554
commit
c673bcd970
6 changed files with 101 additions and 37 deletions
|
|
@ -306,15 +306,15 @@ _UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"]
|
|||
- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream
|
||||
token that is already dead, so sealing it would forward a bearer the edge cannot use
|
||||
An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the
|
||||
envelope caps it, the by-design behaviour for an upstream that omits the field."""
|
||||
envelope uses its fallback lifetime, the by-design behaviour for an upstream that omits the field."""
|
||||
|
||||
|
||||
def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']":
|
||||
"""Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent
|
||||
or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports
|
||||
or unparseable, so the envelope uses its fallback), or ``"expired"`` (a non-positive value the upstream reports
|
||||
as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is
|
||||
already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h
|
||||
cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
|
||||
already dead" is what stops an explicitly-expired token from silently receiving the envelope's
|
||||
one-hour fallback. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
|
||||
positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the
|
||||
envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded
|
||||
(an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` /
|
||||
|
|
@ -335,7 +335,7 @@ def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenG
|
|||
"""Validate an upstream OAuth token response into a typed grant, or say why it cannot back an
|
||||
envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the
|
||||
grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown
|
||||
lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is
|
||||
lifetime leaves the grant ``expires_in`` ``None`` for the envelope fallback, a positive value is
|
||||
honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to
|
||||
the cap."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
|
|
@ -357,8 +357,8 @@ def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenG
|
|||
token_type=token_type if isinstance(token_type, str) and token_type else "Bearer",
|
||||
# The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards
|
||||
# only token_type + access_token), so it would be dead weight embedding a long-lived upstream
|
||||
# credential in the client-held bearer, and it enlarges the envelope. Refresh support is a
|
||||
# follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap.
|
||||
# credential in the client-held bearer, and it enlarges the envelope. The dedicated refresh
|
||||
# envelope carries that credential separately.
|
||||
refresh_token=None,
|
||||
scope=scope if isinstance(scope, str) and scope else None,
|
||||
expires_in=lifetime if isinstance(lifetime, int) else None,
|
||||
|
|
@ -387,6 +387,7 @@ _BridgeMintError = Literal[
|
|||
"not_configured",
|
||||
"no_upstream_token",
|
||||
"upstream_token_expired",
|
||||
"upstream_lifetime_unrepresentable",
|
||||
"too_large",
|
||||
]
|
||||
|
||||
|
|
@ -456,6 +457,12 @@ def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
|
|||
"server_error",
|
||||
"the upstream token response reports an already-expired lifetime",
|
||||
)
|
||||
case "upstream_lifetime_unrepresentable":
|
||||
status, code, desc = (
|
||||
502,
|
||||
"server_error",
|
||||
"the upstream token response reports an unrepresentable lifetime",
|
||||
)
|
||||
case "too_large":
|
||||
status, code, desc = (
|
||||
502,
|
||||
|
|
@ -619,6 +626,7 @@ def _finish_bridge_mint(
|
|||
build_bridge_token_response,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
EnvelopeLifetimeUnrepresentable,
|
||||
SealedEnvelope,
|
||||
UpstreamTokenGrant,
|
||||
)
|
||||
|
|
@ -627,6 +635,8 @@ def _finish_bridge_mint(
|
|||
if not isinstance(grant, UpstreamTokenGrant):
|
||||
return _upstream_rejection_to_mint_error(grant)
|
||||
sealed: Final = build_bridge_token_response(ready.identity, grant, ready.keys, now)
|
||||
if isinstance(sealed, EnvelopeLifetimeUnrepresentable):
|
||||
return "upstream_lifetime_unrepresentable"
|
||||
if not isinstance(sealed, SealedEnvelope):
|
||||
return "too_large"
|
||||
# Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ def build_bridge_token_response(
|
|||
|
||||
The producer mirror of :func:`resolve_bridge_envelope`: a thin, pure wrapper over
|
||||
:func:`mint_envelope` that returns the sealed envelope, or the mint error as a value
|
||||
(an oversized grant) for the caller to map onto an OAuth error response.
|
||||
for the caller to map onto an OAuth error response.
|
||||
"""
|
||||
return mint_envelope(identity, grant, keys, now)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,17 +19,16 @@ in plaintext anywhere in the envelope.
|
|||
|
||||
Failures are values: :func:`open_envelope` returns one of the frozen
|
||||
``EnvelopeOpenError`` variants (discriminated on ``tag``) for invalid, expired,
|
||||
tampered, or undecryptable input, and :func:`mint_envelope` returns
|
||||
``EnvelopeTooLarge`` for oversized grants. Error values carry tags and sizes only,
|
||||
never token material.
|
||||
tampered, or undecryptable input, and :func:`mint_envelope` returns a typed error
|
||||
for oversized grants or an unrepresentable provider lifetime. Error values carry
|
||||
tags and metadata only, never token material.
|
||||
|
||||
The pydantic input models reject programmer errors at construction (e.g. a
|
||||
non-positive ``expires_in`` or an empty required field). :func:`open_envelope` is
|
||||
additionally total over hostile, attacker-controlled input: it never raises, only
|
||||
returns an ``EnvelopeOpenError``. :func:`mint_envelope` operates on a
|
||||
gateway-supplied grant (an upstream IdP's UTF-8 JSON token response), so it does not
|
||||
defend against non-UTF-8 field content that cannot survive JSON parsing; its only
|
||||
value-typed failure is ``EnvelopeTooLarge``.
|
||||
defend against non-UTF-8 field content that cannot survive JSON parsing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -57,10 +56,11 @@ ENVELOPE_ISSUER: Final = "litellm-mcp-bridge"
|
|||
"""``iss`` claim stamped into every envelope and required back on open."""
|
||||
|
||||
MAX_ENVELOPE_TTL_SECONDS: Final = 3600
|
||||
"""Hard ceiling on ACCESS envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)``
|
||||
(the cap alone when the upstream omits ``expires_in``), matching the 1h lifetime of the
|
||||
BYOK session bearer this module's signing approach is borrowed from: a client-held
|
||||
credential should never outlive a bounded window even when the upstream token does."""
|
||||
"""Fallback ACCESS envelope lifetime when the upstream omits ``expires_in``.
|
||||
|
||||
The historical exported name is retained for import compatibility. When the upstream
|
||||
reports a positive lifetime, the envelope matches it so a renewal does not consume a
|
||||
still-valid provider refresh grant."""
|
||||
|
||||
MAX_REFRESH_ENVELOPE_TTL_SECONDS: Final = 1209600
|
||||
"""Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived
|
||||
|
|
@ -202,7 +202,15 @@ class EnvelopeTooLarge(BaseModel):
|
|||
max_bytes: int
|
||||
|
||||
|
||||
EnvelopeMintError: TypeAlias = EnvelopeTooLarge
|
||||
class EnvelopeLifetimeUnrepresentable(BaseModel):
|
||||
"""A positive provider lifetime cannot be represented as a Python datetime."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tag: Literal["envelope_lifetime_unrepresentable"] = "envelope_lifetime_unrepresentable"
|
||||
expires_in: int
|
||||
|
||||
|
||||
EnvelopeMintError: TypeAlias = EnvelopeTooLarge | EnvelopeLifetimeUnrepresentable
|
||||
|
||||
|
||||
class NotAnEnvelope(BaseModel):
|
||||
|
|
@ -307,11 +315,17 @@ def mint_envelope(
|
|||
) -> SealedEnvelope | EnvelopeMintError:
|
||||
"""Seal ``grant`` for ``identity`` into a client-held envelope.
|
||||
|
||||
``exp`` is ``min(grant.expires_in, MAX_ENVELOPE_TTL_SECONDS)`` seconds from ``now``
|
||||
(the cap alone when ``expires_in`` is absent). Returns ``EnvelopeTooLarge`` when the
|
||||
serialized envelope exceeds ``MAX_ENVELOPE_BYTES``.
|
||||
``exp`` is ``grant.expires_in`` seconds from ``now`` when the upstream reports a
|
||||
lifetime, or ``MAX_ENVELOPE_TTL_SECONDS`` when it does not. Returns
|
||||
``EnvelopeLifetimeUnrepresentable`` when that positive lifetime cannot be represented
|
||||
as a Python datetime, or ``EnvelopeTooLarge`` when the serialized envelope exceeds
|
||||
``MAX_ENVELOPE_BYTES``.
|
||||
"""
|
||||
expires_at: Final = now + timedelta(seconds=_envelope_ttl_seconds(grant.expires_in))
|
||||
ttl_seconds: Final = _envelope_ttl_seconds(grant.expires_in)
|
||||
try:
|
||||
expires_at: Final = now + timedelta(seconds=ttl_seconds)
|
||||
except OverflowError:
|
||||
return EnvelopeLifetimeUnrepresentable(expires_in=ttl_seconds)
|
||||
return _seal(
|
||||
kind="access",
|
||||
prefix=ENVELOPE_PREFIX,
|
||||
|
|
@ -457,7 +471,7 @@ def _open_claims(
|
|||
def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int:
|
||||
if upstream_expires_in is None:
|
||||
return MAX_ENVELOPE_TTL_SECONDS
|
||||
return min(upstream_expires_in, MAX_ENVELOPE_TTL_SECONDS)
|
||||
return upstream_expires_in
|
||||
|
||||
|
||||
def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int:
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ the envelope issuer so a token of one family can never validate in the other eve
|
|||
hypothetical shared signing key."""
|
||||
|
||||
SESSION_TTL_SECONDS: Final = 3600
|
||||
"""Session ACCESS token lifetime (1h), matching the access-envelope and BYOK session bearer
|
||||
windows: a client-held credential never outlives a bounded window, and each refresh
|
||||
re-validates the live user before re-minting."""
|
||||
"""Session ACCESS token lifetime (1h), matching the BYOK session bearer window: a
|
||||
client-held credential never outlives a bounded window, and each refresh re-validates
|
||||
the live user before re-minting."""
|
||||
|
||||
SESSION_REFRESH_TTL_SECONDS: Final = 1209600
|
||||
"""Session REFRESH token lifetime (14 days), matching the refresh-envelope bound. Each
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
The envelope is the single client-held bearer carrying both a litellm identity and the
|
||||
encrypted upstream grant, with zero server-side storage. These tests pin the security
|
||||
contract: an envelope opens only under the exact keys that minted it, tampering with any
|
||||
signed byte is detected, expiry is enforced against the injected clock (capped by the
|
||||
module TTL ceiling), oversized envelopes are rejected rather than truncated, and no
|
||||
error value, model repr, or raised exception ever contains the inner access token.
|
||||
signed byte is detected, expiry is enforced against the injected clock and provider
|
||||
lifetime, oversized envelopes are rejected rather than truncated, and no error value,
|
||||
model repr, or raised exception ever contains the inner access token.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
|
@ -30,6 +30,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import
|
|||
DecryptFailed,
|
||||
EnvelopeIdentity,
|
||||
EnvelopeKeys,
|
||||
EnvelopeLifetimeUnrepresentable,
|
||||
EnvelopeMintError,
|
||||
EnvelopeTooLarge,
|
||||
Expired,
|
||||
MalformedPayload,
|
||||
|
|
@ -159,6 +161,20 @@ def test_claim_layout_and_no_plaintext_token_in_envelope():
|
|||
assert _REFRESH_TOKEN not in json.dumps(claims)
|
||||
|
||||
|
||||
def test_unrepresentable_access_lifetime_is_a_typed_mint_error():
|
||||
grant = UpstreamTokenGrant(
|
||||
access_token=SecretStr(_ACCESS_TOKEN),
|
||||
token_type="Bearer",
|
||||
expires_in=10**30,
|
||||
)
|
||||
|
||||
result = mint_envelope(_IDENTITY, grant, _KEYS, _NOW)
|
||||
|
||||
assert isinstance(result, EnvelopeLifetimeUnrepresentable)
|
||||
assert result.tag == "envelope_lifetime_unrepresentable"
|
||||
assert result.expires_in == 10**30
|
||||
|
||||
|
||||
def _refresh_credential() -> RefreshCredential:
|
||||
return RefreshCredential(refresh_token=SecretStr(_REFRESH_TOKEN), scope="read:tools", expires_in=None)
|
||||
|
||||
|
|
@ -243,11 +259,11 @@ def test_refresh_envelope_never_leaks_the_refresh_token_in_plaintext():
|
|||
"expires_in, expected_ttl",
|
||||
[
|
||||
(600, 600),
|
||||
(MAX_ENVELOPE_TTL_SECONDS + 82800, MAX_ENVELOPE_TTL_SECONDS),
|
||||
(MAX_ENVELOPE_TTL_SECONDS + 82800, MAX_ENVELOPE_TTL_SECONDS + 82800),
|
||||
(None, MAX_ENVELOPE_TTL_SECONDS),
|
||||
],
|
||||
)
|
||||
def test_exp_is_min_of_upstream_expires_in_and_cap(expires_in, expected_ttl):
|
||||
def test_exp_matches_upstream_lifetime_or_uses_missing_lifetime_fallback(expires_in: int | None, expected_ttl: int):
|
||||
grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type="Bearer", expires_in=expires_in)
|
||||
sealed = mint_envelope(_IDENTITY, grant, _KEYS, _NOW)
|
||||
assert isinstance(sealed, SealedEnvelope)
|
||||
|
|
@ -261,13 +277,13 @@ def test_expiry_honored_against_injected_clock():
|
|||
assert isinstance(open_envelope(token, _KEYS, _NOW + timedelta(seconds=601)), Expired)
|
||||
|
||||
|
||||
def test_ttl_cap_enforced_on_open_even_when_upstream_token_lives_longer():
|
||||
def test_upstream_token_lifetime_is_enforced_on_open():
|
||||
grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type="Bearer", expires_in=86400)
|
||||
token = _sealed_token(grant)
|
||||
just_before_cap = _NOW + timedelta(seconds=MAX_ENVELOPE_TTL_SECONDS - 1)
|
||||
at_cap = _NOW + timedelta(seconds=MAX_ENVELOPE_TTL_SECONDS)
|
||||
assert isinstance(open_envelope(token, _KEYS, just_before_cap), OpenedEnvelope)
|
||||
assert isinstance(open_envelope(token, _KEYS, at_cap), Expired)
|
||||
just_before_expiry = _NOW + timedelta(seconds=86399)
|
||||
at_expiry = _NOW + timedelta(seconds=86400)
|
||||
assert isinstance(open_envelope(token, _KEYS, just_before_expiry), OpenedEnvelope)
|
||||
assert isinstance(open_envelope(token, _KEYS, at_expiry), Expired)
|
||||
|
||||
|
||||
def test_tampering_any_payload_or_signature_byte_is_bad_signature():
|
||||
|
|
@ -420,7 +436,7 @@ def test_decryptable_blob_that_is_not_a_grant_is_malformed_payload():
|
|||
assert isinstance(open_envelope(forged, _KEYS, _NOW), MalformedPayload)
|
||||
|
||||
|
||||
def _mint_with_token_len(n: int) -> SealedEnvelope | EnvelopeTooLarge:
|
||||
def _mint_with_token_len(n: int) -> SealedEnvelope | EnvelopeMintError:
|
||||
grant = UpstreamTokenGrant(access_token=SecretStr("a" * n), token_type="Bearer")
|
||||
return mint_envelope(_IDENTITY, grant, _KEYS, _NOW)
|
||||
|
||||
|
|
|
|||
|
|
@ -5539,6 +5539,30 @@ async def test_bridge_envelope_too_large_upstream_token_is_502():
|
|||
assert json.loads(response.body)["error"] == "server_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_envelope_unrepresentable_upstream_lifetime_is_502():
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
||||
upstream = {
|
||||
"access_token": "UPSTREAM-SECRET-TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 10**30,
|
||||
}
|
||||
|
||||
response = await _exchange_for_bridge_server(
|
||||
server,
|
||||
upstream,
|
||||
key_hash="hashed-litellm-key-77",
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert json.loads(response.body) == {
|
||||
"error": "server_error",
|
||||
"error_description": "the upstream token response reports an unrepresentable lifetime",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_access_envelope_never_carries_upstream_refresh_token():
|
||||
"""The upstream refresh token is never sealed into the ACCESS envelope, the bearer forwarded upstream
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue