fix(mcp): preserve provider access token lifetime

Co-Authored-By: Codex
This commit is contained in:
Daniel Meismer 2026-08-25 15:51:59 -04:00
parent 04818a3554
commit c673bcd970
6 changed files with 101 additions and 37 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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