fix(mcp): let the bridge envelope report expires_in 0 at the jwt exp boundary

_finish_bridge_mint floored the reported expires_in at 1. Admission expires the
envelope against the JWT's second-truncated exp, so when the mint lands in the same
second that exp falls on (a sub-second upstream lifetime, for instance), the true
remaining life is 0 and reporting 1 tells the client the bearer lives one second past
the point admission already rejects it. Floor at 0 instead so the reported lifetime
never overstates the exp; the value still cannot go negative.

The regression pins the boundary directly: minting at now=100.25 with a 1s upstream
token seals exp=101, and the reported expires_in is max(0, 101 - ceil(100.25)) = 0.
Under the old floor of 1 it reads 1, so the test fails on that mutation.

Also drops the unused mcp_server parameter from _prepare_bridge_mint; identity and
key derivation there never referenced the server.
This commit is contained in:
Tin Chi Lo 2026-07-11 17:47:54 -07:00
parent 2f0ddc82f7
commit 4ba7221b7a
2 changed files with 32 additions and 3 deletions

View file

@ -821,7 +821,7 @@ def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
)
async def _prepare_bridge_mint(request: Request, mcp_server: MCPServer) -> "_BridgeMintReady | _BridgeMintError":
async def _prepare_bridge_mint(request: Request) -> "_BridgeMintReady | _BridgeMintError":
"""Phase 1, BEFORE the upstream exchange: validate that the gateway can mint (master_key set) and
that the request carries a resolvable litellm identity, and derive the envelope keys. Returns a
ready context or a failure value. Running before the exchange is what makes a missing master_key or
@ -866,7 +866,7 @@ def _finish_bridge_mint(
return "too_large"
# Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
# client is never told the bearer lives past the point admission (which uses that exp) rejects it.
expires_in = max(1, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
body = {"access_token": sealed.token.get_secret_value(), "token_type": "Bearer", "expires_in": expires_in}
return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
@ -949,7 +949,7 @@ async def exchange_token_with_server(
# phase 3. A failure here returns without ever touching the upstream credential.
bridge_mint_ready: _BridgeMintReady | None = None
if mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge:
prepared = await _prepare_bridge_mint(request, mcp_server)
prepared = await _prepare_bridge_mint(request)
if not isinstance(prepared, _BridgeMintReady):
return _bridge_mint_error_response(prepared)
bridge_mint_ready = prepared

View file

@ -4609,6 +4609,35 @@ async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp():
assert before + body["expires_in"] <= claims["exp"]
def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
from datetime import datetime, timezone
from fastapi.responses import JSONResponse
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_BridgeMintReady,
_finish_bridge_mint,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.types.mcp import MCPAuth
ready = _BridgeMintReady(
key_hash="hashed-litellm-key-77",
keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY),
)
response = _finish_bridge_mint(
ready=ready,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
token_response={"access_token": "UP", "expires_in": 1},
now=datetime.fromtimestamp(100.25, tz=timezone.utc),
)
assert isinstance(response, JSONResponse)
assert json.loads(response.body)["expires_in"] == 0
def test_bridge_grant_coerces_numeric_expires_in():
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); coerce
it to a positive int so the envelope TTL honors the real lifetime instead of dropping a non-int