mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
feat(mcp_gateway): add RFC 7662 introspection for gateway session tokens (#38726)
* feat(mcp_gateway): add RFC 7662 introspection for gateway session tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp_gateway): omit Bearer token_type for refresh introspection and allow mcp-scoped keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(mcp_gateway): cover introspection of RS256-signed session tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp_gateway): load the discoverable router on a cold /introspect request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(openapi): regenerate lazy snapshot and schema.d.ts for /introspect Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
9ed7de6c02
commit
f0340fef16
9 changed files with 475 additions and 7 deletions
|
|
@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Optional
|
|||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
aggregate_authorize,
|
||||
aggregate_token,
|
||||
complete_connect_flow,
|
||||
introspect_gateway_token,
|
||||
is_gateway_dcr_client_id,
|
||||
is_proxy_api_resource,
|
||||
native_client_auth_contract,
|
||||
|
|
@ -67,6 +68,7 @@ from litellm.proxy._experimental.mcp_server.proxy_api_credentials import (
|
|||
mint_proxy_credential,
|
||||
)
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
decrypt_value_helper,
|
||||
encrypt_value_helper,
|
||||
|
|
@ -1951,6 +1953,26 @@ async def revoke_endpoint(request: Request, token: str = Form(...), client_id: s
|
|||
return await revoke_refresh_token(token=token, client_id=client_id, master_key=master_key, cache=user_api_key_cache)
|
||||
|
||||
|
||||
@router.post("/introspect", dependencies=[Depends(user_api_key_auth)])
|
||||
async def introspect_endpoint(token: str = Form(...)) -> Response:
|
||||
"""RFC 7662 introspection for gateway-issued session tokens (``llm_session_`` /
|
||||
``llm_srefresh_``), so an external gateway can validate them without the signing
|
||||
secret. The caller authenticates with a LiteLLM virtual key (section 2.1, enforced by
|
||||
the route dependency); any token the gateway cannot vouch for answers
|
||||
``{"active": false}`` with no further detail."""
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
|
||||
master_key,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
return await introspect_gateway_token(
|
||||
token=token,
|
||||
master_key=master_key,
|
||||
reload_user=_reload_active_user_by_id,
|
||||
cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/.well-known/litellm-cli-auth")
|
||||
async def native_client_auth_discovery(request: Request) -> JSONResponse:
|
||||
"""The versioned contract a native client (``lite login --pkce``, or a CLI in any other
|
||||
|
|
@ -2456,6 +2478,7 @@ def _build_aggregate_authorization_server_response(request: Request) -> dict:
|
|||
"issuer": f"{request_base_url}/mcp",
|
||||
"authorization_endpoint": f"{request_base_url}/authorize",
|
||||
"token_endpoint": f"{request_base_url}/token",
|
||||
"introspection_endpoint": f"{request_base_url}/introspect",
|
||||
"registration_endpoint": f"{request_base_url}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"scopes_supported": [],
|
||||
|
|
|
|||
|
|
@ -70,13 +70,19 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credent
|
|||
open_session_refresh_bearer,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
|
||||
SESSION_ISSUER,
|
||||
SESSION_REFRESH_TTL_SECONDS,
|
||||
MintedSessionToken,
|
||||
OpenedSessionToken,
|
||||
SessionAudience,
|
||||
SessionPrincipal,
|
||||
SessionSigningKeys,
|
||||
is_session_refresh_token,
|
||||
is_session_token,
|
||||
mint_session_refresh_token,
|
||||
mint_session_token,
|
||||
open_session_refresh_token,
|
||||
open_session_token,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
decrypt_value_helper,
|
||||
|
|
@ -885,6 +891,23 @@ class _SingleUseGuard:
|
|||
count = await self._cache.async_increment_cache(key, 1, ttl=ttl_seconds, local_only=True)
|
||||
return "first" if count == 1 else "replayed"
|
||||
|
||||
async def peek(self, key: str) -> Literal["unclaimed", "claimed", "unavailable"]:
|
||||
"""Read-only view of a single-use marker, resolved against the same shared authority as
|
||||
:meth:`claim` so introspection observes exactly the record redemption and revocation wrote.
|
||||
A backend fault is ``"unavailable"`` (fail closed) rather than a guess either way."""
|
||||
from litellm.proxy.proxy_server import redis_usage_cache # noqa: PLC0415 # circular import at module load
|
||||
|
||||
redis_cache: Final = redis_usage_cache or getattr(self._cache, "redis_cache", None)
|
||||
if redis_cache is not None:
|
||||
try:
|
||||
value = await redis_cache.async_get_cache(key)
|
||||
except Exception as e: # noqa: BLE001 # ANY Redis fault fails the read closed
|
||||
verbose_logger.warning("mcp gateway single-use peek: shared cache backend unavailable: %s", e)
|
||||
return "unavailable"
|
||||
return "unclaimed" if value is None else "claimed"
|
||||
local: Final = await self._cache.async_get_cache(key, local_only=True)
|
||||
return "unclaimed" if local is None else "claimed"
|
||||
|
||||
|
||||
def _session_token_pair(principal: SessionPrincipal, keys: SessionSigningKeys, now: datetime) -> Response:
|
||||
access: Final = mint_session_token(principal, keys, now)
|
||||
|
|
@ -1199,3 +1222,83 @@ async def revoke_refresh_token(token: str, client_id: str, master_key: str | Non
|
|||
if burned == "unavailable":
|
||||
return _oauth_error(503, "temporarily_unavailable", _CLAIM_UNAVAILABLE_DESCRIPTION)
|
||||
return Response(content="{}", media_type="application/json", headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
def _inactive_introspection_response() -> Response:
|
||||
"""RFC 7662 section 2.2: any token the gateway cannot vouch for, whatever the reason
|
||||
(wrong family, bad signature, expired, revoked, or a deactivated user), answers 200
|
||||
with ``active: false`` and nothing else, so introspection is not a token oracle."""
|
||||
return JSONResponse(status_code=200, content={"active": False}, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
def _active_introspection_response(opened: OpenedSessionToken) -> Response:
|
||||
principal: Final = opened.principal
|
||||
optional_claims: Final = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("token_type", "Bearer" if opened.kind == "session" else None),
|
||||
("team_id", principal.team_id),
|
||||
("resource_server_id", principal.resource_server_id),
|
||||
("audience", principal.audience),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"active": True,
|
||||
"iss": SESSION_ISSUER,
|
||||
"sub": principal.user_id,
|
||||
"client_id": principal.client_id,
|
||||
"jti": opened.jti,
|
||||
"iat": opened.iat,
|
||||
"exp": opened.exp,
|
||||
"kind": opened.kind,
|
||||
**optional_claims,
|
||||
},
|
||||
headers=TOKEN_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def introspect_gateway_token(
|
||||
token: str,
|
||||
master_key: str | None,
|
||||
reload_user: ReloadUser,
|
||||
cache: DualCache,
|
||||
) -> Response:
|
||||
"""RFC 7662 introspection for the gateway's session tokens, so an external gateway
|
||||
(Kong, an API management layer) can validate a LiteLLM-issued MCP session credential
|
||||
without holding the signing secret. The caller is already authenticated by the route
|
||||
(section 2.1). Active means everything admission itself would require: valid signature
|
||||
under the configured session signing keys, unexpired, not a revoked or rotated refresh
|
||||
token, and a litellm user that is still live, so a deactivated user's outstanding
|
||||
tokens introspect as inactive immediately. A shared-backend or DB outage answers 503
|
||||
rather than guessing in either direction."""
|
||||
if master_key is None:
|
||||
verbose_logger.error("mcp_gateway_dcr introspect rejected: no master_key configured")
|
||||
return _oauth_error(500, "server_error", "the gateway has no master key configured")
|
||||
keys: Final = active_session_signing_keys(master_key)
|
||||
if isinstance(keys, SessionSigningConfigError):
|
||||
verbose_logger.error("mcp_gateway_dcr introspect rejected: %s", keys.detail)
|
||||
return _oauth_error(500, "server_error", keys.detail)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
if is_session_token(token):
|
||||
opened = open_session_token(token, keys, now)
|
||||
elif is_session_refresh_token(token):
|
||||
opened = open_session_refresh_token(token, keys, now)
|
||||
else:
|
||||
return _inactive_introspection_response()
|
||||
if not isinstance(opened, OpenedSessionToken):
|
||||
return _inactive_introspection_response()
|
||||
if opened.kind == "session_refresh":
|
||||
peeked: Final = await _SingleUseGuard(cache).peek(f"{_USED_REFRESH_CACHE_PREFIX}{opened.jti}")
|
||||
if peeked == "unavailable":
|
||||
return _oauth_error(503, "temporarily_unavailable", _CLAIM_UNAVAILABLE_DESCRIPTION)
|
||||
if peeked == "claimed":
|
||||
return _inactive_introspection_response()
|
||||
failure: Final = await reload_user(opened.principal.user_id)
|
||||
if failure == "unavailable":
|
||||
return _oauth_error(503, "temporarily_unavailable", "the gateway database is unavailable; retry")
|
||||
if failure is not None:
|
||||
return _inactive_introspection_response()
|
||||
return _active_introspection_response(opened)
|
||||
|
|
|
|||
|
|
@ -221,12 +221,17 @@ class MintedSessionToken(BaseModel):
|
|||
|
||||
|
||||
class OpenedSessionToken(BaseModel):
|
||||
"""A validated session token of either kind: the principal it was minted for, plus the
|
||||
``jti`` so the token endpoint can enforce single-use rotation on a refresh token."""
|
||||
"""A validated session token of either kind: the principal it was minted for, the
|
||||
``jti`` so the token endpoint can enforce single-use rotation on a refresh token, and
|
||||
the signed ``kind``/``iat``/``exp`` so an introspection response can report the
|
||||
token's metadata without re-decoding."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
principal: SessionPrincipal
|
||||
jti: str
|
||||
kind: SessionTokenKind
|
||||
iat: int
|
||||
exp: int
|
||||
|
||||
|
||||
class SessionTokenTooLarge(BaseModel):
|
||||
|
|
@ -458,6 +463,9 @@ def _open(
|
|||
team_id=claims.team_id,
|
||||
),
|
||||
jti=claims.jti,
|
||||
kind=claims.kind,
|
||||
iat=claims.iat,
|
||||
exp=claims.exp,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
"/callback",
|
||||
"/register",
|
||||
"/revoke",
|
||||
"/introspect",
|
||||
),
|
||||
# Catches the /{mcp_server_name}/authorize|token|register variants.
|
||||
path_suffixes=("/authorize", "/token", "/register"),
|
||||
|
|
|
|||
|
|
@ -16555,6 +16555,19 @@
|
|||
"title": "Body_authorize_complete_authorize_complete_post",
|
||||
"type": "object"
|
||||
},
|
||||
"Body_introspect_endpoint_introspect_post": {
|
||||
"properties": {
|
||||
"token": {
|
||||
"title": "Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"title": "Body_introspect_endpoint_introspect_post",
|
||||
"type": "object"
|
||||
},
|
||||
"Body_revoke_endpoint_revoke_post": {
|
||||
"properties": {
|
||||
"client_id": {
|
||||
|
|
@ -19134,6 +19147,51 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/introspect": {
|
||||
"post": {
|
||||
"description": "RFC 7662 introspection for gateway-issued session tokens (``llm_session_`` /\n``llm_srefresh_``), so an external gateway can validate them without the signing\nsecret. The caller authenticates with a LiteLLM virtual key (section 2.1, enforced by\nthe route dependency); any token the gateway cannot vouch for answers\n``{\"active\": false}`` with no further detail.",
|
||||
"operationId": "introspect_endpoint_introspect_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_introspect_endpoint_introspect_post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Introspect Endpoint",
|
||||
"tags": [
|
||||
"mcp_discoverable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/register": {
|
||||
"post": {
|
||||
"operationId": "register_client_register_post",
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/mcp-rest/tools/list",
|
||||
"/mcp-rest/tools/call",
|
||||
"/v1/mcp/tools",
|
||||
"/introspect",
|
||||
]
|
||||
|
||||
# MCP server CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS.
|
||||
|
|
|
|||
|
|
@ -10290,3 +10290,82 @@ def test_native_client_authorize_without_the_proxy_resource_keeps_the_mcp_flow(m
|
|||
assert 'name="decision"' not in response.text
|
||||
assert "team-b" not in response.text
|
||||
assert minted == []
|
||||
|
||||
|
||||
def test_introspect_route_requires_virtual_key_auth_and_is_advertised():
|
||||
"""RFC 7662 section 2.1: introspection must not be anonymous. Pins the route-level
|
||||
user_api_key_auth dependency (structure, so removing it fails here without a proxy),
|
||||
and that the aggregate AS metadata advertises the endpoint for discovery."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
route = next(r for r in router.routes if isinstance(r, APIRoute) and r.path == "/introspect")
|
||||
assert route.methods == {"POST"}
|
||||
assert any(dependency.call is user_api_key_auth for dependency in route.dependant.dependencies)
|
||||
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert "/introspect" in LiteLLMRoutes.mcp_routes.value
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
|
||||
discoverable = next(feature for feature in LAZY_FEATURES if feature.name == "mcp_discoverable")
|
||||
assert "/introspect" in discoverable.path_prefixes
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
asm = client.get("/.well-known/oauth-authorization-server/mcp")
|
||||
assert asm.json()["introspection_endpoint"] == "http://testserver/introspect"
|
||||
|
||||
|
||||
def test_introspect_route_answers_for_authenticated_caller(monkeypatch):
|
||||
"""End-to-end over the real route with the auth dependency satisfied: a garbage token
|
||||
is active false, a freshly minted session access token is active true with its claims."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
|
||||
session_keys_from_master_key,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
|
||||
SessionPrincipal,
|
||||
mint_session_token,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
introspect_master_key = "sk-introspect-route-test"
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", introspect_master_key, raising=False)
|
||||
|
||||
async def fake_reload(user_id: str):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._reload_active_user_by_id", fake_reload
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
|
||||
client = TestClient(app)
|
||||
|
||||
garbage = client.post("/introspect", data={"token": "llm_session_garbage"})
|
||||
assert garbage.status_code == 200
|
||||
assert garbage.json() == {"active": False}
|
||||
|
||||
minted = mint_session_token(
|
||||
SessionPrincipal(user_id="u1", client_id="llm_dcrc_client"),
|
||||
session_keys_from_master_key(introspect_master_key),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
active = client.post("/introspect", data={"token": minted.token.get_secret_value()})
|
||||
assert active.status_code == 200
|
||||
assert active.json()["active"] is True
|
||||
assert active.json()["sub"] == "u1"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
aggregate_authorize,
|
||||
aggregate_token,
|
||||
complete_connect_flow,
|
||||
introspect_gateway_token,
|
||||
is_gateway_dcr_client_id,
|
||||
is_proxy_api_resource,
|
||||
native_client_auth_contract,
|
||||
|
|
@ -41,7 +42,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credent
|
|||
resolve_session_bearer,
|
||||
session_keys_from_master_key,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import SESSION_REFRESH_PREFIX
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
|
||||
SESSION_ISSUER,
|
||||
SESSION_REFRESH_PREFIX,
|
||||
SessionPrincipal,
|
||||
mint_session_refresh_token,
|
||||
mint_session_token,
|
||||
)
|
||||
|
||||
MASTER_KEY = "sk-gateway-dcr-flow-tests"
|
||||
REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
|
||||
|
|
@ -1598,17 +1605,24 @@ async def test_refresh_answers_503_without_burning_the_token_while_redis_is_down
|
|||
)
|
||||
|
||||
redis_down = await _refresh_native(
|
||||
payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(side_effect=ConnectionError("redis down")))
|
||||
payload["refresh_token"],
|
||||
client_id,
|
||||
_Minter(),
|
||||
_redis_that(AsyncMock(side_effect=ConnectionError("redis down"))),
|
||||
)
|
||||
assert redis_down.status_code == 503
|
||||
assert json.loads(redis_down.body)["error"] == "temporarily_unavailable"
|
||||
assert "refresh_token" not in json.loads(redis_down.body)
|
||||
|
||||
redis_back = await _refresh_native(payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=1)))
|
||||
redis_back = await _refresh_native(
|
||||
payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=1))
|
||||
)
|
||||
assert redis_back.status_code == 200
|
||||
assert json.loads(redis_back.body)["refresh_token"] != payload["refresh_token"]
|
||||
|
||||
replayed = await _refresh_native(payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=2)))
|
||||
replayed = await _refresh_native(
|
||||
payload["refresh_token"], client_id, _Minter(), _redis_that(AsyncMock(return_value=2))
|
||||
)
|
||||
assert replayed.status_code == 400
|
||||
assert json.loads(replayed.body)["error"] == "invalid_grant"
|
||||
|
||||
|
|
@ -1674,3 +1688,122 @@ def test_native_client_auth_contract_points_every_endpoint_at_this_proxy():
|
|||
)
|
||||
def test_is_proxy_api_resource_matches_only_this_proxy(resource, expected):
|
||||
assert is_proxy_api_resource(_request(), resource) is expected
|
||||
|
||||
|
||||
def _introspection_fixtures():
|
||||
keys = session_keys_from_master_key(MASTER_KEY)
|
||||
now = datetime.now(timezone.utc)
|
||||
principal = SessionPrincipal(user_id="u1", client_id="llm_dcrc_client", team_id="t1")
|
||||
return keys, now, principal
|
||||
|
||||
|
||||
async def _introspect(token, cache=None, reload_user=_reload_user_active, master_key=MASTER_KEY):
|
||||
response = await introspect_gateway_token(
|
||||
token=token, master_key=master_key, reload_user=reload_user, cache=cache or DualCache()
|
||||
)
|
||||
return response.status_code, json.loads(response.body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspect_active_access_token_reports_rfc7662_claims():
|
||||
keys, now, principal = _introspection_fixtures()
|
||||
minted = mint_session_token(principal, keys, now)
|
||||
status, body = await _introspect(minted.token.get_secret_value())
|
||||
assert status == 200
|
||||
assert body["active"] is True
|
||||
assert body["token_type"] == "Bearer"
|
||||
assert body["iss"] == SESSION_ISSUER
|
||||
assert body["sub"] == "u1"
|
||||
assert body["client_id"] == "llm_dcrc_client"
|
||||
assert body["kind"] == "session"
|
||||
assert body["team_id"] == "t1"
|
||||
assert body["exp"] - body["iat"] == 3600
|
||||
assert body["jti"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspect_invalid_tokens_answer_active_false():
|
||||
keys, now, principal = _introspection_fixtures()
|
||||
wrong_key = mint_session_token(principal, session_keys_from_master_key("sk-a-rotated-master-key"), now)
|
||||
expired = mint_session_token(principal, keys, now - timedelta(seconds=7200))
|
||||
for candidate in (
|
||||
"sk-not-a-session-token",
|
||||
"llm_session_malformed",
|
||||
wrong_key.token.get_secret_value(),
|
||||
expired.token.get_secret_value(),
|
||||
):
|
||||
status, body = await _introspect(candidate)
|
||||
assert (status, body) == (200, {"active": False})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspect_refresh_token_goes_inactive_once_rotated():
|
||||
keys, now, _ = _introspection_fixtures()
|
||||
client_id = (await _register([REDIRECT_URI]))["client_id"]
|
||||
minted = mint_session_refresh_token(SessionPrincipal(user_id="u1", client_id=client_id), keys, now)
|
||||
cache = DualCache()
|
||||
status, body = await _introspect(minted.token.get_secret_value(), cache=cache)
|
||||
assert (status, body["active"], body["kind"]) == (200, True, "session_refresh")
|
||||
assert "token_type" not in body
|
||||
|
||||
revoked = await revoke_refresh_token(
|
||||
token=minted.token.get_secret_value(), client_id=client_id, master_key=MASTER_KEY, cache=cache
|
||||
)
|
||||
assert revoked.status_code == 200
|
||||
status, body = await _introspect(minted.token.get_secret_value(), cache=cache)
|
||||
assert (status, body) == (200, {"active": False})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspect_accepts_rs256_signed_tokens_under_configured_signing(monkeypatch):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pydantic import SecretStr
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import AsymmetricSessionKeys
|
||||
|
||||
private_pem = (
|
||||
rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
.decode()
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
proxy_server.general_settings,
|
||||
"mcp_session_token_signing",
|
||||
{"algorithm": "RS256", "kid": "k1", "private_key": private_pem},
|
||||
)
|
||||
_, now, principal = _introspection_fixtures()
|
||||
rs_keys = AsymmetricSessionKeys(private_key_pem=SecretStr(private_pem), kid="k1")
|
||||
minted = mint_session_token(principal, rs_keys, now)
|
||||
status, body = await _introspect(minted.token.get_secret_value())
|
||||
assert (status, body["active"], body["kind"]) == (200, True, "session")
|
||||
|
||||
hs_signed = mint_session_token(principal, session_keys_from_master_key(MASTER_KEY), now)
|
||||
status, body = await _introspect(hs_signed.token.get_secret_value())
|
||||
assert (status, body) == (200, {"active": False})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_introspect_fails_closed_on_dead_user_and_503s_on_outage():
|
||||
keys, now, principal = _introspection_fixtures()
|
||||
minted = mint_session_token(principal, keys, now)
|
||||
|
||||
async def _reload_user_gone(user_id: str):
|
||||
return "unresolvable"
|
||||
|
||||
status, body = await _introspect(minted.token.get_secret_value(), reload_user=_reload_user_gone)
|
||||
assert (status, body) == (200, {"active": False})
|
||||
|
||||
async def _reload_user_outage(user_id: str):
|
||||
return "unavailable"
|
||||
|
||||
status, body = await _introspect(minted.token.get_secret_value(), reload_user=_reload_user_outage)
|
||||
assert (status, body["error"]) == (503, "temporarily_unavailable")
|
||||
|
||||
status, body = await _introspect(minted.token.get_secret_value(), master_key=None)
|
||||
assert (status, body["error"]) == (500, "server_error")
|
||||
|
|
|
|||
62
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
62
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -6974,6 +6974,30 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/introspect": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Introspect Endpoint
|
||||
* @description RFC 7662 introspection for gateway-issued session tokens (``llm_session_`` /
|
||||
* ``llm_srefresh_``), so an external gateway can validate them without the signing
|
||||
* secret. The caller authenticates with a LiteLLM virtual key (section 2.1, enforced by
|
||||
* the route dependency); any token the gateway cannot vouch for answers
|
||||
* ``{"active": false}`` with no further detail.
|
||||
*/
|
||||
post: operations["introspect_endpoint_introspect_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/invitation/delete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -23474,6 +23498,11 @@ export interface components {
|
|||
/** Mask[] */
|
||||
"mask[]"?: string[] | null;
|
||||
};
|
||||
/** Body_introspect_endpoint_introspect_post */
|
||||
Body_introspect_endpoint_introspect_post: {
|
||||
/** Token */
|
||||
token: string;
|
||||
};
|
||||
/** Body_revoke_endpoint_revoke_post */
|
||||
Body_revoke_endpoint_revoke_post: {
|
||||
/** Client Id */
|
||||
|
|
@ -47651,6 +47680,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
introspect_endpoint_introspect_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/x-www-form-urlencoded": components["schemas"]["Body_introspect_endpoint_introspect_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
invitation_delete_invitation_delete_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue